From 7bf6b82194af0877d009c48db9425e6140d32ea0 Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Thu, 22 Oct 2026 01:34:01 +0200 Subject: [PATCH 1/5] feat(windows): bootstrap NVM v2 with explicit standalone Node migration --- README.md | 2 + docs/windows.md | 33 ++++ install.ps1 | 10 ++ tests/windows-node-tests.ps1 | 73 ++++++++ tests/windows-smoke.ps1 | 8 + windows/Dotfiles.WindowsNode.psm1 | 273 ++++++++++++++++++++++++++++++ windows/packages.psd1 | 2 +- 7 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 tests/windows-node-tests.ps1 create mode 100644 windows/Dotfiles.WindowsNode.psm1 diff --git a/README.md b/README.md index 43df486..bac0c92 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,8 @@ Run `./install.sh --help` for the complete option list. macOS-specific options s | `.\install.ps1 -Sync` | Reconcile profiles, copied configuration, and Git defaults without installing packages | | `.\install.ps1 -Force` or `-y` | Answer yes to all package-group prompts; never authorize removal of Store PowerShell | | `.\install.ps1 -Force -MigratePowerShellToMsi` | Explicitly opt into replacing current-user PowerShell MSIX with a fixed-path 7.6 MSI, when not running inside MSIX | +| `.\install.ps1 -Force` or `-y` | Answer yes to package-group prompts, but never to removing a pre-existing Node.js | +| `.\install.ps1 -Force -MigrateNodeToNvm` | Explicitly opt into replacing WinGet-managed standalone Node with NVM v2 after saving an npm global inventory | | `.\install.ps1 -CleanBackups` | Inspect the currently supported Windows backup-cleanup scope and ask before deletion | | `.\install.ps1 -RestoreGitDefaults` | Restore managed Git values when they have not changed since installation | diff --git a/docs/windows.md b/docs/windows.md index 982ee46..d691e1a 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -122,6 +122,8 @@ Windows Terminal and TrafficMonitor configuration are currently copied during no | `.\install.ps1 -Sync` | Reconcile profiles, copied files, manifests, and Git defaults without installing packages | | `.\install.ps1 -Force` | Answer yes to package-group prompts, but not to removing PowerShell MSIX | | `.\install.ps1 -Force -MigratePowerShellToMsi` | Explicit opt-in to replace current-user PowerShell MSIX with fixed-path MSI, only from a non-MSIX shell | +| `.\install.ps1 -Force` | Answer yes to package-group prompts, but not to removing standalone Node.js | +| `.\install.ps1 -Force -MigrateNodeToNvm` | Explicitly consent to replacing WinGet-managed standalone Node.js with NVM v2 | | `.\install.ps1 -y` | Alias for `-Force` | | `.\install.ps1 -CleanBackups` | List backups in the currently supported cleanup scope and ask before deletion | | `.\install.ps1 -RestoreGitDefaults` | Restore installer-managed Git values when they still match the values applied by the installer | @@ -133,6 +135,8 @@ Important mode rules: - `-DryRun` can be combined with `-Force` or `-Sync` for a non-destructive full preview. - `-Force` opts into package groups, including optional and private ones, but never authorizes removal of PowerShell MSIX. Use the additional `-MigratePowerShellToMsi` flag or an interactive confirmation. - `-MigratePowerShellToMsi` is incompatible with `-Minimal`, `-Sync`, `-RestoreGitDefaults`, and `-CleanBackups`. +- `-Force` opts into package groups, not removal of an existing runtime. Node.js migration requires its own prompt or `-MigrateNodeToNvm`. +- `-MigrateNodeToNvm` cannot be combined with `-Minimal`, `-Sync`, or `-RestoreGitDefaults`. ## Package manifests @@ -186,6 +190,35 @@ The MSI invocation should report `C:\Program Files\PowerShell\7`. After migratio **OpenSSH default shell is configured separately:** installation alone does not change `HKLM:\SOFTWARE\OpenSSH` or restart `sshd`. If you explicitly want PowerShell 7 as the SSH default shell, inspect your OpenSSH configuration and set `DefaultShell` to the full Program Files executable path from an elevated session; keep the original registry value for rollback. This repo deliberately does not change service configuration. Windows PowerShell **5.1 is supported as a bootstrap/recovery host**. The Windows smoke checks parse and exercise the dotfiles under both `powershell.exe` (5.1) and `pwsh.exe` (7), without installing or removing anything. If the MSI install or MSIX removal fails, the bootstrap reports failure, preserves Windows PowerShell 5.1, and does not automatically delete the other installation. +### Node.js: NVM for Windows v2 + +The public core installs **NVM for Windows v2** (`AuthorSoftware.NVMWindows`) in place of standalone `OpenJS.NodeJS.LTS`. The bootstrap pins NVM **2.0.1**, which is available in WinGet and code-signed. On a clean machine it installs and activates the latest Node LTS with `nvm install lts` and `nvm use lts`. + +- **No Node or NVM:** install NVM and Node LTS, then open a new PowerShell session. +- **NVM v2 already present:** keep the selected/default Node version unchanged. +- **WinGet-managed standalone Node.js (`OpenJS.NodeJS.LTS` or `OpenJS.NodeJS`):** offer migration with a default answer of **No**. Declining keeps Node and skips installing NVM. +- **`-Force`:** selects package groups, but never authorizes removal of Node. For unattended explicit consent use `.\install.ps1 -Force -MigrateNodeToNvm`. Using `-MigrateNodeToNvm` alone still requires choosing the core package group. +- **Unrecognized standalone Node, NVM v1, Scoop/fnm/Volta, or another Node executable on PATH:** skip automatic changes and explain the conflict. Clean up manually and rerun. + +An approved migration writes the current global npm package names/versions to `%LOCALAPPDATA%\dotfiles\node-migration\before-nvm-*.json`. The installer then installs NVM, activates/verifies a Node version managed by NVM and **only after that** invokes WinGet to uninstall standalone Node. The old Node is not removed if NVM installation or LTS activation fails. A temporary coexistence during migration is intentional, not a supported final state. + +The inventory is for **manual reinstallation** of global npm tools, not an archive of their files. The installer does not silently delete global npm directories or reinstall arbitrary global tools. After migration, review that inventory and install any packages you still need under the NVM-managed Node. + +If WinGet cannot remove the old runtime, the installer reports an incomplete migration and leaves the state for manual repair rather than deleting files. The earlier executable may continue to shadow NVM on PATH. Close and reopen PowerShell, then verify: + +```powershell +nvm --version +nvm default +where.exe node +Get-Command node -All +node --version +npm --version +nvm doctor +``` + +There should not be a standalone `Program Files\nodejs\node.exe` shadowing NVM. To recover, inspect `winget list --id AuthorSoftware.NVMWindows --exact` and `winget list --id OpenJS.NodeJS.LTS --exact`, preserve the saved inventory, and manually uninstall the unwanted manager before reinstalling your previous Node version if needed. + +This repo does not require a root `.nvmrc`; individual Node projects can maintain their own. The shared `npmupg` function continues to operate on whichever Node version is active. ## Local configuration diff --git a/install.ps1 b/install.ps1 index 2675553..abca8d8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -5,6 +5,7 @@ param( [switch]$Force, [switch]$Minimal, [switch]$MigratePowerShellToMsi, + [switch]$MigrateNodeToNvm, [switch]$CleanBackups, [switch]$Sync, [switch]$RestoreGitDefaults @@ -18,6 +19,8 @@ $windowsPackageModule = Join-Path $PSScriptRoot 'windows\Dotfiles.WindowsPackage Import-Module -Name $windowsPackageModule -Force $windowsPowerShellModule = Join-Path $PSScriptRoot 'windows\Dotfiles.WindowsPowerShell.psm1' Import-Module -Name $windowsPowerShellModule -Force +$windowsNodeModule = Join-Path $PSScriptRoot 'windows\Dotfiles.WindowsNode.psm1' +Import-Module -Name $windowsNodeModule -Force function Write-Section { param([string]$Message) @@ -610,6 +613,11 @@ function Install-WindowsPackageBaseline { Invoke-DotfilesPowerShellBootstrap -RepoRoot $PSScriptRoot -DryRun:$DryRun -NonInteractive:$Force -ExplicitMigration:$MigratePowerShellToMsi continue } + if ($section.Manager -eq 'Winget' -and $package.Id -eq 'AuthorSoftware.NVMWindows') { + Invoke-DotfilesNvmBootstrap -DryRun:$DryRun -NonInteractive:$Force -ExplicitMigration:$MigrateNodeToNvm + continue + } + $installed = $false switch ($section.Manager) { @@ -742,6 +750,8 @@ if ($RestoreGitDefaults -and ($Sync -or $Minimal -or $Force -or $CleanBackups)) if ($MigratePowerShellToMsi -and ($Minimal -or $Sync -or $RestoreGitDefaults -or $CleanBackups)) { throw '-MigratePowerShellToMsi requires a normal package run. Use the standalone Windows PowerShell 5.1 migration helper if needed.' +if ($MigrateNodeToNvm -and ($Minimal -or $Sync -or $RestoreGitDefaults)) { + throw '-MigrateNodeToNvm requires normal package installation, not -Minimal, -Sync or -RestoreGitDefaults.' } $RepoRoot = Split-Path -Parent $PSCommandPath diff --git a/tests/windows-node-tests.ps1 b/tests/windows-node-tests.ps1 new file mode 100644 index 0000000..36c76d6 --- /dev/null +++ b/tests/windows-node-tests.ps1 @@ -0,0 +1,73 @@ +# Pure plan regression tests. No real package install/uninstall is performed. +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot +Import-Module (Join-Path $repoRoot 'windows/Dotfiles.WindowsNode.psm1') -Force + +function New-TestNodeState { + param( + [string]$NvmExecutable, + [switch]$Legacy, + [string[]]$StandaloneIds = @(), + [string[]]$StandalonePaths = @(), + [string[]]$OtherNodePaths = @() + ) + + [pscustomobject]@{ + NvmExecutable = $NvmExecutable + HasLegacyNvm = [bool]$Legacy + StandaloneIds = @($StandaloneIds) + StandalonePaths = @($StandalonePaths) + OtherNodePaths = @($OtherNodePaths) + HasStandaloneNode = (@($StandaloneIds).Count -gt 0 -or @($StandalonePaths).Count -gt 0) + } +} + +function Assert-NodePlan { + param( + [Parameter(Mandatory = $true)][psobject]$State, + [Parameter(Mandatory = $true)][string]$Expected, + [switch]$ExplicitMigration + ) + + $actual = Get-DotfilesNodePlan -State $State -ExplicitMigration:$ExplicitMigration + if ($actual -ne $Expected) { + throw "Node plan expected '$Expected' but got '$actual'." + } +} + +$fresh = New-TestNodeState +Assert-NodePlan -State $fresh -Expected 'Install' +Assert-NodePlan -State $fresh -Expected 'Install' -ExplicitMigration + +$existing = New-TestNodeState -NvmExecutable 'C:\users\example\AppData\Local\Author Software\nvm\nvm.exe' +Assert-NodePlan -State $existing -Expected 'Existing' + +$official = New-TestNodeState -StandaloneIds @('OpenJS.NodeJS.LTS') -StandalonePaths @('C:\Program Files\nodejs\node.exe') +Assert-NodePlan -State $official -Expected 'Prompt' +Assert-NodePlan -State $official -Expected 'Migrate' -ExplicitMigration + +$both = New-TestNodeState -NvmExecutable 'C:\nvm\nvm.exe' -StandaloneIds @('OpenJS.NodeJS.LTS') +Assert-NodePlan -State $both -Expected 'Prompt' +Assert-NodePlan -State $both -Expected 'Migrate' -ExplicitMigration + +$unmanaged = New-TestNodeState -StandalonePaths @('C:\Program Files\nodejs\node.exe') +Assert-NodePlan -State $unmanaged -Expected 'ManualNode' +Assert-NodePlan -State $unmanaged -Expected 'ManualNode' -ExplicitMigration + +$otherManager = New-TestNodeState -OtherNodePaths @('C:\Tools\volta\node.exe') +Assert-NodePlan -State $otherManager -Expected 'OtherNode' +Assert-NodePlan -State $otherManager -Expected 'OtherNode' -ExplicitMigration + +$legacy = New-TestNodeState -Legacy -StandaloneIds @('OpenJS.NodeJS.LTS') +Assert-NodePlan -State $legacy -Expected 'LegacyNvm' +Assert-NodePlan -State $legacy -Expected 'LegacyNvm' -ExplicitMigration + +$ambiguous = New-TestNodeState -StandaloneIds @('OpenJS.NodeJS.LTS') -OtherNodePaths @('C:\tools\node.exe') +Assert-NodePlan -State $ambiguous -Expected 'OtherNode' -ExplicitMigration + +Write-Host 'NVM migration decision tests passed.' diff --git a/tests/windows-smoke.ps1 b/tests/windows-smoke.ps1 index 0c64398..da7744a 100644 --- a/tests/windows-smoke.ps1 +++ b/tests/windows-smoke.ps1 @@ -12,6 +12,8 @@ $sources = @( 'windows/Dotfiles.WindowsPowerShell.psm1' 'windows/Install-PowerShellMsi.ps1' 'tests/windows-powershell-msi-tests.ps1' + 'windows/Dotfiles.WindowsNode.psm1' + 'tests/windows-node-tests.ps1' 'windows/start-menu-shortcuts.ps1' ) @@ -56,6 +58,12 @@ if (@($coreManifest.Winget) -notcontains 'Microsoft.PowerShell') { throw 'The Windows core manifest must include Microsoft.PowerShell.' } +& (Join-Path $repoRoot 'tests/windows-node-tests.ps1') + +$corePackages = Import-PowerShellDataFile -Path (Join-Path $repoRoot 'windows/packages.psd1') +if (@($corePackages.Winget) -notcontains 'AuthorSoftware.NVMWindows' -or @($corePackages.Winget) -contains 'OpenJS.NodeJS.LTS') { + throw 'Windows core manifest must use NVM v2 rather than standalone Node LTS.' +} $entry = Resolve-WindowsPackageEntry -PackageEntry 'eza-community.eza' -Manager Winget if ($entry.Source -ne 'winget' -or $entry.Id -ne 'eza-community.eza') { diff --git a/windows/Dotfiles.WindowsNode.psm1 b/windows/Dotfiles.WindowsNode.psm1 new file mode 100644 index 0000000..0cba76e --- /dev/null +++ b/windows/Dotfiles.WindowsNode.psm1 @@ -0,0 +1,273 @@ +# Installer-only NVM v2 bootstrap. Importing this module has no side effects. +Set-StrictMode -Version Latest + +$script:NvmPackageId = 'AuthorSoftware.NVMWindows' +$script:NvmVersion = '2.0.1' +$script:StandaloneNodeIds = @('OpenJS.NodeJS.LTS', 'OpenJS.NodeJS') + +function Get-DotfilesNvmExecutables { + $candidates = @() + if ($env:LOCALAPPDATA) { + $candidates += (Join-Path $env:LOCALAPPDATA 'Author Software\nvm\nvm.exe') + } + foreach ($command in @(Get-Command nvm -All -CommandType Application -ErrorAction SilentlyContinue)) { + if ($command.Source) { $candidates += $command.Source } + } + + foreach ($path in @($candidates | Select-Object -Unique)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } + try { + $version = (@(& $path --version 2>$null) -join ' ').Trim() + if ($LASTEXITCODE -ne 0 -or $version -notmatch 'v?(\d+)\.(\d+)\.(\d+)') { continue } + [pscustomobject]@{ Path = $path; Version = $Matches[0]; Major = [int]$Matches[1] } + } catch { + # Ignore broken executables; never treat them as a working manager. + } + } +} + +function Get-DotfilesNodeState { + $managers = @(Get-DotfilesNvmExecutables) + $nvmV2 = $managers | Where-Object { $_.Major -ge 2 } | Select-Object -First 1 + $legacyNvm = @($managers | Where-Object { $_.Major -lt 2 }).Count -gt 0 + $installedIds = @() + if (Get-Command winget -ErrorAction SilentlyContinue) { + $installedIds = @(Get-WingetInstalledPackageIds) + } + if ($installedIds -contains 'CoreyButler.NVMforWindows') { $legacyNvm = $true } + $standaloneIds = @($script:StandaloneNodeIds | Where-Object { $installedIds -contains $_ }) + + $officialDirs = @() + foreach ($root in @( + $env:ProgramFiles, + [Environment]::GetEnvironmentVariable('ProgramFiles(x86)'), + $env:ProgramW6432 + )) { + if ($root) { $officialDirs += (Join-Path $root 'nodejs') } + } + + $paths = @( + Get-Command node -All -CommandType Application -ErrorAction SilentlyContinue | + Where-Object { $_.Source } | ForEach-Object { $_.Source } + ) + foreach ($directory in @($officialDirs | Select-Object -Unique)) { + $candidate = Join-Path $directory 'node.exe' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { $paths += $candidate } + } + + $standalonePaths = @() + $otherPaths = @() + $nvmRoot = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'Author Software\nvm' } else { '' } + foreach ($path in @($paths | Select-Object -Unique)) { + if ($nvmRoot -and $path.StartsWith(($nvmRoot.TrimEnd('\') + '\'), [StringComparison]::OrdinalIgnoreCase)) { + continue + } + $isOfficial = $false + foreach ($directory in $officialDirs) { + if ($path.Equals((Join-Path $directory 'node.exe'), [StringComparison]::OrdinalIgnoreCase)) { + $isOfficial = $true + break + } + } + if ($isOfficial) { $standalonePaths += $path } else { $otherPaths += $path } + } + + [pscustomobject]@{ + NvmExecutable = if ($nvmV2) { $nvmV2.Path } else { $null } + HasLegacyNvm = $legacyNvm + StandaloneIds = @($standaloneIds) + StandalonePaths = @($standalonePaths) + OtherNodePaths = @($otherPaths) + HasStandaloneNode = ($standaloneIds.Count -gt 0 -or $standalonePaths.Count -gt 0) + } +} + +# Pure decision logic, intentionally independent of -Force. +function Get-DotfilesNodePlan { + param([Parameter(Mandatory = $true)][psobject]$State, [switch]$ExplicitMigration) + if ($State.HasLegacyNvm) { return 'LegacyNvm' } + if (@($State.OtherNodePaths).Count -gt 0) { return 'OtherNode' } + if ($State.HasStandaloneNode) { + if (@($State.StandaloneIds).Count -eq 0) { return 'ManualNode' } + if ($ExplicitMigration) { return 'Migrate' } + return 'Prompt' + } + if ($State.NvmExecutable) { return 'Existing' } + return 'Install' +} + +function Save-DotfilesNodeInventory { + param([Parameter(Mandatory = $true)][psobject]$State) + + $npmPath = $null + foreach ($nodePath in $State.StandalonePaths) { + $candidate = Join-Path (Split-Path -Parent $nodePath) 'npm.cmd' + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $npmPath = $candidate + break + } + } + if (-not $npmPath) { + $npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue + if ($npm) { $npmPath = $npm.Source } + } + + $globals = @() + if ($npmPath) { + $raw = (& $npmPath ls -g --depth=0 --json 2>$null | Out-String).Trim() + if (-not $raw) { throw 'npm returned no global inventory; migration was cancelled.' } + try { + $inventory = $raw | ConvertFrom-Json -ErrorAction Stop + if ($inventory.dependencies) { + foreach ($item in $inventory.dependencies.PSObject.Properties) { + if ($item.Name -in @('npm', 'corepack')) { continue } + if ($item.Value.version) { + $globals += "$($item.Name)@$($item.Value.version)" + } else { + $globals += $item.Name + } + } + } + } catch { + throw 'Unable to parse npm global inventory; migration was cancelled.' + } + } else { + Write-Warning 'npm not found; the migration inventory will not contain global packages.' + } + + $dir = Join-Path $env:LOCALAPPDATA 'dotfiles\node-migration' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $file = Join-Path $dir ("before-nvm-" + (Get-Date -Format 'yyyyMMdd-HHmmss') + "-$PID.json") + [ordered]@{ + SavedAt = (Get-Date).ToString('o') + StandaloneIds = @($State.StandaloneIds) + StandalonePaths = @($State.StandalonePaths) + NpmExecutable = $npmPath + GlobalPackages = @($globals) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $file -Encoding UTF8 -ErrorAction Stop + return $file +} + +function Test-DotfilesNvmDefault { + param([Parameter(Mandatory = $true)][string]$NvmExecutable) + try { + $default = (& $NvmExecutable default 2>$null | Out-String).Trim() + return ($LASTEXITCODE -eq 0 -and $default -match 'v?\d+\.\d+\.\d+') + } catch { + return $false + } +} + +function Install-DotfilesNodeLts { + param([Parameter(Mandatory = $true)][string]$NvmExecutable) + Write-Host ' Installing Node.js LTS using NVM...' + & $NvmExecutable install lts + if ($LASTEXITCODE -ne 0) { throw 'nvm install lts failed; existing Node.js was not removed.' } + & $NvmExecutable use lts + if ($LASTEXITCODE -ne 0 -or -not (Test-DotfilesNvmDefault -NvmExecutable $NvmExecutable)) { + throw 'nvm use lts failed; existing Node.js was not removed.' + } +} + +function Invoke-DotfilesNvmBootstrap { + param([switch]$DryRun, [switch]$NonInteractive, [switch]$ExplicitMigration) + + $state = Get-DotfilesNodeState + $plan = Get-DotfilesNodePlan -State $state -ExplicitMigration:$ExplicitMigration + switch ($plan) { + 'LegacyNvm' { + Write-Warning 'NVM for Windows v1 detected. Migrate it manually before installing v2.' + return + } + 'OtherNode' { + Write-Warning "Another Node runtime is on PATH ($($state.OtherNodePaths -join ', ')); resolve this conflict before installing NVM." + return + } + 'ManualNode' { + Write-Warning "Standalone Node found outside recognized WinGet packages ($($state.StandalonePaths -join ', ')). Uninstall it manually first." + return + } + 'Existing' { + Write-Host ' NVM v2 already installed; keeping its current Node.js version unchanged.' + return + } + 'Prompt' { + if ($DryRun) { + Write-Host ' Would ask whether to replace standalone Node.js (default No); no changes.' + return + } + if ($NonInteractive) { + Write-Warning 'Standalone Node present. -Force does not authorize removal; skipping NVM. Use -MigrateNodeToNvm to opt in.' + return + } + Write-Host " Standalone Node.js detected: $($state.StandaloneIds -join ', ')." + Write-Host ' Migration saves npm globals, activates Node LTS in NVM, then uninstalls standalone Node.' + $reply = Read-Host ' Replace standalone Node.js with NVM for Windows? [y/N]' + if ($reply -notmatch '^[Yy]$') { + Write-Host ' Keeping standalone Node.js and skipping NVM to avoid PATH conflicts.' + return + } + $plan = 'Migrate' + } + } + + if ($DryRun) { + if ($plan -eq 'Migrate') { + Write-Host " Would back up npm globals, install NVM + Node LTS, then uninstall $($state.StandaloneIds -join ', ')." + } else { + Write-Host " Would install $script:NvmPackageId $script:NvmVersion and Node LTS." + } + return + } + + $backup = $null + try { + if ($plan -eq 'Migrate') { + $backup = Save-DotfilesNodeInventory -State $state + Write-Host " Saved npm globals inventory: $backup" + } + + $nvmExe = $state.NvmExecutable + if (-not $nvmExe) { + Write-Host " Installing $script:NvmPackageId $script:NvmVersion..." + # The pinned installer must not uninstall old Node before LTS is ready. + & winget install --id $script:NvmPackageId --exact --version $script:NvmVersion --source winget --accept-package-agreements --accept-source-agreements --silent --custom '/OFFICIALNODE=ignore' + if ($LASTEXITCODE -ne 0) { throw 'WinGet could not install NVM; existing Node.js was not removed.' } + $newNvm = @(Get-DotfilesNvmExecutables | Where-Object { $_.Major -ge 2 } | Select-Object -First 1) + if ($newNvm.Count -eq 0) { throw 'WinGet reported success but nvm.exe was not found; inspect the installation in a new shell.' } + $nvmExe = $newNvm[0].Path + } + + if ($plan -eq 'Install' -or -not (Test-DotfilesNvmDefault -NvmExecutable $nvmExe)) { + Install-DotfilesNodeLts -NvmExecutable $nvmExe + } else { + Write-Host ' Reusing the existing NVM default version.' + } + + if ($plan -eq 'Migrate') { + # Uninstall only AFTER a usable NVM default has been verified. + foreach ($id in $state.StandaloneIds) { + Write-Host " Uninstalling standalone Node.js: $id" + & winget uninstall --id $id --exact --source winget --accept-source-agreements + if ($LASTEXITCODE -ne 0) { throw "Failed to uninstall $id; both installations may remain. Resolve the PATH conflict manually." } + } + $left = @($state.StandalonePaths | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }) + if ($left.Count -gt 0) { throw "Old node.exe still exists at $($left -join ', '); resolve this conflict before using NVM." } + Write-Host ' Migration complete. Review the saved npm global list; reinstall needed tools under NVM.' + } else { + Write-Host ' NVM and Node.js LTS installed.' + } + Write-Host ' Restart PowerShell, then run: nvm default; where.exe node; node -v; npm -v' + } catch { + Write-Warning "NVM bootstrap incomplete: $($_.Exception.Message)" + if ($backup) { Write-Warning "Saved npm inventory: $backup" } + Write-Warning 'No automatic rollback was attempted. Inspect WinGet and PATH before rerunning.' + } +} + +Export-ModuleMember -Function @( + 'Get-DotfilesNvmExecutables' + 'Get-DotfilesNodePlan' + 'Get-DotfilesNodeState' + 'Invoke-DotfilesNvmBootstrap' +) diff --git a/windows/packages.psd1 b/windows/packages.psd1 index 49bb9bb..5dc3f78 100644 --- a/windows/packages.psd1 +++ b/windows/packages.psd1 @@ -11,7 +11,7 @@ 'Microsoft.PowerShell' 'Microsoft.WindowsTerminal' 'GNU.Nano' - 'OpenJS.NodeJS.LTS' + 'AuthorSoftware.NVMWindows' '7zip.7zip' 'sharkdp.bat' 'dandavison.delta' From 62e904b3ccb0b6d6c838d23f06e313e7bd8e0698 Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Thu, 22 Oct 2026 01:36:17 +0200 Subject: [PATCH 2/5] fix(windows): fail explicitly when NVM migration is incomplete or unsupported --- docs/windows.md | 2 +- windows/Dotfiles.WindowsNode.psm1 | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/windows.md b/docs/windows.md index d691e1a..4bd29a9 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -204,7 +204,7 @@ An approved migration writes the current global npm package names/versions to `% The inventory is for **manual reinstallation** of global npm tools, not an archive of their files. The installer does not silently delete global npm directories or reinstall arbitrary global tools. After migration, review that inventory and install any packages you still need under the NVM-managed Node. -If WinGet cannot remove the old runtime, the installer reports an incomplete migration and leaves the state for manual repair rather than deleting files. The earlier executable may continue to shadow NVM on PATH. Close and reopen PowerShell, then verify: +If WinGet cannot remove the old runtime, the installer reports an incomplete migration, returns a failure, and leaves the state for manual repair rather than deleting files. Explicit noninteractive migration also fails instead of silently skipping when unsupported Node/NVM installations are detected. The earlier executable may continue to shadow NVM on PATH. Close and reopen PowerShell, then verify: ```powershell nvm --version diff --git a/windows/Dotfiles.WindowsNode.psm1 b/windows/Dotfiles.WindowsNode.psm1 index 0cba76e..0896cb2 100644 --- a/windows/Dotfiles.WindowsNode.psm1 +++ b/windows/Dotfiles.WindowsNode.psm1 @@ -177,14 +177,17 @@ function Invoke-DotfilesNvmBootstrap { switch ($plan) { 'LegacyNvm' { Write-Warning 'NVM for Windows v1 detected. Migrate it manually before installing v2.' + if ($ExplicitMigration -and -not $DryRun) { throw 'Explicit migration cannot proceed with NVM v1 present.' } return } 'OtherNode' { Write-Warning "Another Node runtime is on PATH ($($state.OtherNodePaths -join ', ')); resolve this conflict before installing NVM." + if ($ExplicitMigration -and -not $DryRun) { throw 'Explicit migration cannot proceed with another Node runtime on PATH.' } return } 'ManualNode' { Write-Warning "Standalone Node found outside recognized WinGet packages ($($state.StandalonePaths -join ', ')). Uninstall it manually first." + if ($ExplicitMigration -and -not $DryRun) { throw 'Explicit migration requires WinGet-managed standalone Node.' } return } 'Existing' { @@ -262,6 +265,7 @@ function Invoke-DotfilesNvmBootstrap { Write-Warning "NVM bootstrap incomplete: $($_.Exception.Message)" if ($backup) { Write-Warning "Saved npm inventory: $backup" } Write-Warning 'No automatic rollback was attempted. Inspect WinGet and PATH before rerunning.' + throw 'NVM bootstrap failed; package installation was stopped to avoid concealing a Node/PATH conflict.' } } From 34dad73ff38bfd35f627d5d014e03ed09ffbdcd9 Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Sun, 8 Nov 2026 22:16:53 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix(windows):=20=F0=9F=90=9B=20verify=20NVM?= =?UTF-8?q?=20adoption=20and=20npm=20globals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/windows.md | 11 +++--- tests/windows-node-tests.ps1 | 9 +++++ windows/Dotfiles.WindowsNode.psm1 | 64 +++++++++++++++++++------------ 4 files changed, 56 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index bac0c92..3b406ec 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ Run `./install.sh --help` for the complete option list. macOS-specific options s | `.\install.ps1 -Force` or `-y` | Answer yes to all package-group prompts; never authorize removal of Store PowerShell | | `.\install.ps1 -Force -MigratePowerShellToMsi` | Explicitly opt into replacing current-user PowerShell MSIX with a fixed-path 7.6 MSI, when not running inside MSIX | | `.\install.ps1 -Force` or `-y` | Answer yes to package-group prompts, but never to removing a pre-existing Node.js | -| `.\install.ps1 -Force -MigrateNodeToNvm` | Explicitly opt into replacing WinGet-managed standalone Node with NVM v2 after saving an npm global inventory | +| `.\install.ps1 -Force -MigrateNodeToNvm` | Explicitly migrate standalone Node and its global npm packages using NVM adoption | | `.\install.ps1 -CleanBackups` | Inspect the currently supported Windows backup-cleanup scope and ask before deletion | | `.\install.ps1 -RestoreGitDefaults` | Restore managed Git values when they have not changed since installation | diff --git a/docs/windows.md b/docs/windows.md index 4bd29a9..f2056d3 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -123,7 +123,7 @@ Windows Terminal and TrafficMonitor configuration are currently copied during no | `.\install.ps1 -Force` | Answer yes to package-group prompts, but not to removing PowerShell MSIX | | `.\install.ps1 -Force -MigratePowerShellToMsi` | Explicit opt-in to replace current-user PowerShell MSIX with fixed-path MSI, only from a non-MSIX shell | | `.\install.ps1 -Force` | Answer yes to package-group prompts, but not to removing standalone Node.js | -| `.\install.ps1 -Force -MigrateNodeToNvm` | Explicitly consent to replacing WinGet-managed standalone Node.js with NVM v2 | +| `.\install.ps1 -Force -MigrateNodeToNvm` | Explicitly migrate standalone Node and its global npm packages using NVM adoption | | `.\install.ps1 -y` | Alias for `-Force` | | `.\install.ps1 -CleanBackups` | List backups in the currently supported cleanup scope and ask before deletion | | `.\install.ps1 -RestoreGitDefaults` | Restore installer-managed Git values when they still match the values applied by the installer | @@ -136,6 +136,7 @@ Important mode rules: - `-Force` opts into package groups, including optional and private ones, but never authorizes removal of PowerShell MSIX. Use the additional `-MigratePowerShellToMsi` flag or an interactive confirmation. - `-MigratePowerShellToMsi` is incompatible with `-Minimal`, `-Sync`, `-RestoreGitDefaults`, and `-CleanBackups`. - `-Force` opts into package groups, not removal of an existing runtime. Node.js migration requires its own prompt or `-MigrateNodeToNvm`. +- `-Force` opts into package groups, not removal of an existing runtime. Migration requires a separate confirmation or `-MigrateNodeToNvm`. - `-MigrateNodeToNvm` cannot be combined with `-Minimal`, `-Sync`, or `-RestoreGitDefaults`. ## Package manifests @@ -196,13 +197,13 @@ The public core installs **NVM for Windows v2** (`AuthorSoftware.NVMWindows`) in - **No Node or NVM:** install NVM and Node LTS, then open a new PowerShell session. - **NVM v2 already present:** keep the selected/default Node version unchanged. -- **WinGet-managed standalone Node.js (`OpenJS.NodeJS.LTS` or `OpenJS.NodeJS`):** offer migration with a default answer of **No**. Declining keeps Node and skips installing NVM. -- **`-Force`:** selects package groups, but never authorizes removal of Node. For unattended explicit consent use `.\install.ps1 -Force -MigrateNodeToNvm`. Using `-MigrateNodeToNvm` alone still requires choosing the core package group. +- **WinGet-managed standalone Node.js (`OpenJS.NodeJS.LTS` or `OpenJS.NodeJS`):** offer migration with a default answer of No. +- **`-Force`:** selects package groups, but does not authorize migration without `-MigrateNodeToNvm`. - **Unrecognized standalone Node, NVM v1, Scoop/fnm/Volta, or another Node executable on PATH:** skip automatic changes and explain the conflict. Clean up manually and rerun. -An approved migration writes the current global npm package names/versions to `%LOCALAPPDATA%\dotfiles\node-migration\before-nvm-*.json`. The installer then installs NVM, activates/verifies a Node version managed by NVM and **only after that** invokes WinGet to uninstall standalone Node. The old Node is not removed if NVM installation or LTS activation fails. A temporary coexistence during migration is intentional, not a supported final state. +An approved migration saves an npm inventory and invokes the community NVM installer with adoption. NVM copies the existing version and global modules; it can uninstall standalone Node during installation, before this bootstrap verifies the replacement. The bootstrap checks the adopted version and saved package versions before attempting to remove any standalone package still present. See the [NVM installer documentation](https://docs.nvm-windows.com/install/installers/). -The inventory is for **manual reinstallation** of global npm tools, not an archive of their files. The installer does not silently delete global npm directories or reinstall arbitrary global tools. After migration, review that inventory and install any packages you still need under the NVM-managed Node. +The inventory contains package names and versions, not a backup of their files. The adopted Node version remains the default so its global modules remain available; additional Node versions manage their own global modules. If WinGet cannot remove the old runtime, the installer reports an incomplete migration, returns a failure, and leaves the state for manual repair rather than deleting files. Explicit noninteractive migration also fails instead of silently skipping when unsupported Node/NVM installations are detected. The earlier executable may continue to shadow NVM on PATH. Close and reopen PowerShell, then verify: diff --git a/tests/windows-node-tests.ps1 b/tests/windows-node-tests.ps1 index 36c76d6..d997b06 100644 --- a/tests/windows-node-tests.ps1 +++ b/tests/windows-node-tests.ps1 @@ -70,4 +70,13 @@ Assert-NodePlan -State $legacy -Expected 'LegacyNvm' -ExplicitMigration $ambiguous = New-TestNodeState -StandaloneIds @('OpenJS.NodeJS.LTS') -OtherNodePaths @('C:\tools\node.exe') Assert-NodePlan -State $ambiguous -Expected 'OtherNode' -ExplicitMigration +$saved = [pscustomobject]@{ GlobalPackages = @('@scope/tool@1.2.3', 'example@4.5.6') } +$adopted = '{"dependencies":{"@scope/tool":{"version":"1.2.3"},"example":{"version":"4.5.6"}}}' | ConvertFrom-Json +Assert-DotfilesNpmInventory -Saved $saved -Adopted $adopted +foreach ($badJson in @('{"dependencies":{"example":{"version":"4.5.6"}}}', '{"dependencies":{"@scope/tool":{"version":"1.2.4"},"example":{"version":"4.5.6"}}}')) { + $failed = $false + try { Assert-DotfilesNpmInventory -Saved $saved -Adopted ($badJson | ConvertFrom-Json) } catch { $failed = $true } + if (-not $failed) { throw 'Inventory verification accepted a missing or changed package.' } +} + Write-Host 'NVM migration decision tests passed.' diff --git a/windows/Dotfiles.WindowsNode.psm1 b/windows/Dotfiles.WindowsNode.psm1 index 0896cb2..2c27e0b 100644 --- a/windows/Dotfiles.WindowsNode.psm1 +++ b/windows/Dotfiles.WindowsNode.psm1 @@ -158,6 +158,16 @@ function Test-DotfilesNvmDefault { } } +function Assert-DotfilesNpmInventory { + param([Parameter(Mandatory = $true)][psobject]$Saved, [Parameter(Mandatory = $true)][psobject]$Adopted) + foreach ($package in @($Saved.GlobalPackages)) { + if ($package -notmatch '^(.+)@([^@]+)$') { throw "Cannot verify saved package: $package" } + $name = $Matches[1]; $version = $Matches[2] + $property = $Adopted.dependencies.PSObject.Properties[$name] + if (-not $property -or $property.Value.version -ne $version) { throw "Adopted npm package missing or changed: $package" } + } +} + function Install-DotfilesNodeLts { param([Parameter(Mandatory = $true)][string]$NvmExecutable) Write-Host ' Installing Node.js LTS using NVM...' @@ -195,28 +205,18 @@ function Invoke-DotfilesNvmBootstrap { return } 'Prompt' { - if ($DryRun) { - Write-Host ' Would ask whether to replace standalone Node.js (default No); no changes.' - return - } - if ($NonInteractive) { - Write-Warning 'Standalone Node present. -Force does not authorize removal; skipping NVM. Use -MigrateNodeToNvm to opt in.' - return - } - Write-Host " Standalone Node.js detected: $($state.StandaloneIds -join ', ')." - Write-Host ' Migration saves npm globals, activates Node LTS in NVM, then uninstalls standalone Node.' - $reply = Read-Host ' Replace standalone Node.js with NVM for Windows? [y/N]' - if ($reply -notmatch '^[Yy]$') { - Write-Host ' Keeping standalone Node.js and skipping NVM to avoid PATH conflicts.' - return - } + if ($DryRun) { Write-Host ' Would ask to migrate standalone Node and its npm globals (default No).'; return } + if ($NonInteractive) { Write-Warning 'Standalone Node detected; migration requires explicit consent.'; return } + Write-Warning 'NVM adoption copies Node and global npm modules, then may uninstall standalone Node during setup.' + $reply = Read-Host ' Migrate standalone Node and global npm modules to NVM? [y/N]' + if ($reply -notmatch '^[Yy]$') { return } $plan = 'Migrate' } } if ($DryRun) { if ($plan -eq 'Migrate') { - Write-Host " Would back up npm globals, install NVM + Node LTS, then uninstall $($state.StandaloneIds -join ', ')." + Write-Host ' Would save the npm inventory and run NVM adoption; setup may uninstall standalone Node.' } else { Write-Host " Would install $script:NvmPackageId $script:NvmVersion and Node LTS." } @@ -233,30 +233,45 @@ function Invoke-DotfilesNvmBootstrap { $nvmExe = $state.NvmExecutable if (-not $nvmExe) { Write-Host " Installing $script:NvmPackageId $script:NvmVersion..." - # The pinned installer must not uninstall old Node before LTS is ready. - & winget install --id $script:NvmPackageId --exact --version $script:NvmVersion --source winget --accept-package-agreements --accept-source-agreements --silent --custom '/OFFICIALNODE=ignore' - if ($LASTEXITCODE -ne 0) { throw 'WinGet could not install NVM; existing Node.js was not removed.' } + $installerArgs = @('install', '--id', $script:NvmPackageId, '--exact', '--version', $script:NvmVersion, '--source', 'winget', '--accept-package-agreements', '--accept-source-agreements', '--silent') + if ($plan -eq 'Migrate') { $installerArgs += @('--custom', '/OFFICIALNODE=adopt') } + & winget @installerArgs + if ($LASTEXITCODE -ne 0) { throw 'WinGet could not install NVM; inspect the machine before retrying.' } $newNvm = @(Get-DotfilesNvmExecutables | Where-Object { $_.Major -ge 2 } | Select-Object -First 1) if ($newNvm.Count -eq 0) { throw 'WinGet reported success but nvm.exe was not found; inspect the installation in a new shell.' } $nvmExe = $newNvm[0].Path } - if ($plan -eq 'Install' -or -not (Test-DotfilesNvmDefault -NvmExecutable $nvmExe)) { + if ($plan -eq 'Install') { Install-DotfilesNodeLts -NvmExecutable $nvmExe - } else { - Write-Host ' Reusing the existing NVM default version.' + } elseif (-not (Test-DotfilesNvmDefault -NvmExecutable $nvmExe)) { + throw 'NVM did not report an adopted default Node version. Inspect the saved inventory and installation before retrying.' } if ($plan -eq 'Migrate') { - # Uninstall only AFTER a usable NVM default has been verified. + # Verify the adopted executable and npm global inventory before removing + # any standalone installation still present after the NVM installer. + $versionText = (& $nvmExe default 2>$null | Out-String) + if ($versionText -notmatch 'v?(\d+\.\d+\.\d+)') { throw 'Cannot identify the adopted Node version.' } + $adoptedDir = Join-Path (Split-Path -Parent $nvmExe) ("installs\v" + $Matches[1]) + $adoptedNode = Join-Path $adoptedDir 'node.exe' + $adoptedNpm = Join-Path $adoptedDir 'node_modules\npm\bin\npm-cli.js' + if (-not (Test-Path -LiteralPath $adoptedNode) -or -not (Test-Path -LiteralPath $adoptedNpm)) { throw 'Adopted Node or npm is missing.' } + $adoptedJson = (& $adoptedNode $adoptedNpm ls -g --depth=0 --json 2>$null | Out-String) + if ($LASTEXITCODE -ne 0) { throw 'Unable to verify adopted npm globals.' } + $adopted = $adoptedJson | ConvertFrom-Json -ErrorAction Stop + $saved = Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json -ErrorAction Stop + Assert-DotfilesNpmInventory -Saved $saved -Adopted $adopted + $installedNow = @(Get-WingetInstalledPackageIds) foreach ($id in $state.StandaloneIds) { + if ($installedNow -notcontains $id) { continue } Write-Host " Uninstalling standalone Node.js: $id" & winget uninstall --id $id --exact --source winget --accept-source-agreements if ($LASTEXITCODE -ne 0) { throw "Failed to uninstall $id; both installations may remain. Resolve the PATH conflict manually." } } $left = @($state.StandalonePaths | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }) if ($left.Count -gt 0) { throw "Old node.exe still exists at $($left -join ', '); resolve this conflict before using NVM." } - Write-Host ' Migration complete. Review the saved npm global list; reinstall needed tools under NVM.' + Write-Host ' Migration complete. The adopted Node version and saved npm globals were verified.' } else { Write-Host ' NVM and Node.js LTS installed.' } @@ -270,6 +285,7 @@ function Invoke-DotfilesNvmBootstrap { } Export-ModuleMember -Function @( + 'Assert-DotfilesNpmInventory' 'Get-DotfilesNvmExecutables' 'Get-DotfilesNodePlan' 'Get-DotfilesNodeState' From ff1c10fb6c6498c942168a967fc3da7c7c7ed185 Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Sun, 8 Nov 2026 22:21:32 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix(windows):=20=F0=9F=90=9B=20refresh=20Wi?= =?UTF-8?q?nGet=20state=20after=20adoption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/windows-node-tests.ps1 | 22 ++++++++++++++++++++++ windows/Dotfiles.WindowsNode.psm1 | 2 +- windows/Dotfiles.WindowsPackages.psm1 | 3 ++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/windows-node-tests.ps1 b/tests/windows-node-tests.ps1 index d997b06..6f10c54 100644 --- a/tests/windows-node-tests.ps1 +++ b/tests/windows-node-tests.ps1 @@ -79,4 +79,26 @@ foreach ($badJson in @('{"dependencies":{"example":{"version":"4.5.6"}}}', '{"de if (-not $failed) { throw 'Inventory verification accepted a missing or changed package.' } } +Import-Module (Join-Path $repoRoot 'windows/Dotfiles.WindowsPackages.psm1') -Force +$global:dotfilesTestWingetIds = @('OpenJS.NodeJS.LTS') +$global:dotfilesTestWingetExports = 0 +function global:winget { + $global:dotfilesTestWingetExports++ + $outputIndex = [array]::IndexOf($args, '-o') + 1 + $packages = @($global:dotfilesTestWingetIds | ForEach-Object { @{ PackageIdentifier = $_ } }) + @{ Sources = @(@{ Packages = $packages }) } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $args[$outputIndex] + $global:LASTEXITCODE = 0 +} +try { + if (@(Get-WingetInstalledPackageIds) -notcontains 'OpenJS.NodeJS.LTS') { throw 'Expected initial package in export.' } + $global:dotfilesTestWingetIds = @('AuthorSoftware.NVMWindows') + if (@(Get-WingetInstalledPackageIds) -notcontains 'OpenJS.NodeJS.LTS') { throw 'Expected cached export before refresh.' } + $refreshed = @(Get-WingetInstalledPackageIds -Refresh) + if ($refreshed -contains 'OpenJS.NodeJS.LTS' -or $refreshed -notcontains 'AuthorSoftware.NVMWindows') { throw 'Refresh returned stale package IDs.' } + if ($global:dotfilesTestWingetExports -ne 2) { throw 'Unexpected WinGet export count.' } +} finally { + Remove-Item Function:\global:winget -ErrorAction SilentlyContinue + Remove-Variable dotfilesTestWingetIds, dotfilesTestWingetExports -Scope Global -ErrorAction SilentlyContinue +} + Write-Host 'NVM migration decision tests passed.' diff --git a/windows/Dotfiles.WindowsNode.psm1 b/windows/Dotfiles.WindowsNode.psm1 index 2c27e0b..59c33cb 100644 --- a/windows/Dotfiles.WindowsNode.psm1 +++ b/windows/Dotfiles.WindowsNode.psm1 @@ -262,7 +262,7 @@ function Invoke-DotfilesNvmBootstrap { $adopted = $adoptedJson | ConvertFrom-Json -ErrorAction Stop $saved = Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json -ErrorAction Stop Assert-DotfilesNpmInventory -Saved $saved -Adopted $adopted - $installedNow = @(Get-WingetInstalledPackageIds) + $installedNow = @(Get-WingetInstalledPackageIds -Refresh) foreach ($id in $state.StandaloneIds) { if ($installedNow -notcontains $id) { continue } Write-Host " Uninstalling standalone Node.js: $id" diff --git a/windows/Dotfiles.WindowsPackages.psm1 b/windows/Dotfiles.WindowsPackages.psm1 index b42a849..d741291 100644 --- a/windows/Dotfiles.WindowsPackages.psm1 +++ b/windows/Dotfiles.WindowsPackages.psm1 @@ -166,11 +166,12 @@ function Test-WingetPackageInstalled { } function Get-WingetInstalledPackageIds { + param([switch]$Refresh) if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { return @() } - if (Get-Variable -Scope Script -Name WingetInstalledPackageIds -ErrorAction SilentlyContinue) { + if (-not $Refresh -and (Get-Variable -Scope Script -Name WingetInstalledPackageIds -ErrorAction SilentlyContinue)) { return $script:WingetInstalledPackageIds } From afeb66b2ff7700f5b177b7de4400787c25df8862 Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Mon, 9 Nov 2026 00:38:37 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(windows):=20=F0=9F=90=9B=20resolve=20mi?= =?UTF-8?q?gration=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +-- docs/windows.md | 5 ++--- install.ps1 | 2 ++ tests/windows-smoke.ps1 | 3 +-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3b406ec..8cc8085 100644 --- a/README.md +++ b/README.md @@ -162,9 +162,8 @@ Run `./install.sh --help` for the complete option list. macOS-specific options s | `.\install.ps1 -DryRun` | Preview file, Git, and package operations without changing the machine | | `.\install.ps1 -Minimal` | Apply managed configuration but skip package installation | | `.\install.ps1 -Sync` | Reconcile profiles, copied configuration, and Git defaults without installing packages | -| `.\install.ps1 -Force` or `-y` | Answer yes to all package-group prompts; never authorize removal of Store PowerShell | +| `.\install.ps1 -Force` or `-y` | Answer yes to package-group prompts; never authorize removal of Store PowerShell or standalone Node.js | | `.\install.ps1 -Force -MigratePowerShellToMsi` | Explicitly opt into replacing current-user PowerShell MSIX with a fixed-path 7.6 MSI, when not running inside MSIX | -| `.\install.ps1 -Force` or `-y` | Answer yes to package-group prompts, but never to removing a pre-existing Node.js | | `.\install.ps1 -Force -MigrateNodeToNvm` | Explicitly migrate standalone Node and its global npm packages using NVM adoption | | `.\install.ps1 -CleanBackups` | Inspect the currently supported Windows backup-cleanup scope and ask before deletion | | `.\install.ps1 -RestoreGitDefaults` | Restore managed Git values when they have not changed since installation | diff --git a/docs/windows.md b/docs/windows.md index f2056d3..9c0e533 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -120,9 +120,8 @@ Windows Terminal and TrafficMonitor configuration are currently copied during no | `.\install.ps1 -DryRun` | Preview files, Git changes, manifest selection, and missing packages without modifying the machine | | `.\install.ps1 -Minimal` | Apply managed files and Git defaults but skip package installation | | `.\install.ps1 -Sync` | Reconcile profiles, copied files, manifests, and Git defaults without installing packages | -| `.\install.ps1 -Force` | Answer yes to package-group prompts, but not to removing PowerShell MSIX | +| `.\install.ps1 -Force` | Answer yes to package-group prompts, but not to removing PowerShell MSIX or standalone Node.js | | `.\install.ps1 -Force -MigratePowerShellToMsi` | Explicit opt-in to replace current-user PowerShell MSIX with fixed-path MSI, only from a non-MSIX shell | -| `.\install.ps1 -Force` | Answer yes to package-group prompts, but not to removing standalone Node.js | | `.\install.ps1 -Force -MigrateNodeToNvm` | Explicitly migrate standalone Node and its global npm packages using NVM adoption | | `.\install.ps1 -y` | Alias for `-Force` | | `.\install.ps1 -CleanBackups` | List backups in the currently supported cleanup scope and ask before deletion | @@ -136,7 +135,6 @@ Important mode rules: - `-Force` opts into package groups, including optional and private ones, but never authorizes removal of PowerShell MSIX. Use the additional `-MigratePowerShellToMsi` flag or an interactive confirmation. - `-MigratePowerShellToMsi` is incompatible with `-Minimal`, `-Sync`, `-RestoreGitDefaults`, and `-CleanBackups`. - `-Force` opts into package groups, not removal of an existing runtime. Node.js migration requires its own prompt or `-MigrateNodeToNvm`. -- `-Force` opts into package groups, not removal of an existing runtime. Migration requires a separate confirmation or `-MigrateNodeToNvm`. - `-MigrateNodeToNvm` cannot be combined with `-Minimal`, `-Sync`, or `-RestoreGitDefaults`. ## Package manifests @@ -191,6 +189,7 @@ The MSI invocation should report `C:\Program Files\PowerShell\7`. After migratio **OpenSSH default shell is configured separately:** installation alone does not change `HKLM:\SOFTWARE\OpenSSH` or restart `sshd`. If you explicitly want PowerShell 7 as the SSH default shell, inspect your OpenSSH configuration and set `DefaultShell` to the full Program Files executable path from an elevated session; keep the original registry value for rollback. This repo deliberately does not change service configuration. Windows PowerShell **5.1 is supported as a bootstrap/recovery host**. The Windows smoke checks parse and exercise the dotfiles under both `powershell.exe` (5.1) and `pwsh.exe` (7), without installing or removing anything. If the MSI install or MSIX removal fails, the bootstrap reports failure, preserves Windows PowerShell 5.1, and does not automatically delete the other installation. + ### Node.js: NVM for Windows v2 The public core installs **NVM for Windows v2** (`AuthorSoftware.NVMWindows`) in place of standalone `OpenJS.NodeJS.LTS`. The bootstrap pins NVM **2.0.1**, which is available in WinGet and code-signed. On a clean machine it installs and activates the latest Node LTS with `nvm install lts` and `nvm use lts`. diff --git a/install.ps1 b/install.ps1 index abca8d8..bf0e43c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -750,6 +750,8 @@ if ($RestoreGitDefaults -and ($Sync -or $Minimal -or $Force -or $CleanBackups)) if ($MigratePowerShellToMsi -and ($Minimal -or $Sync -or $RestoreGitDefaults -or $CleanBackups)) { throw '-MigratePowerShellToMsi requires a normal package run. Use the standalone Windows PowerShell 5.1 migration helper if needed.' +} + if ($MigrateNodeToNvm -and ($Minimal -or $Sync -or $RestoreGitDefaults)) { throw '-MigrateNodeToNvm requires normal package installation, not -Minimal, -Sync or -RestoreGitDefaults.' } diff --git a/tests/windows-smoke.ps1 b/tests/windows-smoke.ps1 index da7744a..0ec3b94 100644 --- a/tests/windows-smoke.ps1 +++ b/tests/windows-smoke.ps1 @@ -60,8 +60,7 @@ if (@($coreManifest.Winget) -notcontains 'Microsoft.PowerShell') { & (Join-Path $repoRoot 'tests/windows-node-tests.ps1') -$corePackages = Import-PowerShellDataFile -Path (Join-Path $repoRoot 'windows/packages.psd1') -if (@($corePackages.Winget) -notcontains 'AuthorSoftware.NVMWindows' -or @($corePackages.Winget) -contains 'OpenJS.NodeJS.LTS') { +if (@($coreManifest.Winget) -notcontains 'AuthorSoftware.NVMWindows' -or @($coreManifest.Winget) -contains 'OpenJS.NodeJS.LTS') { throw 'Windows core manifest must use NVM v2 rather than standalone Node LTS.' }