diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5c40215054..9b4611b3ad 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2040,6 +2040,11 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: ] def _run_tui_install() -> subprocess.CompletedProcess: + from hermes_constants import with_hermes_node_path + + # Managed tree first on PATH: if the EBADENGINE repair below + # provisioned a managed Node, npm's shebang/lifecycle scripts must + # resolve that node, not the mismatched system one. return subprocess.run( npm_install_cmd, cwd=str(npm_cwd), @@ -2048,18 +2053,22 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: text=True, encoding="utf-8", errors="replace", - env={**os.environ, "CI": "1"}, + env={**with_hermes_node_path(), "CI": "1"}, ) result = _run_tui_install() if result.returncode != 0: # An npm outside the root package.json's `engines.npm` range fails - # here before doing any work; upgrade a Hermes-managed npm once and - # retry rather than dumping EBADENGINE at the user. + # here before doing any work; repair once (upgrade a Hermes-managed + # npm in place, or provision a managed runtime when the npm belongs + # to the user) and retry rather than dumping EBADENGINE at the user. from hermes_cli.npm_engine import maybe_repair_npm_engine combined_output = f"{result.stdout or ''}\n{result.stderr or ''}" - if maybe_repair_npm_engine(npm, combined_output): + repaired_npm = maybe_repair_npm_engine(npm, combined_output) + if repaired_npm: + npm = repaired_npm + npm_install_cmd[0] = repaired_npm result = _run_tui_install() if result.returncode != 0: combined = f"{result.stdout or ''}\n{result.stderr or ''}".strip() @@ -5481,30 +5490,39 @@ def _run_npm_install_deterministic( capture_output=capture_output, ) - def _attempt() -> subprocess.CompletedProcess: + def _attempt(npm_exe: str) -> subprocess.CompletedProcess: lockfile = cwd / "package-lock.json" if lockfile.exists(): - ci_result = _run([npm, "ci", "--include=dev", *extra_args]) + ci_result = _run([npm_exe, "ci", "--include=dev", *extra_args]) if ci_result.returncode == 0: return ci_result # Fall through to `npm install` — lockfile may be out of sync on a # WIP fork/branch, or `npm ci` may not be available on very old npm. - return _run([npm, "install", "--no-save", "--include=dev", *extra_args]) + return _run([npm_exe, "install", "--no-save", "--include=dev", *extra_args]) - result = _attempt() + result = _attempt(npm) if result.returncode == 0: return result # An npm outside the root package.json's `engines.npm` range fails every # command here identically (the `npm install` fallback included), so the - # failure is worth exactly one upgrade attempt. `maybe_repair_npm_engine` - # returns True only when it actually upgraded a Hermes-managed npm. + # failure is worth exactly one repair attempt. `maybe_repair_npm_engine` + # returns the npm to retry with — the same one after an in-place upgrade + # of a Hermes-managed install, or a freshly provisioned managed npm when + # the failing npm belongs to the user's own toolchain. from hermes_cli.npm_engine import maybe_repair_npm_engine combined = f"{result.stdout or ''}\n{result.stderr or ''}" - if not maybe_repair_npm_engine(npm, combined): + repaired_npm = maybe_repair_npm_engine(npm, combined) + if not repaired_npm: return result - return _attempt() + # The repaired npm may be a freshly provisioned managed one whose shebang + # and lifecycle scripts resolve `node` from PATH — put the managed tree + # first so they find the managed Node, not the mismatched system one. + from hermes_constants import with_hermes_node_path + + run_env["PATH"] = with_hermes_node_path(run_env)["PATH"] + return _attempt(repaired_npm) def _run_npm_watching_for_engine_failure( diff --git a/hermes_cli/npm_engine.py b/hermes_cli/npm_engine.py index 335a1a818b..12c25d64b0 100644 --- a/hermes_cli/npm_engine.py +++ b/hermes_cli/npm_engine.py @@ -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 diff --git a/hermes_constants.py b/hermes_constants.py index ac93c8da35..dc0de54481 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -437,6 +437,80 @@ def _heal_managed_node_windows() -> bool: return node_tool_runnable(str(target / "node.exe")) +def _bootstrap_managed_node_posix() -> bool: + """Install a fresh managed Node under ``$HERMES_HOME/node`` on POSIX. + + Shells out to ``_nb_install_bundled_node`` in ``scripts/lib/node-bootstrap.sh`` + (the same pinned-nodejs.org path ``install.sh`` uses), so the resulting + tree matches what a normal install would have produced. Runs with + ``HERMES_NODE_SKIP_LINKS=1`` so the user's own node/npm on PATH is not + shadowed by ``~/.local/bin`` symlinks. + """ + if not _NODE_BOOTSTRAP_SCRIPT.is_file(): + return False + + import subprocess + + try: + result = subprocess.run( + [ + "bash", + "-c", + f'source "{_NODE_BOOTSTRAP_SCRIPT}" && _nb_install_bundled_node', + ], + env={ + **os.environ, + "HERMES_HOME": str(get_hermes_home()), + # Private provisioning: do not symlink node/npm/npx into + # ~/.local/bin — the user has their own toolchain on PATH and + # this tree must not shadow it. + "HERMES_NODE_SKIP_LINKS": "1", + }, + capture_output=True, + timeout=600, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +def bootstrap_hermes_managed_node() -> str | None: + """Install a Hermes-managed Node tree and return its npm path. + + Used when the only Node/npm on the machine belongs to the user (system, + nvm, brew, Nix) and cannot satisfy the repo's ``engines`` requirements — + Hermes never modifies a toolchain it does not own, so instead it provisions + its own tree under ``$HERMES_HOME/node`` (the same tree a fresh install + creates) and works with that. + + Returns the managed npm executable path on success, ``None`` on failure. + No-ops (returning the existing npm) when a healthy managed tree is already + present. + """ + existing = find_hermes_node_executable("npm") + if existing: + return existing + + if sys.platform == "win32": + ok = _heal_managed_node_windows() + else: + ok = _bootstrap_managed_node_posix() + if not ok: + return None + + for directory in iter_hermes_node_dirs(): + for name in _candidate_node_command_names("npm"): + candidate = directory / name + if candidate.is_file() and ( + sys.platform == "win32" or os.access(candidate, os.X_OK) + ): + resolved = str(candidate) + if node_tool_runnable(resolved): + return resolved + return None + + def heal_hermes_managed_node() -> bool: """Redownload Hermes-managed Node when the tree exists but is broken. diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index cf0127aa1b..194f87ae51 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -215,10 +215,16 @@ _nb_install_bundled_node() { local _link_dir _link_dir="$(_nb_get_link_dir)" - mkdir -p "$_link_dir" - ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node" - ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" - ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" + # HERMES_NODE_SKIP_LINKS=1: the caller only wants the private managed tree + # (e.g. the EBADENGINE recovery provisioning a runtime alongside a working + # system Node). Skipping the links keeps the user's own node/npm first on + # PATH instead of shadowing them with ours. + if [ "${HERMES_NODE_SKIP_LINKS:-0}" != "1" ]; then + mkdir -p "$_link_dir" + ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node" + ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" + ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" + fi _nb_configure_npm_prefix diff --git a/tests/hermes_cli/test_npm_engine.py b/tests/hermes_cli/test_npm_engine.py index 1e48653fe8..a574c35e5f 100644 --- a/tests/hermes_cli/test_npm_engine.py +++ b/tests/hermes_cli/test_npm_engine.py @@ -126,8 +126,9 @@ class TestManagedDetection: class TestRepairDecision: - """`maybe_repair_npm_engine` returns True only when it actually upgraded, - because its return value is what gates the caller's single retry.""" + """`maybe_repair_npm_engine` returns the npm to retry with (truthy) only + when a repair actually happened, because its return value is what gates + the caller's single retry.""" @pytest.fixture def managed_npm(self, tmp_path, monkeypatch): @@ -150,7 +151,10 @@ class TestRepairDecision: return subprocess.CompletedProcess(cmd, 0, "", "") monkeypatch.setattr(subprocess, "run", fake_run) - assert maybe_repair_npm_engine(str(managed_npm), EBADENGINE_OUTPUT, quiet=True) + repaired = maybe_repair_npm_engine( + str(managed_npm), EBADENGINE_OUTPUT, quiet=True + ) + assert repaired == str(managed_npm) upgrade_cmd = calls[0][0] assert upgrade_cmd[1:3] == ["install", "--global"] @@ -186,27 +190,131 @@ class TestRepairDecision: str(managed_npm), EBADENGINE_OUTPUT, quiet=True ) - def test_unmanaged_npm_is_never_touched(self, managed_npm, tmp_path, monkeypatch, capsys): + def test_foreign_npm_provisions_managed_runtime_instead( + self, tmp_path, monkeypatch + ): + """A system/nvm/brew/Nix npm is never modified — Hermes provisions its + own managed tree, upgrades THAT npm into range, and returns it.""" + home = tmp_path / ".hermes" + monkeypatch.setenv("HERMES_HOME", str(home)) system_npm = tmp_path / "usr-bin-npm" system_npm.write_text("#!/bin/sh\n", encoding="utf-8") - def explode(cmd, **kwargs): # pragma: no cover - must not be reached - raise AssertionError(f"must not run a subprocess for a foreign npm: {cmd}") + managed = home / "node" / "bin" / "npm" - monkeypatch.setattr(subprocess, "run", explode) + import hermes_cli.npm_engine as npm_engine + + def fake_bootstrap(): + managed.parent.mkdir(parents=True, exist_ok=True) + managed.write_text("#!/bin/sh\n", encoding="utf-8") + managed.chmod(0o755) + return str(managed) + + upgrades = [] + monkeypatch.setattr( + npm_engine, "bootstrap_hermes_managed_node", fake_bootstrap + ) + monkeypatch.setattr( + npm_engine, + "upgrade_managed_npm", + lambda npm, rng, *, prefix, quiet=False: upgrades.append((npm, rng)) + or True, + ) + + repaired = maybe_repair_npm_engine( + str(system_npm), EBADENGINE_OUTPUT, quiet=True + ) + assert repaired == str(managed) + # The upgrade targeted the MANAGED npm with npm's own stated range — + # the system npm was never the target of anything. + assert upgrades == [(str(managed), "<11.10.0 || >=12.0.0")] + + def test_foreign_npm_failed_bootstrap_prints_manual_fix( + self, tmp_path, monkeypatch, capsys + ): + home = tmp_path / ".hermes" + monkeypatch.setenv("HERMES_HOME", str(home)) + system_npm = tmp_path / "usr-bin-npm" + system_npm.write_text("#!/bin/sh\n", encoding="utf-8") + + import hermes_cli.npm_engine as npm_engine + + monkeypatch.setattr( + npm_engine, "bootstrap_hermes_managed_node", lambda: None + ) assert not maybe_repair_npm_engine(str(system_npm), EBADENGINE_OUTPUT) # The user gets the exact command to run, since we refuse to run it. err = capsys.readouterr().err assert 'npm install -g npm@"<11.10.0 || >=12.0.0"' in err - def test_non_engine_failure_never_upgrades(self, managed_npm, monkeypatch): + def test_non_engine_failure_never_repairs(self, managed_npm, monkeypatch): def explode(cmd, **kwargs): # pragma: no cover - must not be reached - raise AssertionError("a lockfile mismatch must not trigger an upgrade") + raise AssertionError("a lockfile mismatch must not trigger a repair") monkeypatch.setattr(subprocess, "run", explode) assert not maybe_repair_npm_engine(str(managed_npm), ELOCK_OUTPUT, quiet=True) + def test_node_only_mismatch_on_foreign_npm_still_provisions( + self, tmp_path, monkeypatch + ): + """A too-old system NODE can't be fixed by any npm upgrade, but the + managed tree ships a supported Node — provisioning covers it. The + managed npm is still upgraded to the repo's own engines.npm range.""" + home = tmp_path / ".hermes" + monkeypatch.setenv("HERMES_HOME", str(home)) + system_npm = tmp_path / "usr-bin-npm" + system_npm.write_text("#!/bin/sh\n", encoding="utf-8") + + node_only = ( + "npm error code EBADENGINE\n" + 'npm error notsup Required: {"node":">=20.0.0"}\n' + 'npm error notsup Actual: {"npm":"10.9.8","node":"v18.0.0"}\n' + ) + + managed = home / "node" / "bin" / "npm" + + import hermes_cli.npm_engine as npm_engine + + def fake_bootstrap(): + managed.parent.mkdir(parents=True, exist_ok=True) + managed.write_text("#!/bin/sh\n", encoding="utf-8") + managed.chmod(0o755) + return str(managed) + + upgrades = [] + monkeypatch.setattr( + npm_engine, "bootstrap_hermes_managed_node", fake_bootstrap + ) + monkeypatch.setattr( + npm_engine, + "upgrade_managed_npm", + lambda npm, rng, *, prefix, quiet=False: upgrades.append(rng) or True, + ) + + repaired = maybe_repair_npm_engine(str(system_npm), node_only, quiet=True) + assert repaired == str(managed) + # No range in npm's error → fall back to the repo's own engines.npm + # so the fresh tree's bundled npm doesn't fail the retry identically. + repo_range = npm_engine._repo_npm_range() + assert upgrades == ([repo_range] if repo_range else []) + + def test_node_only_mismatch_on_managed_npm_does_not_upgrade( + self, managed_npm, monkeypatch + ): + """Upgrading a managed npm cannot fix a managed-Node mismatch.""" + node_only = ( + "npm error code EBADENGINE\n" + 'npm error notsup Required: {"node":">=20.0.0"}\n' + 'npm error notsup Actual: {"npm":"10.9.8","node":"v18.0.0"}\n' + ) + + def explode(cmd, **kwargs): # pragma: no cover - must not be reached + raise AssertionError("npm upgrade cannot fix a Node mismatch") + + monkeypatch.setattr(subprocess, "run", explode) + assert not maybe_repair_npm_engine(str(managed_npm), node_only, quiet=True) + class TestRepoRangeIsSatisfiable: """Invariant: whatever the root package.json demands, the recovery can