fix(install): reject incompatible system npm
This commit is contained in:
8
.github/workflows/installer-tests.yml
vendored
8
.github/workflows/installer-tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
142
scripts/tests/test-install-ps1-node-compatibility.ps1
Normal file
142
scripts/tests/test-install-ps1-node-compatibility.ps1
Normal 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'
|
||||
Reference in New Issue
Block a user