From d58cfade3594e5ff2380dbf1680764896ba0ab2f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 06:57:06 -0700 Subject: [PATCH 1/4] Resolve node's real winget package id instead of assuming LTS (#693) winget list --id OpenJS.NodeJS.LTS --exact echoes back the queried id for a package that only shares a publisher and name with what's actually installed. On a host running OpenJS.NodeJS (Current channel), that made every report and action believe OpenJS.NodeJS.LTS was installed, and -Upgrade/-Reinstall would have targeted that wrong id. Node ships under many ids beyond Current and LTS (one per pinned major back to 4), any of which is fine as long as its version clears the LTS floor. Add a Family field to the tools registry (empty for every tool but node) and a Resolve-ToolPackage that cross-references the already-correct installed version against a winget search over the family to find which id it actually belongs to, without trusting winget's id-scoped correlation at all. That resolved id now flows through status, notes, report, and every install/upgrade/remove action. Verified on a host with OpenJS.NodeJS (Current, 26.7.0) installed: report now shows SOURCE=OpenJS.NodeJS instead of the misleading OpenJS.NodeJS.LTS, and -Reinstall -DryRun now targets OpenJS.NodeJS instead of silently swapping to LTS. Other tools are unaffected. Co-Authored-By: Claude Sonnet 5 --- host-setup/windows/install-tools.ps1 | 72 +++++++++++++++++++++------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 5965c19..c10336c 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -184,6 +184,26 @@ function Read-WingetTable { 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, which is what Resolve-ToolPackage needs 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, since 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. @@ -283,6 +303,19 @@ function Get-ExplicitUpgrade { 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 (#693: a host running OpenJS.NodeJS, the Current channel, reads as OpenJS.NodeJS.LTS because the two share a publisher and a name), but the version it reports comes straight from the installed entry regardless of which id was asked about, so matching that version against every id in the family, read from a plain source search rather than an id scoped one, 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, and a version matching none of the family's ids does the same, since a family search that cannot confirm anything is no worse than not asking. +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 } + $match = (Read-WingetSearchTable -Text $text -Prefix $Tool.Family) | Where-Object { $_.Version -eq $Installed } | Select-Object -First 1 + if ($match) { return $match.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, because winget then acts on the copy it found and naming a different scope adds a copy rather than replacing one. function Invoke-WingetInstall { @@ -317,15 +350,16 @@ function Invoke-WingetRemove { # 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 four functions per tool. # Probe names the executable that proves the tool is present when winget knows no package for it, and it is py for python because that is the name a correctly set up Windows host carries. +# Family names the id prefix winget's catalog shares across every channel and pinned major a tool ships under, and is empty everywhere but node: node alone ships as more than one id (Current, LTS, and one per pinned major back to 4), any of which is fine on a host as long as its version clears Available, and Resolve-ToolPackage is what finds which one that is. $TOOLS = @( - @{ Name = 'git'; Package = 'Git.Git'; Probe = 'git'; Optional = @() } - @{ Name = 'gh'; Package = 'GitHub.cli'; Probe = 'gh'; Optional = @() } - @{ Name = 'jq'; Package = 'jqlang.jq'; Probe = 'jq'; Optional = @() } - @{ Name = 'python'; Package = 'Python.Python.3.13'; Probe = 'py'; Optional = @() } - @{ Name = 'uv'; Package = 'astral-sh.uv'; Probe = 'uv'; Optional = @() } - @{ Name = 'docker'; Package = 'Docker.DockerDesktop'; Probe = 'docker'; Optional = @() } - @{ Name = 'node'; Package = 'OpenJS.NodeJS.LTS'; Probe = 'node'; Optional = @() } - @{ Name = 'dotnet'; Package = 'Microsoft.DotNet.SDK.10'; Probe = 'dotnet'; Optional = @('Microsoft.DotNet.SDK.9', 'Microsoft.DotNet.SDK.8') } + @{ 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 = '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.10'; Probe = 'dotnet'; Optional = @('Microsoft.DotNet.SDK.9', 'Microsoft.DotNet.SDK.8'); Family = '' } ) function Get-Tool { @@ -366,6 +400,9 @@ 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, and this 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. @@ -378,7 +415,8 @@ function Get-ToolState { if ($state.Readable) { $state.Rows = $rows $state.Installed = Resolve-InstalledVersion -Version $rows - if ($rows.Count -gt 0) { $state.Scope = Get-WingetScope -Id $Tool.Package } + $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 @@ -397,7 +435,7 @@ function Get-ToolStatus { } if (-not $State.Installed) { return 'multiple' } if (-not $State.Available) { return 'unknown' } - if ((Get-ExplicitUpgrade) -contains $Tool.Package) { return 'self-updating' } + if ((Get-ExplicitUpgrade) -contains $State.Package) { return 'self-updating' } if ((Compare-HostVersion $State.Installed $State.Available) -ge 0) { return 'current' } return 'outdated' } @@ -416,7 +454,7 @@ function Add-ToolNote { 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 $Tool.Package)) { + 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') { @@ -459,7 +497,7 @@ function Show-Report { $installed = if ($state.Status -eq 'multiple') { $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, $record.Package, $scope, $state.Status) + log ($format -f $record.Name, $installed, $available, $state.Package, $scope, $state.Status) Add-ToolNote -Tool $record -State $state } @@ -505,14 +543,14 @@ function Invoke-ToolApply { 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 { '' } - if (-not (confirm "Remove $($record.Package) at $($state.Rows -join ', ')$where and install it again?")) { + if (-not (confirm "Remove $($state.Package) at $($state.Rows -join ', ')$where and install it again?")) { die 'Declined' } # Every copy is removed, each in the scope it was found in, since 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 $record.Package -InScope $scope) -ne 0) { + 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 @@ -537,11 +575,11 @@ function Invoke-ToolApply { log "${ToolName}: $($state.Status)$(if ($state.Available) { ", the source carries $($state.Available)" })" } - $packages = @($record.Package) + $packages = @($state.Package) if ($script:WITH_OPTIONAL) { $packages += $record.Optional } foreach ($package in $packages) { - $code = if ($state.Rows.Count -gt 0 -and $script:MODE -eq 'upgrade' -and $package -eq $record.Package) { + $code = if ($state.Rows.Count -gt 0 -and $script:MODE -eq 'upgrade' -and $package -eq $state.Package) { Invoke-WingetUpgrade -Id $package } else { Invoke-WingetInstall -Id $package @@ -560,7 +598,7 @@ function Invoke-ToolApply { } } - $now = Resolve-InstalledVersion -Version (Get-WingetInstalled -Id $record.Package) + $now = Resolve-InstalledVersion -Version (Get-WingetInstalled -Id $state.Package) if ($now -ne $state.Installed) { $before = if ($state.Installed) { $state.Installed } else { '-' } $after = if ($now) { $now } else { '-' } From fd0621a51ab4d477cd2a630709dc71f17076dc1a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 07:04:12 -0700 Subject: [PATCH 2/4] Resolve-ToolPackage: fall back instead of guessing on an ambiguous version match Select-Object -First 1 picked arbitrarily between sibling ids that happen to share the same version, which can occur right at a channel handoff. Require an unambiguous match before trusting it; otherwise fall back to the tool's own default package, same as no match at all. Co-Authored-By: Claude Sonnet 5 --- host-setup/windows/install-tools.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index c10336c..61f36b6 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -306,13 +306,14 @@ function Get-ExplicitUpgrade { # 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 (#693: a host running OpenJS.NodeJS, the Current channel, reads as OpenJS.NodeJS.LTS because the two share a publisher and a name), but the version it reports comes straight from the installed entry regardless of which id was asked about, so matching that version against every id in the family, read from a plain source search rather than an id scoped one, 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, and a version matching none of the family's ids does the same, since 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 sharing a version happens right at a channel handoff, when a major that just left Current has not yet lost its old id from the search results, and 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 } - $match = (Read-WingetSearchTable -Text $text -Prefix $Tool.Family) | Where-Object { $_.Version -eq $Installed } | Select-Object -First 1 - if ($match) { return $match.Id } + $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 } From fee7c3acc72279fbdd9d3a2cbd069d164e152883 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 07:14:47 -0700 Subject: [PATCH 3/4] Give an outdated capped node id an actual path forward instead of a doomed retry A node install resolved to an id outside the tool's own default (a pinned major, frozen forever below the LTS floor) previously kept being handed to `winget upgrade --id --exact`, which can never succeed under that id, so -Upgrade would fail on it every run with no path forward. -Reinstall carried the same problem: it removed and reinstalled the identical capped copy. Introduce $target, the id fresh work should land on. It equals the resolved installed id except when that id is outdated and not the tool's own default, where it falls back to the default (the only id capable of actually clearing the floor). -Upgrade now says plainly that the installed id is a fixed release and points at -Reinstall; -Reinstall now installs $target after removing the old copy, so it genuinely fixes a capped install instead of restoring it unchanged. A healthy alternate channel (e.g. Current, already above the floor) is untouched by this, since $target only diverges from the installed id when the tool is outdated under it. Also fixes a real bug caught while testing this: a bare `$var?` inside a double-quoted string is not `$var` followed by literal `?` in PowerShell, `?` is itself a valid interpolated-name character, so it silently referenced a nonexistent `$again?` variable. Parenthesized to `$($again)?`. Co-Authored-By: Claude Sonnet 5 --- host-setup/windows/install-tools.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 61f36b6..58c7b1a 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -519,6 +519,8 @@ 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: an outdated tool resolved to an id outside the tool's own default, which for node 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" @@ -544,7 +546,8 @@ function Invoke-ToolApply { 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 { '' } - if (-not (confirm "Remove $($state.Package) at $($state.Rows -join ', ')$where and install it again?")) { + $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, since a tool present in both scopes is exactly the shadowing this action exists to clear. @@ -567,6 +570,9 @@ function Invoke-ToolApply { } 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' -and $state.Status -eq 'outdated' -and $target -ne $state.Package) { + 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 @@ -576,11 +582,11 @@ function Invoke-ToolApply { log "${ToolName}: $($state.Status)$(if ($state.Available) { ", the source carries $($state.Available)" })" } - $packages = @($state.Package) + $packages = @($target) if ($script:WITH_OPTIONAL) { $packages += $record.Optional } foreach ($package in $packages) { - $code = if ($state.Rows.Count -gt 0 -and $script:MODE -eq 'upgrade' -and $package -eq $state.Package) { + $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 @@ -599,7 +605,7 @@ function Invoke-ToolApply { } } - $now = Resolve-InstalledVersion -Version (Get-WingetInstalled -Id $state.Package) + $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 { '-' } From eae196a162d49af17936fe8a92b7a6d107e7cd61 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 07:20:52 -0700 Subject: [PATCH 4/4] Give -Install the same fixed-release guidance as -Upgrade for a capped id -Install on a pinned-major node install still said "-Upgrade moves it", but -Upgrade hits the same fixed-release guard and no-ops, so that pointed the operator at a dead end. Both modes now share the one branch and point at -Reinstall. Co-Authored-By: Claude Sonnet 5 --- host-setup/windows/install-tools.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 58c7b1a..4d1358f 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -570,7 +570,8 @@ function Invoke-ToolApply { } 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' -and $state.Status -eq 'outdated' -and $target -ne $state.Package) { + } 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') {