diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d3f8369731..dbb8631f5d 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 || ^24.0.0 || >=26.0.0" + "node": "^22.22.0 || ^24.11.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 a4a2815290..5e1f45ced2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "typescript-eslint": "8.64.0" }, "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" } }, @@ -171,7 +171,7 @@ "wait-on": "9.0.10" }, "engines": { - "node": "^22.22.0 || ^24.0.0 || >=26.0.0" + "node": "^22.22.0 || ^24.11.0 || >=26.0.0" }, "optionalDependencies": { "get-windows": "9.3.0" diff --git a/package.json b/package.json index aa68df6bed..406b73b205 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "tar": "7.5.22" }, "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" }, "allowScripts": { diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 07ecd63d3c..fec7e48d61 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1643,8 +1643,9 @@ function Set-GitBashEnvVar { 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 -# Node 23 and 25 while its >=26 arm accepts later releases, so accepting 23/25 +# 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, 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 # with the root package.json. function Test-NodeVersionOk { @@ -1656,7 +1657,8 @@ function Test-NodeVersionOk { return $false } 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 @@ -1669,7 +1671,7 @@ function Test-SystemNodeReady { if (Test-NodeVersionOk $version) { Ensure-NodeExeOnPath | Out-Null } 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 } diff --git a/scripts/install.sh b/scripts/install.sh index 41974047a2..2289001f04 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -882,8 +882,9 @@ check_cxx_compiler() { return 1 } -# 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 +# 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, 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 # sync with the root package.json. Anything outside the supported lines is # replaced with the Hermes-managed Node $NODE_VERSION. @@ -895,7 +896,8 @@ node_satisfies_build() { case "$major" in ''|*[!0-9]*) return 1 ;; esac case "$minor" in ''|*[!0-9]*) minor=0 ;; esac 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 } @@ -963,7 +965,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 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 log_info "Node.js not found — installing Node.js via pkg..." else diff --git a/tests/test_engines_satisfiable.py b/tests/test_engines_satisfiable.py index fed951a023..8ce67810db 100644 --- a/tests/test_engines_satisfiable.py +++ b/tests/test_engines_satisfiable.py @@ -203,3 +203,95 @@ class TestManifestMirrors: manifest = _root_manifest()["engines"] lock = json.loads((REPO_ROOT / "package-lock.json").read_text()) 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})" + ) + diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 3689db72ac..98b95c9d7f 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -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) - **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) - **ffmpeg** (audio format conversion for TTS)