fix(update): provision a managed Node runtime when system npm fails engines.npm

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.
This commit is contained in:
Teknium
2026-08-01 16:27:13 -07:00
parent 7f4d155159
commit 6b519255ea
5 changed files with 313 additions and 44 deletions
+82 -19
View File
@@ -17,8 +17,11 @@ Scope of the repair is deliberately narrow. Hermes only upgrades an npm that
lives inside its **own** managed Node tree (``$HERMES_HOME/node``), installing
in place with ``--prefix`` so ``bin/npm`` keeps resolving to the upgraded
``lib/node_modules/npm``. A system / nvm / brew / Nix npm belongs to the user
and their other projects; for those we print the exact command and let the
original failure stand.
and their other projects; Hermes never modifies those. When the failing npm is
one of those foreign installs, Hermes instead provisions its own managed Node
tree (the same tree a fresh install creates), upgrades *that* npm into range,
and hands the caller the managed npm to retry with — leaving the user's
toolchain untouched.
"""
from __future__ import annotations
@@ -31,7 +34,11 @@ import sys
import tempfile
from pathlib import Path
from hermes_constants import get_hermes_home, with_hermes_node_path
from hermes_constants import (
bootstrap_hermes_managed_node,
get_hermes_home,
with_hermes_node_path,
)
__all__ = [
"is_ebadengine",
@@ -242,35 +249,91 @@ def _print_manual_fix(npm: str, npm_range: str, actual: str | None) -> None:
print(
f"\n✗ {have}does not satisfy the range this project requires: {npm_range}\n"
f" Resolved npm: {npm}\n"
" Hermes only upgrades npm inside its own managed Node install, so this\n"
" one is left alone. Upgrade it yourself with:\n"
" Hermes could not provision its own Node.js runtime and never\n"
" modifies a system/nvm/brew/Nix npm. Upgrade yours yourself with:\n"
f' npm install -g npm@"{npm_range}"',
file=sys.stderr,
)
def _provision_managed_npm(npm_range: str | None, *, quiet: bool = False) -> str | None:
"""Provision a Hermes-managed Node tree and return a satisfying npm.
Installs the managed tree under ``$HERMES_HOME/node`` (reusing a healthy
one when present), then upgrades its bundled npm to *npm_range* — a fresh
Node LTS bundles an npm that may itself be outside the repo's range, so
without the upgrade the caller's single retry would fail the same way.
Falls back to the checkout's own ``engines.npm`` when npm did not state a
range (a Node-only mismatch), so the managed npm ends up in range either
way. Returns the managed npm path, or ``None`` when provisioning failed.
"""
if not quiet:
print(
"→ Provisioning a Hermes-managed Node.js runtime "
"(the resolved npm belongs to your system and is left alone)…",
flush=True,
)
managed_npm = bootstrap_hermes_managed_node()
if not managed_npm:
if not quiet:
print(" ✗ Managed Node.js provisioning failed", file=sys.stderr)
return None
prefix = managed_npm_prefix(managed_npm)
if prefix is None: # pragma: no cover - bootstrap returned a foreign path
return None
target_range = npm_range or _repo_npm_range()
if target_range and not upgrade_managed_npm(
managed_npm, target_range, prefix=prefix, quiet=quiet
):
return None
return managed_npm
def maybe_repair_npm_engine(
npm: str | None,
output: str,
*,
quiet: bool = False,
) -> bool:
"""Repair an ``EBADENGINE`` failure when Hermes owns the npm involved.
) -> str | None:
"""Repair an ``EBADENGINE`` failure, never touching a foreign toolchain.
*output* is the combined stdout/stderr of the npm command that just failed.
Returns ``True`` only when npm was actually upgraded, meaning the caller
should retry its command once. Returns ``False`` for every other case —
not an engine failure, a Node (not npm) mismatch, an npm Hermes does not
own, or a failed upgrade — leaving the original failure to stand.
Returns the npm executable the caller should retry its command with —
the same *npm* after an in-place upgrade of a Hermes-managed install, or
a freshly provisioned managed npm when the failing npm belongs to the
user (system / nvm / brew / Nix installs are never modified). Returns
``None`` when no repair happened — not an engine failure, a Node mismatch
a managed npm upgrade cannot fix, or a failed upgrade/bootstrap — leaving
the original failure to stand.
The returned value is truthy exactly when the caller should retry once,
so ``if maybe_repair_npm_engine(...)`` call sites keep working; they just
must run the retry with the returned path.
"""
if not npm or not is_ebadengine(output):
return None
npm_range = required_npm_range(output)
if not npm_range or not npm:
return False
prefix = managed_npm_prefix(npm)
if prefix is None:
if not quiet:
_print_manual_fix(npm, npm_range, actual_npm_version(output))
return False
return upgrade_managed_npm(npm, npm_range, prefix=prefix, quiet=quiet)
if prefix is not None:
# Hermes owns this npm — upgrade it in place. Only an npm-range
# failure is fixable this way; a Node mismatch needs a Node upgrade.
if not npm_range:
return None
if upgrade_managed_npm(npm, npm_range, prefix=prefix, quiet=quiet):
return npm
return None
# Foreign npm (system / nvm / brew / Nix): provision our own runtime
# instead. This also covers Node-version mismatches — the managed tree
# ships a Node the repo supports.
managed = _provision_managed_npm(npm_range, quiet=quiet)
if managed:
return managed
if not quiet and npm_range:
_print_manual_fix(npm, npm_range, actual_npm_version(output))
return None