From 97f13602c1ab823add47f2f5f4282b9e2ee1eb49 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 1 Aug 2026 22:02:30 -0400 Subject: [PATCH] fix(install): install npm 12 into the vendored Node tree on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 6fdc64efc, which fixed only the POSIX bootstrap. install.ps1 unpacks the same nodejs.org build, so Windows had the same EBADENGINE: Node 26.5.1 bundles npm 11.17.0, one minor below the root package.json's `engines.npm` floor of >=12, and .npmrc's engine-strict=true makes that fatal at the first `npm ci`. Update-ManagedNpm mirrors _nb_ensure_bundled_npm_range rung for rung — temp cwd so the checkout's .npmrc cannot gate the upgrade meant to satisfy it, npm_config_min_release_age=0, and an explicit --prefix at the managed tree. EAP is relaxed around the npm call for the same reason Install-Uv does it: npm's stderr would otherwise wrap as ErrorRecords and short-circuit before $LASTEXITCODE is read. Env vars and location are restored in a finally. Called from both branches that yield a managed tree: the fresh portable unpack, and the reuse-an-existing-tree path, where an older install still has its original major's npm sitting there. The in-range check makes the second a one-probe no-op on reruns. The range comes from Get-NpmRange, which prefers the checkout's package.json but falls back to a $NpmRange constant — unlike the POSIX side, Test-Node runs before the repo is cloned, so there is usually no manifest on disk yet (and none at all when install.ps1 is piped from the web). The manifest read means a drifted constant self-corrects on any run against an existing checkout. Not executed locally: no pwsh on this machine, and the repo runs no PowerShell in CI. --- scripts/install.ps1 | 107 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a4eefc72beadf..95dd40d24b9d4 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -151,6 +151,13 @@ $PythonVersion = "3.11" # source of truth shared by Test-Python's fallback and Resolve-AvailablePythonVersion. $PythonFallbackVersions = @("3.12", "3.13", "3.10") $NodeVersion = "26" +# The npm range the root package.json pins in `engines.npm`. A constant rather +# than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo +# is cloned, so there is usually no package.json on disk yet (and none at all +# when install.ps1 is piped straight from the web). Get-NpmRange prefers the +# manifest whenever it does exist, so a drifted constant self-corrects on any +# run against an existing checkout. +$NpmRange = ">=12.0.0" # Stage-protocol version. Bumped only for genuinely breaking changes to the # manifest schema, stage-name set semantics, or stdout JSON shape. Adding a @@ -563,6 +570,100 @@ function Set-ManagedNodeFirstOnUserPath { } } +# The npm range to install into the managed Node tree. Prefers the checkout's +# root package.json so the installer and the manifest cannot drift; falls back +# to the $NpmRange constant, which is the common case here because Test-Node +# runs before the repo is cloned. +function Get-NpmRange { + $manifest = Join-Path $InstallDir "package.json" + if (Test-Path $manifest) { + try { + $engines = (Get-Content $manifest -Raw | ConvertFrom-Json).engines + if ($engines -and $engines.npm) { return [string]$engines.npm } + } catch { } + } + return $NpmRange +} + +# Upgrade the Hermes-managed Node tree's bundled npm into $NpmRange. +# +# The nodejs.org zip ships whatever npm that Node major bundles -- Node 26.5.1 +# bundles npm 11.17.0, one minor below the root package.json's own +# `engines.npm` floor of >=12. The repo .npmrc sets `engine-strict=true`, so +# that is fatal rather than a warning and a brand-new install dies at the first +# `npm ci` with EBADENGINE. Provision the right npm here instead of reacting +# to the failure later. +# +# Three details are load-bearing, mirroring _nb_ensure_bundled_npm_range in +# scripts/lib/node-bootstrap.sh and upgrade_managed_npm in +# hermes_cli/npm_engine.py: +# - a temp cwd, so the checkout's own .npmrc (engine-strict, +# min-release-age) does not gate the very upgrade meant to satisfy it; +# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc; +# - an explicit --prefix at the managed tree, so the upgrade rewrites the +# tree's own npm rather than installing a second copy elsewhere. +# +# Best-effort: a failure leaves a working Node with an old npm, which beats no +# Node at all, and npm_engine.py still covers the EBADENGINE that follows. +function Update-ManagedNpm { + param([string]$NodeDir) + + $npmCmd = Join-Path $NodeDir "npm.cmd" + if (-not (Test-Path $npmCmd)) { return $false } + + $range = Get-NpmRange + + # Skip the network round-trip when the bundled npm already satisfies the + # range. Only the ">=N" shape we actually author is parsed; anything more + # exotic falls through to letting npm itself decide. + if ($range -match '^>=(\d+)') { + $want = [int]$Matches[1] + try { + $have = (& $npmCmd --version 2>$null) + if ($have -match '^(\d+)') { + if ([int]$Matches[1] -ge $want) { return $true } + } + } catch { } + } + + Write-Info "Upgrading bundled npm to satisfy $range ..." + + $tmpCwd = Join-Path $env:TEMP ("hermes-npm-upgrade-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $tmpCwd | Out-Null + $prevAge = $env:npm_config_min_release_age + $prevCI = $env:CI + $prevEAP = $ErrorActionPreference + Push-Location $tmpCwd + try { + $env:npm_config_min_release_age = "0" + $env:CI = "1" + # Relax EAP=Stop so npm's stderr lines don't get wrapped as + # ErrorRecords and short-circuit before $LASTEXITCODE is checked. + # Same pattern as Install-Uv. + $ErrorActionPreference = "Continue" + & $npmCmd install --global --prefix $NodeDir "npm@$range" ` + --no-fund --no-audit --progress=false 2>&1 | Out-Null + $exit = $LASTEXITCODE + } catch { + $exit = 1 + } finally { + $ErrorActionPreference = $prevEAP + Pop-Location + $env:npm_config_min_release_age = $prevAge + $env:CI = $prevCI + Remove-Item -Recurse -Force $tmpCwd -ErrorAction SilentlyContinue + } + + if ($exit -ne 0) { + Write-Warn "Could not upgrade bundled npm to $range -- ``npm ci`` may fail with EBADENGINE." + Write-Info "Fix manually: npm install -g --prefix `"$NodeDir`" npm@`"$range`"" + return $false + } + + Write-Success "npm $(& $npmCmd --version 2>$null) installed" + return $true +} + # Re-discover uv without re-installing it. Cross-process stage drivers # (the desktop GUI's onboarding wizard, CI step-runners) invoke each stage # in a fresh powershell process, so $script:UvCmd set by Install-Uv in a @@ -1124,6 +1225,10 @@ function Test-Node { $env:Path = "$HermesHome\node;$env:Path" Set-ManagedNodeFirstOnUserPath "$HermesHome\node" Write-Success "Node.js $version found (Hermes-managed)" + # A tree from an older install still has that Node major's bundled + # npm, which is below the current engines.npm floor. No-ops when the + # npm is already in range, so reruns cost one --version probe. + Update-ManagedNpm "$HermesHome\node" | Out-Null $script:HasNode = $true return $true } @@ -1172,6 +1277,8 @@ function Test-Node { $version = & "$HermesHome\node\node.exe" --version Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)" + # The zip's bundled npm is below the repo's engines.npm floor. + Update-ManagedNpm "$HermesHome\node" | Out-Null $script:HasNode = $true Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue