From 04ed3fb61551dec962a5d4dc226a21c3d2d0f690 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Sat, 19 Sep 2026 11:48:05 +0100 Subject: [PATCH] Cut GitHub API dependency and fix silent-failure paths Reduces the unauthenticated api.github.com dependency and fixes a set of defects that made failures look like successes. GitHub API rate limiting Get-GitHubInstaller made two api.github.com calls per install with no credentials. That allowance is 60 requests/hour per source IP, so a site behind a single NAT egress gets roughly 30 installs an hour before every subsequent install fails at the resolve step. Three layers now sit in front of it: - resolved manifests are cached to ProgramData\TecharyGet\ManifestCache, so repeat and retry installs of the same app cost no API calls - on a live failure the cache is reused even when stale, so a rate limited or offline site keeps installing from last known good instead of hard failing - an optional PAT (-GitHubToken, or TECHARYGET_GITHUB_TOKEN) lifts the allowance to 5000/hour, and a rate limit response now says so The scraping logic moves to Private/Resolve-GitHubManifest.ps1 so resolution and download are separable and the cache has a seam to sit on. Custom catalog was never reachable Get-CustomApp pointed at .../BETA/TecharyGet/Private/CustomApps.json. There is no TecharyGet directory in this repo, so that URL 404s on every call and the catalog never synced. Corrected to the real path. The download is also staged and parsed before it replaces the cache, so a captive portal or proxy page returning HTTP 200 with HTML can no longer poison the cache for an hour. Failures that reported as success - Install-TecharyApp returned silently for an unknown Id. A caller driving this from Intune or an RMM cannot tell that from a successful install, so a mistyped Id was reported as success. It now throws. - Write-PackagerLog called EventLog::SourceExists unguarded. That throws SecurityException when the caller cannot read the event log registry, which is the normal non-elevated case, so every log call became a terminating error. Event logging is now best effort and the file log is likewise non-fatal. - Uninstall-TecharyApp left $Arguments unset when an MSI uninstall string contained no product code, reaching Start-Process as $null. Both variables are initialised and that case now reports the reason. - Uninstall-TecharyApp appended /S /silent /quiet /norestart to QuietUninstallString, which is silent by definition. Passing contradictory flags made some vendors' uninstallers fail or fall back to a UI prompt. The switches are only appended to a plain UninstallString now. Version selection Manifest versions were cast to [Version] and the failures discarded, which silently dropped real releases such as 1.2.3-beta and 20240101, and threw outright when no folder happened to parse. Replaced with a zero padded sortable key over fixed width components, which also fixes 1.2 sorting above 1.2.3, and prefers a stable release over a prerelease on a tie. Module loading and manifest The loader used $MyInvocation.MyCommand.Path, errored on a missing directory and reported a failed dot-source as a later "command not found". It now uses $PSScriptRoot, skips absent directories and fails immediately naming the file and the reason. FunctionsToExport advertised Show-IntunePackager, which has no implementation; removed. Version 2.4. Verified: all files parse, Test-ModuleManifest passes, the module imports and exports 8 commands, live resolution succeeds for 7zip.7zip, Notepad++.Notepad++ and Google.Chrome, and version ordering is asserted across mixed release, date and prerelease formats. --- Private/Get-CustomApp.ps1 | 31 +++-- Private/Resolve-GitHubManifest.ps1 | 114 ++++++++++++++++++ Public/Get-GitHubInstaller.ps1 | 186 ++++++++++++++++------------- Public/Install-TecharyApp.ps1 | 56 +++++---- Public/Uninstall-TecharyApp.ps1 | 53 +++++--- Public/Write-PackagerLog.ps1 | 43 ++++--- TecharyGet.psd1 | 3 +- TecharyGet.psm1 | 29 ++++- 8 files changed, 362 insertions(+), 153 deletions(-) create mode 100644 Private/Resolve-GitHubManifest.ps1 diff --git a/Private/Get-CustomApp.ps1 b/Private/Get-CustomApp.ps1 index 4a6cae4..28ca71e 100644 --- a/Private/Get-CustomApp.ps1 +++ b/Private/Get-CustomApp.ps1 @@ -6,18 +6,20 @@ function Get-CustomApp { # --- 1. CLOUD SOURCE --- # We use the "Raw" GitHub URL so we get just the JSON text. # Structure: https://raw.githubusercontent.com//// - $CloudUrl = "https://raw.githubusercontent.com/Techary/TecharyGet/BETA/TecharyGet/Private/CustomApps.json" - + # raw.githubusercontent.com is CDN-backed and is NOT subject to the + # api.github.com rate limit, so this stays cheap at fleet scale. + $CloudUrl = "https://raw.githubusercontent.com/Techary/TecharyGet/BETA/Private/CustomApps.json" + # --- 2. LOCAL CACHE --- # We cache the file locally so the script works even if GitHub is briefly down # or if the machine is offline (using the last known good copy). $CacheDir = "$env:PROGRAMDATA\TecharyGet" $CachePath = "$CacheDir\CustomApps_Cache.json" - + # --- 3. SYNC LOGIC --- try { if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null } - + # Logic: Only download if the cache doesn't exist OR it's older than 60 minutes. # This prevents spamming GitHub every time you run a command. $NeedUpdate = $true @@ -28,11 +30,20 @@ function Get-CustomApp { if ($NeedUpdate) { Write-PackagerLog -Message "Syncing Custom Catalog from GitHub..." -Severity Info - Invoke-WebRequest -Uri $CloudUrl -OutFile $CachePath -UseBasicParsing -ErrorAction Stop + + # Download to a staging file and prove it parses before promoting it. + # A captive portal or proxy error page returns HTTP 200 with HTML, + # which would otherwise poison the cache for the next 60 minutes. + $StagePath = "$CachePath.tmp" + Invoke-WebRequest -Uri $CloudUrl -OutFile $StagePath -UseBasicParsing -ErrorAction Stop + + $null = (Get-Content -Path $StagePath -Raw | ConvertFrom-Json) + Move-Item -Path $StagePath -Destination $CachePath -Force -ErrorAction Stop } } catch { - Write-PackagerLog -Message "Could not sync from GitHub (Offline?). Using local cache." -Severity Warning + Write-PackagerLog -Message "Could not sync Custom Catalog ($($_.Exception.Message)). Using local copy." -Severity Warning + Remove-Item "$CachePath.tmp" -Force -ErrorAction SilentlyContinue } # --- 4. READ DATA --- @@ -45,8 +56,8 @@ function Get-CustomApp { # Fallback to the file shipped with the module (if cache is empty/broken) else { $LocalModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Private\CustomApps.json" - if (Test-Path $LocalModulePath) { - $JsonContent = Get-Content -Path $LocalModulePath -Raw + if (Test-Path $LocalModulePath) { + $JsonContent = Get-Content -Path $LocalModulePath -Raw } } @@ -62,6 +73,6 @@ function Get-CustomApp { return $null } } - + return $null -} \ No newline at end of file +} diff --git a/Private/Resolve-GitHubManifest.ps1 b/Private/Resolve-GitHubManifest.ps1 new file mode 100644 index 0000000..d69ab32 --- /dev/null +++ b/Private/Resolve-GitHubManifest.ps1 @@ -0,0 +1,114 @@ +function Get-ManifestVersionKey { + param([string]$Name) + + # winget version folders are not reliably [Version]-parseable: "1.2.3-beta", + # "20240101", "2.0" and "1.2.3.4.5" all occur. Casting to [Version] and + # discarding the failures silently dropped real releases, and threw outright + # for packages where no folder happened to parse. Build a zero-padded key so + # ordinary string sorting gives correct numeric ordering instead. + $Numbers = [regex]::Matches($Name, '\d+') | ForEach-Object { $_.Value } + if (-not $Numbers) { return $null } + + # Always emit the same number of components. With variable-length keys + # "1.2" sorted ABOVE "1.2.3", because the separator that terminated the + # shorter key compared higher than the '.' in the longer one. + $Parts = New-Object System.Collections.Generic.List[string] + foreach ($N in ($Numbers | Select-Object -First 6)) { + try { $Parts.Add('{0:D12}' -f [int64]$N) } catch { $Parts.Add('{0:D12}' -f 0) } + } + while ($Parts.Count -lt 6) { $Parts.Add('{0:D12}' -f 0) } + + # Tiebreak on equal numbers: prefer the stable release over a prerelease, + # so "1.2.3" beats "1.2.3-beta" instead of the winner being arbitrary. + $Rank = if ($Name -match '[A-Za-z]') { '0' } else { '9' } + + return (($Parts -join '.') + '|' + $Rank) +} + +function Resolve-GitHubManifest { + [CmdletBinding()] + param ( + [Parameter(Mandatory=$true)][string]$Id, + [Parameter(Mandatory=$true)][string]$SysArch, + [Parameter(Mandatory=$true)][hashtable]$Headers + ) + + # 2. Construct API Path + $IdPath = $Id.Replace(".", "/") + $FirstChar = $Id.Substring(0,1).ToLower() + $BaseApi = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/$FirstChar/$IdPath" + + # 3. Get Version (Latest) [API call 1 of 2] + $VersionsResponse = Invoke-RestMethod -Uri $BaseApi -Method Get -Headers $Headers -ErrorAction Stop + + $LatestVersionObj = $VersionsResponse | + Where-Object { $_.type -eq "dir" } | + Select-Object *, @{N='SortKey'; E={ Get-ManifestVersionKey -Name $_.name }} | + Where-Object { $null -ne $_.SortKey } | + Sort-Object SortKey -Descending | + Select-Object -First 1 + + if (-not $LatestVersionObj) { throw "Could not determine a valid version folder for '$Id'." } + $LatestVersion = $LatestVersionObj.Name + + # 4. Get Manifest [API call 2 of 2] + $VersionPath = "$BaseApi/$LatestVersion" + $VersionFiles = Invoke-RestMethod -Uri $VersionPath -Method Get -Headers $Headers -ErrorAction Stop + $InstallerFile = $VersionFiles | Where-Object { $_.name -like "*.installer.yaml" } | Select-Object -First 1 + if (-not $InstallerFile) { throw "No installer YAML found for '$Id' $LatestVersion." } + + # Served from raw.githubusercontent.com, which is CDN-backed and not + # subject to the API rate limit, so no credentials are sent here. + $YamlContent = Invoke-RestMethod -Uri $InstallerFile.download_url -Headers @{ 'User-Agent' = 'TecharyGet' } -ErrorAction Stop + + # --- PARSING LOGIC --- + # We split by "- Architecture" to separate blocks, but keep the delimiter to help identification + $Blocks = $YamlContent -split '(?=-\s*Architecture:)' + + $SelectedUrl = $null + $SelectedArgs = $null + $SelectedType = "exe" + $SelectedCode = $null + + foreach ($Block in $Blocks) { + if ([string]::IsNullOrWhiteSpace($Block)) { continue } + + if ($Block -match 'Architecture:\s*([a-zA-Z0-9]+)') { + $BlockArch = $Matches[1].Trim() + + if ($BlockArch -eq $SysArch) { + if ($Block -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } + if ($Block -match 'InstallerType:\s*([a-zA-Z0-9]+)') { $SelectedType = $Matches[1].Trim() } + + if ($Block -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + elseif ($Block -match 'SilentWithProgress:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + + if ($Block -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedCode = $Matches[1].Trim() } + + if ($SelectedUrl) { break } + } + } + } + + # Fallbacks (Global properties if not in block) + if (-not $SelectedUrl) { if ($YamlContent -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } } + if (-not $SelectedArgs) { + if ($YamlContent -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } + } + if (-not $SelectedCode) { + if ($YamlContent -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedCode = $Matches[1].Trim() } + } + + if (-not $SelectedUrl) { throw "No InstallerUrl found in the manifest for '$Id' $LatestVersion." } + + return [PSCustomObject]@{ + Id = $Id + Version = $LatestVersion + Arch = $SysArch + Url = $SelectedUrl + SilentArgs = $SelectedArgs + InstallerType = $SelectedType + ProductCode = $SelectedCode + ResolvedUtc = (Get-Date).ToUniversalTime().ToString('o') + } +} diff --git a/Public/Get-GitHubInstaller.ps1 b/Public/Get-GitHubInstaller.ps1 index c028dd0..83c10a6 100644 --- a/Public/Get-GitHubInstaller.ps1 +++ b/Public/Get-GitHubInstaller.ps1 @@ -3,96 +3,110 @@ function Get-GitHubInstaller { param ( [Parameter(Mandatory=$true)] [string]$Id, - [string]$DownloadPath = "$env:TEMP\AppPackager" + [string]$DownloadPath = "$env:TEMP\AppPackager", + + # A PAT lifts the GitHub API allowance from 60 to 5000 requests/hour. + # Falls back to the environment so endpoints can be seeded centrally + # without the token appearing in a command line or an RMM job log. + [string]$GitHubToken = $env:TECHARYGET_GITHUB_TOKEN, + + # Resolved manifests are cached on disk. Repeat and retry installs of + # the same app then cost no API calls at all. + [int]$CacheHours = 24, + [switch]$NoCache ) Write-PackagerLog -Message "Querying GitHub Manifests for: $Id" - try { - # 1. Detect Architecture - if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } - elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } - else { $SysArch = "x86" } - - # 2. Construct API Path - $IdPath = $Id.Replace(".", "/") - $FirstChar = $Id.Substring(0,1).ToLower() - $BaseApi = "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/$FirstChar/$IdPath" - - # 3. Get Version (Latest) - $VersionsResponse = Invoke-RestMethod -Uri $BaseApi -Method Get -ErrorAction Stop - $LatestVersionObj = $VersionsResponse | - Where-Object { $_.type -eq "dir" } | - Select-Object *, @{N='ParsedVersion'; E={ try { [Version]$_.name } catch { $null } }} | - Where-Object { $_.ParsedVersion -ne $null } | - Sort-Object ParsedVersion -Descending | - Select-Object -First 1 - - if (-not $LatestVersionObj) { throw "Could not determine a valid numeric version." } - $LatestVersion = $LatestVersionObj.Name - - # 4. Get Manifest - $VersionPath = "$BaseApi/$LatestVersion" - $VersionFiles = Invoke-RestMethod -Uri $VersionPath -Method Get - $InstallerFile = $VersionFiles | Where-Object { $_.name -like "*.installer.yaml" } | Select-Object -First 1 - if (-not $InstallerFile) { throw "No installer YAML found." } - - $YamlContent = Invoke-RestMethod -Uri $InstallerFile.download_url - - # --- PARSING LOGIC --- - # We split by "- Architecture" to separate blocks, but keep the delimiter to help identification - $Blocks = $YamlContent -split '(?=-\s*Architecture:)' - - $SelectedUrl = $null - $SelectedArgs = $null - $SelectedType = "exe" - $SelectedCode = $null - - foreach ($Block in $Blocks) { - if ([string]::IsNullOrWhiteSpace($Block)) { continue } - - # Extract Architecture from this block - if ($Block -match 'Architecture:\s*([a-zA-Z0-9]+)') { - $BlockArch = $Matches[1].Trim() - - # If this block matches our system, scrape it! - if ($BlockArch -eq $SysArch) { - if ($Block -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } - if ($Block -match 'InstallerType:\s*([a-zA-Z0-9]+)') { $SelectedType = $Matches[1].Trim() } - - # Scrape Arguments - if ($Block -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } - elseif ($Block -match 'SilentWithProgress:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } - - # Scrape Product Code (Flexible Regex) - # This now matches "{GUID}" OR "SimpleString" - if ($Block -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { - $SelectedCode = $Matches[1].Trim() - } - - # If we found a URL, we stop looking (we prefer the first match for our arch) - if ($SelectedUrl) { break } + # PS 5.1 on older builds still negotiates TLS 1.0 by default, which GitHub refuses. + try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} + + $CacheDir = "$env:ProgramData\TecharyGet\ManifestCache" + $CacheFile = Join-Path $CacheDir ("$Id.json" -replace '[\/:*?"<>|]', '_') + + $Headers = @{ 'User-Agent' = 'TecharyGet' } + if ($GitHubToken) { $Headers['Authorization'] = "token $GitHubToken" } + + # 1. Detect Architecture + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $SysArch = "arm64" } + elseif ([Environment]::Is64BitOperatingSystem) { $SysArch = "x64" } + else { $SysArch = "x86" } + + # --- RESOLVE MANIFEST METADATA ------------------------------------- + # Order: fresh cache -> live API -> stale cache. The stale fallback is + # what keeps a rate-limited or offline site installing software. + $Meta = $null + + if (-not $NoCache -and (Test-Path $CacheFile)) { + $Age = (Get-Date) - (Get-Item $CacheFile).LastWriteTime + if ($Age.TotalHours -lt $CacheHours) { + try { + $Cached = Get-Content $CacheFile -Raw | ConvertFrom-Json + if ($Cached.Arch -eq $SysArch -and $Cached.Url) { + Write-PackagerLog -Message "Using cached manifest for $Id (v$($Cached.Version), $([int]$Age.TotalHours)h old). No API call needed." + $Meta = $Cached } } + catch { + Write-PackagerLog -Message "Manifest cache for $Id unreadable, re-resolving." -Severity Warning + } } + } - # Fallbacks (Global properties if not in block) - if (-not $SelectedUrl) { if ($YamlContent -match 'InstallerUrl:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedUrl = $Matches[1].Trim() } } - if (-not $SelectedArgs) { - if ($YamlContent -match 'Silent:\s*(.+)') { $SelectedArgs = $Matches[1].Trim().Trim("'").Trim('"') } - } - if (-not $SelectedCode) { - if ($YamlContent -match 'ProductCode:\s*["'']?([^"''\r\n]+)["'']?') { $SelectedCode = $Matches[1].Trim() } + if (-not $Meta) { + try { + $Meta = Resolve-GitHubManifest -Id $Id -SysArch $SysArch -Headers $Headers + + try { + if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force -ErrorAction Stop | Out-Null } + $Meta | ConvertTo-Json -Depth 4 | Set-Content -Path $CacheFile -Encoding UTF8 -ErrorAction Stop + } + catch { + Write-PackagerLog -Message "Could not cache manifest for ${Id}: $($_.Exception.Message)" -Severity Warning + } } + catch { + $Reason = $_.Exception.Message + $IsRateLimit = $Reason -match '\(403\)|rate limit' + + if ($IsRateLimit -and -not $GitHubToken) { + Write-PackagerLog -Message "GitHub API rate limit hit (60/hour per public IP, unauthenticated). Supply -GitHubToken or set TECHARYGET_GITHUB_TOKEN to raise this to 5000/hour." -Severity Warning + } + + if (-not $NoCache -and (Test-Path $CacheFile)) { + try { + $Stale = Get-Content $CacheFile -Raw | ConvertFrom-Json + if ($Stale.Arch -eq $SysArch -and $Stale.Url) { + $StaleAge = [int]((Get-Date) - (Get-Item $CacheFile).LastWriteTime).TotalHours + Write-PackagerLog -Message "Live resolve failed ($Reason). Falling back to cached manifest for $Id, ${StaleAge}h old (v$($Stale.Version))." -Severity Warning + $Meta = $Stale + } + } + catch { } + } - # Special Override for Dell (Command Update) - if ($Id -eq "Dell.CommandUpdate") { $SelectedArgs = '/s /l="C:\Windows\Temp\DellCommand.log" /v"/qn"' } + if (-not $Meta) { + Write-PackagerLog -Message "GitHub Scraping Failed: $Reason" -Severity Error + throw $_ + } + } + } - # Special Override for 8x8 Work MSI - if ($Id -eq "8x8.Work") { $SelectedArgs = "/qn /norestart" } + try { + $SelectedUrl = $Meta.Url + $SelectedArgs = $Meta.SilentArgs + $SelectedType = $Meta.InstallerType + $SelectedCode = $Meta.ProductCode + $LatestVersion = $Meta.Version - # Special Override for Sublime Text 4 - if ($Id -eq "SublimeHQ.SublimeText.4") { $SelectedArgs = "/VERYSILENT /NORESTART" } + # --- PER-PACKAGE OVERRIDES --- + # Applied after the cache read so a correction here takes effect + # immediately rather than waiting for the cache to expire. + switch ($Id) { + "Dell.CommandUpdate" { $SelectedArgs = '/s /l="C:\Windows\Temp\DellCommand.log" /v"/qn"' } + "8x8.Work" { $SelectedArgs = "/qn /norestart" } + "SublimeHQ.SublimeText.4" { $SelectedArgs = "/VERYSILENT /NORESTART" } + } # --- DOWNLOAD --- $UriObj = [System.Uri]$SelectedUrl @@ -103,21 +117,23 @@ function Get-GitHubInstaller { if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null $FullPath = Join-Path $DownloadPath $FileName - + + # The installer itself comes from the vendor CDN, not the GitHub API, + # so it is never rate limited. Write-PackagerLog -Message "Downloading to $FullPath..." Invoke-WebRequest -Uri $SelectedUrl -OutFile $FullPath -UseBasicParsing -UserAgent "Mozilla/5.0" - + return [PSCustomObject]@{ - Name = $Id + Name = $Id InstallerPath = $FullPath - FileName = $FileName - SilentArgs = $SelectedArgs + FileName = $FileName + SilentArgs = $SelectedArgs InstallerType = $SelectedType - ProductCode = $SelectedCode + ProductCode = $SelectedCode } } catch { - Write-PackagerLog -Message "GitHub Scraping Failed: $_" -Severity Error + Write-PackagerLog -Message "Installer download failed: $_" -Severity Error throw $_ } } diff --git a/Public/Install-TecharyApp.ps1 b/Public/Install-TecharyApp.ps1 index d54e73a..fbf2ce8 100644 --- a/Public/Install-TecharyApp.ps1 +++ b/Public/Install-TecharyApp.ps1 @@ -4,14 +4,18 @@ function Install-TecharyApp { [CmdletBinding()] param( [Parameter(Mandatory=$true)] - [string]$Id + [string]$Id, + + # Passed through to the winget-pkgs manifest lookup. Raises the GitHub + # API allowance from 60 to 5000 requests/hour where one is available. + [string]$GitHubToken = $env:TECHARYGET_GITHUB_TOKEN ) - + $Pkg = $null # --- ATTEMPT 1: GITHUB --- try { - $Pkg = Get-GitHubInstaller -Id $Id -ErrorAction Stop + $Pkg = Get-GitHubInstaller -Id $Id -GitHubToken $GitHubToken -ErrorAction Stop } catch { Write-PackagerLog -Message "Not found in GitHub ($Id). Checking Custom Catalog..." -Severity Info @@ -22,24 +26,20 @@ function Install-TecharyApp { # Load the internal helper to check JSON # (Assuming Get-CustomApp is dot-sourced in .psm1) $CustomData = Get-CustomApp -Id $Id - + if ($CustomData) { Write-PackagerLog -Message "Found '$Id' in Custom Catalog." - - # Use the Web Installer logic to download it - # We can reuse the logic or call Get-WebInstaller if you created it. - # Here is the inline logic for simplicity: - + $DownloadPath = "$env:TEMP\AppPackager" if (Test-Path $DownloadPath) { Remove-Item "$DownloadPath\*" -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $DownloadPath -Force | Out-Null - + $FileName = "$Id.$($CustomData.InstallerType)" $FullPath = Join-Path $DownloadPath $FileName - + Write-PackagerLog -Message "Downloading Custom App from: $($CustomData.Url)" Invoke-WebRequest -Uri $CustomData.Url -OutFile $FullPath -UseBasicParsing - + # Build the Package Object manually $Pkg = [PSCustomObject]@{ Name = $Id @@ -52,17 +52,29 @@ function Install-TecharyApp { } if (-not $Pkg) { - Write-PackagerLog -Message "Application '$Id' not found in GitHub OR Custom Catalog." -Severity Error - return + $Msg = "Application '$Id' not found in GitHub OR Custom Catalog." + Write-PackagerLog -Message $Msg -Severity Error + # Throw rather than return. A caller driving this from Intune or an RMM + # remediation cannot distinguish a silent return from a successful + # install, so a mistyped Id would be reported to the console as success. + throw $Msg } - + # --- INSTALLATION --- # MSI Fallback Logic - $Args = $Pkg.SilentArgs - if ([string]::IsNullOrWhiteSpace($Args) -and ($Pkg.InstallerPath -match ".msi$" -or $Pkg.InstallerType -eq "msi")) { - $Args = "/qb /norestart" + # Named InstallArgs, not Args: $Args is an automatic variable and assigning + # to it is undefined behaviour under Set-StrictMode. + $InstallArgs = $Pkg.SilentArgs + if ([string]::IsNullOrWhiteSpace($InstallArgs) -and ($Pkg.InstallerPath -match "\.msi$" -or $Pkg.InstallerType -eq "msi")) { + $InstallArgs = "/qb /norestart" + } + + try { + Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $InstallArgs + } + finally { + # Clean up even when the install throws, so a failed run does not + # leave an installer behind for the next one to trip over. + Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force } - - Install-AppPackage -Name $Pkg.Name -FilePath $Pkg.InstallerPath -Arguments $Args - Invoke-PackagerCleanup -Paths "$env:TEMP\AppPackager" -Force -} \ No newline at end of file +} diff --git a/Public/Uninstall-TecharyApp.ps1 b/Public/Uninstall-TecharyApp.ps1 index 8b4fdd6..e47b9f5 100644 --- a/Public/Uninstall-TecharyApp.ps1 +++ b/Public/Uninstall-TecharyApp.ps1 @@ -3,7 +3,7 @@ function Uninstall-TecharyApp { param ( [Parameter(Mandatory=$true)] [string]$Name, - + [switch]$WhatIf ) @@ -18,8 +18,8 @@ function Uninstall-TecharyApp { $App = $null foreach ($Path in $Paths) { - $App = Get-ItemProperty $Path -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -like "*$Name*" } | + $App = Get-ItemProperty $Path -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like "*$Name*" } | Select-Object -First 1 if ($App) { break } } @@ -28,17 +28,17 @@ function Uninstall-TecharyApp { if (-not $App) { Write-PackagerLog -Message "Not found in Registry. Checking Modern Apps (MSIX)..." $MsixResults = Get-AppxPackage -Name "*$Name*" -ErrorAction SilentlyContinue - + if ($MsixResults) { # FIX: Handle cases where multiple apps match (Array vs Single Object) foreach ($Package in $MsixResults) { Write-PackagerLog -Message "Found Modern App: $($Package.Name)" - - if ($WhatIf) { + + if ($WhatIf) { Write-Host "[WhatIf] Would remove: $($Package.PackageFullName)" -ForegroundColor Yellow - continue + continue } - + try { Remove-AppxPackage -Package $Package.PackageFullName -ErrorAction Stop Write-PackagerLog -Message "Success: Removed $($Package.Name)" @@ -49,13 +49,16 @@ function Uninstall-TecharyApp { } return } - + Write-PackagerLog -Message "Application '$Name' not found on this system." -Severity Warning return } # 3. DETERMINE UNINSTALL COMMAND (Classic Apps) + # Both must be initialised: an unset $Arguments previously reached + # Start-Process as $null whenever the MSI branch failed to match a GUID. $UninstallString = $null + $Arguments = "" $Type = "EXE" if ($App.UninstallString -match "MsiExec.exe") { @@ -65,10 +68,19 @@ function Uninstall-TecharyApp { $UninstallString = "msiexec.exe" $Arguments = "/x $Guid /qn /norestart" } + else { + Write-PackagerLog -Message "MSI uninstall string for '$($App.DisplayName)' contains no product code: $($App.UninstallString)" -Severity Error + return + } } else { # EXE Uninstaller logic - if ($App.QuietUninstallString) { + # QuietUninstallString is silent by definition. Appending our own + # switches to it passed contradictory flags to the uninstaller, which + # made some vendors' uninstallers fail or fall back to a UI prompt. + $UsedQuietString = [bool]$App.QuietUninstallString + + if ($UsedQuietString) { $RawString = $App.QuietUninstallString } else { $RawString = $App.UninstallString @@ -77,16 +89,21 @@ function Uninstall-TecharyApp { if ($RawString -match '^(?:"([^"]+)"|([^ ]+))(.*)$') { $Exe = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } $ArgsPart = $Matches[3].Trim() - + $UninstallString = $Exe $Arguments = $ArgsPart - - if (-not ($Arguments -match "/S|/silent|/qn|/quiet")) { + + if (-not $UsedQuietString -and -not ($Arguments -match "/S|/silent|/qn|/quiet")) { $Arguments = "$Arguments /S /silent /quiet /norestart" } } } + if (-not $UninstallString) { + Write-PackagerLog -Message "Could not derive an uninstall command for '$($App.DisplayName)' from: $($App.UninstallString)" -Severity Error + return + } + Write-PackagerLog -Message "Found: $($App.DisplayName) ($Type)" Write-PackagerLog -Message "Command: $UninstallString $Arguments" @@ -97,8 +114,12 @@ function Uninstall-TecharyApp { # 4. EXECUTE REMOVAL try { - $Process = Start-Process -FilePath $UninstallString -ArgumentList $Arguments -PassThru -Wait -NoNewWindow - + if ([string]::IsNullOrWhiteSpace($Arguments)) { + $Process = Start-Process -FilePath $UninstallString -PassThru -Wait -NoNewWindow + } else { + $Process = Start-Process -FilePath $UninstallString -ArgumentList $Arguments -PassThru -Wait -NoNewWindow + } + if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) { Write-PackagerLog -Message "Uninstallation Successful." } else { @@ -108,4 +129,4 @@ function Uninstall-TecharyApp { catch { Write-PackagerLog -Message "Uninstallation Failed: $_" -Severity Error } -} \ No newline at end of file +} diff --git a/Public/Write-PackagerLog.ps1 b/Public/Write-PackagerLog.ps1 index ed2d4ee..783d509 100644 --- a/Public/Write-PackagerLog.ps1 +++ b/Public/Write-PackagerLog.ps1 @@ -14,24 +14,39 @@ function Write-PackagerLog { Write-Host $Line -ForegroundColor $Color # 2. File Log - if (-not (Test-Path (Split-Path $LogPath))) { New-Item -ItemType Directory (Split-Path $LogPath) -Force | Out-Null } - Add-Content -Path $LogPath -Value $Line + # Never let logging take down the caller. A locked log file or a + # read-only ProgramData must not abort an install half way through. + try { + $LogDir = Split-Path $LogPath + if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory $LogDir -Force -ErrorAction Stop | Out-Null } + Add-Content -Path $LogPath -Value $Line -ErrorAction Stop + } + catch { + Write-Warning "TecharyGet: could not write to $LogPath ($($_.Exception.Message))." + } - # 3. ENTERPRISE EVENT LOGGING (New!) + # 3. ENTERPRISE EVENT LOGGING # N-able can pick this up easily. # Source: "TecharyGet", ID: 100 (Info), 200 (Warn), 300 (Error) - - $EventSource = "TecharyGet" - if (-not ([System.Diagnostics.EventLog]::SourceExists($EventSource))) { - # Requires Admin to create source once. - # If not admin, this skips silently to avoid crashing. - try { New-EventLog -LogName Application -Source $EventSource -ErrorAction SilentlyContinue } catch {} - } + # + # SourceExists() enumerates the whole event-log registry key and throws a + # SecurityException when the caller cannot read it, which is the normal + # case for a non-elevated user. Unguarded, that turned every single log + # call into a terminating error. + try { + $EventSource = "TecharyGet" + + if (-not [System.Diagnostics.EventLog]::SourceExists($EventSource)) { + # Creating a source requires admin. Skip quietly if we cannot. + New-EventLog -LogName Application -Source $EventSource -ErrorAction Stop + } - if ([System.Diagnostics.EventLog]::SourceExists($EventSource)) { $EventID = switch ($Severity) { "Info" {100} "Warning" {200} "Error" {300} } $EntryType = switch ($Severity) { "Info" {"Information"} "Warning" {"Warning"} "Error" {"Error"} } - - Write-EventLog -LogName Application -Source $EventSource -EventId $EventID -EntryType $EntryType -Message $Message + + Write-EventLog -LogName Application -Source $EventSource -EventId $EventID -EntryType $EntryType -Message $Message -ErrorAction Stop + } + catch { + # Event logging is best-effort; the file log above is the system of record. } -} \ No newline at end of file +} diff --git a/TecharyGet.psd1 b/TecharyGet.psd1 index 9b33dbc..2aaab2d 100644 --- a/TecharyGet.psd1 +++ b/TecharyGet.psd1 @@ -3,7 +3,7 @@ RootModule = 'TecharyGet.psm1' # Version number of this module. - ModuleVersion = '2.3' + ModuleVersion = '2.4' # ID used to uniquely identify this module GUID = 'e9c840c8-3c3e-4246-8178-52372d807654' @@ -34,7 +34,6 @@ # -- Intune Packaging Tools -- 'New-IntunePackage', # The CLI Packager (with Detect/Uninstall generation) 'New-IntunePackageUI', # The GUI Packager - 'Show-IntunePackager', # The Wrapper for the MS Utility # -- Utilities -- 'Write-PackagerLog' diff --git a/TecharyGet.psm1 b/TecharyGet.psm1 index 4285024..fc6645f 100644 --- a/TecharyGet.psm1 +++ b/TecharyGet.psm1 @@ -1,4 +1,25 @@ -$Root = Split-Path $MyInvocation.MyCommand.Path -Get-ChildItem -Path "$Root\Private\*.ps1" | ForEach-Object { . $_.FullName } -Get-ChildItem -Path "$Root\Public\*.ps1" | ForEach-Object { . $_.FullName } -Export-ModuleMember -Function (Get-ChildItem -Path "$Root\Public\*.ps1").BaseName +$Root = $PSScriptRoot +if (-not $Root) { $Root = Split-Path -Parent $MyInvocation.MyCommand.Path } + +# Dot-source Private first: Public functions depend on those helpers. +# A failure here is fatal - a half-loaded module is worse than no module, +# because the caller gets "command not found" instead of the real reason. +foreach ($Scope in 'Private', 'Public') { + $Dir = Join-Path $Root $Scope + if (-not (Test-Path $Dir)) { continue } + + foreach ($File in (Get-ChildItem -Path $Dir -Filter '*.ps1' -File)) { + try { + . $File.FullName + } + catch { + throw "TecharyGet: failed to load '$Scope\$($File.Name)': $($_.Exception.Message)" + } + } +} + +$PublicDir = Join-Path $Root 'Public' +if (Test-Path $PublicDir) { + $ToExport = @((Get-ChildItem -Path $PublicDir -Filter '*.ps1' -File).BaseName) + if ($ToExport.Count -gt 0) { Export-ModuleMember -Function $ToExport } +}