fix(install): reject incompatible system npm

This commit is contained in:
Gille
2026-08-08 15:37:30 -06:00
committed by Teknium
parent d5d80963fa
commit 3e1629009a
3 changed files with 277 additions and 12 deletions

View File

@@ -36,3 +36,11 @@ jobs:
- name: 8.3 short-path normalization (Windows PowerShell 5.1)
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
- name: System Node and npm compatibility (pwsh 7)
shell: pwsh
run: pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-node-compatibility.ps1
- name: System Node and npm compatibility (Windows PowerShell 5.1)
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-node-compatibility.ps1

View File

@@ -934,6 +934,88 @@ function Get-NpmRange {
return $NpmRange
}
# Convert the numeric core of an npm version or range operand into a stable
# three-component System.Version. npm reports semantic versions, but the
# installer only needs the numeric core for the comparator ranges authored in
# package.json (for example, <11.10.0 || >=11.17.0).
function ConvertTo-NpmVersion {
param([string]$Version)
if (-not $Version) { return $null }
$core = ($Version.Trim() -replace '^v', '' -replace '-.*$', '')
$parts = @($core -split '\.')
if ($parts.Count -lt 1 -or $parts.Count -gt 3) { return $null }
foreach ($part in $parts) {
if ($part -notmatch '^\d+$') { return $null }
}
while ($parts.Count -lt 3) { $parts += '0' }
try {
return [version]($parts -join '.')
} catch {
return $null
}
}
# Evaluate the comparator-only npm ranges used by the root manifest and the
# pre-clone fallback. Alternatives are separated with || and each alternative
# may contain one or more whitespace-separated <, <=, >, or >= comparators.
# Unknown range syntax fails closed so an incompatible system npm cannot reach
# npm ci and fail later with EBADENGINE.
function Test-NpmVersionOk {
param(
[string]$Version,
[string]$Range = (Get-NpmRange)
)
$actual = ConvertTo-NpmVersion $Version
if (-not $actual -or -not $Range) { return $false }
foreach ($alternative in @($Range -split '\s*\|\|\s*')) {
$clause = $alternative.Trim()
if (-not $clause) { continue }
$comparators = [regex]::Matches(
$clause,
'(?:^|\s)(<=|>=|<|>)\s*(\d+(?:\.\d+){0,2})(?=\s|$)'
)
if ($comparators.Count -eq 0) { continue }
$remainder = [regex]::Replace(
$clause,
'(?:^|\s)(?:<=|>=|<|>)\s*\d+(?:\.\d+){0,2}(?=\s|$)',
''
).Trim()
if ($remainder) { continue }
$matchesClause = $true
foreach ($comparator in $comparators) {
$target = ConvertTo-NpmVersion $comparator.Groups[2].Value
if (-not $target) {
$matchesClause = $false
break
}
$matchesComparator = switch ($comparator.Groups[1].Value) {
'<' { $actual -lt $target }
'<=' { $actual -le $target }
'>' { $actual -gt $target }
'>=' { $actual -ge $target }
default { $false }
}
if (-not $matchesComparator) {
$matchesClause = $false
break
}
}
if ($matchesClause) { return $true }
}
return $false
}
# 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
@@ -1587,20 +1669,55 @@ function Test-NodeVersionOk {
return ($v.Major -gt 22)
}
# Accept a system Node only when its companion npm also satisfies the same
# range used to provision the Hermes-managed tree. Keeping this probe separate
# lets the initial PATH check and the post-winget check share one authority.
function Test-SystemNodeReady {
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false }
$version = node --version
if (-not (Test-NodeVersionOk $version)) {
Write-Warn "Node.js $version is too old (Hermes requires Node >=22.22.0)"
return $false
}
Ensure-NodeExeOnPath | Out-Null
$npmRange = Get-NpmRange
$npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue
if (-not $npmCmd) {
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
}
$npmVersion = $null
if ($npmCmd) {
try {
$npmVersion = (& $npmCmd --version 2>$null | Select-Object -First 1)
} catch { }
}
if ($npmVersion -and (Test-NpmVersionOk $npmVersion $npmRange)) {
Write-Success "Node.js $version with npm $npmVersion found"
return $true
}
if ($npmVersion) {
Write-Warn "Node.js $version uses npm $npmVersion, which does not satisfy Hermes requirement $npmRange"
} else {
Write-Warn "Node.js $version was found, but npm is missing or could not report its version"
}
return $false
}
function Test-Node {
Write-Info "Checking Node.js (for browser tools)..."
if (Get-Command node -ErrorAction SilentlyContinue) {
$version = node --version
if (Test-NodeVersionOk $version) {
Ensure-NodeExeOnPath | Out-Null
Write-Success "Node.js $version found"
$script:HasNode = $true
return $true
}
Write-Warn "Node.js $version is too old (Hermes requires Node >=26)"
if (Test-SystemNodeReady) {
$script:HasNode = $true
return $true
}
Write-Info "Using a Hermes-managed Node.js installation instead..."
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
$managedNode = "$HermesHome\node\node.exe"
if ((Test-Path $managedNode) -and (Test-NodeVersionOk (& $managedNode --version))) {
@@ -1774,9 +1891,7 @@ function Test-Node {
$ErrorActionPreference = $prevEAP
# Refresh PATH
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command node -ErrorAction SilentlyContinue) {
$version = node --version
Write-Success "Node.js $version installed via winget"
if (Test-SystemNodeReady) {
$script:HasNode = $true
return $true
}

View File

@@ -0,0 +1,142 @@
# Behavioral tests for install.ps1 system Node/npm compatibility selection.
#
# The installer itself is not executed. The real shipped functions are lifted
# through the PowerShell AST, then external commands and downloads are replaced
# with deterministic in-process stubs. This exercises the actual range parser
# and Test-Node acceptance gate without changing PATH, installing software, or
# touching the user's Hermes home.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
$installScript = Join-Path $repoRoot 'scripts\install.ps1'
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$installScript, [ref]$tokens, [ref]$parseErrors
)
if ($parseErrors.Count -gt 0) {
throw "install.ps1 has parse errors: $($parseErrors -join '; ')"
}
foreach ($name in @(
'ConvertTo-NpmVersion',
'Test-NpmVersionOk',
'Test-NodeVersionOk',
'Test-SystemNodeReady',
'Test-Node'
)) {
$fn = $ast.FindAll(
{
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq $name
},
$true
) | Select-Object -First 1
if (-not $fn) { throw "$name not found in install.ps1" }
. ([scriptblock]::Create($fn.Extent.Text))
}
$script:Failures = 0
function Assert-Equal {
param($Expected, $Actual, [string]$Label)
if ($Expected -ceq $Actual) {
Write-Host "PASS: $Label"
} else {
Write-Host "FAIL: $Label"
Write-Host " expected: [$Expected]"
Write-Host " actual: [$Actual]"
$script:Failures++
}
}
Write-Host '-- npm range evaluation --'
$supportedRange = '<11.10.0 || >=11.17.0'
Assert-Equal $true (Test-NpmVersionOk '11.9.9' $supportedRange) 'lower alternative is accepted'
Assert-Equal $false (Test-NpmVersionOk '11.10.0' $supportedRange) 'excluded band starts at 11.10.0'
Assert-Equal $false (Test-NpmVersionOk '11.16.0' $supportedRange) 'reported npm 11.16.0 is rejected'
Assert-Equal $true (Test-NpmVersionOk '11.17.0' $supportedRange) 'upper alternative starts at 11.17.0'
Assert-Equal $false (Test-NpmVersionOk '11.17.0' '>=12.0.0') 'pre-clone npm floor rejects 11.x'
Assert-Equal $true (Test-NpmVersionOk '12.0.0' '>=12.0.0') 'pre-clone npm floor accepts 12.0.0'
Assert-Equal $false (Test-NpmVersionOk 'not-a-version' $supportedRange) 'malformed version fails closed'
Assert-Equal $false (Test-NpmVersionOk '12.0.0' '^12.0.0') 'unsupported range syntax fails closed'
# Controlled command surface used by the lifted Test-Node function.
$script:FakeNpmAvailable = $true
$script:FakeNpmVersion = '11.16.0'
$script:FakeNpmRange = $supportedRange
$script:DownloadAttempts = 0
$script:HasNode = $null
$HermesHome = Join-Path $env:TEMP ("hermes-node-compatibility-test-" + [Guid]::NewGuid().ToString('N'))
$NodeVersion = '22'
function node { 'v24.18.0' }
function npm.cmd { $script:FakeNpmVersion }
function Get-Command {
[CmdletBinding()]
param([string]$Name)
switch ($Name) {
'node' {
return Microsoft.PowerShell.Core\Get-Command node -CommandType Function
}
'npm.cmd' {
if ($script:FakeNpmAvailable) {
return Microsoft.PowerShell.Core\Get-Command npm.cmd -CommandType Function
}
return $null
}
'npm' { return $null }
'winget' { return $null }
default { return $null }
}
}
function Get-NpmRange { $script:FakeNpmRange }
function Ensure-NodeExeOnPath { $true }
function Get-WindowsArch { 'x64' }
function Invoke-WebRequest {
$script:DownloadAttempts++
throw 'network disabled by test'
}
function Write-Info { param([string]$Message) }
function Write-Warn { param([string]$Message) }
function Write-Success { param([string]$Message) }
function Invoke-SystemNodeProbe {
param([string]$NpmVersion, [bool]$NpmAvailable = $true)
$script:FakeNpmVersion = $NpmVersion
$script:FakeNpmAvailable = $NpmAvailable
$script:DownloadAttempts = 0
$script:HasNode = $null
[void](Test-Node)
return [pscustomobject]@{
HasNode = $script:HasNode
DownloadAttempts = $script:DownloadAttempts
}
}
Write-Host ''
Write-Host '-- system Node acceptance --'
$result = Invoke-SystemNodeProbe '11.17.0'
Assert-Equal $true $result.HasNode 'compatible system Node/npm is accepted'
Assert-Equal 0 $result.DownloadAttempts 'compatible system npm avoids managed download'
$result = Invoke-SystemNodeProbe '11.16.0'
Assert-Equal $false $result.HasNode 'incompatible system npm is not accepted'
Assert-Equal 1 $result.DownloadAttempts 'incompatible system npm falls through to managed Node'
$result = Invoke-SystemNodeProbe '' $false
Assert-Equal $false $result.HasNode 'missing system npm is not accepted'
Assert-Equal 1 $result.DownloadAttempts 'missing system npm falls through to managed Node'
if ($script:Failures -gt 0) {
Write-Host ''
Write-Host "$script:Failures assertion(s) failed"
exit 1
}
Write-Host ''
Write-Host 'all assertions passed'