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