Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
35 changes: 34 additions & 1 deletion docs/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ param(
[switch]$Force,
[switch]$Minimal,
[switch]$MigratePowerShellToMsi,
[switch]$MigrateNodeToNvm,
[switch]$CleanBackups,
[switch]$Sync,
[switch]$RestoreGitDefaults
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'
Expand Down
104 changes: 104 additions & 0 deletions tests/windows-node-tests.ps1
Original file line number Diff line number Diff line change
@@ -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.'
7 changes: 7 additions & 0 deletions tests/windows-smoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)

Expand Down Expand Up @@ -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') {
Expand Down
Loading
Loading