The bundled-Node bootstrap unpacked the nodejs.org tarball and stopped.
Node 26.5.1 bundles npm 11.17.0, one minor below the root package.json's
own `engines.npm` floor of >=12 — and .npmrc sets `engine-strict=true`,
so that is fatal rather than a warning:
npm error code EBADENGINE
npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
npm error notsup Actual: {"node":"v26.5.1","npm":"11.17.0"}
A brand-new install died at the first `npm ci` with "Desktop workspace
npm install failed". CI never saw it because the workflows run an
explicit `npm i -g npm@12`; the Python update path recovers through
hermes_cli/npm_engine.py, but the installer path had no such rung.
_nb_ensure_bundled_npm_range() now upgrades the managed tree's npm into
range right after the tarball lands, mirroring upgrade_managed_npm():
- temp cwd, so the checkout's own .npmrc (engine-strict,
min-release-age) does not gate the upgrade meant to satisfy it;
- npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
- explicit --prefix at the managed tree, because
_nb_configure_npm_prefix writes prefix=~/.local into its etc/npmrc
and a bare `npm i -g` would install a second npm elsewhere while the
managed tree stayed stale.
The range is read out of package.json rather than duplicated, so the two
cannot drift, with HERMES_NPM_TARGET_RANGE as an override and a >=12.0.0
fallback for a stripped install tree. An already-in-range npm skips the
network round-trip. Best-effort: a failed upgrade warns with the manual
command and keeps the working Node, since npm_engine.py still covers the
EBADENGINE that follows.
Verified against a real tree provisioned by this bootstrap: node v26.5.1
/ npm 12.0.2, bin/npm and bin/npx still relative-symlinked into the
upgraded lib/node_modules/npm, the ~/.local/bin links resolving to 12.0.2
through the tree, and no stray second npm under ~/.local/lib.
Existing users who only ever launch Hermes (never re-run an installer)
kept their managed Node 22 tree forever: the heal path only fired for
*broken* trees, and a healthy 22 passes the --version probe. Now
"outdated" heals the same way "broken" does, on both sides of the mirror:
- hermes_constants.py: find_hermes_node_executable() checks
_managed_node_tree_outdated() (managed node major <
_HERMES_NODE_TARGET_MAJOR) and routes through the existing
once-per-process heal_hermes_managed_node(), which redownloads
latest-v26.x. When the heal fails (offline, download error) the
outdated-but-runnable tree is still returned — old Node beats no Node.
- scripts/lib/node-bootstrap.sh: _nb_managed_node_needs_heal() gains the
matching _nb_managed_node_outdated() rung, so heal_managed_node agrees
with the Python side.
This is the same shape as the managed-uv flow: resolve the managed
runtime, notice it can't satisfy the requirement, provision the right one
in place, fall back gracefully.
Tests (tests/test_hermes_constants.py): outdated tree triggers heal and
returns the upgraded binary; failed heal still serves the old tree; an
at-target tree never heals (heal stub raises).
Hermes now pins its toolchain to Node 26 everywhere. Every path that
installs, accepts, heals, or upgrades a Node runtime moves from the old
22-default / `^20.19 || >=22.12` floor to a single rule: Node >=26.
Installers:
- scripts/install.sh — NODE_VERSION=26; node_satisfies_build() collapses
the two-branch Vite floor to `major >= 26`; user-facing messages updated.
- scripts/install.ps1 — $NodeVersion=26; Test-NodeVersionOk likewise;
winget fallback switches OpenJS.NodeJS.LTS -> OpenJS.NodeJS (26 is
Current, not LTS — the LTS manifest would reinstall a too-old Node).
- Dockerfile — node_source stage node:22-bookworm-slim -> node:26 (digest
pinned, amd64 sha256:9e6f...bf73).
- nix/ was already on nodejs_26 (lib.nix, npm-12-0-2.nix); the checks.nix
wrapper check ratchets from `>= 20` to `>= 26`.
Heal/upgrade paths:
- scripts/lib/node-bootstrap.sh — HERMES_NODE_TARGET_MAJOR default 22->26
and HERMES_NODE_MIN_VERSION default 20->26, so heal_managed_node,
_nb_install_bundled_node, and the fnm/proto/nvm/brew rungs all target 26
and stop accepting an on-PATH Node below it. Both remain env-overridable.
- hermes_constants.py — _HERMES_NODE_TARGET_MAJOR fallback 22->26, which
drives the Windows heal path's latest-v26.x download.
Version gates:
- package.json engines.node >=20 -> >=26; apps/desktop engines
`^20.19.0 || >=22.12.0` -> `>=26.0.0`.
- CI setup-node: all five workflows 22 -> 26.
- Docs describing Hermes's own toolchain updated (windows-native, docker,
acp, nix-setup, contributing). Skill docs describing third-party tools'
own requirements are untouched.
Termux still installs via `pkg install nodejs` best-effort (nodejs.org
ships no Android tarballs); that path was never version-gated.
Verified: bash -n on both shell scripts, PowerShell AST parse of
install.ps1, latest-v26.x index resolves (node-v26.5.1), and the install
test suite — 18 tests across the 5 install/runtime test files — passes.
The npm 12 requirement (f88ed6c717) strands every system-Node install:
no shipping Node bundles npm >=12, engine-strict makes EBADENGINE fatal,
and the recovery in npm_engine.py refuses to touch a system npm — so
'hermes update' leaves the install in a mixed state (updated code, stale
Node deps, no TUI/web/desktop rebuild) with only a manual-fix hint.
Instead of modifying the user's toolchain (still never done), the
EBADENGINE recovery now provisions Hermes' own managed Node tree under
$HERMES_HOME/node — the same pinned-nodejs.org path install.sh and
install.ps1 use — upgrades THAT npm into the required range, and hands
the caller the managed npm for its single retry.
- hermes_constants.bootstrap_hermes_managed_node(): cross-platform
provisioning (POSIX via node-bootstrap.sh _nb_install_bundled_node,
Windows via the existing portable-zip download); reuses a healthy tree.
- node-bootstrap.sh: HERMES_NODE_SKIP_LINKS=1 skips the ~/.local/bin
node/npm/npx symlinks so the private tree never shadows the user's
own toolchain on PATH.
- maybe_repair_npm_engine() now returns the npm path to retry with
(managed-in-place upgrade or freshly provisioned runtime); both call
sites retry with the returned path and put the managed tree first on
PATH so npm lifecycle scripts resolve the managed node.
- Node-only mismatches on a foreign npm are now also recoverable (the
managed tree ships a supported Node); on a managed npm they still
correctly decline.
E2E (real download, temp HERMES_HOME): provisioned node v22.23.2,
upgraded bundled npm 10.9.4 -> 12.0.2, system npm byte-identical after,
no ~/.local/bin links re-pointed, healthy-tree reuse in 0.05s.
When a Hermes-managed node/npm/npx shim exists but fails --version, redownload
the pinned nodejs.org bundle under HERMES_HOME/node and retry. Do not fall
back to system npm on PATH when a managed tree is present.
POSIX heal probes node, npm, and npx (npm can break while node still runs).
The initial fix only wrote the prefix npmrc on a fresh Node install, so
pre-existing bundled-Node installs (Node already present) were not repaired
by re-running the installer — install_node/ensure_node skip when Node is
already up to date.
Extract the redirect into an idempotent helper
(configure_managed_node_npm_prefix / _nb_configure_npm_prefix) that no-ops
when there's no Hermes-managed npm, and call it unconditionally from
check_node (install.sh) and at the top of ensure_node (node-bootstrap.sh).
Re-running the install command now repairs an affected install in place,
not just brand-new ones.
When the installer falls back to a bundled Node under $HERMES_HOME/node,
npm's default global prefix is that Node dir, so `npm install -g <pkg>`
drops the package binary in $HERMES_HOME/node/bin. Only node/npm/npx are
symlinked into the command link dir (~/.local/bin, /usr/local/bin, or
$PREFIX/bin) — so user-installed global package binaries are NOT on PATH
and can't be run, even though `npm i -g` reports success. They also get
wiped on every Node upgrade (the dir is rm -rf'd and re-extracted).
Redirect the bundled Node's npm global prefix to the command link dir's
parent, so global bins land in the link dir (already on PATH, alongside
node/npm/npx) and survive Node upgrades. Scoped to the bundled Node via
its prefix-local global npmrc ($HERMES_HOME/node/etc/npmrc), so the user's
other Node installs and their ~/.npmrc are untouched. Hermes's own global
installs (agent-browser) pass an explicit --prefix and are unaffected.
Root installs on Linux (FHS layout, #15608) put the `hermes` command in
`/usr/local/bin` (on PATH) but symlinked the bundled node/npm/npx into
`~/.local/bin`, which isn't on PATH for a stock root shell. `node`/`npm`
were 'command not found' and `hermes dashboard` failed with 'npm is not
available' because its build-on-demand fallback couldn't find npm.
Fix: `install_node()` now symlinks into `get_command_link_dir()` — the same
helper the `hermes` command link already uses — so node/npm/npx land
wherever the command does (`/usr/local/bin` on FHS root, `~/.local/bin`
otherwise, `$PREFIX/bin` on Termux). Non-root and Termux installs are
unchanged.
Also fixes:
- `scripts/lib/node-bootstrap.sh`: adds `_nb_get_link_dir()` mirroring
the same root/Termux/user logic for the standalone bootstrap path
(used by `hermes update`, TUI node bootstrap, etc.)
- `hermes_cli/uninstall.py`: `remove_node_symlinks()` now checks all
candidate directories (`~/.local/bin`, `/usr/local/bin`, `$PREFIX/bin`)
so root FHS uninstalls don't leave orphan symlinks
Regression from #15608, which created the FHS path for the command but
left `install_node` pointed at the legacy user-local dir.