Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
41 changes: 30 additions & 11 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1562,34 +1562,53 @@ 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 {
Write-Info "Checking Node.js (for browser tools)..."

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.
Expand Down Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions tests-js/node-engine-alignment.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Manifest>
}

function readJson<T>(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]

Check warning on line 24 in tests-js/node-engine-alignment.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / tests-js / check

Expected blank line before this statement
}

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

Check warning on line 36 in tests-js/node-engine-alignment.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / tests-js / check

Expected blank line before this statement
}

function satisfiesClause(version: string, clause: string): boolean {
assert.match(clause, /^(?:\^|>=|<=|>|<|=)?\d+(?:\.\d+){0,2}$/, `unsupported semver clause: ${clause}`)
if (clause.startsWith('^')) {

Check warning on line 41 in tests-js/node-engine-alignment.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / tests-js / check

Expected blank line before this statement
const bound = clause.slice(1)
return parseVersion(version)[0] === parseVersion(bound)[0] && compare(version, bound) >= 0

Check warning on line 43 in tests-js/node-engine-alignment.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / tests-js / check

Expected blank line before this statement
}

const match = clause.match(/^(>=|<=|>|<|=)?(.+)$/)
assert.ok(match)
const [, operator = '=', bound] = match
const result = compare(version, bound)
return operator === '>='

Check warning on line 50 in tests-js/node-engine-alignment.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / tests-js / check

Expected blank line before this statement
? 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)))

Check warning on line 64 in tests-js/node-engine-alignment.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / tests-js / check

Expected blank line before this statement
}

const rootManifest = readJson<Manifest>('package.json')
const desktopManifest = readJson<Manifest>('apps/desktop/package.json')
const lockfile = readJson<Lockfile>('package-lock.json')

function nodeRange(manifest: Manifest, label: string): string {
assert.ok(manifest.engines?.node, `${label} must declare engines.node`)
return manifest.engines.node

Check warning on line 73 in tests-js/node-engine-alignment.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / tests-js / check

Expected blank line before this statement
}

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/)
})
})
15 changes: 5 additions & 10 deletions tests/test_engines_satisfiable.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from __future__ import annotations

import json
import re
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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."
Expand Down
15 changes: 13 additions & 2 deletions tests/test_install_ps1_node_path_for_npm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading