feat(runtime): heal outdated managed Node trees up to the target major

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).
This commit is contained in:
ethernet
2026-08-01 21:14:42 -04:00
parent 713a983e4a
commit b13148d354
3 changed files with 134 additions and 16 deletions

View File

@@ -551,21 +551,54 @@ def heal_hermes_managed_node() -> bool:
return result.returncode == 0
def find_hermes_node_executable(command: str) -> str | None:
"""Return a Hermes-managed Node/npm executable path, healing broken trees."""
names = _candidate_node_command_names(command)
broken_present = False
for directory in iter_hermes_node_dirs():
for name in names:
def _managed_node_tree_outdated(home: Path | None = None) -> bool:
"""Return True when the managed tree's node runs but is below the target major.
An outdated managed Node (e.g. a 22 tree from an older install) heals the
same way a broken one does: :func:`find_hermes_node_executable` triggers
the once-per-process heal, which redownloads
``latest-v{_HERMES_NODE_TARGET_MAJOR}.x`` — so existing users are upgraded
on next launch, not just on the next installer re-run. Mirrors
``_nb_managed_node_outdated`` in ``scripts/lib/node-bootstrap.sh``.
"""
import subprocess
for directory in iter_hermes_node_dirs(home):
for name in _candidate_node_command_names("node"):
candidate = directory / name
if candidate.is_file() and (
sys.platform == "win32" or os.access(candidate, os.X_OK)
if not candidate.is_file() or (
sys.platform != "win32" and not os.access(candidate, os.X_OK)
):
resolved = str(candidate)
if node_tool_runnable(resolved):
return resolved
broken_present = True
if broken_present and heal_hermes_managed_node():
continue
try:
from hermes_cli._subprocess_compat import windows_hide_flags
result = subprocess.run(
[str(candidate), "--version"],
capture_output=True,
timeout=10,
creationflags=windows_hide_flags(),
)
major = int(result.stdout.decode().strip().lstrip("v").split(".")[0])
except (OSError, subprocess.TimeoutExpired, ValueError, IndexError):
return False # broken, not outdated — the runnable probe handles it
return major < _HERMES_NODE_TARGET_MAJOR
return False
def find_hermes_node_executable(command: str) -> str | None:
"""Return a Hermes-managed Node/npm executable path, healing broken trees.
Outdated trees (node major below ``_HERMES_NODE_TARGET_MAJOR``) heal the
same way broken ones do — the once-per-process heal redownloads the target
major, upgrading existing users on next launch rather than next reinstall.
When the heal fails (offline, download error), an outdated-but-runnable
tree is still returned: old Node beats no Node.
"""
names = _candidate_node_command_names(command)
def _first_runnable() -> tuple[str | None, bool]:
broken = False
for directory in iter_hermes_node_dirs():
for name in names:
candidate = directory / name
@@ -574,8 +607,19 @@ def find_hermes_node_executable(command: str) -> str | None:
):
resolved = str(candidate)
if node_tool_runnable(resolved):
return resolved
return None
return resolved, broken
broken = True
return None, broken
resolved, broken_present = _first_runnable()
needs_heal = broken_present or (
resolved is not None and _managed_node_tree_outdated()
)
if needs_heal and heal_hermes_managed_node():
healed, _ = _first_runnable()
if healed:
return healed
return resolved
def find_node_executable_on_path(command: str) -> str | None:

View File

@@ -255,6 +255,24 @@ _nb_managed_tool_broken() {
return 1
}
# The managed node runs but is below HERMES_NODE_TARGET_MAJOR — an old tree
# from a previous install (e.g. 22). Outdated heals the same way broken does,
# so existing users get upgraded on the next heal probe, not just on a full
# installer re-run. Mirrors _managed_node_tree_outdated() in
# hermes_constants.py.
_nb_managed_node_outdated() {
local probe ver major
for probe in "$HERMES_HOME/node/bin/node" "$HERMES_HOME/node/node"; do
[ -x "$probe" ] || continue
ver="$("$probe" --version 2>/dev/null)" || return 1
major="${ver#v}"; major="${major%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
[ "$major" -lt "$HERMES_NODE_TARGET_MAJOR" ] && return 0
return 1
done
return 1
}
_nb_managed_node_needs_heal() {
local tool
for tool in node npm npx; do
@@ -262,7 +280,7 @@ _nb_managed_node_needs_heal() {
return 0
fi
done
return 1
_nb_managed_node_outdated
}
# Redownload the pinned nodejs.org tarball when a managed tree exists but

View File

@@ -213,6 +213,62 @@ class TestNodeToolRunnable:
assert find_node_executable("npm") is None
def test_outdated_managed_node_heals_to_target_major(self, tmp_path, monkeypatch):
"""A healthy managed tree below the target major upgrades on next resolve."""
profile_home = tmp_path / "profiles" / "assistant"
managed_bin = profile_home / "node" / "bin"
managed_bin.mkdir(parents=True)
old_node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v22.20.0'\nexit 0\n")
heal_called = {"value": False}
monkeypatch.setenv("HERMES_HOME", str(profile_home))
monkeypatch.setenv("PATH", "")
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
def _heal():
heal_called["value"] = True
old_node.write_text("#!/bin/sh\necho 'v26.5.1'\nexit 0\n")
old_node.chmod(0o755)
return True
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal)
resolved = hermes_constants.find_hermes_node_executable("node")
assert heal_called["value"] is True
assert resolved == str(old_node)
def test_outdated_managed_node_survives_failed_heal(self, tmp_path, monkeypatch):
"""Offline heal failure keeps serving the old tree — old Node beats no Node."""
profile_home = tmp_path / "profiles" / "assistant"
managed_bin = profile_home / "node" / "bin"
managed_bin.mkdir(parents=True)
old_node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v22.20.0'\nexit 0\n")
monkeypatch.setenv("HERMES_HOME", str(profile_home))
monkeypatch.setenv("PATH", "")
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", lambda: False)
assert hermes_constants.find_hermes_node_executable("node") == str(old_node)
def test_target_major_managed_node_does_not_heal(self, tmp_path, monkeypatch):
"""A tree already at the target major never triggers the heal."""
profile_home = tmp_path / "profiles" / "assistant"
managed_bin = profile_home / "node" / "bin"
managed_bin.mkdir(parents=True)
node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v26.5.1'\nexit 0\n")
monkeypatch.setenv("HERMES_HOME", str(profile_home))
monkeypatch.setenv("PATH", "")
monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False)
def _heal():
raise AssertionError("heal must not run for an up-to-date tree")
monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal)
assert hermes_constants.find_hermes_node_executable("node") == str(node)
class TestIsContainer: