Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions hermes_cli/npm_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from hermes_constants import (
bootstrap_hermes_managed_node,
get_hermes_home,
managed_node_meets_target,
with_hermes_node_path,
)

Expand Down Expand Up @@ -256,24 +257,28 @@ def _print_manual_fix(npm: str, npm_range: str, actual: str | None) -> None:
)


def _provision_managed_npm(npm_range: str | None, *, quiet: bool = False) -> str | None:
def _provision_managed_npm(
npm_range: str | None, *, quiet: bool = False, force: 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.
Installs the managed tree under ``$HERMES_HOME/node`` (reusing a healthy,
current-major one when present), then upgrades its bundled npm to
*npm_range* — a fresh Node 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. *force* re-provisions even over a healthy tree (used
when the managed Node's major itself is what failed the engine check).
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)…",
"(your own Node/npm install is left alone)…",
flush=True,
)
managed_npm = bootstrap_hermes_managed_node()
managed_npm = bootstrap_hermes_managed_node(force=force)
if not managed_npm:
if not quiet:
print(" ✗ Managed Node.js provisioning failed", file=sys.stderr)
Expand Down Expand Up @@ -319,8 +324,19 @@ def maybe_repair_npm_engine(
prefix = managed_npm_prefix(npm)

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.
# Hermes owns this npm. An npm-range failure gets an in-place npm
# upgrade — but only when the managed NODE itself is still current.
# After a repo-wide Node floor bump (e.g. 22 -> 26), a healthy managed
# tree fails EBADENGINE on the *node* constraint; no npm upgrade can
# fix that, and heal only fires on broken trees. Re-provision the
# managed tree at the current target major instead.
if managed_node_meets_target() is False:
managed = _provision_managed_npm(npm_range, quiet=quiet, force=True)
if managed:
return managed
if not quiet and npm_range:
_print_manual_fix(npm, npm_range, actual_npm_version(output))
return None
if not npm_range:
return None
if upgrade_managed_npm(npm, npm_range, prefix=prefix, quiet=quiet):
Expand Down
60 changes: 54 additions & 6 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,46 @@ def _bootstrap_managed_node_posix() -> bool:
return result.returncode == 0


def bootstrap_hermes_managed_node() -> str | None:
def _probe_node_major(node: str | None) -> int | None:
"""Return the major version of the Node binary at *node*, or ``None``."""
if not node:
return None

import re
import subprocess

try:
from hermes_cli._subprocess_compat import windows_hide_flags

result = subprocess.run(
[node, "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
env=with_hermes_node_path(),
creationflags=windows_hide_flags(),
)
except (OSError, subprocess.SubprocessError, ValueError):
return None
match = re.match(r"v?(\d+)\.", (result.stdout or "").strip())
return int(match.group(1)) if match else None


def managed_node_meets_target() -> bool | None:
"""Whether the managed Node tree's major is at least the current target.

Returns ``None`` when there is no runnable managed Node to probe, so
callers can distinguish "no tree" from "outdated tree".
"""
major = _probe_node_major(find_hermes_node_executable("node"))
if major is None:
return None
return major >= _HERMES_NODE_TARGET_MAJOR


def bootstrap_hermes_managed_node(force: bool = False) -> 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,
Expand All @@ -486,12 +525,21 @@ def bootstrap_hermes_managed_node() -> str | None:
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.
Reuses an existing managed tree only when it is healthy AND its Node major
is at least ``_HERMES_NODE_TARGET_MAJOR`` — a runnable-but-outdated tree
(e.g. Node 22 from an older install after the repo moved to Node 26) is
re-provisioned in place, since ``heal`` only fires on broken trees and
would otherwise keep handing back a Node the repo no longer accepts.
*force* skips the reuse check entirely.
"""
existing = find_hermes_node_executable("npm")
if existing:
return existing
if not force:
existing = find_hermes_node_executable("npm")
if existing:
major = _probe_node_major(find_hermes_node_executable("node"))
if major is not None and major >= _HERMES_NODE_TARGET_MAJOR:
return existing
# Healthy but outdated (or unprobeable) tree — fall through and
# replace it with the current target major.

if sys.platform == "win32":
ok = _heal_managed_node_windows()
Expand Down
54 changes: 50 additions & 4 deletions tests/hermes_cli/test_npm_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def test_foreign_npm_provisions_managed_runtime_instead(

import hermes_cli.npm_engine as npm_engine

def fake_bootstrap():
def fake_bootstrap(force=False):
managed.parent.mkdir(parents=True, exist_ok=True)
managed.write_text("#!/bin/sh\n", encoding="utf-8")
managed.chmod(0o755)
Expand Down Expand Up @@ -240,7 +240,7 @@ def test_foreign_npm_failed_bootstrap_prints_manual_fix(
import hermes_cli.npm_engine as npm_engine

monkeypatch.setattr(
npm_engine, "bootstrap_hermes_managed_node", lambda: None
npm_engine, "bootstrap_hermes_managed_node", lambda force=False: None
)
assert not maybe_repair_npm_engine(str(system_npm), EBADENGINE_OUTPUT)

Expand Down Expand Up @@ -276,7 +276,7 @@ def test_node_only_mismatch_on_foreign_npm_still_provisions(

import hermes_cli.npm_engine as npm_engine

def fake_bootstrap():
def fake_bootstrap(force=False):
managed.parent.mkdir(parents=True, exist_ok=True)
managed.write_text("#!/bin/sh\n", encoding="utf-8")
managed.chmod(0o755)
Expand All @@ -302,19 +302,65 @@ def fake_bootstrap():
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."""
"""Upgrading a managed npm cannot fix a managed-Node mismatch (when
the managed Node already meets the target major, there is nothing a
re-provision would change either)."""
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'
)

import hermes_cli.npm_engine as npm_engine

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)
monkeypatch.setattr(
npm_engine, "managed_node_meets_target", lambda: True
)
assert not maybe_repair_npm_engine(str(managed_npm), node_only, quiet=True)

def test_outdated_managed_node_is_reprovisioned(
self, managed_npm, monkeypatch
):
"""After a repo Node-floor bump (22 -> 26), a HEALTHY managed tree
fails EBADENGINE on the node constraint. heal only fires on broken
trees, so the repair must force a re-provision at the new target —
an npm-only upgrade would loop failing forever."""
node_bump = (
"npm error code EBADENGINE\n"
'npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}\n'
'npm error notsup Actual: {"npm":"12.0.2","node":"v22.23.2"}\n'
)

import hermes_cli.npm_engine as npm_engine

provisions = []
monkeypatch.setattr(
npm_engine, "managed_node_meets_target", lambda: False
)
monkeypatch.setattr(
npm_engine,
"_provision_managed_npm",
lambda rng, *, quiet=False, force=False: provisions.append(
(rng, force)
)
or str(managed_npm),
)

def explode(cmd, **kwargs): # pragma: no cover - must not be reached
raise AssertionError("must re-provision, not npm-upgrade in place")

monkeypatch.setattr(subprocess, "run", explode)

repaired = maybe_repair_npm_engine(str(managed_npm), node_bump, quiet=True)
assert repaired == str(managed_npm)
# force=True: the existing healthy-but-outdated tree must be replaced,
# not reused by the bootstrap's healthy-tree shortcut.
assert provisions == [(">=12.0.0", True)]


class TestRepoRangeIsSatisfiable:
"""Invariant: whatever the root package.json demands, the recovery can
Expand Down
Loading