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
48 changes: 41 additions & 7 deletions hermes_cli/dep_ensure.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,36 @@
import sys
from pathlib import Path

from hermes_cli.node_runtime import (
augment_path_with_hermes_node,
has_valid_private_hermes_node,
hermes_node_bin_dir,
node_satisfies_hermes_floor,
remove_legacy_node_symlinks,
)

_IS_WINDOWS = platform.system() == "Windows"


def _has_node_runtime() -> bool:
node = shutil.which("node")
if node and node_satisfies_hermes_floor(node):
return True
return has_valid_private_hermes_node()


def _remove_legacy_node_links() -> None:
try:
from hermes_constants import get_hermes_home

remove_legacy_node_symlinks(get_hermes_home())
except Exception:
# Cleanup is best-effort. Dependency detection must remain fail-open.
pass


_DEP_CHECKS = {
"node": lambda: shutil.which("node") is not None,
"node": _has_node_runtime,
"browser": lambda: (
shutil.which("agent-browser") is not None
or _has_system_browser()
Expand Down Expand Up @@ -57,13 +83,13 @@ def _has_system_browser() -> bool:
def _has_hermes_agent_browser() -> bool:
from hermes_constants import get_hermes_home
home = get_hermes_home()
if _IS_WINDOWS:
# npm -g --prefix puts .cmd shims directly in the prefix dir on Windows
return (home / "node" / "agent-browser.cmd").is_file()
# install.sh installs globally into $HERMES_HOME/node/bin/ via npm -g --prefix
node_bin = hermes_node_bin_dir(home)
if _IS_WINDOWS and (node_bin / "agent-browser.cmd").is_file():
return True
# install.sh installs globally into the Hermes-managed Node bin dir.
# Also check legacy node_modules/.bin/ path for git-clone installs.
return (
(home / "node" / "bin" / "agent-browser").is_file()
(node_bin / "agent-browser").is_file()
or (home / "node_modules" / ".bin" / "agent-browser").is_file()
)

Expand Down Expand Up @@ -105,11 +131,16 @@ def ensure_dependency(
interactive: bool = True,
) -> bool:
"""Ensure a non-Python dependency is available. Returns True if available."""
if dep in {"node", "browser"}:
_remove_legacy_node_links()

check = _DEP_CHECKS.get(dep)
if check is None:
# Unknown dep — don't silently forward to install script.
return False
if check():
if dep == "node":
augment_path_with_hermes_node()
return True

script, shell = _find_install_script()
Expand Down Expand Up @@ -155,5 +186,8 @@ def ensure_dependency(
return False

if check:
return check()
ok = check()
if ok and dep == "node":
augment_path_with_hermes_node()
return ok
return True
72 changes: 55 additions & 17 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1596,7 +1596,23 @@ def _ensure_tui_node() -> None:
Idempotent no-op when node+npm are already discoverable. Set
``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable auto-install.
"""
if shutil.which("node") and shutil.which("npm"):
from hermes_cli.node_runtime import (
augment_path_with_hermes_node,
node_satisfies_hermes_floor,
prepend_node_bin_dir,
remove_legacy_node_symlinks,
)

hermes_home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
try:
remove_legacy_node_symlinks(Path(hermes_home))
except Exception:
pass

augment_path_with_hermes_node()

node = shutil.which("node")
if node and shutil.which("npm") and node_satisfies_hermes_floor(node):
return
if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"):
return
Expand All @@ -1605,7 +1621,6 @@ def _ensure_tui_node() -> None:
if not helper.is_file():
return

hermes_home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
try:
# Helper writes logs to stderr; we ask bash to print `command -v node`
# on stdout once ensure_node succeeds. Subshell PATH edits don't leak
Expand All @@ -1626,20 +1641,9 @@ def _ensure_tui_node() -> None:
except (OSError, subprocess.SubprocessError):
return

parts = os.environ.get("PATH", "").split(os.pathsep)
extras: list[Path] = []

resolved = (result.stdout or "").strip()
if resolved:
extras.append(Path(resolved).resolve().parent)

extras.extend([Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"])

for extra in extras:
s = str(extra)
if extra.is_dir() and s not in parts:
parts.insert(0, s)
os.environ["PATH"] = os.pathsep.join(parts)
prepend_node_bin_dir(Path(resolved).resolve())


def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
Expand Down Expand Up @@ -7656,10 +7660,38 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
return resolve_uv() or shutil.which("uv")


def _cleanup_legacy_node_symlinks() -> Path:
try:
from hermes_constants import get_hermes_home
from hermes_cli.node_runtime import remove_legacy_node_symlinks

hermes_home = get_hermes_home()
remove_legacy_node_symlinks(hermes_home)
except Exception:
hermes_home = Path(os.environ.get("HERMES_HOME") or (Path.home() / ".hermes"))
return hermes_home


def _resolve_update_npm() -> tuple[str | None, bool]:
from hermes_cli.node_runtime import (
node_satisfies_hermes_floor,
private_hermes_npm_path,
)

hermes_home = _cleanup_legacy_node_symlinks()
npm = shutil.which("npm")
if npm:
node = shutil.which("node")
use_hermes_node_path = not (node and node_satisfies_hermes_floor(node))
return npm, use_hermes_node_path
private_npm = private_hermes_npm_path(hermes_home)
return (str(private_npm), True) if private_npm else (None, False)


def _update_node_dependencies() -> None:
from hermes_constants import find_node_executable, with_hermes_node_path
from hermes_constants import with_hermes_node_path

npm = find_node_executable("npm")
npm, use_hermes_node_path = _resolve_update_npm()
if not npm:
return

Expand All @@ -7676,7 +7708,9 @@ def _update_node_dependencies() -> None:
print("→ Updating Node.js dependencies...")
extra_args = ["--no-fund", "--no-audit", "--progress=false"]

nixos_env = with_hermes_node_path(_nixos_build_env())
nixos_env = _nixos_build_env()
if use_hermes_node_path:
nixos_env = with_hermes_node_path(nixos_env)

# Step 1: root install (no workspace recursion).
root_args = [*extra_args, "--workspaces=false"]
Expand Down Expand Up @@ -8591,6 +8625,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("⚕ Updating Hermes Agent...")
print()

# Update is the common self-healing entrypoint for older installs. Clean up
# legacy Hermes-owned node/npm/npx PATH shims before any early return.
_cleanup_legacy_node_symlinks()

# On Windows, abort early if another hermes.exe is holding the venv shim
# open. Continuing would result in a string of WinError 32 warnings and
# then either a deferred-rename leftover or a failed git-pull fast path
Expand Down
197 changes: 197 additions & 0 deletions hermes_cli/node_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Helpers for Hermes-managed Node.js runtime discovery.

Hermes may use its private Node inside Hermes subprocesses, but it must not
expose that Node by installing user-global ``node``/``npm``/``npx`` shims.
"""

from __future__ import annotations

import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Iterable


def hermes_node_bin_dir(hermes_home: Path | None = None) -> Path:
"""Return the platform-preferred directory for Hermes-managed Node tools."""
if hermes_home is None:
from hermes_constants import get_hermes_home

hermes_home = get_hermes_home()
node_root = Path(hermes_home) / "node"
return node_root if sys.platform == "win32" else node_root / "bin"


def _node_command_name() -> str:
return "node.exe" if sys.platform == "win32" else "node"


def _npm_command_names() -> tuple[str, ...]:
if sys.platform == "win32":
return ("npm.cmd", "npm.exe", "npm")
return ("npm",)


def _parse_node_version(text: str) -> tuple[int, int] | None:
match = re.search(r"v?(\d+)\.(\d+)(?:\.\d+)?", text.strip())
if not match:
return None
return int(match.group(1)), int(match.group(2))


def _version_satisfies_floor(version: tuple[int, int]) -> bool:
major, minor = version
if major == 20:
return minor >= 19
if major == 22:
return minor >= 12
return major > 22


def node_satisfies_hermes_floor(node_bin: str | Path) -> bool:
"""Return whether ``node --version`` satisfies ``^20.19 || >=22.12``.

Keep this floor in sync with ``scripts/install.sh::node_satisfies_build``
and ``scripts/install.ps1::Test-NodeVersionOk``.
"""
try:
result = subprocess.run(
[str(node_bin), "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return False
if result.returncode != 0:
return False
version = _parse_node_version(result.stdout or result.stderr or "")
return bool(version and _version_satisfies_floor(version))


def _path_parts() -> list[str]:
return [part for part in os.environ.get("PATH", "").split(os.pathsep) if part]


def prepend_node_bin_dir(node_bin: str | Path) -> bool:
"""Prepend the directory containing *node_bin* to process-local ``PATH``."""
path = Path(node_bin)
bin_dir = path.parent if path.name in {"node", "node.exe"} or path.is_file() else path
if not bin_dir.is_dir():
return False

bin_dir_s = str(bin_dir)
parts = _path_parts()
if parts and parts[0] == bin_dir_s:
return False
parts = [part for part in parts if part != bin_dir_s]
os.environ["PATH"] = os.pathsep.join([bin_dir_s, *parts])
return True


def _private_node_path(hermes_home: Path | None = None) -> Path:
return hermes_node_bin_dir(hermes_home) / _node_command_name()


def _private_npm_path(hermes_home: Path | None = None) -> Path | None:
node_bin = hermes_node_bin_dir(hermes_home)
for name in _npm_command_names():
candidate = node_bin / name
if candidate.is_file() and (sys.platform == "win32" or os.access(candidate, os.X_OK)):
return candidate
return None


def has_valid_private_hermes_node(hermes_home: Path | None = None) -> bool:
"""Return whether Hermes has a complete private Node/npm pair."""
node = _private_node_path(hermes_home)
if not node.is_file() or (sys.platform != "win32" and not os.access(node, os.X_OK)):
return False
if _private_npm_path(hermes_home) is None:
return False
return node_satisfies_hermes_floor(node)


def private_hermes_npm_path(hermes_home: Path | None = None) -> Path | None:
"""Return private Hermes npm when the private Node runtime is complete."""
if not has_valid_private_hermes_node(hermes_home):
return None
return _private_npm_path(hermes_home)


def augment_path_with_hermes_node() -> bool:
"""Prepend Hermes-managed Node to this process when current Node is unusable.

Missing ``npm`` alone is not a reason to shadow a modern user-managed Node.
"""
current_node = shutil.which("node")
if current_node and node_satisfies_hermes_floor(current_node):
return False
if not has_valid_private_hermes_node():
return False
return prepend_node_bin_dir(_private_node_path())


def legacy_node_symlink_candidate_dirs() -> list[Path]:
"""Return directories where old POSIX installers created node/npm/npx links."""
dirs: list[Path] = [Path.home() / ".local" / "bin"]
prefix = os.environ.get("PREFIX")
if prefix:
dirs.append(Path(prefix) / "bin")
if sys.platform != "win32":
dirs.append(Path("/usr/local/bin"))

deduped: list[Path] = []
seen: set[str] = set()
for directory in dirs:
key = str(directory)
if key not in seen:
deduped.append(directory)
seen.add(key)
return deduped


def _link_target_points_into(link: Path, node_dir: Path) -> bool:
if not link.is_symlink():
return False
try:
target = Path(os.readlink(link))
except OSError:
return False
if not target.is_absolute():
target = link.parent / target
try:
target_resolved = target.resolve(strict=False)
node_resolved = node_dir.resolve(strict=False)
except OSError:
return False
return target_resolved == node_resolved or node_resolved in target_resolved.parents


def remove_legacy_node_symlinks(
hermes_home: Path,
*,
candidate_dirs: Iterable[Path] | None = None,
) -> list[Path]:
"""Remove legacy Hermes-owned node/npm/npx symlinks from command dirs."""
node_dir = Path(hermes_home) / "node"
removed: list[Path] = []
for bin_dir in candidate_dirs or legacy_node_symlink_candidate_dirs():
if not bin_dir.is_dir():
continue
for name in ("node", "npm", "npx"):
link = bin_dir / name
if not _link_target_points_into(link, node_dir):
continue
try:
link.unlink()
except OSError:
continue
removed.append(link)
return removed
Loading