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:
@@ -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",
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user