lib/Winget.ps1
|
function Get-WingetPackagesConfig { <# .SYNOPSIS Reads the WingetPackages array from the same config file used by Get-PathsConfig. Returns an empty array when the key is absent or the file doesn't exist. #> [CmdletBinding()] param( [Parameter()] [string] $ConfigFile = $script:ConfigFilePath ) if (-not $ConfigFile -or -not (Test-Path -Path $ConfigFile -PathType Leaf)) { return @() } try { $raw = Get-Content -Path $ConfigFile -Raw -Encoding UTF8 $parsed = $raw | ConvertFrom-Json -ErrorAction Stop } catch { throw "Failed to parse '$ConfigFile': $($_.Exception.Message)" } if ($null -eq $parsed) { return @() } if (-not $parsed.PSObject.Properties['WingetPackages']) { return @() } @($parsed.WingetPackages | Where-Object { $_ }) } function Initialize-Winget { <# .SYNOPSIS Ensures winget is available on PATH. If it is missing, attempts to bootstrap it via the Microsoft.WinGet.Client PowerShell module. Throws with a clear "install manually" message if the bootstrap fails. Under -WhatIf, just notes that the bootstrap would happen without actually mutating the system. #> [CmdletBinding()] param() if (Get-Command winget -ErrorAction SilentlyContinue) { return } if ($WhatIfPreference) { Write-Status -Level Info -Message ' [INFO] winget is not installed; would bootstrap on a real run.' return } Write-Status -Level Info -Message ' [INFO] winget not found; bootstrapping via Microsoft.WinGet.Client...' # Step 1: install the helper module from PSGallery if we don't already have it. if (-not (Get-Module -ListAvailable -Name Microsoft.WinGet.Client)) { # The PSGallery prompt asking "are you sure you want to install from # an untrusted repository" isn't always bypassed by -Force, so flip # the policy to Trusted just for this install and restore it after. $repo = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue $prevPolicy = if ($repo) { $repo.InstallationPolicy } else { $null } try { if ($prevPolicy -and $prevPolicy -ne 'Trusted') { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop } Install-Module -Name Microsoft.WinGet.Client -Force -Scope CurrentUser -AllowClobber -Confirm:$false -ErrorAction Stop } catch { throw "Failed to install Microsoft.WinGet.Client module: $($_.Exception.Message). Try ``Install-Module -Name PowerShellGet -Force`` first, or install winget manually from https://aka.ms/getwinget." } finally { if ($prevPolicy -and $prevPolicy -ne 'Trusted') { Set-PSRepository -Name PSGallery -InstallationPolicy $prevPolicy -ErrorAction SilentlyContinue } } } Import-Module Microsoft.WinGet.Client -ErrorAction Stop # Step 2: install or repair winget itself. try { Repair-WinGetPackageManager -Latest -Force -ErrorAction Stop | Out-Null } catch { throw "Failed to bootstrap winget: $($_.Exception.Message). Install winget manually from https://aka.ms/getwinget." } if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { throw "winget bootstrap appeared to succeed but the command is still not on PATH. Restart your terminal or check the App Installer state in the Microsoft Store." } Write-Status -Level Info -Message ' [INFO] winget bootstrapped.' } function Reset-EnvPath { <# .SYNOPSIS Refresh the current process's environment from the registry so newly-installed binaries are visible without a shell restart. Copies every Machine-scope env var into the process, then every User-scope env var (User overrides Machine, matching the OS's own resolution order). PATH is special-cased: machine PATH + user PATH are concatenated (not overridden) so user-scope additions don't wipe out the system PATH. REG_EXPAND_SZ placeholders like "%NVM_HOME%" are then expanded against the freshly-loaded env, which is needed for installers that set their own helper vars (e.g. nvm-windows: NVM_HOME, NVM_SYMLINK). #> [CmdletBinding()] param() # Step 1: pull non-PATH env vars from machine, then user. foreach ($scope in 'Machine','User') { $vars = [System.Environment]::GetEnvironmentVariables($scope) foreach ($key in $vars.Keys) { if ([string]::Equals($key, 'PATH', [System.StringComparison]::OrdinalIgnoreCase)) { continue } [System.Environment]::SetEnvironmentVariable($key, [string]$vars[$key], 'Process') } } # Step 2: rebuild PATH as machine+user concatenation, then expand # placeholders against the env we just loaded. $machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') $userPath = [System.Environment]::GetEnvironmentVariable('PATH', 'User') $parts = @($machinePath, $userPath) | Where-Object { $_ } $combined = ($parts -join ';').TrimEnd(';') $env:PATH = [System.Environment]::ExpandEnvironmentVariables($combined) } function Set-WingetProgressBarDisabled { <# .SYNOPSIS Writes "visual.progressBar = disabled" into winget's user-scope settings.json so winget's own animated spinner stops filling captured logs with carriage-return frames. This is the only reliable suppression in CI - --disable-interactivity does not fully kill the progress UI in Actions' pwsh steps (see microsoft/winget-cli#3300). Idempotent. Preserves any other settings already in the file. #> [CmdletBinding()] param() if (-not $env:LOCALAPPDATA) { return } $dir = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState' if (-not (Test-Path -LiteralPath $dir)) { # winget creates this directory on first invocation. We need it # to exist so we can drop our settings file in advance. $null = New-Item -ItemType Directory -Path $dir -Force -ErrorAction SilentlyContinue if (-not (Test-Path -LiteralPath $dir)) { return } } $path = Join-Path $dir 'settings.json' $settings = $null if (Test-Path -LiteralPath $path) { try { $settings = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop } catch { $settings = $null } } if ($null -eq $settings) { $settings = [pscustomobject]@{ '$schema' = 'https://aka.ms/winget-settings.schema.json' } } if (-not $settings.PSObject.Properties['visual']) { $settings | Add-Member -NotePropertyName 'visual' -NotePropertyValue ([pscustomobject]@{}) -Force } $settings.visual | Add-Member -NotePropertyName 'progressBar' -NotePropertyValue 'disabled' -Force $json = $settings | ConvertTo-Json -Depth 10 Set-Content -LiteralPath $path -Value $json -Encoding UTF8 } function Get-WingetStatePath { <# .SYNOPSIS Returns the path to the script's local winget-state JSON file (under %LocalAppData%\Initialize-DeveloperMachine), or $null if $env:LOCALAPPDATA isn't available. The state file tracks packages we've installed but winget can't detect afterwards (e.g. Microsoft.DotNet.Framework.DeveloperPack_4 installs reference assemblies + targeting pack but doesn't register in a way `winget list -e` can match against). Without this tracking the script would reinstall those packages on every run, wasting minutes for the user and breaking idempotency in CI. #> [CmdletBinding()] param() if (-not $env:LOCALAPPDATA) { return $null } Join-Path $env:LOCALAPPDATA 'Initialize-DeveloperMachine\winget-state.json' } function Get-InstalledUndetectablePackages { <# .SYNOPSIS Returns the list of package IDs the script has previously installed but winget couldn't detect afterwards. Empty array when the state file is missing or unreadable. #> [CmdletBinding()] param() $path = Get-WingetStatePath if (-not $path -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { return @() } try { $j = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop if ($null -eq $j -or -not $j.PSObject.Properties['undetectable']) { return @() } @($j.undetectable | Where-Object { $_ }) } catch { @() } } function Add-InstalledUndetectablePackage { <# .SYNOPSIS Records that we installed $PackageId but winget can't detect it afterwards, so future runs skip it instead of reinstalling. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $PackageId ) $path = Get-WingetStatePath if (-not $path) { return } $existing = @(Get-InstalledUndetectablePackages) if ($PackageId -in $existing) { return } $dir = Split-Path -Parent $path [void][System.IO.Directory]::CreateDirectory($dir) $merged = @($existing) + $PackageId @{ undetectable = $merged } | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $path -Encoding UTF8 } function Install-WingetPackages { <# .SYNOPSIS Installs each winget package in $Packages, skipping anything winget already considers installed (either via its own package database or because a non-winget installer left a newer version on disk - the WINGET_E_PACKAGE_ALREADY_INSTALLED and WINGET_E_NO_APPLICABLE_INSTALLER exit codes are treated as success/skip rather than failure). Refreshes $env:PATH at the end via Reset-EnvPath so freshly installed binaries are visible to subsequent steps in the same run. Self-skips when winget isn't on PATH (e.g. before Initialize-Winget has finished bootstrapping it, or under -WhatIf when the bootstrap is itself a no-op). #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $Packages ) if ($Packages.Count -eq 0) { return } if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { Write-Status -Level Skip -Message ' [SKIP] winget is not on PATH; package installs would happen on a real run.' return } # See https://learn.microsoft.com/windows/package-manager/winget/returnCodes $alreadyPresent = @( 0x8A150061, # WINGET_E_NO_APPLICABLE_INSTALLER (newer/equal already present) 0x8A150064 # WINGET_E_PACKAGE_ALREADY_INSTALLED ) # In a CI / non-interactive shell, winget's own progress spinner spams # the captured log with carriage-return frames. Two layers of defense: # 1. --disable-interactivity stops the interactive prompts. # 2. settings.json visual.progressBar = disabled stops the progress # spinner that --disable-interactivity does not fully suppress # (microsoft/winget-cli#3300). $nonInteractive = ` $env:CI -or ` $env:GITHUB_ACTIONS -or ` $env:TF_BUILD -or ` [Console]::IsOutputRedirected $extraArgs = @() if ($nonInteractive) { $extraArgs += '--disable-interactivity' Set-WingetProgressBarDisabled } $undetectables = @(Get-InstalledUndetectablePackages) $listArgs = @('--accept-source-agreements') + $extraArgs # Warm up winget once. The very first winget call of a session # prints the source-agreement boilerplate ("The msstore source # requires that you view the following agreements...") even when # --accept-source-agreements is passed - the flag accepts the # agreement but doesn't suppress the informational text. Doing # one quiet call here so subsequent per-package list/install # output isn't preceded by a wall of agreement text. $null = & winget list --accept-source-agreements @extraArgs *>&1 2>$null foreach ($pkg in $Packages) { # Pre-install check: only the exit code matters. winget's # stdout for the "not installed" case is the boilerplate # 'No installed package found matching input criteria.', which # is redundant given we follow up with '[INFO] Installing X...' # whenever it's missing. Discard the output. $null = & winget list --id $pkg -e @listArgs 2>&1 $listRc = $LASTEXITCODE if ($listRc -eq 0) { Write-Status -Level Info -Message " [INFO] $pkg is already installed." continue } # Some packages (notably Microsoft.DotNet.Framework.DeveloperPack_4) # install fine but don't register in a way `winget list -e` can # match against. We tracked those after their first install in # the local state file - skip them here so we don't reinstall # on every run. if ($pkg -in $undetectables) { Write-Status -Level Info -Message " [INFO] $pkg is already installed (tracked locally; winget can't detect this package post-install)." continue } if ($PSCmdlet.ShouldProcess($pkg, 'winget install')) { Write-Status -Level Info -Message " [INFO] Installing $pkg..." # winget's per-install chatter ("Found X", "Downloading...", # "Successfully installed", legal boilerplate) is suppressed by # default and streamed indented under -Verbose. See # Format-ToolOutput in lib/Status.ps1. winget install --id $pkg -e ` --accept-package-agreements ` --accept-source-agreements ` --silent ` @extraArgs 2>&1 | Format-ToolOutput -SkipPattern 'licensed to you by its owner','Microsoft is not responsible' $rc = $LASTEXITCODE if ($rc -eq 0) { # Verify winget can actually see the install. If not, # remember this package locally so the next run doesn't # try to reinstall - and dump winget's response as # [DEBUG] so the user can see why winget didn't match # (typically "No installed package found matching # input criteria" even though the install succeeded). $verifyOutput = & winget list --id $pkg -e @listArgs 2>&1 $verifyRc = $LASTEXITCODE if ($verifyRc -ne 0) { $verifyRcHex = '0x{0:X8}' -f $verifyRc Write-Status -Level Warn -Message (" [WARN] {0} installed but winget can't detect it (exit code {1}); recording in local state to skip on next run." -f $pkg, $verifyRcHex) $verifyLines = @($verifyOutput | ForEach-Object { ([string]$_).TrimEnd() } | Where-Object { $_ }) foreach ($line in $verifyLines) { Write-Status -Level Debug -Message " [DEBUG] winget list --id $pkg -e: $line" } if ($verifyLines.Count -eq 0) { Write-Status -Level Debug -Message " [DEBUG] winget list --id $pkg -e produced no output." } # Investigation: did the installer register the # package under a different ID/Name than winget's # catalog uses, or does winget genuinely not track # it at all? Pull every installed package and grep # for tokens of the failing ID + a couple of known # OS-level uninstall registry keys. # # Wrapped in try/catch so a diagnostic-side bug # can never break the install path - the # local-state tracking below must run regardless. try { $tokens = @($pkg -split '\.' | Where-Object { $_.Length -ge 4 }) if ($tokens.Count -gt 0) { $pattern = ($tokens | ForEach-Object { [regex]::Escape($_) }) -join '|' Write-Status -Level Debug -Message " [DEBUG] Searching all installed winget packages for tokens: $pattern" $allInstalled = & winget list @listArgs 2>&1 $matches = @($allInstalled | ForEach-Object { ([string]$_).TrimEnd() } | Where-Object { $_ -and $_ -match $pattern }) if ($matches.Count -gt 0) { foreach ($m in $matches) { Write-Status -Level Debug -Message " [DEBUG] $m" } } else { Write-Status -Level Debug -Message ' [DEBUG] No matching lines in `winget list` output.' } Write-Status -Level Debug -Message " [DEBUG] Searching Windows uninstall registry for tokens: $pattern" $uninstallRoots = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' ) $hits = foreach ($root in $uninstallRoots) { if (Test-Path -Path $root) { Get-ChildItem -Path $root -ErrorAction SilentlyContinue | ForEach-Object { $p = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue # Set-StrictMode is on, so we can't # touch a property that doesn't exist. # Many uninstall keys have no DisplayName. $dn = if ($p -and $p.PSObject.Properties['DisplayName']) { [string]$p.DisplayName } else { '' } $dv = if ($p -and $p.PSObject.Properties['DisplayVersion']) { [string]$p.DisplayVersion } else { '' } if ($dn -and $dn -match $pattern) { [pscustomobject]@{ Hive = $root Key = $_.PSChildName Name = $dn Version = $dv } } } } } if ($hits) { foreach ($h in $hits) { Write-Status -Level Debug -Message (" [DEBUG] uninstall: {0} = {1} {2}" -f $h.Key, $h.Name, $h.Version) } } else { Write-Status -Level Debug -Message ' [DEBUG] No matching DisplayName under HKLM/HKCU Uninstall.' } } } catch { Write-Status -Level Debug -Message " [DEBUG] Diagnostic walk failed (non-fatal): $($_.Exception.Message)" } Add-InstalledUndetectablePackage -PackageId $pkg } Write-Status -Level Ok -Message " [INFO] $pkg installed." } elseif ($rc -in $alreadyPresent) { Write-Status -Level Info -Message (" [INFO] {0} already present (winget exit 0x{1:X8})." -f $pkg, $rc) } else { throw ("winget install {0} failed with exit code 0x{1:X8}." -f $pkg, $rc) } } } Reset-EnvPath } |