diff --git a/README.md b/README.md index 43df486..8cc8085 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,9 @@ 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 -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 982ee46..9c0e533 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -120,8 +120,9 @@ 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 -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 | @@ -133,6 +134,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 @@ -187,6 +190,36 @@ The MSI invocation should report `C:\Program Files\PowerShell\7`. After migratio 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. +- **`-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 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 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: + +```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 ### PowerShell overlays diff --git a/install.ps1 b/install.ps1 index 2675553..bf0e43c 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) { @@ -744,6 +752,10 @@ if ($MigratePowerShellToMsi -and ($Minimal -or $Sync -or $RestoreGitDefaults -or 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 $WindowsProfileSource = Join-Path $RepoRoot 'windows/profile.ps1' $WindowsPackageModuleSource = Join-Path $RepoRoot 'windows/Dotfiles.WindowsPackages.psm1' diff --git a/tests/windows-node-tests.ps1 b/tests/windows-node-tests.ps1 new file mode 100644 index 0000000..6f10c54 --- /dev/null +++ b/tests/windows-node-tests.ps1 @@ -0,0 +1,104 @@ +# 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 + +$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.' } +} + +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/tests/windows-smoke.ps1 b/tests/windows-smoke.ps1 index 0c64398..0ec3b94 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,11 @@ if (@($coreManifest.Winget) -notcontains 'Microsoft.PowerShell') { throw 'The Windows core manifest must include Microsoft.PowerShell.' } +& (Join-Path $repoRoot 'tests/windows-node-tests.ps1') + +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.' +} $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..59c33cb --- /dev/null +++ b/windows/Dotfiles.WindowsNode.psm1 @@ -0,0 +1,293 @@ +# 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 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...' + & $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.' + 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' { + Write-Host ' NVM v2 already installed; keeping its current Node.js version unchanged.' + return + } + 'Prompt' { + 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 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." + } + 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..." + $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') { + Install-DotfilesNodeLts -NvmExecutable $nvmExe + } 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') { + # 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 -Refresh) + 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. The adopted Node version and saved npm globals were verified.' + } 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.' + throw 'NVM bootstrap failed; package installation was stopped to avoid concealing a Node/PATH conflict.' + } +} + +Export-ModuleMember -Function @( + 'Assert-DotfilesNpmInventory' + 'Get-DotfilesNvmExecutables' + 'Get-DotfilesNodePlan' + 'Get-DotfilesNodeState' + 'Invoke-DotfilesNvmBootstrap' +) 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 } 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'