diff --git a/hermes_constants.py b/hermes_constants.py index e0368657510e..f8e43b460ffe 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -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: diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 3a5fd7fbc6fe..2f9383430e85 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -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 diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index f136504a8faf..e94c185233d2 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -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: