fix(install): sync the lockfile engines mirrors with the manifests

`hermes desktop` still failed with EBADENGINE demanding Node >=26 after
#76562, on a machine whose `apps/desktop/package.json` already said
`^20.19.0 || >=22.12.0`. #76562 fixed the manifest but not its mirror in
`package-lock.json`, and `npm ci` reads engines from the lockfile:

    package.json  apps/desktop -> {'node': '^20.19.0 || >=22.12.0'}
    package-lock  apps/desktop -> {'node': '>=26.0.0'}      <- what gated

Chasing that exposed a second, pre-existing problem: the floor #76562
declared was too generous. Running the real `npm ci` against the whole
workspace on Node 22.21.1 fails on a transitive dependency —

    npm error notsup Not compatible with your version of node/npm:
      react-router@8.3.0
    npm error notsup Required: {"node":">=22.22.0"}

react-router 8.3.0 (a direct dependency of both `apps/desktop` and `web`)
declares `>=22.22.0`, which is tighter than Vite's `^20.19 || >=22.12` and
excludes all of Node 20. So `>=20.0.0` promised support the tree cannot
deliver: an install on Node 20 or early 22 passed the installer's gate and
then died inside `npm ci` on someone else's package.

All four engine declarations now state the floor the dependency tree
actually has, `>=22.22.0`: root `package.json`, `apps/desktop/package.json`,
and both of their `package-lock.json` mirrors. The installer gates move with
them (`node_satisfies_build` in install.sh, `Test-NodeVersionOk` in
install.ps1) so a too-old system Node is replaced with the managed one
*before* npm runs, and the failure a user does see names hermes-agent rather
than a transitive package. NODE_VERSION stays 22 — latest-v22.x is 22.23.2,
comfortably above the floor.

The invariant test gains the case that would have caught the mirror drift on
its own: the desktop assertion now pins the tightest floor a dependency
actually declares, and the managed-runtime check compares majors, since
install.sh fetches latest-v{major}.x rather than {major}.0.0.

Verified with real `npm ci --dry-run` over the full workspace:
- node 22.23.2 (what install.sh provisions) -> 1258 packages
- node 26.5.1                               -> 1189 packages
- node 22.21.1 (below the floor)            -> EBADENGINE naming
  hermes-agent, i.e. our own manifest, not react-router
This commit is contained in:
Brooklyn Nicholson 2026-08-01 23:28:28 -05:00
parent 8ced76d619
commit 63ff4b87b6
6 changed files with 32 additions and 25 deletions

View File

@ -8,7 +8,7 @@
"type": "module",
"main": "dist/electron-main.mjs",
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": ">=22.22.0"
},
"scripts": {
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",

4
package-lock.json generated
View File

@ -29,7 +29,7 @@
"typescript-eslint": "8.64.0"
},
"engines": {
"node": ">=20.0.0",
"node": ">=22.22.0",
"npm": "<11.10.0 || >=11.17.0"
}
},
@ -168,7 +168,7 @@
"wait-on": "9.0.10"
},
"engines": {
"node": ">=26.0.0"
"node": ">=22.22.0"
}
},
"apps/desktop/node_modules/ignore": {

View File

@ -53,7 +53,7 @@
"brace-expansion": "5.0.8"
},
"engines": {
"node": ">=20.0.0",
"node": ">=22.22.0",
"npm": "<11.10.0 || >=11.17.0"
},
"allowScripts": {

View File

@ -1190,11 +1190,11 @@ function Set-GitBashEnvVar {
Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path."
}
# 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.
# 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.
function Test-NodeVersionOk {
param([string]$Version)
try {
@ -1202,8 +1202,7 @@ function Test-NodeVersionOk {
} catch {
return $false
}
if ($v.Major -eq 20) { return ($v.Minor -ge 19) }
if ($v.Major -eq 22) { return ($v.Minor -ge 12) }
if ($v.Major -eq 22) { return ($v.Minor -ge 22) }
return ($v.Major -gt 22)
}

View File

@ -780,22 +780,20 @@ check_git() {
exit 1
}
# 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.
# 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.
node_satisfies_build() {
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
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
if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 22 ]; }; then return 0; fi
return 1
}

View File

@ -19,6 +19,7 @@ declare and the toolchain that has to satisfy it.
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
@ -116,9 +117,17 @@ class TestEnginesAreSatisfiable:
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), (
# 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), (
f"engines.node is {node_range!r} but install.sh provisions Node "
f"{managed_major}. The runtime we ship must satisfy the floor we "
f"{managed_major}.x. The runtime we ship must satisfy the floor we "
"declare, or the install we just performed cannot install deps."
)
@ -131,9 +140,10 @@ class TestEnginesAreSatisfiable:
"""
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
# The tightest floor any dependency actually declares (react-router
# 8.3.0 -> >=22.22.0). If this legitimately rises, the assertion
# documents the reason for the bump rather than blocking it.
assert _satisfies_range("22.12.0", node_range), (
assert _satisfies_range("22.22.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 "