fix(install): restore installs — engines floor no shipping toolchain can meet

Fresh installs and `hermes update` both fail at the first `npm ci`:

    npm error code EBADENGINE
    npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
    npm error notsup Actual:   {"node":"v24.15.0","npm":"11.12.1"}

`.npmrc` sets engine-strict=true, so `engines` is a hard gate on every
install. The floor was raised to npm >=12 — but no Node release bundles
npm 12: Node 26 ships 11.17.0, 24 ships 11.16.0, 22 ships 10.9.8. The
requirement is unsatisfiable by any stock toolchain, so the installer
provisions a Node and is immediately unable to install with it.

engines.npm becomes `<11.10.0 || >=11.17.0`. That still excludes the band
the strictness was actually for: npm 11.10-11.16 honor `min-release-age`
but ignore `min-release-age-exclude`, both set in .npmrc, so they apply the
14-day gate to packages we exempted. Verified rather than assumed — npm
11.12.1 fails `ETARGET ... vite@8.2.0 with a date before 7/18/2026` while
11.17.0 installs it.

engines.node returns to >=20.0.0 and the toolchain floor to Node 22.
Nothing in the tree needs 26: Vite 8.2.0 declares `^20.19.0 || >=22.12.0`
and Electron 40 declares `>=12.20.55`. Requiring 26 force-migrated every
working install for no dependency reason. apps/desktop drops to Vite's own
floor for the same reason; the desktop bundle builds clean on Node 22.

install.sh gained a second gate: a system Node was accepted on version
alone, so a machine with Node 24 + its bundled npm 11.16.0 (the bad band)
passed the check and then failed `npm ci`. npm_supports_npmrc() now rejects
that band and installs the managed Node instead.

CI, Docker and nix are hermetic and keep pinning Node 26 / npm 12 — they
provision their own toolchain, and both satisfy the relaxed range.

tests/test_engines_satisfiable.py encodes the invariants that would have
caught this: the npm floor must be met by an npm some shipping Node
bundles, the node floor by the runtime install.sh provisions, the desktop
floor by its own build toolchain, and the lockfile mirror must match.
Restoring the broken values fails 5 of them with the reason stated.

Verified end-to-end (real downloads, temp HERMES_HOME):
- fresh install: managed node v22.23.2 / npm 10.9.8 -> npm ci, 209 packages
- existing managed tree (v22.22.3 / npm 10.9.8) -> npm ci, 209 packages
- node 26.5.1 + bundled npm 11.17.0 -> npm ci, 208 packages
- system npm 11.12.1 (the reported case) -> EBADENGINE, recovery provisions
  a managed tree and retries green
- apps/desktop `npm run build` on Node 22 -> dist built, assert passes
This commit is contained in:
Brooklyn Nicholson 2026-08-01 22:56:52 -05:00
parent 8bfd5af3bc
commit 8e08a4a16e
9 changed files with 254 additions and 30 deletions

View File

@ -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",

View File

@ -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"

4
package-lock.json generated
View File

@ -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": {

View File

@ -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,

View File

@ -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 {

View File

@ -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.011.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.

View File

@ -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

View File

@ -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` / `^x.y.z` comparator against *version*."""
clause = clause.strip()
if clause.startswith("^"):
bound = clause[1:].strip()
have = _parse_major_minor_patch(version)
want = _parse_major_minor_patch(bound)
# ^x.y.z allows >=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.1011.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

View File

@ -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", "")