diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 479219e9eb41b..518402303711f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,7 @@ "type": "module", "main": "dist/electron-main.mjs", "engines": { - "node": ">=26.0.0" + "node": "^20.19.0 || >=22.12.0" }, "scripts": { "clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron", diff --git a/hermes_constants.py b/hermes_constants.py index f8e43b460ffe3..96b9227322131 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -317,7 +317,7 @@ def _candidate_node_command_names(command: str) -> list[str]: return [f"{base}.cmd", f"{base}.exe", base] -_HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "26")) +_HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "22")) _managed_node_heal_attempted = False _NODE_BOOTSTRAP_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lib" / "node-bootstrap.sh" diff --git a/package-lock.json b/package-lock.json index 337d5ef1e0d6e..9cbd2126a79e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,8 +29,8 @@ "typescript-eslint": "8.64.0" }, "engines": { - "node": ">=26.0.0", - "npm": ">=12.0.0" + "node": ">=20.0.0", + "npm": "<11.10.0 || >=11.17.0" } }, "apps/bootstrap-installer": { diff --git a/package.json b/package.json index a22d0f1b8e668..8da1e0f176958 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ "brace-expansion": "5.0.8" }, "engines": { - "node": ">=26.0.0", - "npm": ">=12.0.0" + "node": ">=20.0.0", + "npm": "<11.10.0 || >=11.17.0" }, "allowScripts": { "unicode-animations": false, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 95dd40d24b9d4..09310dffae7ca 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -150,7 +150,7 @@ $PythonVersion = "3.11" # interpreters, so this list also matches a pre-existing system Python. Single # source of truth shared by Test-Python's fallback and Resolve-AvailablePythonVersion. $PythonFallbackVersions = @("3.12", "3.13", "3.10") -$NodeVersion = "26" +$NodeVersion = "22" # 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 @@ -1190,10 +1190,11 @@ function Set-GitBashEnvVar { Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." } -# Hermes requires Node 26 across every install: the desktop build's toolchain -# floor is pinned there and the managed runtime, heal, and upgrade paths all -# provision latest-v26.x. Returns $true when a `node --version` string clears -# that floor. +# The desktop build runs Vite ^8, which refuses to start on Node outside +# `^20.19 || >=22.12`. That toolchain floor is the real constraint; do NOT +# raise it past what a dependency actually demands, or every user on a working +# Node gets their toolchain replaced for nothing. Returns $true when a +# `node --version` string clears that floor. function Test-NodeVersionOk { param([string]$Version) try { @@ -1201,7 +1202,9 @@ function Test-NodeVersionOk { } catch { return $false } - return ($v.Major -ge 26) + if ($v.Major -eq 20) { return ($v.Minor -ge 19) } + if ($v.Major -eq 22) { return ($v.Minor -ge 12) } + return ($v.Major -gt 22) } function Test-Node { diff --git a/scripts/install.sh b/scripts/install.sh index 38198eab463b0..6f3d777ba4f30 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -57,7 +57,7 @@ else INSTALL_DIR_EXPLICIT=false fi PYTHON_VERSION="3.11" -NODE_VERSION="26" +NODE_VERSION="22" # FHS-style root install layout (set by resolve_install_layout when applicable): # code at /usr/local/lib/hermes-agent, command at /usr/local/bin/hermes, @@ -780,16 +780,43 @@ check_git() { exit 1 } -# Hermes requires Node 26 across every install: the desktop build's toolchain -# floor is pinned there and the managed runtime, heal, and upgrade paths all -# provision latest-v26.x. Returns 0 when the given `node --version` string -# clears that floor; anything below it is replaced with the Hermes-managed -# Node $NODE_VERSION. +# The desktop build runs Vite ^8, which refuses to start on Node outside +# `^20.19 || >=22.12` — older Node lacks `node:util.styleText`, so `vite build` +# crashes with a SyntaxError that surfaces only as the opaque "Build desktop +# app … exit code 1" install failure. That toolchain floor is the real +# constraint; do NOT raise it past what a dependency actually demands, or every +# user on a working Node gets their toolchain replaced 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. node_satisfies_build() { local ver="${1#v}" local major="${ver%%.*}" + local minor="${ver#*.}"; minor="${minor%%.*}" case "$major" in ''|*[!0-9]*) return 1 ;; esac - [ "$major" -ge 26 ] + case "$minor" in ''|*[!0-9]*) minor=0 ;; esac + if [ "$major" -eq 20 ] && [ "$minor" -ge 19 ]; then return 0; fi + if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 12 ]; }; then return 0; fi + return 1 +} + +# npm 11.10.0–11.16.x honor `min-release-age` but ignore +# `min-release-age-exclude`, both of which `.npmrc` sets. That combination +# applies the 14-day age gate to packages we deliberately exempted, so every +# install fails ETARGET on a freshly published dependency. The root +# package.json excludes that band via `engines.npm`, and `engine-strict=true` +# makes it fatal — so a system npm in the band cannot install this repo, no +# matter how new its Node is. Returns 0 when the npm is usable. +npm_supports_npmrc() { + local ver="${1#v}" + local major="${ver%%.*}" + local minor="${ver#*.}"; minor="${minor%%.*}" + case "$major" in ''|*[!0-9]*) return 1 ;; esac + case "$minor" in ''|*[!0-9]*) minor=0 ;; esac + # The bad band is 11.10.0 through 11.16.x. + if [ "$major" -eq 11 ] && [ "$minor" -ge 10 ] && [ "$minor" -le 16 ]; then + return 1 + fi + return 0 } check_node() { @@ -800,10 +827,20 @@ check_node() { # every install — including re-runs that skip the Node (re)install below. configure_managed_node_npm_prefix + # The system toolchain is only usable when BOTH halves work: a Node new + # enough for the desktop build AND an npm that can read our .npmrc. A + # bad-band npm (see npm_supports_npmrc) fails `npm ci` outright, and the + # managed Node we install instead bundles one that works. if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then - log_success "Node.js $(node --version) found" - HAS_NODE=true - return 0 + if ! command -v npm &> /dev/null || npm_supports_npmrc "$(npm --version 2>/dev/null)"; then + log_success "Node.js $(node --version) found" + HAS_NODE=true + return 0 + fi + log_warn "npm $(npm --version) cannot honor this repo's .npmrc (npm 11.10-11.16 ignore" + log_warn "min-release-age-exclude) — installing Hermes-managed Node $NODE_VERSION instead..." + install_node + return fi # Prefer a Hermes-managed Node from a previous run over a too-old system one. diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 1c68310156417..a9a10d3fb17db 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -18,13 +18,13 @@ # if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi # # Env inputs (set before sourcing to override defaults): -# HERMES_NODE_MIN_VERSION (default: 26) — accepted on PATH -# HERMES_NODE_TARGET_MAJOR (default: 26) — installed when we install +# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH +# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install # HERMES_HOME (default: $HOME/.hermes) # ============================================================================ -HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-26}" -HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-26}" +HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}" +HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}" HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" HERMES_NODE_AVAILABLE=false diff --git a/tests/test_engines_satisfiable.py b/tests/test_engines_satisfiable.py new file mode 100644 index 0000000000000..117764f894ab6 --- /dev/null +++ b/tests/test_engines_satisfiable.py @@ -0,0 +1,175 @@ +"""The manifest's ``engines`` must be satisfiable by a toolchain we can actually ship. + +`engine-strict=true` in `.npmrc` makes `engines` a hard gate on every +`npm ci` / `npm install` — the installer's workspace step, `hermes update`'s +dependency refresh, and CI alike. So a floor nobody's toolchain can meet is +not a strict-hygiene win; it is a total install outage. + +That is exactly what happened: `engines.npm` was raised to `>=12.0.0` while +**no Node release bundles npm 12** (Node 26 ships 11.17.0, 24 ships 11.16.0, +22 ships 10.9.8). Every fresh install died at the first `npm ci`, and +`hermes update` left installs in a mixed state. These tests encode the +invariants that would have caught it. + +Deliberately behavioral, not a snapshot: nothing here pins a version we +expect to change. Each test asserts a *relationship* — between the floor we +declare and the toolchain that has to satisfy it. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# npm releases bundled with a Node major, newest-per-major. Not a catalog +# snapshot: the point is that *some* real, shipping toolchain must clear the +# floor, and these are the ones users actually arrive with. +_STOCK_NPM_BY_NODE_MAJOR = { + 20: "10.8.2", + 22: "10.9.8", + 24: "11.16.0", + 26: "11.17.0", +} + + +def _root_manifest() -> dict: + return json.loads((REPO_ROOT / "package.json").read_text()) + + +def _parse_major_minor_patch(version: str) -> tuple[int, int, int]: + parts = version.split("-", 1)[0].split(".") + nums = [int(p) for p in parts[:3]] + while len(nums) < 3: + nums.append(0) + return nums[0], nums[1], nums[2] + + +def _satisfies_clause(version: str, clause: str) -> bool: + """Evaluate one `>=x.y.z` / `=x.y.z within the same major (x > 0). + return have[0] == want[0] and have >= want + for op in (">=", "<=", "<", ">", "="): + if clause.startswith(op): + bound = clause[len(op) :].strip() + break + else: + op, bound = "=", clause + have = _parse_major_minor_patch(version) + want = _parse_major_minor_patch(bound) + if op == ">=": + return have >= want + if op == "<=": + return have <= want + if op == "<": + return have < want + if op == ">": + return have > want + return have == want + + +def _satisfies_range(version: str, spec: str) -> bool: + """Evaluate the `A || B` / space-joined-AND subset of semver we author.""" + for alternative in spec.split("||"): + clauses = [c for c in alternative.strip().split() if c] + if clauses and all(_satisfies_clause(version, c) for c in clauses): + return True + return False + + +class TestEnginesAreSatisfiable: + def test_npm_floor_is_met_by_a_shipping_node(self): + """Some stock Node must bundle an npm our floor accepts. + + Without this, a fresh install cannot run `npm ci` at all: the + installer provisions a Node from nodejs.org and immediately uses the + npm that came with it. + """ + npm_range = _root_manifest()["engines"]["npm"] + satisfying = { + major: npm + for major, npm in _STOCK_NPM_BY_NODE_MAJOR.items() + if _satisfies_range(npm, npm_range) + } + assert satisfying, ( + f"engines.npm is {npm_range!r}, which no shipping Node bundles " + f"(checked {_STOCK_NPM_BY_NODE_MAJOR}). With engine-strict=true " + "every fresh install fails at the first `npm ci`." + ) + + def test_node_floor_is_met_by_the_managed_runtime(self): + """The Node major the installers provision must clear engines.node.""" + node_range = _root_manifest()["engines"]["node"] + install_sh = (REPO_ROOT / "scripts" / "install.sh").read_text() + for line in install_sh.splitlines(): + if line.startswith("NODE_VERSION="): + managed_major = int(line.split("=", 1)[1].strip().strip('"').strip("'")) + break + else: # pragma: no cover - install.sh always defines it + pytest.fail("install.sh does not define NODE_VERSION") + + assert _satisfies_range(f"{managed_major}.0.0", node_range), ( + f"engines.node is {node_range!r} but install.sh provisions Node " + f"{managed_major}. The runtime we ship must satisfy the floor we " + "declare, or the install we just performed cannot install deps." + ) + + def test_desktop_node_floor_is_not_stricter_than_its_toolchain(self): + """apps/desktop must not demand more Node than its own build tools do. + + Vite is the real constraint (it needs `node:util.styleText`). Raising + the desktop floor beyond it silently force-migrates every user's + toolchain for no dependency reason. + """ + desktop = json.loads((REPO_ROOT / "apps" / "desktop" / "package.json").read_text()) + node_range = desktop["engines"]["node"] + # Vite 8's own floor. If this ever legitimately rises, the assertion + # documents the reason for the bump rather than blocking it. + assert _satisfies_range("22.12.0", node_range), ( + f"apps/desktop engines.node is {node_range!r}, which rejects Node " + "22.12 — stricter than Vite requires. A desktop floor above the " + "build toolchain's own floor replaces working user toolchains for " + "nothing." + ) + + +class TestExcludedNpmBand: + """npm 11.10–11.16 honor `min-release-age` but ignore `min-release-age-exclude`. + + `.npmrc` sets both, so that band applies the 14-day age gate to packages + we deliberately exempted and installs fail with ETARGET. The floor must + keep excluding them. + """ + + @pytest.mark.parametrize("bad_npm", ["11.10.0", "11.12.1", "11.16.0"]) + def test_band_that_ignores_the_exclude_list_is_rejected(self, bad_npm): + npm_range = _root_manifest()["engines"]["npm"] + assert not _satisfies_range(bad_npm, npm_range), ( + f"engines.npm {npm_range!r} accepts npm {bad_npm}, which supports " + "min-release-age but not min-release-age-exclude — it will fail " + "ETARGET on any freshly published dependency in .npmrc's exclude list." + ) + + @pytest.mark.parametrize("good_npm", ["10.9.8", "11.17.0", "12.0.2"]) + def test_versions_handling_the_exclude_list_are_accepted(self, good_npm): + npm_range = _root_manifest()["engines"]["npm"] + assert _satisfies_range(good_npm, npm_range), ( + f"engines.npm {npm_range!r} rejects npm {good_npm}, which handles " + ".npmrc correctly and should be usable." + ) + + +class TestManifestMirrors: + def test_lockfile_engines_match_the_manifest(self): + """A stale lockfile mirror re-imposes the old floor on `npm ci`.""" + manifest = _root_manifest()["engines"] + lock = json.loads((REPO_ROOT / "package-lock.json").read_text()) + assert lock["packages"][""]["engines"] == manifest diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index e94c185233d2d..1d07d9d6b4d22 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -215,10 +215,13 @@ class TestNodeToolRunnable: def test_outdated_managed_node_heals_to_target_major(self, tmp_path, monkeypatch): """A healthy managed tree below the target major upgrades on next resolve.""" + target = hermes_constants._HERMES_NODE_TARGET_MAJOR profile_home = tmp_path / "profiles" / "assistant" managed_bin = profile_home / "node" / "bin" managed_bin.mkdir(parents=True) - old_node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v22.20.0'\nexit 0\n") + old_node = self._stub( + managed_bin, "node", f"#!/bin/sh\necho 'v{target - 1}.20.0'\nexit 0\n" + ) heal_called = {"value": False} monkeypatch.setenv("HERMES_HOME", str(profile_home)) @@ -227,7 +230,7 @@ class TestNodeToolRunnable: def _heal(): heal_called["value"] = True - old_node.write_text("#!/bin/sh\necho 'v26.5.1'\nexit 0\n") + old_node.write_text(f"#!/bin/sh\necho 'v{target}.5.1'\nexit 0\n") old_node.chmod(0o755) return True @@ -239,10 +242,13 @@ class TestNodeToolRunnable: def test_outdated_managed_node_survives_failed_heal(self, tmp_path, monkeypatch): """Offline heal failure keeps serving the old tree — old Node beats no Node.""" + target = hermes_constants._HERMES_NODE_TARGET_MAJOR profile_home = tmp_path / "profiles" / "assistant" managed_bin = profile_home / "node" / "bin" managed_bin.mkdir(parents=True) - old_node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v22.20.0'\nexit 0\n") + old_node = self._stub( + managed_bin, "node", f"#!/bin/sh\necho 'v{target - 1}.20.0'\nexit 0\n" + ) monkeypatch.setenv("HERMES_HOME", str(profile_home)) monkeypatch.setenv("PATH", "") @@ -253,10 +259,13 @@ class TestNodeToolRunnable: def test_target_major_managed_node_does_not_heal(self, tmp_path, monkeypatch): """A tree already at the target major never triggers the heal.""" + target = hermes_constants._HERMES_NODE_TARGET_MAJOR profile_home = tmp_path / "profiles" / "assistant" managed_bin = profile_home / "node" / "bin" managed_bin.mkdir(parents=True) - node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v26.5.1'\nexit 0\n") + node = self._stub( + managed_bin, "node", f"#!/bin/sh\necho 'v{target}.5.1'\nexit 0\n" + ) monkeypatch.setenv("HERMES_HOME", str(profile_home)) monkeypatch.setenv("PATH", "")