fix(install): Node 24.0–24.10 no longer passes the gates only to die at npm EBADENGINE

The locked dependency tree now carries @babel/* 8.x, which requires
node ^22.18.0 || >=24.11.0. Our engines.node arm said ^24.0.0 and the
installer gates (node_satisfies_build / Test-NodeVersionOk) accepted any
Node 24 — so a system Node 24.0–24.10 cleared every gate we own and then
failed 'npm install' with EBADENGINE under engine-strict=true.

- Raise the 24 arm to ^24.11.0 in root + desktop package.json and the
  package-lock.json mirrors
- Tighten node_satisfies_build (install.sh) and Test-NodeVersionOk
  (install.ps1) to 24.11+; update user-facing wording
- Add invariant tests: every engines.node arm floor must satisfy every
  locked dependency's engines.node, and the installer gates must encode
  the same floors as the manifest — so the next babel-style floor bump
  turns into a CI red instead of a user install outage
- docs: correct stale 'Node.js v22' provisioning claim
This commit is contained in:
Teknium
2026-08-28 12:06:19 -07:00
parent c9fa2bba45
commit 15eb5caf7a
7 changed files with 109 additions and 13 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"type": "module", "type": "module",
"main": "dist/electron-main.mjs", "main": "dist/electron-main.mjs",
"engines": { "engines": {
"node": "^22.22.0 || ^24.0.0 || >=26.0.0" "node": "^22.22.0 || ^24.11.0 || >=26.0.0"
}, },
"scripts": { "scripts": {
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron", "clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",
+2 -2
View File
@@ -25,7 +25,7 @@
"typescript-eslint": "8.64.0" "typescript-eslint": "8.64.0"
}, },
"engines": { "engines": {
"node": "^22.22.0 || ^24.0.0 || >=26.0.0", "node": "^22.22.0 || ^24.11.0 || >=26.0.0",
"npm": "<11.10.0 || >=11.17.0" "npm": "<11.10.0 || >=11.17.0"
} }
}, },
@@ -171,7 +171,7 @@
"wait-on": "9.0.10" "wait-on": "9.0.10"
}, },
"engines": { "engines": {
"node": "^22.22.0 || ^24.0.0 || >=26.0.0" "node": "^22.22.0 || ^24.11.0 || >=26.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"get-windows": "9.3.0" "get-windows": "9.3.0"
+1 -1
View File
@@ -59,7 +59,7 @@
"tar": "7.5.22" "tar": "7.5.22"
}, },
"engines": { "engines": {
"node": "^22.22.0 || ^24.0.0 || >=26.0.0", "node": "^22.22.0 || ^24.11.0 || >=26.0.0",
"npm": "<11.10.0 || >=11.17.0" "npm": "<11.10.0 || >=11.17.0"
}, },
"allowScripts": { "allowScripts": {
+6 -4
View File
@@ -1643,8 +1643,9 @@ function Set-GitBashEnvVar {
Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path."
} }
# The dependency tree supports Node 22.22+, 24, and 26+. nanoid 6 excludes # The dependency tree supports Node 22.22+, 24.11+, and 26+. nanoid 6 excludes
# Node 23 and 25 while its >=26 arm accepts later releases, so accepting 23/25 # Node 23 and 25 while its >=26 arm accepts later releases, and @babel/* 8.x
# requires ^22.18.0 || >=24.11.0 -- so accepting 23/25 or an early Node 24
# only defers the failure to `npm ci` under engine-strict. Keep this in sync # only defers the failure to `npm ci` under engine-strict. Keep this in sync
# with the root package.json. # with the root package.json.
function Test-NodeVersionOk { function Test-NodeVersionOk {
@@ -1656,7 +1657,8 @@ function Test-NodeVersionOk {
return $false return $false
} }
if ($v.Major -eq 22) { return ($v.Minor -ge 22) } if ($v.Major -eq 22) { return ($v.Minor -ge 22) }
return (($v.Major -eq 24) -or ($v.Major -ge 26)) if ($v.Major -eq 24) { return ($v.Minor -ge 11) }
return ($v.Major -ge 26)
} }
# Accept a system Node only when its companion npm also satisfies the same # Accept a system Node only when its companion npm also satisfies the same
@@ -1669,7 +1671,7 @@ function Test-SystemNodeReady {
if (Test-NodeVersionOk $version) { if (Test-NodeVersionOk $version) {
Ensure-NodeExeOnPath | Out-Null Ensure-NodeExeOnPath | Out-Null
} else { } else {
Write-Warn "Node.js $version is unsupported (Hermes requires Node 22.22+, 24, or 26+)" Write-Warn "Node.js $version is unsupported (Hermes requires Node 22.22+, 24.11+, or 26+)"
return $false return $false
} }
+6 -4
View File
@@ -882,8 +882,9 @@ check_cxx_compiler() {
return 1 return 1
} }
# The dependency tree supports Node 22.22+, 24, and 26+. nanoid 6 excludes # The dependency tree supports Node 22.22+, 24.11+, and 26+. nanoid 6 excludes
# Node 23 and 25 while its >=26 arm accepts later releases, so accepting 23/25 # Node 23 and 25 while its >=26 arm accepts later releases, and @babel/* 8.x
# requires ^22.18.0 || >=24.11.0 — so accepting 23/25 or an early Node 24
# here only defers the failure to `npm ci` under engine-strict. Keep this in # 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 # sync with the root package.json. Anything outside the supported lines is
# replaced with the Hermes-managed Node $NODE_VERSION. # replaced with the Hermes-managed Node $NODE_VERSION.
@@ -895,7 +896,8 @@ node_satisfies_build() {
case "$major" in ''|*[!0-9]*) return 1 ;; esac case "$major" in ''|*[!0-9]*) return 1 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
if [ "$major" -eq 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 if [ "$major" -eq 24 ] && [ "$minor" -ge 11 ]; then return 0; fi
if [ "$major" -ge 26 ]; then return 0; fi
return 1 return 1
} }
@@ -963,7 +965,7 @@ check_node() {
if command -v node &> /dev/null && ! command -v npm &> /dev/null; then 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..." 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 elif command -v node &> /dev/null; then
log_warn "Node.js $(node --version) is unsupported (Hermes requires Node 22.22+, 24, or 26+) — installing Hermes-managed Node $NODE_VERSION..." log_warn "Node.js $(node --version) is unsupported (Hermes requires Node 22.22+, 24.11+, or 26+) — installing Hermes-managed Node $NODE_VERSION..."
elif [ "$DISTRO" = "termux" ]; then elif [ "$DISTRO" = "termux" ]; then
log_info "Node.js not found — installing Node.js via pkg..." log_info "Node.js not found — installing Node.js via pkg..."
else else
+92
View File
@@ -203,3 +203,95 @@ class TestManifestMirrors:
manifest = _root_manifest()["engines"] manifest = _root_manifest()["engines"]
lock = json.loads((REPO_ROOT / "package-lock.json").read_text()) lock = json.loads((REPO_ROOT / "package-lock.json").read_text())
assert lock["packages"][""]["engines"] == manifest assert lock["packages"][""]["engines"] == manifest
def _normalize_range(spec: str) -> str:
"""Normalize the wilder styles real deps publish so our tiny evaluator
can read them: collapse space after operators (``">= 10"``), drop ``v``
prefixes (``">=v12.22.7"``), and rewrite ``x``/``*`` wildcards to floors.
"""
import re
spec = re.sub(r"(>=|<=|>|<|\^|~|=)\s+", r"\1", spec)
spec = re.sub(r"(>=|<=|>|<|\^|~|=)v", r"\1", spec)
# "6.x" / "10.*" -> "^6.0.0"-ish floor within the major; ">= 10.*" -> ">=10.0.0"
spec = re.sub(r"(\d+)\.[x*](?:\.[x*])?", r"\1.0.0", spec)
return spec
class TestDeclaredFloorsClearTheLockedTree:
"""Every Node version our own gates accept must survive `npm ci`.
The class of outage this pins: the installers' version gates
(node_satisfies_build in install.sh, Test-NodeVersionOk in install.ps1)
and `engines.node` are hand-maintained, while the *real* floor is
whatever the strictest locked dependency demands. When they drift, a
user's system Node clears every gate we own and then dies at
`npm install` with EBADENGINE under engine-strict=true.
Aug 2026 instance: @babel/* 8.x requires `^22.18.0 || >=24.11.0`; our
engines arm said `^24.0.0`, so Node 24.4 passed the installer and the
manifest and failed on 28 babel packages.
"""
def _arm_floors(self, node_range: str) -> list[str]:
floors = []
for arm in node_range.split("||"):
arm = arm.strip()
for op in ("^", ">=", "="):
if arm.startswith(op):
floors.append(arm[len(op):].strip())
break
else:
floors.append(arm)
return floors
def _locked_node_ranges(self) -> dict[str, str]:
lock = json.loads((REPO_ROOT / "package-lock.json").read_text())
ranges: dict[str, str] = {}
for path, meta in lock["packages"].items():
engines = meta.get("engines")
if not isinstance(engines, dict):
continue
node_range = engines.get("node")
if isinstance(node_range, str) and node_range.strip() not in ("", "*"):
ranges.setdefault(node_range, path)
return ranges
def test_every_engines_arm_floor_clears_every_locked_dependency(self):
node_range = _root_manifest()["engines"]["node"]
violations = []
for floor in self._arm_floors(node_range):
for dep_range, example in self._locked_node_ranges().items():
if not _satisfies_range(floor, _normalize_range(dep_range)):
violations.append((floor, dep_range, example))
assert not violations, (
"engines.node arms admit Node versions the locked dependency "
"tree rejects — those users pass every install gate and then "
"die at `npm install` with EBADENGINE (engine-strict=true). "
"Raise the arm floor (and the installer gates: "
"node_satisfies_build in scripts/install.sh, Test-NodeVersionOk "
f"in scripts/install.ps1) or relax the dep. Violations: {violations}"
)
def test_installer_gates_match_the_manifest_arms(self):
"""install.sh's node_satisfies_build must encode the same floors as
engines.node — a laxer gate accepts a Node that npm then rejects."""
node_range = _root_manifest()["engines"]["node"]
install_sh = (REPO_ROOT / "scripts" / "install.sh").read_text()
install_ps1 = (REPO_ROOT / "scripts" / "install.ps1").read_text()
for arm in node_range.split("||"):
arm = arm.strip()
major, minor = _parse_major_minor_patch(arm.lstrip("^>="))[:2]
if arm.startswith("^") and minor > 0:
sh_gate = f'[ "$major" -eq {major} ] && [ "$minor" -ge {minor} ]'
ps1_gate = f"if ($v.Major -eq {major}) {{ return ($v.Minor -ge {minor}) }}"
assert sh_gate in install_sh, (
f"engines.node arm {arm!r} has no matching gate in "
f"install.sh node_satisfies_build (expected: {sh_gate})"
)
assert ps1_gate in install_ps1, (
f"engines.node arm {arm!r} has no matching gate in "
f"install.ps1 Test-NodeVersionOk (expected: {ps1_gate})"
)
+1 -1
View File
@@ -94,7 +94,7 @@ You don't need to rebuild your setup from scratch. Restore a full backup with `h
- **uv** (fast Python package manager) - **uv** (fast Python package manager)
- **Python 3.11** (via uv, no sudo needed) - **Python 3.11** (via uv, no sudo needed)
- **Node.js v22** (for browser automation and WhatsApp bridge) - **Node.js v26** (for browser automation and WhatsApp bridge; existing system Node 22.22+, 24.11+, or 26+ is used as-is)
- **ripgrep** (fast file search) - **ripgrep** (fast file search)
- **ffmpeg** (audio format conversion for TTS) - **ffmpeg** (audio format conversion for TTS)