diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3c222af9268c7..534386106c94c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,7 +12,7 @@ "type": "module", "main": "dist/electron-main.mjs", "engines": { - "node": ">=22.22.0" + "node": "^22.22.0 || ^24.0.0 || >=26.0.0" }, "scripts": { "clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron", diff --git a/package-lock.json b/package-lock.json index 451f5a10df7aa..0a37abc543f0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "typescript-eslint": "8.64.0" }, "engines": { - "node": ">=22.22.0", + "node": "^22.22.0 || ^24.0.0 || >=26.0.0", "npm": "<11.10.0 || >=11.17.0" } }, @@ -165,7 +165,7 @@ "wait-on": "9.0.10" }, "engines": { - "node": ">=22.22.0" + "node": "^22.22.0 || ^24.0.0 || >=26.0.0" }, "optionalDependencies": { "get-windows": "9.3.0" diff --git a/package.json b/package.json index 0ad8e0cbfdd29..56de1ec452088 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "tar": "7.5.22" }, "engines": { - "node": ">=22.22.0", + "node": "^22.22.0 || ^24.0.0 || >=26.0.0", "npm": "<11.10.0 || >=11.17.0" }, "allowScripts": { diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 38c2012a97248..a6b4f029eaafb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1562,20 +1562,31 @@ function Set-GitBashEnvVar { Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." } -# The dependency tree's real Node floor is >=22.22.0, set by react-router 8.3.0 -# (`engines.node`). Keep this in sync with the root package.json: looser lets an -# install reach a `npm ci` that dies with EBADENGINE, stricter replaces a working -# user toolchain for nothing. Returns $true when a `node --version` string -# clears that floor. +# The dependency tree supports Node 22.22+, 24, and 26+. nanoid 6 excludes +# Node 23 and 25 while its >=26 arm accepts later releases, so accepting 23/25 +# only defers the failure to `npm ci` under engine-strict. Keep this in sync +# with the root package.json. function Test-NodeVersionOk { param([string]$Version) + if ($Version -match '-') { return $false } try { - $v = [version]($Version -replace '^v', '' -replace '-.*$', '') + $v = [version]($Version -replace '^v', '') } catch { return $false } if ($v.Major -eq 22) { return ($v.Minor -ge 22) } - return ($v.Major -gt 22) + return (($v.Major -eq 24) -or ($v.Major -ge 26)) +} + +function Test-NpmVersionOk { + param([string]$Version) + if ($Version -match '-') { return $false } + try { + $v = [version]($Version -replace '^v', '') + } catch { + return $false + } + return -not ($v.Major -eq 11 -and $v.Minor -ge 10 -and $v.Minor -le 16) } function Test-Node { @@ -1583,13 +1594,21 @@ function Test-Node { if (Get-Command node -ErrorAction SilentlyContinue) { $version = node --version - if (Test-NodeVersionOk $version) { + $npmCmd = Resolve-NpmCmd + $npmVersion = if ($npmCmd) { & $npmCmd --version 2>$null } else { $null } + if ((Test-NodeVersionOk $version) -and $npmCmd -and (Test-NpmVersionOk $npmVersion)) { 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 (-not (Test-NodeVersionOk $version)) { + Write-Warn "Node.js $version is unsupported (Hermes requires Node 22.22+, 24, or 26+)" + } elseif (-not $npmCmd) { + Write-Warn "Node.js $version has no npm.cmd on PATH" + } else { + Write-Warn "npm $npmVersion is unsupported (Hermes rejects npm 11.10 through 11.16)" + } } # Prefer a Hermes-managed Node from a previous run over a too-old system one. @@ -3824,8 +3843,8 @@ function Install-Desktop { # Always re-resolve Node here. Stages run in separate PowerShell processes, # so $script:HasNode from Stage-Node isn't visible; more importantly Test-Node - # enforces the build floor (Node >=26) and prepends the Hermes-managed - # Node to PATH, so the build never runs on a too-old system Node -- the cause + # enforces the supported Node lines and prepends the Hermes-managed Node to + # PATH, so the build never runs on an unsupported system Node -- the cause # of the opaque "Build desktop app ... exit code 1" failure (Vite crashes on # old Node). Test-Node | Out-Null diff --git a/scripts/install.sh b/scripts/install.sh index 9496397c5276b..8eea23a5681bd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -786,20 +786,20 @@ check_git() { exit 1 } -# The dependency tree's real Node floor is >=22.22.0, set by react-router 8.3.0 -# (`engines.node`), with Vite ^8 next at `^20.19 || >=22.12`. Keep this in sync -# with the root package.json — a gate looser than the manifest lets an install -# proceed to a `npm ci` that then dies with EBADENGINE, and a gate stricter than -# the manifest replaces a working user toolchain for nothing. Returns 0 when the -# given `node --version` string clears the floor; anything below it is replaced -# with the Hermes-managed Node $NODE_VERSION. +# The dependency tree supports Node 22.22+, 24, and 26+. nanoid 6 excludes +# Node 23 and 25 while its >=26 arm accepts later releases, so accepting 23/25 +# here only defers the failure to `npm ci` under engine-strict. Keep this in +# sync with the root package.json. Anything outside the supported lines is +# replaced with the Hermes-managed Node $NODE_VERSION. node_satisfies_build() { local ver="${1#v}" + case "$ver" in *-*) return 1 ;; esac local major="${ver%%.*}" local minor="${ver#*.}"; minor="${minor%%.*}" case "$major" in ''|*[!0-9]*) return 1 ;; esac case "$minor" in ''|*[!0-9]*) minor=0 ;; esac - if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 22 ]; }; then return 0; fi + if [ "$major" -eq 22 ] && [ "$minor" -ge 22 ]; then return 0; fi + if [ "$major" -eq 24 ] || [ "$major" -ge 26 ]; then return 0; fi return 1 } @@ -867,7 +867,7 @@ check_node() { if command -v node &> /dev/null && ! command -v npm &> /dev/null; then log_warn "node found but npm is not on PATH (stray node symlink?) — installing Hermes-managed Node $NODE_VERSION LTS..." elif command -v node &> /dev/null; then - log_warn "Node.js $(node --version) is too old (Hermes requires Node >=26) — installing Hermes-managed Node $NODE_VERSION..." + log_warn "Node.js $(node --version) is unsupported (Hermes requires Node 22.22+, 24, or 26+) — installing Hermes-managed Node $NODE_VERSION..." elif [ "$DISTRO" = "termux" ]; then log_info "Node.js not found — installing Node.js via pkg..." else @@ -3123,8 +3123,8 @@ install_desktop() { # failure, not a silent skip — a silent skip yields a "complete" install # with no app and a confusing "couldn't find a built desktop" at launch. # Always re-resolve Node here. Stages run in separate processes, so we can't - # trust an earlier check; more importantly check_node now enforces the build - # floor (Node >=26) and prepends the Hermes-managed Node to PATH, so + # trust an earlier check; more importantly check_node now enforces the + # supported Node lines and prepends the Hermes-managed Node to PATH, so # the build never runs on a too-old system Node — the cause of the opaque # "Build desktop app … exit code 1" failure (Vite crashes on old Node). check_node diff --git a/tests-js/node-engine-alignment.test.ts b/tests-js/node-engine-alignment.test.ts new file mode 100644 index 0000000000000..d5cccfd82a932 --- /dev/null +++ b/tests-js/node-engine-alignment.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' + +import { describe, test } from 'vitest' + +const REPO_ROOT = path.resolve(__dirname, '..') + +interface Manifest { + engines?: { node?: string } +} + +interface Lockfile { + packages?: Record +} + +function readJson(relativePath: string): T { + return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf-8')) as T +} + +function parseVersion(version: string): [number, number, number] { + assert.match(version, /^\d+(?:\.\d+){0,2}$/, `unsupported semver version: ${version}`) + const [major = 0, minor = 0, patch = 0] = version.split('.').map(Number) + return [major, minor, patch] +} + +function compare(left: string, right: string): number { + const have = parseVersion(left) + const want = parseVersion(right) + + for (let index = 0; index < have.length; index += 1) { + if (have[index] !== want[index]) { + return have[index] - want[index] + } + } + return 0 +} + +function satisfiesClause(version: string, clause: string): boolean { + assert.match(clause, /^(?:\^|>=|<=|>|<|=)?\d+(?:\.\d+){0,2}$/, `unsupported semver clause: ${clause}`) + if (clause.startsWith('^')) { + const bound = clause.slice(1) + return parseVersion(version)[0] === parseVersion(bound)[0] && compare(version, bound) >= 0 + } + + const match = clause.match(/^(>=|<=|>|<|=)?(.+)$/) + assert.ok(match) + const [, operator = '=', bound] = match + const result = compare(version, bound) + return operator === '>=' + ? result >= 0 + : operator === '<=' + ? result <= 0 + : operator === '>' + ? result > 0 + : operator === '<' + ? result < 0 + : result === 0 +} + +function satisfiesRange(version: string, range: string): boolean { + const alternatives = range.split('||').map(alternative => alternative.trim().split(/\s+/)) + alternatives.flat().forEach(clause => satisfiesClause(version, clause)) + return alternatives.some(clauses => clauses.every(clause => satisfiesClause(version, clause))) +} + +const rootManifest = readJson('package.json') +const desktopManifest = readJson('apps/desktop/package.json') +const lockfile = readJson('package-lock.json') + +function nodeRange(manifest: Manifest, label: string): string { + assert.ok(manifest.engines?.node, `${label} must declare engines.node`) + return manifest.engines.node +} + +describe('Node engine alignment', () => { + const rootRange = nodeRange(rootManifest, 'root package.json') + const desktopRange = nodeRange(desktopManifest, 'apps/desktop/package.json') + + test.each(['22.22.0', '22.23.1', '24.0.0', '26.0.0'])('all workspace manifests accept supported Node %s', version => { + assert.ok(satisfiesRange(version, rootRange)) + assert.ok(satisfiesRange(version, desktopRange)) + }) + + test.each(['22.21.1', '23.0.0', '25.2.1'])( + 'all workspace manifests reject dependency-incompatible Node %s', + version => { + assert.ok(!satisfiesRange(version, rootRange)) + assert.ok(!satisfiesRange(version, desktopRange)) + } + ) + + test('lockfile workspace mirrors match their manifests', () => { + assert.equal(nodeRange(lockfile.packages?.[''] ?? {}, 'root lock entry'), rootRange) + assert.equal(nodeRange(lockfile.packages?.['apps/desktop'] ?? {}, 'desktop lock entry'), desktopRange) + }) + + test.each(['~22.22.0', '22.x', '>=26.0.0-rc.1'])( + 'the alignment helper rejects unsupported semver clause %s instead of misclassifying it', + clause => { + assert.throws(() => satisfiesRange('26.0.0', clause), /unsupported semver clause/) + } + ) + + test('unsupported clauses are rejected even after a matching alternative', () => { + assert.throws(() => satisfiesRange('26.0.0', '>=26.0.0 || ~28.0.0'), /unsupported semver clause/) + }) +}) diff --git a/tests/test_engines_satisfiable.py b/tests/test_engines_satisfiable.py index e150765de6d1d..fed951a023915 100644 --- a/tests/test_engines_satisfiable.py +++ b/tests/test_engines_satisfiable.py @@ -19,7 +19,6 @@ from __future__ import annotations import json -import re from pathlib import Path import pytest @@ -117,15 +116,11 @@ def test_node_floor_is_met_by_the_managed_runtime(self): else: # pragma: no cover - install.sh always defines it pytest.fail("install.sh does not define NODE_VERSION") - # install.sh fetches latest-v{major}.x, not {major}.0.0, so compare on - # the major: the newest release of that line must be able to clear the - # floor. A floor in a HIGHER major than we provision can never be met. - floor_majors = [ - int(m.group(1)) - for m in re.finditer(r">=\s*v?(\d+)", node_range) - ] - assert floor_majors, f"cannot read a floor out of {node_range!r}" - assert managed_major >= min(floor_majors), ( + # install.sh fetches latest-v{major}.x, not {major}.0.0. Use a high + # representative release from that major so ranges that enumerate LTS + # lines (rather than one continuous floor) are checked correctly. + managed_release = f"{managed_major}.999.999" + assert _satisfies_range(managed_release, node_range), ( f"engines.node is {node_range!r} but install.sh provisions Node " f"{managed_major}.x. The runtime we ship must satisfy the floor we " "declare, or the install we just performed cannot install deps." diff --git a/tests/test_install_ps1_node_path_for_npm.py b/tests/test_install_ps1_node_path_for_npm.py index 9f3a6cb432db7..778db533ad445 100644 --- a/tests/test_install_ps1_node_path_for_npm.py +++ b/tests/test_install_ps1_node_path_for_npm.py @@ -30,9 +30,20 @@ def test_install_ps1_defines_ensure_node_exe_on_path_helper() -> None: def test_test_node_prepends_node_dir_before_success() -> None: text = _install_ps1() assert re.search( - r"if \(Test-NodeVersionOk \$version\) \{[\s\S]{0,200}?Ensure-NodeExeOnPath", + r"if \(\(Test-NodeVersionOk \$version\) -and \$npmCmd -and " + r"\(Test-NpmVersionOk \$npmVersion\)\) \{[\s\S]{0,200}?Ensure-NodeExeOnPath", text, - ), "Test-Node must call Ensure-NodeExeOnPath when a system Node passes the version floor" + ), "Test-Node must validate Node and npm before prepending the system Node directory" + + +def test_test_node_rejects_the_incompatible_npm_band() -> None: + text = _install_ps1() + assert re.search( + r"return -not \(\$v\.Major -eq 11 -and \$v\.Minor -ge 10 -and " + r"\$v\.Minor -le 16\)", + text, + ), "Test-NpmVersionOk must reject npm 11.10 through 11.16" + assert "elseif (-not $npmCmd)" in text def test_install_node_deps_prepends_node_dir_before_npm() -> None: