# Installs and upgrades the host tools the fleet's repositories expect, on native Windows, through winget. # Every tool in the contract has a winget package, so winget is the only source here. # The Linux script needs three, because an apt feed trails upstream on half its tools. # No version is written into this script, and winget is asked what each package carries now, so the script does not go stale between releases. # # Every step is idempotent. # A package is installed only where winget reports none, and upgraded only where the installed version differs from what the source carries. # Re-running repairs drift rather than assuming a clean host. # # Nothing here elevates, and no scope is passed unless the caller names one. # An installer that needs administrator raises its own prompt. # That is the path with the fewest failures. # This is because forcing user scope installs a second copy beside a machine wide one. # It is also because some installers fail outright when launched from an already elevated process. # CmdletBinding with an explicit position on the tool list is what keeps every other parameter named only. # Without it a stray word binds to the first parameter that takes a value, and a mistyped tool name is reported against -Scope instead. [CmdletBinding()] param( [Alias('r')][switch]$Report, [Alias('i')][switch]$Install, [Alias('u')][switch]$Upgrade, [switch]$Reinstall, [Alias('l')][switch]$List, [Alias('j')][switch]$Json, [Alias('n')][switch]$DryRun, [Alias('y')][switch]$Yes, [Alias('o')][switch]$Optional, [string]$Repo, [ValidateSet('user', 'machine')][string]$Scope, [Alias('h')][switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments)][string[]]$Name ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # A non-zero exit from winget is an answer here rather than a failure. # Setting this keeps a profile that turned it on from turning every read into a terminating error. $PSNativeCommandUseErrorActionPreference = $false # Returned by winget when nothing matches the query, which is an answer rather than a failure. $NOT_FOUND = -1978335212 # Every parameter is read into a variable here rather than from inside a function. # A script parameter reached only from a nested scope reads as declared and never used. # That is a finding on each one, and it hides a parameter that genuinely is unused. $ACTIONS = [ordered]@{ report = [bool]$Report install = [bool]$Install upgrade = [bool]$Upgrade reinstall = [bool]$Reinstall list = [bool]$List } $WANT_HELP = [bool]$Help # Filtered rather than wrapped, because wrapping an unset parameter yields a one element list holding nothing, which reads as one tool named the empty string. $WANT_TOOLS = @($Name | Where-Object { $_ }) $MODE = 'report' $DRY_RUN = [bool]$DryRun $JSON_OUTPUT = [bool]$Json $ASSUME_YES = [bool]$Yes $WITH_OPTIONAL = [bool]$Optional $WANT_SCOPE = $Scope $REPO = $Repo $ELEVATED = $false $SELECTED = @() $NOTES = @() $NOTE_TEXTS = @() $FAILED = @() $CHANGED = @() $EXPLICIT = $null # --- Output --- function log { param([string]$Message = '') Write-Host $Message } function info { param([string]$Message) Write-Host " $Message" } function step { param([string]$Message) Write-Host "`n==> $Message" } function warn { param([string]$Message) [Console]::Error.WriteLine("WARNING: $Message") } function die { param([string]$Message) [Console]::Error.WriteLine("ERROR: $Message"); exit 1 } function note { param([string]$Tool, [string]$Message) $script:NOTES += "${Tool}: $Message" $script:NOTE_TEXTS += $Message } function Get-NoteText { param([int]$First, [int]$Last) if ($Last -le $First) { return , @() } return , @($script:NOTE_TEXTS[$First..($Last - 1)]) } # A path with the home directory replaced by the variable that names it. # A report is written to be pasted into an issue or a pull request. # A path it prints therefore carries the account name into wherever it is pasted. # The comments here already avoid writing one for the same reason. # The variable is what a reader expands themselves, so the path stays as actionable as it was. function Hide-Home { param([string]$Path) if (-not $Path -or -not $HOME) { return $Path } # The match ends at a path component, so a sibling profile such as the same name with a suffix is shown as it is. $profileRoot = $HOME.TrimEnd('\', '/') if ($Path -ieq $profileRoot -or $Path.StartsWith("$profileRoot\", [StringComparison]::OrdinalIgnoreCase) -or $Path.StartsWith("$profileRoot/", [StringComparison]::OrdinalIgnoreCase)) { return '%USERPROFILE%' + $Path.Substring($profileRoot.Length) } return $Path } function usage { # The closing marker of a here-string has to sit at column 0, so this block is deliberately unindented. Write-Host @' Usage: install-tools.ps1 [options] [tool ...] Installs the host tools the fleet's repositories expect, from winget, which is the only source every one of them has. With no tool named, every managed tool is selected. Actions, name one, default -Report: -r, -Report Report installed and available versions, change nothing -i, -Install Install what is missing, leave an installed tool at its version -u, -Upgrade Install what is missing and upgrade what is behind -Reinstall Remove the installed copy, then install it again -l, -List List the managed tools and their winget package ids -h, -Help Show this help Options: -j, -Json Write the report as JSON rather than a table, for a program to read -n, -DryRun Print the commands instead of running them -y, -Yes Do not prompt before changing the host -o, -Optional Include the optional package set, where a tool has one -Scope Name a scope, either user or machine, for the copy to act on -Repo PATH Include winget packages declared by PATH\host-tools.json -Json writes one object carrying the same rows: "schema" (1), "platform" ("windows"), "tools", one entry per tool with "tool", "installed", "available", "source" (the winget package id), "mechanism" ("winget", how this script manages every tool), "status", "scope" and that tool's own "notes", and a top-level "notes" for what belongs to no tool. A version that was not read, or that did not resolve to one, is null. Run this without elevation. No scope is passed unless -Scope names one, so winget acts on the copy it finds and an installer that needs administrator asks for it itself. Naming a scope that disagrees with the installed copy would add a second copy beside the first rather than replacing it, so an upgrade refuses that case and names -Reinstall, which removes the old copy first. An install, upgrade or reinstall of docker also brings the WSL platform itself up to Docker Desktop's own floor where it is behind, and stops Docker Desktop first either way, since Docker Desktop holds the WSL service open and both a platform update and its own WSL integration otherwise need a restart to recover from an engine bump. wsl --update raises its own administrator prompt, which this refuses to start unattended where nothing could answer it, rather than hang. Examples: install-tools.ps1 Report on every tool install-tools.ps1 -Json Report on every tool, as JSON install-tools.ps1 -Install Install what is missing install-tools.ps1 -Upgrade -Yes Bring every tool current, no prompt install-tools.ps1 -Upgrade uv jq Bring two tools current install-tools.ps1 -Install -Optional dotnet install-tools.ps1 -Upgrade -DryRun Show what an upgrade would run install-tools.ps1 -Install -Repo ..\my-repo install-tools.ps1 -Reinstall jq -Scope machine '@ } # --- Host --- # Read the host identity, and refuse a host this script cannot install for. # A host that cannot run this script cannot be repaired by it, so each refusal prints the one command that fixes it. function Test-HostSupported { if ($PSVersionTable.PSVersion.Major -lt 7) { die "This script needs PowerShell 7 or later, and this is $($PSVersionTable.PSVersion). Install it with: winget install --id Microsoft.PowerShell --exact --source winget" } if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { die 'winget not found, and this script installs winget packages. Install App Installer from the Microsoft Store, then run this again.' } $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $script:ELEVATED = ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } # --- Execution --- # Run a command, or print it under -DryRun. # A read used to decide what to do runs either way, and only a command that changes the host goes through here. function run { param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) if ($script:DRY_RUN) { Write-Host " [dry run] $Command $($Arguments -join ' ')" return 0 } # The command's own output goes to the console rather than down the pipeline. # A native command writes to this function's output stream. # So without this the caller receives every line the command printed with the exit code appended. # A check against 0 then compares against the first line of output. & $Command @Arguments | Out-Host return $LASTEXITCODE } # Whether this process's own output reaches a console rather than a file or a pipe. # A function of its own so a test can drive the console branch of Invoke-Utf8Run from a harness whose output is always captured. function Test-OutputToConsole { return (-not [Console]::IsOutputRedirected) } # The run wrapper above, for a command that writes UTF-8 whatever the console's code page, as Docker's CLI does. # A console left at its OEM default decodes those bytes as that code page. # The check mark docker desktop prints on success therefore reads as three unrelated characters. # The console is switched to UTF-8 for this one call and put back after, rather than for the whole run. # Every other native tool here therefore keeps the decoding it was written against. # Output redirected to a file or a pipe is left alone. # PowerShell keeps writing it in the encoding it started with. # Decoding the command as UTF-8 there would therefore turn the check mark into one lossy byte, where it otherwise passes through intact. # A process with no console at all refuses the switch, and that is not a failure either, since nothing renders the bytes there. function Invoke-Utf8Run { param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) $previous = [Console]::OutputEncoding $switched = $false if (Test-OutputToConsole) { try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) $switched = $true } catch { $switched = $false } } try { return (run -Command $Command -Arguments $Arguments) } finally { if ($switched) { [Console]::OutputEncoding = $previous } } } function confirm { param([Parameter(Mandatory)][string]$Question) if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } # Both are checked because a scheduled task reports one and not the other, and either alone misses a case. if (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected) { die 'Not a terminal and -Yes was not given, refusing to change the host unattended' } return ((Read-Host "$Question [y/N]") -match '^(y|yes)$') } # --- winget --- # The version column of every row winget printed for one id. # The offsets come from the header rather than from a whitespace split. # This is because a package name carries spaces, and splitting on them moves the version into the name. # A row is kept only where the id column holds the id that was asked for. # That drops the trailing count line without a list of strings to ignore. # Every list returning function here returns with a leading comma, which is what stops PowerShell unrolling a one element array into the element. # Without it a single installed version arrives as a string. # Asking a string for its Count is an error under Set-StrictMode rather than the 1 the caller expects. function Read-WingetTable { param([string]$Text, [string]$Id) $rows = @() $lines = $Text -split "`r?`n" $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 if (-not $header) { return , $rows } $idColumn = $header.IndexOf('Id') $versionColumn = $header.IndexOf('Version') foreach ($line in $lines) { if ($line.Length -le $versionColumn) { continue } if (-not $line.Substring($idColumn).StartsWith($Id)) { continue } $rows += ($line.Substring($versionColumn) -split '\s+')[0] } return , $rows } # The id and version of every row winget printed for a source search, unfiltered by id. # Shares the header driven offsets with Read-WingetTable, but keeps the id column instead of assuming the caller already knows it. # Resolve-ToolPackage needs that from a family search. function Read-WingetSearchTable { param([string]$Text, [string]$Prefix) $rows = @() $lines = $Text -split "`r?`n" $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 if (-not $header) { return , $rows } $idColumn = $header.IndexOf('Id') $versionColumn = $header.IndexOf('Version') foreach ($line in $lines) { if ($line.Length -le $versionColumn) { continue } $id = ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() if (-not $id.StartsWith($Prefix)) { continue } $version = ($line.Substring($versionColumn) -split '\s+')[0] $rows += [PSCustomObject]@{ Id = $id; Version = $version } } return , $rows } # The installed versions of one package id, or an empty list where none is installed. # The exit code decides rather than the output text. # This is because a missing id and an unreadable source both print prose, and only the code tells them apart. # A null answer means the question could not be answered, which is not the same as none installed. function Get-WingetInstalled { param([Parameter(Mandatory)][string]$Id, [string]$InScope) $arguments = @('list', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity') if ($InScope) { $arguments += @('--scope', $InScope) } $text = (& winget @arguments 2>&1 | Out-String -Width 500) if ($LASTEXITCODE -eq $script:NOT_FOUND) { return , @() } if ($LASTEXITCODE -ne 0) { return $null } return , (Read-WingetTable -Text $text -Id $Id) } # Whether a Version column token is a version, rather than the Unknown winget prints for an entry recording none. # A bare < or > left by winget's range forms is likewise not a version. function Test-WingetVersion { param([string]$Token) return $Token -match '^\d' } # One version for a package winget lists more than once. # The result is nothing where the rows do not describe one product, or one of them carries no version. # Rows sharing a major version are side by side builds of one product, and the newest is the answer. # That is what a dotnet SDK line looks like. # Rows whose majors differ are two products sharing an id, and no single version compares there. # Two products sharing an id are what the legacy WSL installer looks like beside WSL itself. function Resolve-InstalledVersion { param([string[]]$Version) if (-not $Version -or $Version.Count -eq 0) { return $null } if (@($Version | Where-Object { -not (Test-WingetVersion $_) }).Count -gt 0) { return $null } if ($Version.Count -eq 1) { return $Version[0] } $majors = @($Version | ForEach-Object { ($_ -split '\.')[0] } | Sort-Object -Unique) if ($majors.Count -ne 1) { return $null } # Compared component by component rather than sorted, because Sort-Object orders a version as text and 10.0.9 then outranks 10.0.10. # Three side by side dotnet builds hid this, since 110, 204 and 302 are all three digits and sort the same either way. $newest = $Version[0] foreach ($candidate in $Version) { if ((Compare-HostVersion $candidate $newest) -gt 0) { $newest = $candidate } } return $newest } # What the source carries now, read without installing anything. # The show command prints one Version line even for an id the list command answers with several rows. # That is what makes it the reader for the target rather than a second list call. function Get-WingetAvailable { param([Parameter(Mandatory)][string]$Id) $text = (& winget show --id $Id --exact --source winget --disable-interactivity 2>&1 | Out-String -Width 500) if ($LASTEXITCODE -ne 0) { return $null } if ($text -match '(?m)^Version:\s+(\S+)\s*$') { if ($Matches[1] -eq 'Unknown') { return $null } return $Matches[1] } return $null } # Which scopes carry a copy, as a sorted list. # Two probes rather than one reading, because winget reports no scope column and a package installed in both scopes is the case worth catching. function Get-WingetScope { param([Parameter(Mandatory)][string]$Id) $found = @() foreach ($candidate in 'user', 'machine') { $rows = Get-WingetInstalled -Id $Id -InScope $candidate if ($null -ne $rows -and $rows.Count -gt 0) { $found += $candidate } } return , $found } # Whether winget wrote the uninstall entry for this package itself. # It does that for a portable or an archive package, and not for an exe or an msi. # This is the only positive evidence of provenance available, and its absence proves nothing. # For an exe or an msi, winget runs the vendor's own installer. # That entry is therefore identical whether winget invoked it or a person did. # Reported where present and silent where not, rather than being turned into a claim it cannot support. function Test-WingetOwnedEntry { param([Parameter(Mandatory)][string]$Id) $suffix = '_Microsoft.Winget.Source_8wekyb3d8bbwe' $roots = @( 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' ) foreach ($root in $roots) { if (Test-Path (Join-Path $root ($Id + $suffix))) { return $true } } return $false } # The package ids winget refuses to move as part of an upgrade of everything. # A package lands here because its manifest asks for it. # That is the author saying the application updates itself. # The version winget reports is then the one the installer first wrote rather than the one the application runs. # Read once and cached, since it costs a full upgrade query. function Get-ExplicitUpgrade { if ($null -ne $script:EXPLICIT) { return , $script:EXPLICIT } $script:EXPLICIT = @() $text = (& winget upgrade --include-unknown --disable-interactivity 2>&1 | Out-String -Width 500) if ($LASTEXITCODE -ne 0) { return $script:EXPLICIT } $marker = 'require explicit targeting for upgrade' $index = $text.IndexOf($marker) if ($index -lt 0) { return $script:EXPLICIT } $tail = $text.Substring($index) $lines = $tail -split "`r?`n" $header = $lines | Where-Object { $_ -match '^Name\s+Id\s+Version' } | Select-Object -First 1 if (-not $header) { return $script:EXPLICIT } $idColumn = $header.IndexOf('Id') $versionColumn = $header.IndexOf('Version') foreach ($line in $lines) { if ($line.Length -le $versionColumn) { continue } if ($line -match '^Name\s+Id\s+Version') { continue } $script:EXPLICIT += ($line.Substring($idColumn, $versionColumn - $idColumn)).Trim() } return , $script:EXPLICIT } # The package id winget's own catalog says produced the version this tool reports as installed. # `winget list --id X --exact` answers yes for a package sharing this tool's family even where X itself is not what is installed. # For example, a host running OpenJS.NodeJS, the Current channel, reads as OpenJS.NodeJS.LTS, because the two share a publisher and a name. # The version that command reports still comes straight from the installed entry, regardless of which id was asked about. # So the version is matched against every id in the family, read from a plain source search rather than an id scoped one. # That is what tells the ids apart without trusting the id column winget answered with at all. # A tool naming no family resolves to its own package unchanged and costs nothing extra. # A version matching none of the family's ids does the same. # This is because a family search that cannot confirm anything is no worse than not asking. # A version matching more than one id falls back the same way rather than picking the first. # Two ids share a version right at a channel handoff. # That is when a major that just left Current has not yet lost its old id from the search results. # Picking between them by list order would be a guess wearing the shape of an answer. function Resolve-ToolPackage { param([Parameter(Mandatory)][hashtable]$Tool, [string]$Installed) if (-not $Tool.Family -or -not $Installed) { return $Tool.Package } $text = (& winget search --query $Tool.Family --source winget --disable-interactivity 2>&1 | Out-String -Width 500) if ($LASTEXITCODE -ne 0) { return $Tool.Package } $found = @((Read-WingetSearchTable -Text $text -Prefix $Tool.Family) | Where-Object { $_.Version -eq $Installed }) if ($found.Count -eq 1) { return $found[0].Id } return $Tool.Package } # Install, upgrade and remove, named here so the flags appear once in this file. # No scope is passed unless the caller named one. # This is because winget then acts on the copy it found, and naming a different scope adds a copy rather than replacing one. function Invoke-WingetInstall { param([Parameter(Mandatory)][string]$Id) $arguments = @('install', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity', '--accept-source-agreements', '--accept-package-agreements', '--silent') if ($script:WANT_SCOPE) { $arguments += @('--scope', $script:WANT_SCOPE) } return (run 'winget' @arguments) } # An upgrade takes the named scope too, on the same rule as an install. # Reaching here with a scope that disagrees with the installed copy is already refused. # So the scope named here is one a copy sits in. # Passing it is what says which copy to move where a tool is installed in both. function Invoke-WingetUpgrade { param([Parameter(Mandatory)][string]$Id) $arguments = @('upgrade', '--id', $Id, '--exact', '--source', 'winget', '--disable-interactivity', '--accept-source-agreements', '--accept-package-agreements', '--silent', '--include-unknown') if ($script:WANT_SCOPE) { $arguments += @('--scope', $script:WANT_SCOPE) } return (run 'winget' @arguments) } # The scope to remove from is the one the copy was found in, which the caller passes. # It is not the one the caller asked to end up in. # Removing in the requested scope finds nothing where the copy sits in the other one. # So the old copy survives, and the install that follows adds a second beside it. # That is the state -Reinstall exists to clear. function Invoke-WingetRemove { param([Parameter(Mandatory)][string]$Id, [string]$InScope) $arguments = @('uninstall', '--id', $Id, '--exact', '--disable-interactivity', '--silent') if ($InScope) { $arguments += @('--scope', $InScope) } return (run -Command 'winget' -Arguments $arguments) } # Where App Installer keeps every installer log winget asks a package for, named .-.log. $WINGET_LOG_DIRECTORY = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir' # The entries of the installer log one failed winget call wrote, and of winget's own log for that call, that say why it failed. # Each is none where no such log or entry exists. # For a package with an installer, all winget reports is that the installer exited non-zero, with a code the installer chose. # So the reason lives in that log and nowhere a person reads by default. # A silent Inno Setup installer, Git for Windows among them, answers its own blocking dialog and logs the dialog's text. # That text is the whole reason. # So an entry keeps its indented continuation lines rather than being read one line at a time. # Capped at the last few matches so a verbose MSI log cannot flood the console. # The log's own path is printed beside them for the rest. # Whether files were in use is read from every entry before that cap. # An in-use entry is always among those shown, the first one put back where none survived the cap. # So a run of later error and abort lines cannot push the actual reason out of the report. # A portable or zip package has no installer, so winget's own WinGet-.log for the call holds its reason instead. # That log is the latest one started since the attempt that names the package id, and only its error lines are kept. # Only there does an access denial count as files in use, and only one naming a file, as winget reports failing to replace one. # In an installer log a denial can be a permission failure, which no running process causes. function Get-InstallerFailureReason { param([Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][datetime]$Since, [string]$Directory = $script:WINGET_LOG_DIRECTORY) $result = @{ Log = $null; Entries = @(); WingetLog = $null; WingetEntries = @(); InUse = $false } if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { return $result } $log = Get-ChildItem -LiteralPath $Directory -Filter "$Id.*.log" -File -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -ge $Since } | Sort-Object LastWriteTime | Select-Object -Last 1 if ($log) { $result.Log = $log.FullName $entries = [System.Collections.Generic.List[string]]::new() foreach ($line in (Get-Content -LiteralPath $log.FullName -ErrorAction SilentlyContinue)) { if (($line -eq '' -or $line -match '^\s') -and $entries.Count -gt 0) { $entries[$entries.Count - 1] = "$($entries[$entries.Count - 1]) $($line.Trim())".TrimEnd() } else { $entries.Add(($line -replace '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+\s+', '').Trim()) } } $selected = Select-FailureEntry -Entries $entries -Pattern '(?i)suppressed message box|\berror\b|\bfail|\babort|denied|in use|return value 3' $result.Entries = $selected.Entries $result.InUse = $selected.InUse } $idPattern = "(?i)(? ' } | ForEach-Object { ($_ -replace '^\S+ \S+ \[[^\]]*\]\s*', '').Trim() }) $selected = Select-FailureEntry -Entries $errors -Pattern '.' -InUsePattern '(?i)in use by|used by another process|access is denied\.?:\s*"' $result.WingetEntries = $selected.Entries $result.InUse = $result.InUse -or $selected.InUse } return $result } function Select-FailureEntry { param([string[]]$Entries, [Parameter(Mandatory)][string]$Pattern, [string]$InUsePattern = '(?i)process\(es\) use|in use by|used by another process') $matched = @($Entries | Where-Object { $_ -match $Pattern -or $_ -match $InUsePattern }) $inUse = @($matched | Where-Object { $_ -match $InUsePattern }) $shown = @($matched | Select-Object -Last 4) if ($inUse.Count -gt 0 -and -not ($shown | Where-Object { $_ -match $InUsePattern })) { $shown = @($inUse[0]) + @($matched | Select-Object -Last 3) } return @{ Entries = $shown; InUse = $inUse.Count -gt 0 } } # The directory one installed file's package owns, read as the child of the container it was installed into. # The result is nothing where the file sits in no such container. # A tool's executable can sit several levels down its own tree and in more than one place. # Git for Windows carries git.exe in both cmd and mingw64\bin. # So walking up from the executable a fixed number of levels lands on a different directory depending on which copy PATH resolved. # Anything outside these containers is left unread rather than guessed at. # On a machine wide Python install, py.exe sits in the Windows directory. # Reading that as Python's tree would name half the host. function Get-InstallRoot { param([Parameter(Mandatory)][string]$Path) $containers = @( $env:ProgramFiles ${env:ProgramFiles(x86)} "$env:ProgramFiles\WinGet\Packages" "$env:LOCALAPPDATA\Programs" "$env:LOCALAPPDATA\Programs\Python" "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" ) # Longest first. # A container nested inside another therefore answers before the one holding it. # Examples are WinGet's machine wide package directory inside Program Files, and the per-user Python directory every line installs beside its siblings in. # Otherwise the holding container would claim every package in it as one tree. foreach ($container in ($containers | Where-Object { $_ } | Sort-Object Length -Descending)) { $prefix = "$($container.TrimEnd('\'))\" if (-not $Path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { continue } $rest = $Path.Substring($prefix.Length).Split('\') # A file sitting directly in the container belongs to no directory of its own, so there is no tree to read. if ($rest.Count -lt 2) { return $null } return "$prefix$($rest[0])" } return $null } # Every running process whose executable sits inside the tree one tool installed, which is what an installer refusing over files in use is refusing over. # Matching the tool's own process name misses the helpers a tool ships and leaves running in the background. # An example is the GnuPG key daemon Git for Windows carries. # It outlives the git command that started it and blocks Git's own upgrade. # Executable names the file to read the tree from where the probe sits outside it, as py does for Python. # The launcher installs in a directory of its own, while what blocks an upgrade is the interpreter the line installed. # A process is matched by the probe's name only where no tree could be read. # This is because once a tree is read, a same-named process running from somewhere else holds nothing the installer replaces. # An example is py.exe beside an upgraded Python line. function Get-ToolHolder { param([Parameter(Mandatory)][string]$Probe, [string]$Executable) $source = $Executable if (-not $source) { $command = Get-Command $Probe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($command) { $source = $command.Source } } $item = if ($source) { Get-Item -LiteralPath $source -ErrorAction SilentlyContinue } if ($item -and $item.LinkTarget) { $resolved = $item.ResolveLinkTarget($true) if ($resolved) { $item = $resolved } } $root = if ($item) { Get-InstallRoot -Path $item.FullName } if ($root) { $holders = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith("$root\", [StringComparison]::OrdinalIgnoreCase) }) } else { $holders = @(Get-Process -Name $Probe -ErrorAction SilentlyContinue) } return , @($holders | Sort-Object Id -Unique) } # What a failed winget call owes its reader: # - winget's code # - the reason the installer's own log gives, and the one winget's own log gives # - the processes that may hold the tool's files # The hex form of the code is the one winget documents its codes under, where the signed decimal matches nothing a search finds. # The log entries pass through the same home folder masking as the log's path, since an installer's reason commonly names a file under the profile. # Windows will not replace a file that is open. # So the processes running from the tool's install are named only where something says files were in use. # Something says so in one of these ways: # - an installer log entry in Inno Setup's or Windows' own in-use wording # - an error in winget's own log for the call saying a file was in use or denied # - winget's code being one of its own package-in-use or file-in-use codes, a sharing violation, or an access denial # Anything else names nobody, for this reason. # A Git upgrade that failed on a download logs no such entry and still finds every open Git Bash running from Git's own tree. # Blaming those would send the reader after the wrong thing. function Write-InstallFailure { param([Parameter(Mandatory)][string]$ToolName, [Parameter(Mandatory)][string]$Package, [Parameter(Mandatory)][int]$Code, [Parameter(Mandatory)][datetime]$Since, [Parameter(Mandatory)][string]$Probe, [string]$Executable) warn "$ToolName failed on $Package, winget exited $Code ($('0x{0:X8}' -f $Code))" $reason = Get-InstallerFailureReason -Id $Package -Since $Since foreach ($source in @( @{ Heading = 'The installer log says:'; Entries = @($reason.Entries); Log = $reason.Log; Label = 'Full installer log' } @{ Heading = "winget's own log says:"; Entries = @($reason.WingetEntries); Log = $reason.WingetLog; Label = 'Full winget log' } )) { if ($source.Entries.Count -gt 0) { info $source.Heading foreach ($entry in $source.Entries) { $shown = if ($HOME) { $entry -ireplace [regex]::Escape($HOME), '%USERPROFILE%' } else { $entry } info " $shown" } } if ($source.Log) { info "$($source.Label): $(Hide-Home $source.Log)" } } $logInUse = [bool]$reason.InUse # 0x8A150101 and 0x8A150103 are winget's own INSTALL_PACKAGE_IN_USE and INSTALL_FILE_IN_USE, which it returns where a manifest maps the installer's exit code to either. # 0x80070020 and 0x80070005 are ERROR_SHARING_VIOLATION and ERROR_ACCESS_DENIED carried as an HRESULT. $codeInUse = ('0x{0:X8}' -f $Code) -in @('0x8A150101', '0x8A150103', '0x80070020', '0x80070005') if (-not ($logInUse -or $codeInUse)) { return } $running = Get-ToolHolder -Probe $Probe -Executable $Executable if ($running.Count -eq 0) { return } info "Running from $ToolName's install, and Windows cannot replace a file a running process holds:" foreach ($process in $running) { info " $($process.ProcessName) (process $($process.Id))" } info 'Close them, or stop them with the command below, then run this again:' info " Stop-Process -Id $($running.Id -join ', ')" } # --- Tools --- # The fleet's default SDK major, the one the hub's global.json baseline selects, and the winget package's major. # It is named here rather than read from the hub's global.json baseline, so the script still runs fetched on its own. # The hub's tests/test_install_tools.py fails while it differs from that baseline, so a move of that baseline edits both. $DOTNET_MAJOR = 10 # Managed tools, in the order a report lists them. # Every one is a single winget package, which is what makes this a table where the Linux script needs several functions per tool. # Probe names the executable that proves the tool is present when winget knows no package for it. # It is py for python rather than python3. # This is because py is registered by the interpreter's own installer, while python3 is created by Repair-PythonName below. # It is also because a probe that answers only because this script wrote it proves nothing about the package. # Family names the id prefix winget's catalog shares across every channel and pinned major a tool ships under. # It is empty everywhere but node. # Only node ships as more than one id (Current, LTS, and one per pinned major back to 4). # Any of those ids is fine on a host as long as its version clears Available. # Resolve-ToolPackage is what finds which one that is. $TOOLS = @( @{ Name = 'git'; Package = 'Git.Git'; Probe = 'git'; Optional = @(); Family = '' } @{ Name = 'gh'; Package = 'GitHub.cli'; Probe = 'gh'; Optional = @(); Family = '' } @{ Name = 'jq'; Package = 'jqlang.jq'; Probe = 'jq'; Optional = @(); Family = '' } @{ Name = 'python'; Package = 'Python.Python.3.13'; Probe = 'py'; Optional = @(); Family = '' } @{ Name = 'ripgrep'; Package = 'BurntSushi.ripgrep.MSVC'; Probe = 'rg'; Optional = @(); Family = '' } @{ Name = 'uv'; Package = 'astral-sh.uv'; Probe = 'uv'; Optional = @(); Family = '' } @{ Name = 'docker'; Package = 'Docker.DockerDesktop'; Probe = 'docker'; Optional = @(); Family = '' } @{ Name = 'node'; Package = 'OpenJS.NodeJS.LTS'; Probe = 'node'; Optional = @(); Family = 'OpenJS.NodeJS' } @{ Name = 'dotnet'; Package = "Microsoft.DotNet.SDK.$DOTNET_MAJOR"; Probe = 'dotnet'; Optional = @("Microsoft.DotNet.SDK.$($DOTNET_MAJOR + 1)", "Microsoft.DotNet.SDK.$($DOTNET_MAJOR - 1)", "Microsoft.DotNet.SDK.$($DOTNET_MAJOR - 2)"); Family = '' } ) function Get-Tool { param([Parameter(Mandatory)][string]$ToolName) return ($script:TOOLS | Where-Object { $_.Name -eq $ToolName } | Select-Object -First 1) } function Add-RepositoryToolCatalog { if (-not $script:REPO) { return } $declaration = Join-Path $script:REPO 'host-tools.json' if (-not (Test-Path -LiteralPath $declaration -PathType Leaf)) { die "$($script:REPO) carries no host-tools.json" } try { $overlay = Get-Content -LiteralPath $declaration -Raw | ConvertFrom-Json } catch { die "Cannot read $declaration as JSON: $_" } if (-not $overlay.PSObject.Properties['tools'] -or $null -eq $overlay.tools -or $overlay.tools -isnot [System.Array]) { die "$declaration carries no tools array" } $fleetNames = @($script:TOOLS | ForEach-Object { $_.Name }) $seen = @{} foreach ($entry in @($overlay.tools)) { if ($null -eq $entry -or -not $entry.PSObject.Properties['name'] -or $entry.name -isnot [string] -or -not $entry.name) { die "$declaration carries an entry without a non-empty tool name" } if (-not $entry.PSObject.Properties['install'] -or $null -eq $entry.install -or -not $entry.install.PSObject.Properties['windows']) { continue } $metadata = $entry.install.windows if ($null -eq $metadata) { continue } if ($metadata -isnot [PSCustomObject]) { die "$($entry.name) has unsupported or unsafe Windows install metadata" } $metadataKeys = @($metadata.PSObject.Properties.Name) if ($metadataKeys.Count -ne 2 -or $metadataKeys -notcontains 'manager' -or $metadataKeys -notcontains 'package' -or $metadata.manager -isnot [string] -or $metadata.package -isnot [string] -or $metadata.manager -ne 'winget' -or $metadata.package -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { die "$($entry.name) has unsupported or unsafe Windows install metadata" } if ($seen.ContainsKey($entry.name)) { die "$declaration declares repository tool $($entry.name) more than once" } $seen[$entry.name] = $true $record = @{ Name = $entry.name; Package = $metadata.package; Probe = $entry.name; Optional = @(); Family = '' } if ($fleetNames -contains $entry.name) { die "$($entry.name) is already managed by the fleet installer and repository metadata cannot replace it" } $script:TOOLS += $record } } # --- Status --- # A version as a comparable list of integers, with anything non numeric dropped. # Mirrors the gate's own comparison so a host reads the same either side of it. function Get-VersionKey { param([string]$Version) $parts = @() foreach ($part in ($Version -split '[._-]')) { if ($part -match '^\d+$') { $parts += [int]$part } else { break } } if ($parts.Count -eq 0) { return , @(0) } return , $parts } # Compare two versions, padding the shorter with zeros so more components alone does not read as newer. function Compare-HostVersion { param([string]$Left, [string]$Right) $a = Get-VersionKey $Left $b = Get-VersionKey $Right for ($i = 0; $i -lt [Math]::Max($a.Count, $b.Count); $i++) { $x = if ($i -lt $a.Count) { $a[$i] } else { 0 } $y = if ($i -lt $b.Count) { $b[$i] } else { 0 } if ($x -lt $y) { return -1 } if ($x -gt $y) { return 1 } } return 0 } # Everything a report and an apply both need about one tool, read once. function Get-ToolState { param([Parameter(Mandatory)][hashtable]$Tool) $rows = Get-WingetInstalled -Id $Tool.Package $state = @{ # The id acted on from here down. # Reading rows above already answered whether something from this tool's family is installed and at what version. # The id acted on may still change below, to the id that version actually belongs to, before anything here is reported or acted on. Package = $Tool.Package Installed = $null Rows = @() # Whether the installed state was read at all, kept apart from what it said. # Folding a failed read into an empty list would report a tool whose state is unknown as one that is not installed. # An install would then run against a host nobody measured. Readable = ($null -ne $rows) Available = (Get-WingetAvailable -Id $Tool.Package) Scope = @() Status = 'unknown' } if ($state.Readable) { $state.Rows = $rows $state.Installed = Resolve-InstalledVersion -Version $rows $state.Package = Resolve-ToolPackage -Tool $Tool -Installed $state.Installed if ($rows.Count -gt 0) { $state.Scope = Get-WingetScope -Id $state.Package } } $state.Status = Get-ToolStatus -Tool $Tool -State $state return $state } # One word for what a tool needs. # The three Windows meanings sit beside the five the Linux peer carries, and each names a different reason a version comparison would mislead. function Get-ToolStatus { param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) if (-not $State.Readable) { return 'unreadable' } if ($State.Rows.Count -eq 0) { # A tool answering on PATH that winget knows no package for is one winget cannot manage at all. if (Get-Command $Tool.Probe -ErrorAction SilentlyContinue) { return 'unmanaged' } if (-not $State.Available) { return 'unavailable' } return 'missing' } $unread = @($State.Rows | Where-Object { -not (Test-WingetVersion $_) }).Count -gt 0 if (-not $State.Installed -and -not $unread) { return 'multiple' } if (-not $State.Available) { return 'unknown' } if ((Get-ExplicitUpgrade) -contains $State.Package) { return 'self-updating' } if ($unread) { return 'unknown' } if ((Compare-HostVersion $State.Installed $State.Available) -ge 0) { return 'current' } return 'outdated' } # Per tool detail worth a line under the report, rather than a column of its own. function Add-ToolNote { param([Parameter(Mandatory)][hashtable]$Tool, [Parameter(Mandatory)][hashtable]$State) if ($Tool.Name -eq 'python') { # The note reads the alias's own target package rather than describing every alias as the placeholder. # This is because a report that promises an action the apply then refuses is worse than no report at all. # A Microsoft Store Python registers aliases under these same two names carrying the same tag, and Repair-PythonName deliberately leaves those alone. # So the two have to be told apart here as well. foreach ($name in $script:PYTHON_ALIAS_NAMES) { $stub = Join-Path $script:PYTHON_ALIAS_DIR $name if (-not (Test-AppExecutionAlias -Path $stub)) { continue } $package = Get-AppExecutionAliasPackage -Path $stub if (-not $package) { note 'python' "$name in the app execution alias directory is an app execution alias whose target package cannot be read, so an apply of python leaves it alone rather than removing it on a guess" } elseif ($package.StartsWith($script:PYTHON_ALIAS_PACKAGE, [StringComparison]::OrdinalIgnoreCase)) { note 'python' "$name in the app execution alias directory is the Microsoft Store placeholder, which only offers to open the Store and answers ahead of the installed interpreter wherever that directory sits earlier on PATH. Any apply of python removes it, whether -Install, -Upgrade or -Reinstall" } else { note 'python' "$name in the app execution alias directory belongs to $package rather than the Microsoft Store placeholder, so it reaches an interpreter somebody chose and no apply of python removes it. Turn it off under Settings, Apps, Advanced app settings, App execution aliases if the managed interpreter should answer instead" } } # The fleet contract uses python3 as the interpreter name on every platform, so what that name actually reaches here is the fact worth reporting. # A foreign python3 earlier on PATH, from MSYS2, Cygwin or Scoop, is the state nothing else surfaces. # It is a real interpreter and answers cleanly. # So it is the wrong one silently rather than loudly, and PATH order alone decides it. $line = Get-PythonLine -Version $State.Installed $interpreter = if ($line) { Get-PythonLineInterpreter -Line $line } else { $null } $shim = if ($interpreter) { Join-Path (Split-Path -Parent $interpreter) 'python3.exe' } else { $null } $resolved = Get-Python3OnPath if ($shim -and -not $resolved) { note 'python' 'python3 answers nothing on PATH, and -Install python creates it beside the managed interpreter' } elseif ($shim -and $resolved -ne $shim -and -not (Test-PythonVirtualEnv -Path $resolved)) { note 'python' "python3 resolves to $(Hide-Home $resolved) rather than to the managed $line interpreter at $(Hide-Home $shim), and PATH order alone decides that" } # Compared against the managed interpreter where one could be resolved, and against the weaker shape test otherwise. # The fallback matters because the registry lookup failing is exactly the state where a reader most needs to be told what python reaches. # It also matters because a check that goes quiet there would be silent on the worst host rather than on the best one. # Every read of the resolved command sits inside this guard rather than beside it. # This is because Set-StrictMode turns a property read on a host where python answers nothing into a terminating error. # That error would crash the report on exactly the host that most needs one. $python = @(Get-Command python -CommandType Application -ErrorAction SilentlyContinue) | Select-Object -First 1 if ($python) { $wrongPython = if ($interpreter) { $python.Source -ne $interpreter } else { $python.Source -notmatch 'Python\d' } if ($wrongPython -and -not (Test-PythonVirtualEnv -Path $python.Source)) { note 'python' "python resolves to $(Hide-Home $python.Source), which is not the interpreter winget installed" } } } if ($State.Scope.Count -gt 1) { note $Tool.Name 'installed in both scopes, so one copy shadows the other on PATH, and -Reinstall removes one' } if ($State.Rows.Count -gt 0 -and (Test-WingetOwnedEntry -Id $State.Package)) { note $Tool.Name 'winget wrote this uninstall entry, so winget installed it' } if ($State.Status -eq 'multiple') { note $Tool.Name "winget lists $($State.Rows -join ', ') under one id, and their major versions differ, so no single installed version compares" } if ($State.Status -eq 'unmanaged') { note $Tool.Name 'answers on PATH and winget knows no package for it, so winget cannot upgrade it and -Reinstall does not apply' } if ($State.Status -eq 'unreadable') { note $Tool.Name 'winget did not answer what is installed, so this row reports nothing rather than reporting it as absent' } if ($State.Status -eq 'self-updating') { note $Tool.Name 'updates itself, so the version winget reports is the one it was installed at rather than the one it runs' } if ($Tool.Name -eq 'dotnet' -and -not $script:WITH_OPTIONAL) { note 'dotnet' "optional set not selected: $($Tool.Optional -join ', ')" } if ($Tool.Name -eq 'dotnet') { $listed = Get-DotnetSdk $managed = @() if ($State.Installed) { $installedMajor = [int](($State.Installed -split '\.')[0]) $managed = @($Tool.Optional | Where-Object { $major = ($_ -split '\.')[-1] [int]$major -gt $installedMajor -and @($listed | Where-Object { ($_ -split '\.')[0] -eq $major }).Count -gt 0 -and @(Get-WingetInstalled -Id $_ | Where-Object { $_ }).Count -gt 0 } | ForEach-Object { ($_ -split '\.')[-1] }) } $sdk = @($listed | Where-Object { $managed -notcontains ($_ -split '\.')[0] }) $untracked = Get-UntrackedDotnetSdk -Installed $State.Installed -Sdk $sdk if ($untracked) { note 'dotnet' "dotnet --list-sdks holds $untracked, newer than the $($State.Installed) winget installed, so something other than $($State.Package), such as Visual Studio, put it there. dotnet runs it wherever no global.json pins another, and the status compares $($State.Package) alone" } } if ($Tool.Name -eq 'docker') { $wslProblem = Test-WslReadyForDocker if ($wslProblem) { note 'docker' $wslProblem } } } # --- dotnet --- function Read-DotnetSdkList { param([string[]]$Line) return , @($Line | ForEach-Object { ($_ -split ' ', 2)[0] } | Where-Object { $_ -match '^\d+\.\d+\.\d+' }) } # Every SDK dotnet itself can see. # This is because Visual Studio and other winget SDK packages land theirs in the same directory, where the row's own winget package never lists them. function Get-DotnetSdk { if (-not (Get-Command dotnet -CommandType Application -ErrorAction SilentlyContinue)) { return , @() } $lines = @(& dotnet --list-sdks) if ($LASTEXITCODE -ne 0) { return , @() } return Read-DotnetSdkList -Line $lines } # The newest SDK above the version winget installed, which is the one a caller runs wherever no global.json pins another. # A prerelease compares equal to its own release, so on an equal key the release wins. # Between two prereleases the later one wins, since dotnet lists in ascending order. function Get-UntrackedDotnetSdk { param([string]$Installed, [string[]]$Sdk) if (-not $Installed) { return $null } $newest = $null foreach ($version in $Sdk) { if ((Compare-HostVersion $version $Installed) -le 0) { continue } $order = if ($newest) { Compare-HostVersion $version $newest } else { 1 } if ($order -gt 0 -or ($order -eq 0 -and ($version -notmatch '-' -or $newest -match '-'))) { $newest = $version } } return $newest } # --- Python --- # The reparse tag every Windows app execution alias carries, IO_REPARSE_TAG_APPEXECLINK. # Matched as the bare hex value rather than by the label fsutil prints beside it, because that label is localized and the value is not. $APPEXECLINK_TAG = '0x8000001b' # The directory Windows keeps its app execution alias stubs in, which is on PATH by default. # Read from the environment rather than written out, because the expanded form names a real account and the prose gate rejects that. $PYTHON_ALIAS_DIR = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps' # Both names rather than python3 alone. # The name python.exe is stubbed the same way and lies identically the moment PATH order puts the alias directory ahead of a real interpreter. $PYTHON_ALIAS_NAMES = @('python.exe', 'python3.exe') # The one package whose python aliases this script will remove, which is the App Installer placeholder that offers to open the Microsoft Store. # The tag alone does not identify it. # A Microsoft Store Python installs aliases under these same two names carrying this same tag, and those reach a real interpreter the operator chose. # So removing one on the tag alone would delete a working setup rather than a placeholder. $PYTHON_ALIAS_PACKAGE = 'Microsoft.DesktopAppInstaller_' # Whether a file is one of Windows' app execution alias stubs rather than a real executable someone put there. # All the conditions below are required together, for these reasons: # - the deletion this gates cannot be undone # - the alias directory is one a person may legitimately have put a program in # The conditions are: # - the file exists # - it is empty # - it is a reparse point # - its reparse tag is the app execution alias one # Reading the tag at all is what fsutil is here for, and no other reader answers with it. # PowerShell surfaces the ReparsePoint attribute but leaves LinkType empty for this tag. # So the attribute alone cannot tell an alias stub from a symlink or a junction. # Deleting on the attribute alone would delete either. function Test-AppExecutionAlias { param([Parameter(Mandatory)][string]$Path) $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue if (-not $item -or $item.PSIsContainer -or $item.Length -ne 0) { return $false } if (-not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { return $false } # Checked before it is called, the same way Stop-DockerDesktop checks for the docker CLI. # An absent command is a PowerShell exception rather than a native exit code. # So calling it blind would crash a report rather than answer one. # Answering false where the tag cannot be read is the safe direction, since every caller only ever deletes on a true. if (-not (Get-Command fsutil.exe -ErrorAction SilentlyContinue)) { return $false } $text = (& fsutil.exe reparsepoint query $Path 2>&1 | Out-String) if ($LASTEXITCODE -ne 0) { return $false } return ($text -match [regex]::Escape($script:APPEXECLINK_TAG)) } # The package family an app execution alias points at, or nothing where that cannot be read. # Only fsutil exposes the reparse buffer at all, and it prints it as a hex dump. # So the bytes are decoded from the hex columns rather than from the ASCII rendering beside them. # That rendering is lossy for every byte outside printable ASCII. # The buffer is UTF-16 and holds several null separated strings. # The family is the one carrying an underscore and no path separator. # That was verified against the Windows Terminal, App Installer, Notepad and Get Help aliases on a fleet host. function Get-AppExecutionAliasPackage { param([Parameter(Mandatory)][string]$Path) if (-not (Get-Command fsutil.exe -ErrorAction SilentlyContinue)) { return $null } $text = (& fsutil.exe reparsepoint query $Path 2>&1 | Out-String) if ($LASTEXITCODE -ne 0) { return $null } $bytes = [System.Collections.Generic.List[byte]]::new() foreach ($line in ($text -split "`r?`n")) { if ($line -notmatch '^[0-9a-f]{4}:\s+(.*)$') { continue } # Capped at the sixteen bytes a dump line carries, so the ASCII column beside them cannot contribute a pair that happens to read as hex. $tokens = @(($Matches[1] -split '\s+') | Where-Object { $_ -match '^[0-9a-f]{2}$' } | Select-Object -First 16) foreach ($token in $tokens) { $bytes.Add([Convert]::ToByte($token, 16)) } } if ($bytes.Count -eq 0) { return $null } $decoded = [Text.Encoding]::Unicode.GetString($bytes.ToArray()) $family = @($decoded -split "`0" | Where-Object { $_ -match '_' -and $_ -notmatch '[\\/:]' }) if ($family.Count -gt 0) { return $family[0] } return $null } # The major.minor line a version names, which is the unit winget publishes Python under and the unit PEP 514 registers it under. # Empty where the version names none, which a caller checks rather than proceeding on a guess. function Get-PythonLine { param([string]$Version) if ($Version -match '^(\d+)\.(\d+)') { return "$($Matches[1]).$($Matches[2])" } return '' } # The registry roots PEP 514 interpreters register under, in the order a lookup should prefer them. # The 32 bit view is listed because a 32 bit interpreter on a 64 bit host registers there and nowhere else. # It is also listed because an ARM64 host reaches an emulated x64 install the same way. $PYTHON_REGISTRY_ROOTS = @( 'HKCU:\SOFTWARE\Python\PythonCore' 'HKLM:\SOFTWARE\Python\PythonCore' 'HKCU:\SOFTWARE\WOW6432Node\Python\PythonCore' 'HKLM:\SOFTWARE\WOW6432Node\Python\PythonCore' ) # Every registered key for one line, as root and key name, bare key first. # PEP 514 lets a line qualify its tag by architecture. # So 3.13 is joined by 3.13-32 on a 32 bit install and 3.13-arm64 on an ARM64 one. # A lookup that matches only the bare tag finds nothing on either host while the interpreter is plainly installed. # The bare tag sorts first so a host carrying both is answered with its native build rather than whichever key enumerated first. # The bare tags and the qualified ones are collected into separate lists and joined at the end. # They are not collected into one list a bare tag is pushed onto the front of. # Pushing onto the front looks like it expresses the same preference, and it does not. # Each root's bare tag lands ahead of the one before it. # So the last root searched is answered first. # The root order above is therefore inverted for exactly the hosts that carry a line in both. function Get-PythonLineTag { param([Parameter(Mandatory)][string]$Line) $bare = @() $qualified = @() foreach ($root in $script:PYTHON_REGISTRY_ROOTS) { foreach ($key in (Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue)) { if ($key.PSChildName -eq $Line) { $bare += @{ Root = $root; Name = $key.PSChildName } } elseif ($key.PSChildName -like "$Line-*") { $qualified += @{ Root = $root; Name = $key.PSChildName } } } } return , ($bare + $qualified) } # The interpreter one Python line installed, read from that line's own PEP 514 registry key. # That key is what the python.org installer writes and what the py launcher itself reads. # So it answers for the line asked about rather than for whatever PATH resolves. # That is the whole difference this function exists for. # It also answers on a host whose PATH has not been refreshed since the install. # HKCU then HKLM, matching the two scopes winget installs into. # The second is not a fallback for a missing line. # A line installed machine wide is registered in that root and in no other. function Get-PythonLineInterpreter { param([Parameter(Mandatory)][string]$Line) foreach ($tag in (Get-PythonLineTag -Line $Line)) { $properties = Get-ItemProperty -LiteralPath "$($tag.Root)\$($tag.Name)\InstallPath" -ErrorAction SilentlyContinue if (-not $properties) { continue } # ExecutablePath is preferred over the key's default value. # This is because PEP 514 lets a line name an executable that is not the default value's own python.exe. # Reading the directory would then name a file that line does not run. $default = [string]$properties.'(default)' $candidates = @($properties.ExecutablePath) if ($default) { $candidates += (Join-Path $default 'python.exe') } foreach ($candidate in $candidates) { if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { return $candidate } } } return $null } # Every Python line PEP 514 says is installed here, as line to interpreter. # That is more than the one line this script manages. # This is because each winget id installs its own copy, and uninstalling one does not touch another. # So lines accumulate on a host that has ever moved between them. function Get-PythonLineMap { $map = [ordered]@{} foreach ($root in $script:PYTHON_REGISTRY_ROOTS) { foreach ($key in (Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue)) { # Keyed on the bare line rather than the architecture qualified tag. # Two lines that mean the same thing to a caller comparing against the managed one are then reported as one. # An example is a host carrying 3.13 and 3.13-32. $line = ($key.PSChildName -split '-')[0] if ($map.Contains($line)) { continue } $interpreter = Get-PythonLineInterpreter -Line $line if ($interpreter) { $map[$line] = $interpreter } } } return $map } # Where python3 answers from right now, or nothing where it does not answer at all. # Filtered to an application because Get-Command alone also answers with a function or an alias defined in this session. # Neither of those is what a command run from another shell reaches. function Get-Python3OnPath { $found = @(Get-Command 'python3' -CommandType Application -ErrorAction SilentlyContinue) | Select-Object -First 1 if ($found) { return $found.Source } return $null } # Whether an interpreter is a virtual environment's rather than a host one. # A report distinguishes the two because an activated environment legitimately puts its own interpreter first. # That is right for running project code and is not drift worth naming. # Every other interpreter answering ahead of the managed one, by contrast, is drift worth naming. # The marker is the environment's own pyvenv.cfg, which sits one directory above the Scripts directory the interpreter is in. function Test-PythonVirtualEnv { param([string]$Path) if (-not $Path) { return $false } $root = Split-Path -Parent (Split-Path -Parent $Path) if (-not $root) { return $false } return (Test-Path -LiteralPath (Join-Path $root 'pyvenv.cfg') -PathType Leaf) } # On Windows the name python3 lies twice over, and this is the one place both lies are fixed. # Windows ships app execution alias stubs called python.exe and python3.exe that only ever offer to open the Microsoft Store. # They fail loudly rather than quietly, writing to stderr and exiting 9009 as measured on a fleet host. # So a chained command does stop on one, and the harm is not a false pass. # What they do is occupy the name. # The alias directory is on PATH by default. # So wherever it sits ahead of the install directory, the stub answers and the installed interpreter cannot be reached under that name at all. # Removing the stub file is the only mechanism here, and it frees the name immediately, which is verified rather than assumed. # It is not the same act as the Settings toggle, though, and the difference is measured. # The toggle keeps reading On afterwards. # This is because whatever store it reads is not this file, and no documented interface writes that store. # So the alias can return when App Installer is next serviced. # So this removal runs again on the next apply rather than assuming a host stays fixed. # Turning the toggle off in Settings is what stops the alias coming back at all. # The package's own copy under the App Installer subdirectory is deliberately untouched. # This is because winget.exe and the package manager server sit beside it, and removing one would break winget itself. # And the interpreter winget installs registers python.exe but no python3.exe of its own. # So removing the stub alone would leave the name simply absent on a host that does have Python. # Both halves therefore run together. # The stub goes, and a real python3.exe is put beside the interpreter this script manages. # That is what makes python3 the one interpreter name the fleet uses on every platform, rather than a name that means something different here. # Nothing below names a version. # The line comes from whatever winget reports installed for the managed id. # So this keeps working unchanged when that id moves to a later line. function Repair-PythonName { param([string]$Installed) # The replacement is resolved before anything is removed, and a failure to resolve one ends the run here with both aliases still in place. # The other order frees the name and then discovers it has nothing to put there. # That is worse than either half alone. # In the other order, an unmanaged or unreadable python leaves a host with: # - the Store alias deleted # - no python3 created # - not even the Store offer it started with $line = Get-PythonLine -Version $Installed if (-not $line) { note 'python' 'winget reports no version installed for the managed package, so there is no interpreter here for python3 to sit beside, and the app execution aliases are left alone rather than removed with nothing to replace them' return } $interpreter = Get-PythonLineInterpreter -Line $line if (-not $interpreter) { note 'python' "winget reports $line installed and no interpreter is registered under that line, so python3 was not created and the app execution aliases are left alone. Run this again once the installer has finished writing its registry entries" return } foreach ($name in $script:PYTHON_ALIAS_NAMES) { $stub = Join-Path $script:PYTHON_ALIAS_DIR $name if (-not (Test-AppExecutionAlias -Path $stub)) { continue } # Which package the alias points at decides whether it goes, because the tag alone cannot tell a placeholder from a working setup. # An unreadable family is left alone on the same rule. # This is because not knowing what an alias reaches is a reason to keep it rather than a reason to delete it. $package = Get-AppExecutionAliasPackage -Path $stub if (-not $package) { warn "$name is an app execution alias whose target package could not be read, so it is left alone rather than removed on a guess" continue } if (-not $package.StartsWith($script:PYTHON_ALIAS_PACKAGE, [StringComparison]::OrdinalIgnoreCase)) { warn "$name is an app execution alias belonging to $package rather than the Microsoft Store placeholder, so it reaches an interpreter somebody chose and is left alone. Turn it off under Settings, Apps, Advanced app settings, App execution aliases if the managed interpreter should answer instead" continue } info "Removing the $name app execution alias stub from $(Hide-Home $script:PYTHON_ALIAS_DIR)" if ($script:DRY_RUN) { Write-Host " [dry run] Remove-Item $(Hide-Home $stub)" continue } Remove-Item -LiteralPath $stub -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $stub) { warn "could not remove the $name app execution alias stub, so it still answers ahead of a real interpreter. Turn it off by hand under Settings, Apps, Advanced app settings, App execution aliases" } else { $script:CHANGED += "python removed the $name app execution alias stub" } } # Beside the interpreter rather than anywhere else on PATH, and a copy rather than a launcher script. # The containing directory is what CPython resolves its own prefix from. # So a copy placed there runs as that interpreter, and a copy placed anywhere else does not run at all. # A .cmd or .bat shim would be worse than useless. # Git Bash appends .exe and nothing else when it resolves a bare name. # So a shim in either of those forms is invisible to exactly the shell the reported failure came from. $shim = Join-Path (Split-Path -Parent $interpreter) 'python3.exe' $source = Get-Item -LiteralPath $interpreter -ErrorAction SilentlyContinue $existing = Get-Item -LiteralPath $shim -ErrorAction SilentlyContinue if ($existing -and $source -and $existing.Length -eq $source.Length -and $existing.LastWriteTime -eq $source.LastWriteTime) { info "python3 already sits beside the $line interpreter at $(Hide-Home $shim)" } elseif ($script:DRY_RUN) { info "Creating python3 beside the $line interpreter" Write-Host " [dry run] Copy-Item $(Hide-Home $interpreter) $(Hide-Home $shim)" } else { info "Creating python3 beside the $line interpreter at $(Hide-Home $shim)" # Two different failures land here and the remedy differs, so the message names both rather than guessing between them. # A machine wide install sits under a directory this unelevated run cannot write, and the rest of this script deliberately stays unelevated. # And Windows will not replace an executable that is open. # An editor or a language server still holding the old shim is what that looks like. # So the process is named where one is running, the same way a failed winget install names it. try { Copy-Item -LiteralPath $interpreter -Destination $shim -Force -ErrorAction Stop $script:CHANGED += "python created python3 beside the $line interpreter" } catch { $holding = @(Get-Process -Name 'python3' -ErrorAction SilentlyContinue) $remedy = if ($holding.Count -gt 0) { "python3 is running as process $($holding.Id -join ', '), and Windows cannot replace a running executable, so close whatever is running it and run this again" } else { 'copy python.exe to python3.exe there by hand from a prompt that can write that directory, or reinstall python user wide' } # Warned and failed rather than noted, because this is the half of the contract that did not hold. # Python is installed and python3 still does not reach it. # A note would be the wrong instrument twice over, since it reads as an aside. # It is also the wrong instrument since, on an apply, it is printed under a heading a reader scanning for failures does not look at. warn "python could not write python3 beside the $line interpreter at $(Hide-Home $shim): $($_.Exception.Message). $remedy" $script:FAILED += 'python' return } } # A line this script does not manage can carry a python3 of its own and win on PATH. # So it is reported and left alone. # This is because this run did not necessarily create it, and removing another line's interpreter is not this script's to do. foreach ($entry in (Get-PythonLineMap).GetEnumerator()) { if ($entry.Key -eq $line) { continue } $stale = Join-Path (Split-Path -Parent $entry.Value) 'python3.exe' if (Test-Path -LiteralPath $stale -PathType Leaf) { note 'python' "$($entry.Key) carries a python3 of its own at $(Hide-Home $stale), and PATH order alone decides which one answers; remove it by hand if that line is no longer wanted" } } } # --- WSL --- # Docker Desktop's own documented floor for the WSL platform, per docs.docker.com/desktop/features/wsl. $DOCKER_WSL_FLOOR = '2.1.5' # Every wsl.exe read goes through here, because wsl.exe emits UTF-16 by default and its output then reads as NUL separated characters. # A wsl.exe call that changes the host instead of reading it goes through Invoke-WslRun below. # That function applies the same guard to a live streamed run rather than a captured one. # Mirrored from upgrade-host.ps1 rather than shared with it, on the same rule as the rest of this directory. # A script here has to stay independently fetchable. function Invoke-Wsl { param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) $previous = $env:WSL_UTF8 try { $env:WSL_UTF8 = '1' $text = (& wsl.exe @Arguments 2>&1 | Out-String -Width 500) if ($text.Contains([char]0)) { $text = $text -replace "`0", '' } return $text } finally { if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } else { $env:WSL_UTF8 = $previous } } } # The mutation-side counterpart to Invoke-Wsl. # It runs a wsl.exe command that changes the host through the same run wrapper every other mutating command in this script uses. # The same WSL_UTF8 guard is applied around it. # So its live streamed output does not read to a person as NUL separated characters either. function Invoke-WslRun { param([Parameter(ValueFromRemainingArguments)][string[]]$Arguments) $previous = $env:WSL_UTF8 try { $env:WSL_UTF8 = '1' return (run 'wsl.exe' @Arguments) } finally { if ($null -eq $previous) { Remove-Item Env:\WSL_UTF8 -ErrorAction SilentlyContinue } else { $env:WSL_UTF8 = $previous } } } # Just the "WSL version" line, as a value Compare-HostVersion can read. # Get-WslVersion in upgrade-host.ps1 concatenates WSL, kernel and WSLg into one display string instead, which serves a report rather than a comparison. function Get-WslPlatformVersion { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $null } $text = Invoke-Wsl '--version' if ($LASTEXITCODE -ne 0) { return $null } if ($text -match '(?m)^WSL version:\s*(\S+)\s*$') { return $Matches[1] } return $null } # What stands between this host and installing or upgrading docker, or $null where nothing does. # A pure report either way, used as-is for -Report's note and as Enter-DockerMaintenance's own pre-check below. # Enter-DockerMaintenance is what actually drives the WSL platform update this used to only name. # Standing up WSL from nothing with wsl --install stays a person's own step regardless. # This is because that is a different action on a different subject from bringing an existing platform current. # It is the same reasoning that keeps distribution installs in setup-wsl.ps1 rather than folded in here. function Test-WslReadyForDocker { if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return "wsl.exe was not found, and Docker Desktop needs WSL2. Install it with: wsl --install --no-distribution" } $version = Get-WslPlatformVersion if (-not $version) { return "the WSL version could not be read, and Docker Desktop needs WSL $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" } if ((Compare-HostVersion $version $script:DOCKER_WSL_FLOOR) -lt 0) { return "WSL is at $version, and Docker Desktop needs $($script:DOCKER_WSL_FLOOR) or later. Update it with: host-setup\windows\upgrade-host.ps1 -Wsl" } return $null } # --- Docker --- # The processes that mean Docker Desktop is up when its own CLI cannot answer. # They are mirrored from upgrade-host.ps1's Get-DockerProcess rather than shared with it, on the same rule as the rest of this directory. # A script here has to stay independently fetchable. $DOCKER_PROCESSES = @('Docker Desktop', 'com.docker.backend', 'com.docker.build') # Whether Docker Desktop answers as running right now, preferring its own CLI over a process probe. # This is because the CLI is what agrees with what the tray icon shows. # It is also because a vmmem process surviving a crash is not the same as the backend actually being up. # Falls back to the process probe where the CLI itself cannot answer (missing from PATH, or the status call failing). # Neither case is read as "not running" outright. # That reading is what let Enter-DockerMaintenance run wsl --update against a Docker Desktop this function wrongly reported as already down. function Test-DockerDesktopRunning { if (Get-Command docker -ErrorAction SilentlyContinue) { $text = (& docker desktop status --format json 2>&1 | Out-String) if ($LASTEXITCODE -eq 0) { # Parsed rather than matched, the same way setup-wsl.ps1 reads Docker's own settings file. # The regex is kept as a fallback for a CLI version that ever answers non-JSON, rather than reading that as "not running". try { return ((ConvertFrom-Json $text -ErrorAction Stop).Status -eq 'running') } catch { return ($text -match '"Status"\s*:\s*"running"') } } } return [bool](Get-Process -Name $script:DOCKER_PROCESSES -ErrorAction SilentlyContinue) } function Stop-DockerDesktop { # Test-DockerDesktopRunning can answer true through the process probe alone, with the docker CLI missing or off PATH. # The & docker call there is a command-not-found PowerShell throws rather than a native exit code this can warn on. # So this checks first rather than let that surface as a crash. if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { warn 'docker desktop stop needs the docker CLI, which is not on PATH; stop Docker Desktop by hand from its tray icon' return 1 } info 'Stopping Docker Desktop' $code = Invoke-Utf8Run 'docker' @('desktop', 'stop', '--timeout', '90') if ($code -ne 0) { warn "docker desktop stop exited $code" } return $code } function Start-DockerDesktop { if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { warn 'docker desktop start needs the docker CLI, which is not on PATH; start Docker Desktop by hand and check WSL integration per distro' return 1 } info 'Starting Docker Desktop' $code = Invoke-Utf8Run 'docker' @('desktop', 'start', '--timeout', '180') if ($code -ne 0) { warn 'docker desktop start did not exit cleanly, start Docker Desktop by hand and check WSL integration per distro' } return $code } # Everything a docker install, upgrade or reinstall needs from Docker Desktop and WSL before winget touches the package is folded into one window. # It is not run twice. # Docker Desktop holds the WSL service open, so a platform update fails part way while it is running. # That is the same reason upgrade-host.ps1 -Wsl refuses outright. # Docker Desktop's own per distro WSL integration also goes stale across an engine bump. # It does so often enough that it has a name on Docker's own tracker. # It surfaces as "WSL integration with distro '' unexpectedly stopped" the next time anything in that distro touches docker. # The in app "Restart the WSL integration" button on that dialog does not clear it. # This is because it retries the proxy inside the distro that is already running against the same stale state. # Both are fixed the same way where Docker Desktop is running to begin with: # - Docker Desktop is stopped # - WSL is brought current # - every distro is shut down with it, so each one's integration remounts fresh rather than being patched in place # - Docker Desktop is started again once the docker package itself is also settled, by Exit-DockerMaintenance below # A run that finds Docker Desktop already stopped has nothing to restore, and none of that runs. # Boundary note: this script used to be read-only on WSL by design, naming upgrade-host.ps1 -Wsl as a person's own step rather than running it. # The maintainer moved that boundary once a docker version bump on its own already needed this same stop, then restart, window. # That window was what Docker's own integration needed to recover. # The maintainer moved the boundary because a platform update asks for nothing more than that same window with Docker Desktop already stopped inside it. # Returns whether docker is clear to proceed, and whether this stopped Docker Desktop, which is what Exit-DockerMaintenance needs to know whether it owes a restart. function Enter-DockerMaintenance { param([Parameter(Mandatory)][bool]$WasRunning, [string]$WslProblem) $result = @{ Proceed = $true; Stopped = $false } if (-not $WasRunning -and -not $WslProblem) { return $result } # Test-WslReadyForDocker's own string carries a remedy clause for a caller that will not fix WSL itself, "Update it with: host-setup\windows\upgrade-host.ps1 -Wsl". # That clause reads as misleading and duplicative embedded in a prompt for a run about to do exactly that. $wslReason = if ($WslProblem) { $WslProblem -replace '\s*Update it with:.*$', '' } else { $WslProblem } $question = if ($WslProblem -and $WasRunning) { "docker needs WSL updated first ($wslReason). Stop Docker Desktop, update WSL, and restart Docker Desktop to continue?" } elseif ($WslProblem) { "docker needs WSL updated first ($wslReason). Update WSL to continue?" } else { "docker is about to be upgraded or reinstalled, and Docker Desktop's own WSL integration commonly goes stale across a change like that. Stop Docker Desktop first, and restart it after, to avoid that?" } if (-not (confirm $question)) { if ($WslProblem) { $result.Proceed = $false; return $result } note 'docker' 'WSL integration may report a stale per-distro error until Docker Desktop and WSL are both restarted (docker desktop stop; wsl --shutdown; docker desktop start); declined the automatic cycle' return $result } # Gated on whether Docker Desktop is still observed running after the attempt, not on Stop-DockerDesktop's own exit code. # A non-zero exit that still left Docker Desktop stopped is not a reason to refuse the run. # It is, just as importantly, not a reason to leave Stopped unset. # Leaving it unset would tell Exit-DockerMaintenance below there is nothing to restart, and strand the host stopped when it started running. # Skipped under -DryRun, where nothing actually stopped and Docker Desktop is still genuinely running. # The check would otherwise read every dry run on a live host as this exact failure rather than as a preview. if ($WasRunning) { Stop-DockerDesktop | Out-Null if (-not $script:DRY_RUN -and (Test-DockerDesktopRunning)) { warn 'docker skipped, Docker Desktop did not stop, and a WSL platform update fails part way while it is running' $result.Proceed = $false return $result } $result.Stopped = $true } if ($WslProblem) { # Installing through wsl --update's own MSI raises a UAC prompt when this pwsh is not itself elevated. # Not being elevated is the state the rest of this script deliberately stays in. # Running elevated trades this one prompt for winget installers elsewhere that fail pre-elevated instead. # Nothing can answer that prompt without a person at the console. # So a run with neither elevation nor a person would hang rather than fail. # That is what -Yes is supposed to rule out, not walk into unattended. if (-not $script:ELEVATED -and (-not [Environment]::UserInteractive -or [Console]::IsInputRedirected)) { # Built rather than a fixed string. # This is because whether this refusal itself restarts Docker Desktop depends on whether $result.Stopped is set. # It is also because a run that found Docker Desktop already stopped restarts nothing for the remedy below to run into. $restartNote = if ($result.Stopped) { ' This refusal also restarts Docker Desktop, to leave the host as it found it, so it may already be running again by the time this is read.' } else { '' } warn "wsl --update needs administrator and raises its own prompt, which nothing can answer unattended; it would hang rather than fail, so this refuses to start it. Run interactively once so the prompt has someone to answer, or quit Docker Desktop yourself and run host-setup\windows\upgrade-host.ps1 -Wsl, which refuses on its own while Docker Desktop is running.$restartNote" if ($result.Stopped) { Start-DockerDesktop | Out-Null; $result.Stopped = $false } $result.Proceed = $false return $result } info 'Updating the WSL platform' info 'This restarts every distribution, so anything running inside one is stopped' $updateCode = Invoke-WslRun '--update' if ($updateCode -ne 0) { warn "wsl --update exited $updateCode" } # Skipped under -DryRun on the same rule as the stop check above. # The command wsl --update never ran, so WSL is still genuinely behind. # Re-checking it here would read every dry run as this same failure rather than as a preview. $stillBroken = if ($script:DRY_RUN) { $null } else { Test-WslReadyForDocker } if ($stillBroken) { warn "docker skipped, still $stillBroken after the update" if ($result.Stopped) { Start-DockerDesktop | Out-Null; $result.Stopped = $false } $result.Proceed = $false return $result } } return $result } # The other half of Enter-DockerMaintenance, run once the docker package itself is settled. # It shuts every WSL distro down so each one's integration remounts fresh, then starts Docker Desktop again. # A no-op where Enter-DockerMaintenance never stopped anything. # A run that changed nothing about Docker Desktop therefore does not restart it for no reason. # A run that was declined does not either. function Exit-DockerMaintenance { param([Parameter(Mandatory)][bool]$Stopped) if (-not $Stopped) { return } info 'Shutting down WSL' $shutdownCode = Invoke-WslRun '--shutdown' if ($shutdownCode -ne 0) { warn "wsl --shutdown exited $shutdownCode" } Start-DockerDesktop | Out-Null } # --- Actions --- function Show-List { $format = '{0,-10} {1,-24} {2}' log ($format -f 'TOOL', 'PACKAGE', 'OPTIONAL') foreach ($tool in $script:SELECTED) { $record = Get-Tool $tool $optional = if ($record.Optional.Count -gt 0) { $record.Optional -join ', ' } else { '-' } log ($format -f $record.Name, $record.Package, $optional) } } # The expanded length of the machine and user Path together, which is the length Explorer's rebuild of its environment measures. # Reading each scope from the registry rather than $env:Path matters, because this process may have inherited a Path that was already rebuilt. function Get-HostPathLength { $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') $user = [Environment]::GetEnvironmentVariable('Path', 'User') return "$machine;$user".Length } # The warning for a Path this long, or nothing below the margin. # Past about 4095 characters Explorer drops the user half of Path and reports no error. # So the warning has to come from the install that grows it, because nothing else on the host says so. function Get-PathLengthWarning { param([int]$Length) $margin = 3700 if ($Length -le $margin) { return $null } return "the machine and user Path together expand to $Length characters, above the warning margin of $margin. Past about 4095, Explorer silently drops the user half of Path from everything it launches, so an editor stops finding per-user tools while a new terminal tab still finds them. Remove entries, since shortening one with a variable leaves its expanded length the same, then sign out and back in." } function Add-PathLengthNote { $warning = Get-PathLengthWarning -Length (Get-HostPathLength) if ($warning) { note 'path' $warning } } function Show-Report { $format = '{0,-10} {1,-16} {2,-16} {3,-24} {4,-13} {5}' if (-not $script:JSON_OUTPUT) { log ($format -f 'TOOL', 'INSTALLED', 'AVAILABLE', 'SOURCE', 'SCOPE', 'STATUS') } $rows = @() foreach ($tool in $script:SELECTED) { $first = $script:NOTE_TEXTS.Count $record = Get-Tool $tool $state = Get-ToolState -Tool $record # Every row is printed only where they did not resolve to one version. # This is because a dotnet line carrying three side by side builds resolves cleanly, and listing all three would overflow the column for nothing. if (-not $script:JSON_OUTPUT) { $installed = if (-not $state.Installed -and $state.Rows.Count -gt 0) { $state.Rows -join ',' } elseif ($state.Installed) { $state.Installed } else { '-' } $available = if ($state.Available) { $state.Available } else { '-' } $scope = if ($state.Scope.Count -gt 0) { $state.Scope -join '+' } else { '-' } log ($format -f $record.Name, $installed, $available, $state.Package, $scope, $state.Status) } Add-ToolNote -Tool $record -State $state $rows += [ordered]@{ tool = $record.Name installed = $(if ($state.Installed) { $state.Installed } else { $null }) available = $(if ($state.Available) { $state.Available } else { $null }) source = $state.Package mechanism = 'winget' status = $state.Status scope = @($state.Scope) notes = (Get-NoteText -First $first -Last $script:NOTE_TEXTS.Count) } } $last = $script:NOTE_TEXTS.Count if ($script:ELEVATED) { note 'report' 'this pwsh is elevated, and some installers fail when launched from an elevated process, so an unelevated run is the one to prefer' } Add-PathLengthNote if ($script:JSON_OUTPUT) { [ordered]@{ schema = 1 platform = 'windows' tools = $rows notes = (Get-NoteText -First $last -Last $script:NOTE_TEXTS.Count) } | ConvertTo-Json -Depth 4 -EscapeHandling EscapeNonAscii return } if ($script:NOTES.Count -eq 0) { return } log '' log 'Notes:' foreach ($entry in $script:NOTES) { info $entry } } # Install, upgrade or reinstall one tool. # A tool whose install returns non-zero is collected rather than fatal, so one failure does not strand the rest of the run. # A refusal is not a failure and does end the run. # A declined prompt, or a scope that disagrees with the installed copy, stops everything rather than being collected. # This is because continuing past either would install a copy nobody asked for. function Invoke-ToolApply { param([Parameter(Mandatory)][string]$ToolName) $record = Get-Tool $ToolName $state = Get-ToolState -Tool $record # The id fresh work goes to, which is the installed id itself except in exactly one case. # That case is an outdated tool resolved to an id outside the tool's own default. # For node that means a pinned major that is frozen and can never itself clear Available. # So the default is the only id capable of fixing it. $target = if ($state.Status -eq 'outdated' -and $state.Package -ne $record.Package) { $record.Package } else { $state.Package } if ($state.Status -eq 'unmanaged') { log "${ToolName}: answers on PATH and winget knows no package for it, leaving it alone" return } # Collected rather than fatal, on the same rule as a failed install, and never installed past. # Installing against a state nobody could read is how a second copy lands beside a first one that was there all along. if ($state.Status -eq 'unreadable') { warn "$ToolName skipped, winget did not answer what is installed and this will not install against an unknown state" $script:FAILED += $ToolName return } # Naming a scope the installed copy does not sit in would add a second copy beside it. # So the removal is asked for rather than done on the way past. if ($script:WANT_SCOPE -and $state.Rows.Count -gt 0 -and $state.Scope.Count -gt 0 -and $state.Scope -notcontains $script:WANT_SCOPE -and $script:MODE -ne 'reinstall') { die "${ToolName}: installed $($state.Scope -join ' and ') wide at $(if ($state.Installed) { $state.Installed } else { $state.Rows -join ', ' }), and -Scope $($script:WANT_SCOPE) was given. Installing would add a second copy beside it. Remove the existing copy first with: install-tools.ps1 -Reinstall $ToolName -Scope $($script:WANT_SCOPE)" } if ($script:MODE -eq 'reinstall') { if ($state.Rows.Count -eq 0) { log "${ToolName}: not installed, so there is nothing to remove" } else { $where = if ($state.Scope.Count -gt 0) { " installed $($state.Scope -join ' and ') wide" } else { '' } $again = if ($target -eq $state.Package) { 'install it again' } else { "install $target instead" } if (-not (confirm "Remove $($state.Package) at $($state.Rows -join ', ')$where and $($again)?")) { die 'Declined' } # Every copy is removed, each in the scope it was found in. # This is because a tool present in both scopes is exactly the shadowing this action exists to clear. # An empty scope means winget reported none, and there the removal names none either and lets winget act on what it finds. $found = if ($state.Scope.Count -gt 0) { $state.Scope } else { @('') } foreach ($scope in $found) { if ((Invoke-WingetRemove -Id $state.Package -InScope $scope) -ne 0) { warn "$ToolName failed to uninstall$(if ($scope) { " the $scope wide copy" })" $script:FAILED += $ToolName return } } } } elseif ($state.Status -eq 'current') { log "${ToolName}: current at $($state.Installed), leaving it alone" return } elseif ($state.Status -eq 'self-updating') { log "${ToolName}: updates itself, and winget does not move it" return } elseif ($state.Status -eq 'multiple') { log "${ToolName}: winget lists $($state.Rows -join ', ') under one id, so -Reinstall is the action that resolves it" return } elseif (($script:MODE -eq 'upgrade' -or $script:MODE -eq 'install') -and $state.Status -eq 'outdated' -and $target -ne $state.Package) { # Named ahead of the plain install-mode branch below, since -Upgrade would hit this same fixed-release wall. # So pointing -Install at -Upgrade here would only trade one dead end for another. log "${ToolName}: $($state.Package) at $($state.Installed) is a fixed release and cannot advance under that id, -Reinstall $ToolName replaces it with $target" return } elseif ($script:MODE -eq 'install' -and $state.Status -eq 'outdated') { log "${ToolName}: at $($state.Installed), the source carries $($state.Available), -Upgrade moves it" return } elseif ($script:MODE -eq 'install' -and $state.Status -eq 'unknown' -and $state.Rows.Count -gt 0) { log "${ToolName}: winget lists $($state.Rows -join ', ') and no version comparison is possible, -Upgrade or -Reinstall acts on it" return } # Reached only once real work is about to happen: every "leave it alone" status above already returned. # That is deliberate here, not incidental. # An already current docker with a stale WSL platform has nothing this run needs to fix. # Gating on WSL readiness any earlier would skip a tool that was never going to change anyway. $dockerMaintenance = $null if ($ToolName -eq 'docker') { $wasRunning = Test-DockerDesktopRunning $wslProblem = Test-WslReadyForDocker if ($wslProblem -and -not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { warn "docker skipped, $wslProblem" $script:FAILED += $ToolName return } $dockerMaintenance = Enter-DockerMaintenance -WasRunning $wasRunning -WslProblem $wslProblem if (-not $dockerMaintenance.Proceed) { # $wslProblem is empty on the path where Enter-DockerMaintenance refused over a failed Docker Desktop stop rather than a WSL gap. # That reason was already warned there. # A bare "docker skipped, " with nothing after the comma still reads as a broken log line rather than a completed one. warn "docker skipped$(if ($wslProblem) { ", $wslProblem" } else { ', Docker Desktop maintenance failed, see the warning above' })" $script:FAILED += $ToolName return } } if ($script:MODE -ne 'reinstall') { log "${ToolName}: $($state.Status)$(if ($state.Available) { ", the source carries $($state.Available)" })" } $packages = @($target) if ($script:WITH_OPTIONAL) { $packages += $record.Optional } foreach ($package in $packages) { $started = Get-Date $code = if ($state.Rows.Count -gt 0 -and $script:MODE -eq 'upgrade' -and $package -eq $target) { Invoke-WingetUpgrade -Id $package } else { Invoke-WingetInstall -Id $package } if ($code -eq $script:NOT_FOUND -and $package -ne $target) { info "${ToolName}: winget carries no $package yet, so it is skipped" continue } if ($code -ne 0) { $executable = $null if ($ToolName -eq 'python') { $line = Get-PythonLine -Version $state.Installed if ($line) { $executable = Get-PythonLineInterpreter -Line $line } } Write-InstallFailure -ToolName $ToolName -Package $package -Code $code -Since $started -Probe $record.Probe -Executable $executable $script:FAILED += $ToolName # Owed regardless of how this returns: Enter-DockerMaintenance stopped Docker Desktop for this attempt, and a failed install is not a reason to leave it down. if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } return } } $now = Resolve-InstalledVersion -Version (Get-WingetInstalled -Id $target) if ($now -ne $state.Installed) { $before = if ($state.Installed) { $state.Installed } else { '-' } $after = if ($now) { $now } else { '-' } $script:CHANGED += "$ToolName $before -> $after" } if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } } function Invoke-Apply { log "Selected: $($script:SELECTED -join ' ')" if ($script:ELEVATED) { warn 'This pwsh is elevated, and some installers fail when launched from an elevated process. An unelevated run lets each installer ask for administrator only where it needs it.' } if (-not (confirm "$($script:MODE) these tools?")) { die 'Declined' } foreach ($tool in $script:SELECTED) { step $tool Invoke-ToolApply -ToolName $tool # Owed after the package itself is settled, on every path Invoke-ToolApply returns by rather than only the one where winget did work. # That is where this deviates from the docker branch inside that function. # Docker Desktop only needs stopping when the package is about to move. # So its window belongs past the statuses that leave a tool alone. # An app execution alias stub returns on a host whose python has been current for months. # A reinstall removes the interpreter this puts python3 beside. # So this belongs after all of those statuses instead. # A failed tool is the one case this does skip, and it is the case that matters most. # A reinstall whose install half failed has already removed the interpreter. # So removing the Store alias on top of it would leave the host with no python, no python3, and no way to get one. # The version is re-read here rather than taken from inside that function, so the line it acts on is the one the host carries now. if ($tool -eq 'python' -and $script:FAILED -notcontains $tool) { Repair-PythonName -Installed (Resolve-InstalledVersion -Version (Get-WingetInstalled -Id (Get-Tool $tool).Package)) } } log '' if ($script:CHANGED.Count -gt 0) { log 'Changed:' foreach ($entry in $script:CHANGED) { info $entry } } else { log 'Nothing changed' } # After the installs rather than before, because an install is what grows Path. Add-PathLengthNote # Printed here as well as under a report. # This is because an apply is where most notes are actually raised, and until now none of them reached the console. # Every note this script writes during an apply was collected into a list only Show-Report rendered. # So a declined docker maintenance cycle and every python finding were both discarded by the one mode that had just performed the work they describe. if ($script:NOTES.Count -gt 0) { log '' log 'Notes:' foreach ($entry in $script:NOTES) { info $entry } } if ($script:FAILED.Count -gt 0) { warn "Failed: $($script:FAILED -join ' ')" return 1 } return 0 } # --- Entry --- # PowerShell records which switches were given and not the order they came in, so two actions is a refusal rather than the last one winning. # Refusing is also the better answer: an action silently discarded is one the caller believes ran. function Resolve-Mode { $given = @($script:ACTIONS.Keys | Where-Object { $script:ACTIONS[$_] }) if ($given.Count -gt 1) { die "More than one action given ($($given -join ', ')), name one" } $mode = if ($given.Count -eq 0) { 'report' } else { $given[0] } if ($script:JSON_OUTPUT -and $mode -ne 'report') { die "-Json changes how a report is written, so it applies only to -Report, and the $mode action was given" } return $mode } function Resolve-Selection { $names = @($script:TOOLS | ForEach-Object { $_.Name }) if ($script:WANT_TOOLS.Count -eq 0) { return , $names } foreach ($candidate in $script:WANT_TOOLS) { if ($names -notcontains $candidate) { die "Unknown tool `"$candidate`", -List names the managed tools" } } # Sorted into registry order rather than the order they were typed, so a run reads the same however it was asked for. return , @($names | Where-Object { $script:WANT_TOOLS -contains $_ }) } function main { if ($script:WANT_HELP) { usage; exit 0 } $unknown = @($script:WANT_TOOLS | Where-Object { $_ -like '-*' }) if ($unknown.Count -gt 0) { die "Unknown option `"$($unknown[0])`", -Help lists the options" } $script:MODE = Resolve-Mode Test-HostSupported Add-RepositoryToolCatalog $script:SELECTED = Resolve-Selection switch ($script:MODE) { 'list' { Show-List; exit 0 } 'report' { Show-Report; exit 0 } default { exit (Invoke-Apply) } } } main