mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 05:09:01 +08:00
Compare commits
3 Commits
main
...
fix/node-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200fc3c794 | ||
|
|
4361159cbc | ||
|
|
85b03a0c91 |
@@ -245,6 +245,14 @@ def _install_npm(
|
||||
needs ``typescript`` next to it; intelephense ships standalone).
|
||||
"""
|
||||
npm = shutil.which("npm")
|
||||
if npm is None:
|
||||
# Fall back to the bundled npm at <HERMES_HOME>/node/bin when off-PATH
|
||||
# (e.g. root FHS install whose symlink is missing, #38889).
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
npm = find_node_executable("npm")
|
||||
except Exception:
|
||||
npm = None
|
||||
if npm is None:
|
||||
logger.info("[install] cannot install %s: npm not on PATH", pkg)
|
||||
return None
|
||||
|
||||
@@ -197,10 +197,15 @@ def check_whatsapp_requirements() -> bool:
|
||||
|
||||
WhatsApp requires a Node.js bridge for most implementations.
|
||||
"""
|
||||
# Check for Node.js. Resolve via shutil.which so we respect PATHEXT
|
||||
# (node.exe vs node) and get a meaningful "not installed" signal
|
||||
# instead of spawning a cmd flash on Windows.
|
||||
_node = shutil.which("node")
|
||||
# Check for Node.js. Resolve with bundled-fallback awareness (PATH first,
|
||||
# then <HERMES_HOME>/node/bin) so a bundled-but-off-PATH install (e.g. a
|
||||
# root FHS install whose symlink is missing, #38889) doesn't make the
|
||||
# WhatsApp bridge silently unavailable.
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
_node = find_node_executable("node")
|
||||
except Exception:
|
||||
_node = shutil.which("node")
|
||||
if not _node:
|
||||
return False
|
||||
try:
|
||||
@@ -592,8 +597,16 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
|
||||
# Resolve npm path so Windows can execute the .cmd shim.
|
||||
# shutil.which honours PATHEXT; on POSIX it returns the
|
||||
# plain executable path.
|
||||
_npm_bin = shutil.which("npm") or "npm"
|
||||
# plain executable path. Fall back to the bundled npm at
|
||||
# <HERMES_HOME>/node/bin when off-PATH (#38889).
|
||||
_npm_bin = shutil.which("npm")
|
||||
if not _npm_bin:
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
_npm_bin = find_node_executable("npm")
|
||||
except Exception:
|
||||
_npm_bin = None
|
||||
_npm_bin = _npm_bin or "npm"
|
||||
try:
|
||||
# Read timeout from environment variable, default to 300 seconds (5 minutes)
|
||||
# to accommodate slower systems like Unraid NAS
|
||||
@@ -659,9 +672,32 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
if self._reply_prefix is not None:
|
||||
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
|
||||
|
||||
# Resolve node with bundled fallback and ensure the bundled node bin
|
||||
# dir is on the bridge's PATH. The requirement check and `npm install`
|
||||
# above already use find_node_executable, so on a bundled-but-off-PATH
|
||||
# install (root FHS w/ missing symlink, #38889) they pass — but a bare
|
||||
# "node" argv0 here would still raise FileNotFoundError, and the bridge
|
||||
# itself shells out to node tooling, so it needs the dir on PATH too.
|
||||
node_bin = "node"
|
||||
try:
|
||||
from hermes_constants import (
|
||||
find_node_executable,
|
||||
bundled_node_bin_dir,
|
||||
)
|
||||
|
||||
node_bin = find_node_executable("node") or "node"
|
||||
_node_dir = str(bundled_node_bin_dir())
|
||||
_path = bridge_env.get("PATH", "")
|
||||
if _node_dir not in _path.split(os.pathsep):
|
||||
bridge_env["PATH"] = (
|
||||
_node_dir + os.pathsep + _path if _path else _node_dir
|
||||
)
|
||||
except Exception:
|
||||
node_bin = shutil.which("node") or "node"
|
||||
|
||||
self._bridge_process = subprocess.Popen(
|
||||
[
|
||||
"node",
|
||||
node_bin,
|
||||
str(bridge_path),
|
||||
"--port", str(self._bridge_port),
|
||||
"--session", str(self._session_path),
|
||||
|
||||
@@ -448,9 +448,10 @@ def run_import(args) -> None:
|
||||
if skipped:
|
||||
print(f" Profile aliases skipped: {', '.join(skipped)}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
|
||||
_wd = _get_wrapper_dir()
|
||||
print(f"\n Note: {_wd} is not in your PATH.")
|
||||
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
|
||||
print(' export PATH="$HOME/.local/bin:$PATH"')
|
||||
print(f' export PATH="{_wd}:$PATH"')
|
||||
except ImportError:
|
||||
# hermes_cli.profiles might not be available (fresh install)
|
||||
if any(profiles_dir.iterdir()):
|
||||
|
||||
@@ -13,6 +13,11 @@ from pathlib import Path
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_constants import (
|
||||
command_link_dir as _command_link_dir,
|
||||
command_link_display_dir as _command_link_display_dir,
|
||||
bundled_node_bin_dir as _bundled_node_bin_dir,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = get_project_root()
|
||||
HERMES_HOME = get_hermes_home()
|
||||
@@ -198,6 +203,80 @@ def _section(title: str) -> None:
|
||||
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
def _resolve_node_for_doctor(issues: list) -> str | None:
|
||||
"""Resolve Node.js with bundled-fallback awareness and diagnose off-PATH.
|
||||
|
||||
Returns the resolved ``node`` binary path if node is usable from *some*
|
||||
known location, else ``None``. Emits the appropriate check_ok/check_warn/
|
||||
check_info lines and appends a fix to ``issues`` when node is installed but
|
||||
unreachable via PATH (the PR #38889 class of regression: bundled node lives
|
||||
at ``<HERMES_HOME>/node/bin`` but its PATH symlink is missing or off-PATH).
|
||||
|
||||
Discovery mirrors tools/browser_tool._browser_candidate_path_dirs and
|
||||
hermes_cli/main._ensure_tui_node so doctor's verdict matches what actually
|
||||
runs. As a side effect, when a bundled node is found off-PATH it is
|
||||
prepended to ``os.environ["PATH"]`` for the remainder of this doctor run so
|
||||
downstream npm/agent-browser checks don't cascade into false negatives.
|
||||
"""
|
||||
on_path = _safe_which("node")
|
||||
if on_path:
|
||||
check_ok("Node.js", f"({on_path})")
|
||||
return on_path
|
||||
|
||||
# Not on PATH — is it installed at the bundled location?
|
||||
bundled = _bundled_node_bin_dir() / "node"
|
||||
if bundled.exists() and os.access(bundled, os.X_OK):
|
||||
bin_dir = bundled.parent
|
||||
check_warn(
|
||||
"Node.js installed but not on PATH",
|
||||
f"(found {bundled}, but `node` is not resolvable via PATH)",
|
||||
)
|
||||
# Root FHS installs are supposed to symlink node into /usr/local/bin.
|
||||
# Verify that canonical symlink so doctor catches the exact PR #38889
|
||||
# breakage rather than only the generic PATH miss.
|
||||
try:
|
||||
is_root = hasattr(os, "geteuid") and os.geteuid() == 0
|
||||
except OSError:
|
||||
is_root = False
|
||||
if is_root and sys.platform == "linux":
|
||||
fhs_link = Path("/usr/local/bin/node")
|
||||
# Use lexists()/is_symlink(), not exists(): exists() follows the
|
||||
# symlink, so a *dangling* link (target removed) would otherwise be
|
||||
# misreported as "missing" and skip the stale-target diagnostic.
|
||||
if not os.path.lexists(fhs_link):
|
||||
check_info(
|
||||
"Root FHS install: node should be linked into /usr/local/bin."
|
||||
)
|
||||
check_info(f"Fix: ln -sf {bundled} /usr/local/bin/node "
|
||||
f"(and the same for npm, npx)")
|
||||
issues.append(
|
||||
"Bundled Node.js is off-PATH on a root FHS install — run: "
|
||||
f"ln -sf {bundled} /usr/local/bin/node "
|
||||
"(repeat for npm, npx), or re-run the installer"
|
||||
)
|
||||
elif not fhs_link.exists() or fhs_link.resolve() != bundled.resolve():
|
||||
# Present but dangling (target gone) or pointing at the wrong node.
|
||||
_actual = os.readlink(fhs_link) if fhs_link.is_symlink() else fhs_link
|
||||
check_warn(
|
||||
"/usr/local/bin/node points to the wrong target",
|
||||
f"(→ {_actual}, expected {bundled})",
|
||||
)
|
||||
issues.append(
|
||||
f"Fix stale node symlink: ln -sf {bundled} /usr/local/bin/node"
|
||||
)
|
||||
else:
|
||||
check_info(f"Bundled Node.js exists at {bin_dir} but isn't on PATH.")
|
||||
check_info(f'Fix: export PATH="{bin_dir}:$PATH" (add to your shell rc)')
|
||||
issues.append(f"Node.js is installed but off-PATH — add {bin_dir} to PATH")
|
||||
|
||||
# Make the rest of the doctor run see this node so npm/agent-browser
|
||||
# checks succeed instead of reporting more false negatives.
|
||||
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||
return str(bundled)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:
|
||||
"""Emit a check_fail and append the corresponding fix instruction."""
|
||||
check_fail(text, detail)
|
||||
@@ -1185,15 +1264,11 @@ def run_doctor(args):
|
||||
_venv_bin = _candidate
|
||||
break
|
||||
|
||||
# Determine the expected command link directory (mirrors install.sh logic)
|
||||
_prefix = os.environ.get("PREFIX", "")
|
||||
_is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix
|
||||
if _is_termux_env and _prefix:
|
||||
_cmd_link_dir = Path(_prefix) / "bin"
|
||||
_cmd_link_display = "$PREFIX/bin"
|
||||
else:
|
||||
_cmd_link_dir = Path.home() / ".local" / "bin"
|
||||
_cmd_link_display = "~/.local/bin"
|
||||
# Determine the expected command link directory (canonical helper —
|
||||
# single source of truth shared with scripts/install.sh, so root FHS
|
||||
# installs correctly resolve to /usr/local/bin instead of ~/.local/bin).
|
||||
_cmd_link_dir = _command_link_dir()
|
||||
_cmd_link_display = _command_link_display_dir()
|
||||
_cmd_link = _cmd_link_dir / "hermes"
|
||||
|
||||
if _venv_bin is None:
|
||||
@@ -1244,7 +1319,7 @@ def run_doctor(args):
|
||||
if str(_cmd_link_dir) not in _path_dirs:
|
||||
check_warn(
|
||||
f"{_cmd_link_display} is not on your PATH",
|
||||
"(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")"
|
||||
f'(add it to your shell config: export PATH="{_cmd_link_dir}:$PATH")'
|
||||
)
|
||||
manual_issues.append(f"Add {_cmd_link_display} to your PATH")
|
||||
else:
|
||||
@@ -1373,8 +1448,10 @@ def run_doctor(args):
|
||||
)
|
||||
|
||||
# Node.js + agent-browser (for browser automation tools)
|
||||
if _safe_which("node"):
|
||||
check_ok("Node.js")
|
||||
# Resolve with bundled-fallback awareness so an off-PATH bundled install
|
||||
# is diagnosed as "installed but not on PATH" instead of "not found".
|
||||
_node_resolved = _resolve_node_for_doctor(issues)
|
||||
if _node_resolved:
|
||||
# Check if agent-browser is installed
|
||||
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||
agent_browser_ok = False
|
||||
@@ -1451,8 +1528,15 @@ def run_doctor(args):
|
||||
else:
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
# npm audit for all Node.js packages. Use bundled-fallback resolution so a
|
||||
# bundled-but-off-PATH npm is still found (the _resolve_node_for_doctor call
|
||||
# above already prepended the bundled bin dir to PATH for this run, so plain
|
||||
# which usually works now; the explicit fallback is belt-and-suspenders).
|
||||
_npm_bin = _safe_which("npm")
|
||||
if not _npm_bin:
|
||||
_bundled_npm = _bundled_node_bin_dir() / "npm"
|
||||
if _bundled_npm.exists() and os.access(_bundled_npm, os.X_OK):
|
||||
_npm_bin = str(_bundled_npm)
|
||||
if _npm_bin:
|
||||
npm_dirs = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)"),
|
||||
|
||||
@@ -6817,6 +6817,7 @@ def _run_with_idle_timeout(
|
||||
*,
|
||||
idle_timeout_seconds: int = 180,
|
||||
indent: str = " ",
|
||||
env: dict | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a subprocess that streams output, with an idle-output timeout.
|
||||
|
||||
@@ -6851,6 +6852,7 @@ def _run_with_idle_timeout(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
env=env,
|
||||
)
|
||||
except OSError as exc:
|
||||
# E.g. npm not on PATH between the which() check and now.
|
||||
@@ -6915,6 +6917,7 @@ def _run_npm_install_deterministic(
|
||||
*,
|
||||
extra_args: tuple[str, ...] = (),
|
||||
capture_output: bool = True,
|
||||
env: dict | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
|
||||
|
||||
@@ -6936,6 +6939,7 @@ def _run_npm_install_deterministic(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
if ci_result.returncode == 0:
|
||||
return ci_result
|
||||
@@ -6950,6 +6954,7 @@ def _run_npm_install_deterministic(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
@@ -6981,12 +6986,44 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
|
||||
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
|
||||
|
||||
# Resolve npm with bundled-fallback awareness: on a root FHS install whose
|
||||
# PATH symlink is missing, or any context with a stripped PATH (systemd
|
||||
# service, RHEL non-login shell), shutil.which("npm") returns None even
|
||||
# though the bundled npm exists at <HERMES_HOME>/node/bin/npm. See #38889.
|
||||
npm = shutil.which("npm")
|
||||
if not npm:
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
npm = find_node_executable("npm")
|
||||
except Exception:
|
||||
npm = None
|
||||
if not npm:
|
||||
if fatal:
|
||||
_say("Web UI frontend not built and npm is not available.")
|
||||
_say("Install Node.js, then run: cd web && npm install && npm run build")
|
||||
return not fatal
|
||||
|
||||
# Ensure the bundled node/bin dir is on PATH for the build subprocesses so
|
||||
# the `npm run build` step (which shells out to tsc / vite from
|
||||
# node_modules/.bin, and those re-invoke `node`) can find node even when the
|
||||
# caller's PATH doesn't include it.
|
||||
_build_env = None
|
||||
try:
|
||||
from hermes_constants import bundled_node_bin_dir
|
||||
_node_bin = bundled_node_bin_dir()
|
||||
if _node_bin.is_dir():
|
||||
_build_env = os.environ.copy()
|
||||
_existing = _build_env.get("PATH", "")
|
||||
if str(_node_bin) not in _existing.split(os.pathsep):
|
||||
_build_env["PATH"] = str(_node_bin) + os.pathsep + _existing
|
||||
# Also fold in the resolved npm's own dir (covers system node installs).
|
||||
_npm_dir = str(Path(npm).resolve().parent)
|
||||
if _build_env is None:
|
||||
_build_env = os.environ.copy()
|
||||
if _npm_dir not in _build_env.get("PATH", "").split(os.pathsep):
|
||||
_build_env["PATH"] = _npm_dir + os.pathsep + _build_env.get("PATH", "")
|
||||
except Exception:
|
||||
_build_env = None
|
||||
_say("→ Building web UI...")
|
||||
|
||||
def _relay(result: "subprocess.CompletedProcess") -> None:
|
||||
@@ -7008,6 +7045,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
npm,
|
||||
_workspace_root(web_dir),
|
||||
extra_args=("--silent",),
|
||||
env=_build_env,
|
||||
)
|
||||
if r1.returncode != 0:
|
||||
_say(
|
||||
@@ -7023,13 +7061,13 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
# users react by rebooting, which leaves the editable install in a
|
||||
# half-state. Streaming + idle-kill makes failures observable AND
|
||||
# recoverable (the stale-dist fallback below handles the kill path).
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
|
||||
if r2.returncode != 0:
|
||||
# Retry once after a short delay — covers boot-time races on Windows
|
||||
# (antivirus scanning Node.js binaries, npm cache not ready, transient
|
||||
# I/O when launched via Scheduled Task at logon). See issue #23817.
|
||||
_time.sleep(3)
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
|
||||
|
||||
if r2.returncode != 0:
|
||||
# _run_with_idle_timeout merges stderr into stdout; older callers
|
||||
@@ -11438,11 +11476,12 @@ def cmd_profile(args):
|
||||
if wrapper_path:
|
||||
print(f"Wrapper created: {wrapper_path}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"\n⚠ {_get_wrapper_dir()} is not in your PATH.")
|
||||
_wd = _get_wrapper_dir()
|
||||
print(f"\n⚠ {_wd} is not in your PATH.")
|
||||
print(
|
||||
f" Add to your shell config (~/.bashrc or ~/.zshrc):"
|
||||
)
|
||||
print(f' export PATH="$HOME/.local/bin:$PATH"')
|
||||
print(f' export PATH="{_wd}:$PATH"')
|
||||
|
||||
# Profile dir for display
|
||||
try:
|
||||
|
||||
@@ -240,8 +240,25 @@ def _get_active_profile_path() -> Path:
|
||||
|
||||
|
||||
def _get_wrapper_dir() -> Path:
|
||||
"""Return the directory for wrapper scripts."""
|
||||
return Path.home() / ".local" / "bin"
|
||||
"""Return the directory for profile-alias wrapper scripts.
|
||||
|
||||
Uses the canonical command-link directory so aliases land wherever the
|
||||
``hermes`` command itself lives and is therefore on PATH: ``/usr/local/bin``
|
||||
for root FHS installs, ``$PREFIX/bin`` on Termux, ``~/.local/bin`` otherwise
|
||||
(including Windows). Previously hardcoded ``~/.local/bin``, which left
|
||||
aliases off-PATH on root FHS installs (PR #38889).
|
||||
"""
|
||||
from hermes_constants import command_link_dir
|
||||
|
||||
return command_link_dir()
|
||||
|
||||
|
||||
def _wrapper_candidate_dirs() -> list[Path]:
|
||||
"""All dirs a profile alias may live in, for cleanup that must find links
|
||||
regardless of which layout created them."""
|
||||
from hermes_constants import command_link_candidate_dirs
|
||||
|
||||
return command_link_candidate_dirs()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -354,13 +371,16 @@ def check_alias_collision(name: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _is_wrapper_dir_in_path() -> bool:
|
||||
"""Check if ~/.local/bin is in PATH."""
|
||||
"""Check if the layout-aware wrapper dir (see ``_get_wrapper_dir``) is in PATH."""
|
||||
wrapper_dir = str(_get_wrapper_dir())
|
||||
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
|
||||
|
||||
|
||||
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
|
||||
"""Create a shell wrapper script at ~/.local/bin/<name>.
|
||||
"""Create a shell wrapper script at ``<wrapper_dir>/<name>``.
|
||||
|
||||
``<wrapper_dir>`` is layout-aware (``_get_wrapper_dir``): ``/usr/local/bin``
|
||||
for a root FHS install, ``$PREFIX/bin`` on Termux, else ``~/.local/bin``.
|
||||
|
||||
The wrapper file is named after ``name`` (the alias). The profile it
|
||||
activates is ``target`` if given, otherwise ``name`` — this lets a custom
|
||||
@@ -399,27 +419,34 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
|
||||
|
||||
|
||||
def remove_wrapper_script(name: str) -> bool:
|
||||
"""Remove the wrapper script for a profile. Returns True if removed."""
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
"""Remove the wrapper script for a profile. Returns True if removed.
|
||||
|
||||
Scans all candidate command-link directories (``~/.local/bin``,
|
||||
``/usr/local/bin``, ``$PREFIX/bin``) so aliases are removable regardless of
|
||||
which layout created them — e.g. an alias written to ``/usr/local/bin`` on a
|
||||
root FHS install, or a legacy one left in ``~/.local/bin``.
|
||||
"""
|
||||
canon = normalize_profile_name(name)
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
# Check both the extensionless path (POSIX) and .bat (Windows)
|
||||
candidates = [wrapper_dir / canon]
|
||||
if is_windows:
|
||||
candidates.insert(0, wrapper_dir / f"{canon}.bat")
|
||||
removed = False
|
||||
for wrapper_dir in _wrapper_candidate_dirs():
|
||||
# Check both the extensionless path (POSIX) and .bat (Windows)
|
||||
candidates = [wrapper_dir / canon]
|
||||
if is_windows:
|
||||
candidates.insert(0, wrapper_dir / f"{canon}.bat")
|
||||
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
removed = True
|
||||
except Exception:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -119,16 +119,14 @@ def remove_wrapper_script():
|
||||
|
||||
|
||||
def _node_symlink_candidate_dirs() -> "list[Path]":
|
||||
"""Directories where the installer may have placed node/npm/npx symlinks."""
|
||||
dirs: list[Path] = [Path.home() / ".local" / "bin"]
|
||||
# Root FHS installs put links in /usr/local/bin.
|
||||
if sys.platform == "linux":
|
||||
dirs.append(Path("/usr/local/bin"))
|
||||
# Termux installs put links in $PREFIX/bin.
|
||||
prefix = os.environ.get("PREFIX", "")
|
||||
if prefix and "com.termux" in prefix:
|
||||
dirs.append(Path(prefix) / "bin")
|
||||
return dirs
|
||||
"""Directories where the installer may have placed node/npm/npx symlinks.
|
||||
|
||||
Delegates to the canonical helper in hermes_constants so the layout logic
|
||||
lives in exactly one place (shared with profiles, doctor, backup).
|
||||
"""
|
||||
from hermes_constants import command_link_candidate_dirs
|
||||
|
||||
return command_link_candidate_dirs()
|
||||
|
||||
|
||||
def remove_node_symlinks(hermes_home: Path) -> list:
|
||||
@@ -474,14 +472,28 @@ def _uninstall_profile(profile) -> None:
|
||||
except Exception as e:
|
||||
log_warn(f" Could not run gateway {subcmd} for '{name}': {e}")
|
||||
|
||||
# 2. Remove the wrapper alias script at ~/.local/bin/<name> (if any).
|
||||
alias_path = getattr(profile, "alias_path", None)
|
||||
if alias_path and alias_path.exists():
|
||||
try:
|
||||
alias_path.unlink()
|
||||
log_success(f" Removed alias {alias_path}")
|
||||
except Exception as e:
|
||||
log_warn(f" Could not remove alias {alias_path}: {e}")
|
||||
# 2. Remove the wrapper alias script wherever it landed. Use the
|
||||
# profiles helper which scans all candidate command-link dirs
|
||||
# (~/.local/bin, /usr/local/bin, $PREFIX/bin) so root FHS aliases are
|
||||
# removed too — then fall back to the recorded alias_path for safety.
|
||||
removed_alias = False
|
||||
try:
|
||||
from hermes_cli.profiles import remove_wrapper_script
|
||||
|
||||
removed_alias = remove_wrapper_script(name)
|
||||
if removed_alias:
|
||||
log_success(f" Removed profile alias '{name}'")
|
||||
except Exception as e:
|
||||
log_warn(f" Could not scan for profile alias '{name}': {e}")
|
||||
|
||||
if not removed_alias:
|
||||
alias_path = getattr(profile, "alias_path", None)
|
||||
if alias_path and alias_path.exists():
|
||||
try:
|
||||
alias_path.unlink()
|
||||
log_success(f" Removed alias {alias_path}")
|
||||
except Exception as e:
|
||||
log_warn(f" Could not remove alias {alias_path}: {e}")
|
||||
|
||||
# 3. Wipe the profile's HERMES_HOME directory.
|
||||
try:
|
||||
|
||||
@@ -414,6 +414,156 @@ def get_env_path() -> Path:
|
||||
return get_hermes_home() / ".env"
|
||||
|
||||
|
||||
# ─── Command-Link & Bundled-Node Locations ───────────────────────────────────
|
||||
#
|
||||
# Canonical, single source of truth for *where the installer places executables
|
||||
# so they land on PATH*. This MUST stay in lockstep with the bash helper
|
||||
# ``get_command_link_dir()`` in ``scripts/install.sh`` and ``_nb_get_link_dir()``
|
||||
# in ``scripts/lib/node-bootstrap.sh``. Historically this logic was duplicated
|
||||
# (and went stale) in doctor.py, profiles.py, uninstall.py and backup.py, which
|
||||
# caused root-FHS installs to look for / write the ``hermes`` command and node
|
||||
# symlinks in ``~/.local/bin`` even though they actually live in
|
||||
# ``/usr/local/bin``. See PR #38889.
|
||||
|
||||
|
||||
def _is_root_fhs_layout() -> bool:
|
||||
"""Return True when this is a root install using the Linux FHS layout.
|
||||
|
||||
Heuristic (not a strict line-by-line mirror) for ``resolve_install_layout()``
|
||||
in ``scripts/install.sh``: root (uid 0) on Linux normally uses
|
||||
``/usr/local/lib/hermes-agent`` for code and ``/usr/local/bin`` for the
|
||||
command link. We can't see the installer's ``--dir``/``$HERMES_INSTALL_DIR``
|
||||
or legacy-install flags from here, so we infer the layout from on-disk
|
||||
evidence instead, preferring it over the bare uid check:
|
||||
|
||||
* legacy git install at ``<HERMES_HOME>/hermes-agent`` → not FHS
|
||||
(``resolve_install_layout`` keeps ``~/.local/bin`` for it);
|
||||
* ``/usr/local`` markers present (command link or code dir) → FHS;
|
||||
* a ``~/.local/bin/hermes`` command present (e.g. an explicit ``--dir`` root
|
||||
install, which ``resolve_install_layout`` does NOT flip to FHS) → not FHS;
|
||||
* no evidence at all (e.g. mid-install) → assume FHS, the root default.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
return False
|
||||
try:
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
# A legacy git install at <HERMES_HOME>/hermes-agent means
|
||||
# resolve_install_layout() kept the ~/.local/bin layout — mirror that.
|
||||
if (get_hermes_home() / "hermes-agent" / ".git").exists():
|
||||
return False
|
||||
if Path("/usr/local/bin/hermes").exists():
|
||||
return True
|
||||
if Path("/usr/local/lib/hermes-agent").exists():
|
||||
return True
|
||||
# No /usr/local markers: a root user who installed into ~/.local/bin (e.g.
|
||||
# via --dir/$HERMES_INSTALL_DIR, where resolve_install_layout() does not flip
|
||||
# to FHS) keeps the command there. Honor that evidence before assuming FHS,
|
||||
# so command_link_dir() doesn't point at /usr/local/bin for such a box.
|
||||
if (Path.home() / ".local" / "bin" / "hermes").exists():
|
||||
return False
|
||||
# No markers at all (e.g. mid-install): the installer defaults a fresh root
|
||||
# Linux box to the FHS layout, so assume FHS.
|
||||
return True
|
||||
|
||||
|
||||
def command_link_dir() -> Path:
|
||||
"""Return the directory where the ``hermes`` command (and bundled node/npm/
|
||||
npx symlinks, profile-alias wrappers) are placed so they land on PATH.
|
||||
|
||||
Resolution mirrors ``get_command_link_dir()`` in ``scripts/install.sh``:
|
||||
|
||||
* Termux → ``$PREFIX/bin``
|
||||
* root FHS install on Linux → ``/usr/local/bin``
|
||||
* everything else (the common non-root case, and Windows) → ``~/.local/bin``
|
||||
"""
|
||||
if is_termux():
|
||||
prefix = os.environ.get("PREFIX", "").strip()
|
||||
if prefix:
|
||||
return Path(prefix) / "bin"
|
||||
if _is_root_fhs_layout():
|
||||
return Path("/usr/local/bin")
|
||||
return Path.home() / ".local" / "bin"
|
||||
|
||||
|
||||
def command_link_display_dir() -> str:
|
||||
"""User-friendly display string for :func:`command_link_dir`.
|
||||
|
||||
Uses ``~/.local/bin`` shorthand and ``$PREFIX/bin`` for Termux, matching
|
||||
``get_command_link_display_dir()`` in ``scripts/install.sh``.
|
||||
"""
|
||||
if is_termux() and os.environ.get("PREFIX", "").strip():
|
||||
return "$PREFIX/bin"
|
||||
if _is_root_fhs_layout():
|
||||
return "/usr/local/bin"
|
||||
return "~/.local/bin"
|
||||
|
||||
|
||||
def command_link_candidate_dirs() -> list[Path]:
|
||||
"""All directories the installer may have placed command links in.
|
||||
|
||||
Used by uninstall and other cleanup paths that must find links regardless
|
||||
of which layout created them (e.g. an old ``~/.local/bin`` install upgraded
|
||||
to FHS, or vice-versa). Always includes ``~/.local/bin`` plus the
|
||||
layout-specific dirs, de-duplicated and order-preserving.
|
||||
"""
|
||||
dirs: list[Path] = [Path.home() / ".local" / "bin"]
|
||||
if sys.platform == "linux":
|
||||
dirs.append(Path("/usr/local/bin"))
|
||||
prefix = os.environ.get("PREFIX", "").strip()
|
||||
if prefix and "com.termux" in prefix:
|
||||
dirs.append(Path(prefix) / "bin")
|
||||
# De-dupe while preserving order.
|
||||
seen: set[str] = set()
|
||||
out: list[Path] = []
|
||||
for d in dirs:
|
||||
key = str(d)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def bundled_node_bin_dir() -> Path:
|
||||
"""Return the bundled Node.js ``bin`` directory: ``<HERMES_HOME>/node/bin``.
|
||||
|
||||
This is where ``install_node()`` / ``node-bootstrap.sh`` extract the
|
||||
Hermes-managed Node runtime. Profile-aware via :func:`get_hermes_home`.
|
||||
Discovery code that gates a feature on ``node``/``npm``/``npx`` should fall
|
||||
back to this directory when ``shutil.which`` returns nothing, so a misplaced
|
||||
or missing PATH symlink doesn't make an installed runtime invisible.
|
||||
"""
|
||||
return get_hermes_home() / "node" / "bin"
|
||||
|
||||
|
||||
def find_node_executable(name: str = "node") -> str | None:
|
||||
"""Resolve a Node executable (node/npm/npx) with bundled fallback.
|
||||
|
||||
Returns an absolute path string if found on PATH or in the bundled
|
||||
``<HERMES_HOME>/node/bin`` directory, else ``None``. Prefer this over a
|
||||
bare ``shutil.which(name)`` anywhere a feature depends on Node, so an
|
||||
off-PATH bundled install still works.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
on_path = shutil.which(name)
|
||||
if on_path:
|
||||
return on_path
|
||||
candidate = bundled_node_bin_dir() / name
|
||||
if sys.platform == "win32" and not candidate.suffix:
|
||||
# Windows ships node.exe / npm.cmd; try common suffixes.
|
||||
for suffix in (".exe", ".cmd", ".bat", ""):
|
||||
c = candidate.with_suffix(suffix) if suffix else candidate
|
||||
if c.exists() and os.access(c, os.X_OK):
|
||||
return str(c)
|
||||
return None
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
# ─── Network Preferences ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -731,6 +731,12 @@ check_node() {
|
||||
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
|
||||
if [ -x "$HERMES_HOME/node/bin/node" ] && node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
# Migration repair (#38889): a previously-broken install may have its
|
||||
# node symlinks only in ~/.local/bin (off-PATH on root FHS) or missing.
|
||||
# Re-link into the canonical dir + prune stale copies so re-running the
|
||||
# installer (or `hermes update`) heals the box instead of leaving it
|
||||
# broken.
|
||||
link_bundled_node
|
||||
log_success "Node.js $("$HERMES_HOME/node/bin/node" --version) found (Hermes-managed)"
|
||||
HAS_NODE=true
|
||||
return 0
|
||||
@@ -746,6 +752,36 @@ check_node() {
|
||||
install_node
|
||||
}
|
||||
|
||||
# Idempotently (re)create node/npm/npx PATH symlinks in the command-link dir
|
||||
# and prune stale ones in the other candidate dirs. Shared by install_node
|
||||
# (fresh install) and check_node (migration repair of an existing broken box,
|
||||
# #38889). Pruning only removes symlinks that resolve into THIS Hermes home's
|
||||
# node dir — never a real binary or a user's nvm/fnm link.
|
||||
link_bundled_node() {
|
||||
local node_link_dir stale_dir name target
|
||||
node_link_dir="$(get_command_link_dir)"
|
||||
mkdir -p "$node_link_dir"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx"
|
||||
|
||||
for stale_dir in "$HOME/.local/bin" "/usr/local/bin"; do
|
||||
[ "$stale_dir" = "$node_link_dir" ] && continue
|
||||
for name in node npm npx; do
|
||||
[ -L "$stale_dir/$name" ] || continue
|
||||
target="$(readlink "$stale_dir/$name" 2>/dev/null || true)"
|
||||
case "$target" in
|
||||
# `|| true`: pruning a shadow link is best-effort. A failing
|
||||
# rm (read-only parent dir, uid mismatch) must NOT abort the
|
||||
# whole installer via `set -e` (line 16). See #38889.
|
||||
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" 2>/dev/null || true ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
# Never let this best-effort helper be the failing last command under set -e.
|
||||
return 0
|
||||
}
|
||||
|
||||
install_node() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Installing Node.js via pkg..."
|
||||
@@ -844,12 +880,7 @@ install_node() {
|
||||
mv "$extracted_dir" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
local node_link_dir
|
||||
node_link_dir="$(get_command_link_dir)"
|
||||
mkdir -p "$node_link_dir"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx"
|
||||
link_bundled_node
|
||||
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
|
||||
@@ -2179,6 +2210,12 @@ ensure_browser() {
|
||||
|
||||
ensure_mode() {
|
||||
detect_os
|
||||
# Resolve the install layout so $ROOT_FHS_LAYOUT is set before check_node →
|
||||
# install_node → get_command_link_dir() decides where to symlink node/npm/npx.
|
||||
# Without this, a root FHS box reached via `install.sh --ensure node`
|
||||
# (hermes_cli/dep_ensure.py, acp_adapter, TUI fallback) leaves ROOT_FHS_LAYOUT
|
||||
# false and links node into ~/.local/bin (off-PATH) — the #38889 regression.
|
||||
resolve_install_layout
|
||||
|
||||
IFS=',' read -ra DEPS <<< "$ENSURE_DEPS"
|
||||
for dep in "${DEPS[@]}"; do
|
||||
@@ -2217,6 +2254,9 @@ ensure_mode() {
|
||||
postinstall_mode() {
|
||||
print_banner
|
||||
detect_os
|
||||
# Set $ROOT_FHS_LAYOUT before check_node/install_node so node/npm/npx are
|
||||
# symlinked into the same dir as the hermes command on a root FHS box. (#38889)
|
||||
resolve_install_layout
|
||||
|
||||
log_info "Post-install mode: setting up Hermes for pip install"
|
||||
|
||||
|
||||
@@ -47,14 +47,70 @@ _nb_is_termux() {
|
||||
# Where to symlink node/npm/npx so they land on PATH.
|
||||
# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin,
|
||||
# Termux → $PREFIX/bin, otherwise ~/.local/bin.
|
||||
#
|
||||
# Parity note (#38889): install.sh keys off $ROOT_FHS_LAYOUT, which
|
||||
# resolve_install_layout() leaves FALSE (→ ~/.local/bin) for a root user with
|
||||
# EITHER a legacy git install at $HERMES_HOME/hermes-agent/.git OR an explicit
|
||||
# --dir/$HERMES_INSTALL_DIR install (INSTALL_DIR_EXPLICIT). The bootstrap can't
|
||||
# see those flags, so it can't recompute the layout — instead it puts node where
|
||||
# the `hermes` command actually landed. That keeps node and the command in the
|
||||
# same dir no matter how the box was installed, which is the only invariant that
|
||||
# matters here and avoids re-deriving (and diverging from) the installer's logic.
|
||||
_nb_get_link_dir() {
|
||||
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo "$PREFIX/bin"
|
||||
elif [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
|
||||
echo "/usr/local/bin"
|
||||
else
|
||||
echo "$HOME/.local/bin"
|
||||
return
|
||||
fi
|
||||
if [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
|
||||
# Legacy git install keeps ~/.local/bin (matches resolve_install_layout).
|
||||
if [ -d "${HERMES_HOME:-$HOME/.hermes}/hermes-agent/.git" ]; then
|
||||
echo "$HOME/.local/bin"
|
||||
return
|
||||
fi
|
||||
# Explicit --dir root installs keep the command in ~/.local/bin; detect
|
||||
# that from where `hermes` actually is rather than re-deriving the flag.
|
||||
if [ -e "$HOME/.local/bin/hermes" ] && [ ! -e "/usr/local/bin/hermes" ]; then
|
||||
echo "$HOME/.local/bin"
|
||||
return
|
||||
fi
|
||||
# Fresh/standard root FHS install.
|
||||
echo "/usr/local/bin"
|
||||
return
|
||||
fi
|
||||
echo "$HOME/.local/bin"
|
||||
}
|
||||
|
||||
# Idempotently (re)create the node/npm/npx PATH symlinks in the canonical link
|
||||
# dir, and prune stale ones left in OTHER candidate dirs by an older/broken
|
||||
# install (the #38889 migration case: a root box upgraded from the old layout
|
||||
# has links only in ~/.local/bin, off-PATH). Safe to call repeatedly.
|
||||
#
|
||||
# Pruning rule mirrors hermes_cli/uninstall.remove_node_symlinks: only remove a
|
||||
# symlink that still resolves into THIS Hermes home's node dir — never touch a
|
||||
# real binary or a link the user repointed at nvm/fnm.
|
||||
_nb_link_bundled_node() {
|
||||
local link_dir stale_dir name target
|
||||
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"
|
||||
|
||||
# Prune stale links in the other candidate dirs (so a migrated root install
|
||||
# doesn't keep shadowing copies in ~/.local/bin — #34536 nvm-shadow class).
|
||||
for stale_dir in "$HOME/.local/bin" "/usr/local/bin"; do
|
||||
[ "$stale_dir" = "$link_dir" ] && continue
|
||||
for name in node npm npx; do
|
||||
[ -L "$stale_dir/$name" ] || continue
|
||||
target="$(readlink "$stale_dir/$name" 2>/dev/null || true)"
|
||||
case "$target" in
|
||||
# `|| true`: best-effort prune must never fail a caller that
|
||||
# runs under `set -e` (install.sh sources/mirrors this). #38889
|
||||
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" 2>/dev/null || true ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_node_major() {
|
||||
@@ -200,12 +256,8 @@ _nb_install_bundled_node() {
|
||||
mv "$extracted" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp"
|
||||
|
||||
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"
|
||||
# Create PATH symlinks in the canonical link dir (and prune stale ones).
|
||||
_nb_link_bundled_node
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
|
||||
_nb_have_modern_node || return 1
|
||||
@@ -229,6 +281,12 @@ ensure_node() {
|
||||
if [ -x "$HERMES_HOME/node/bin/node" ]; then
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
if _nb_have_modern_node; then
|
||||
# Migration repair (#38889): an existing install may have its node
|
||||
# symlinks only in ~/.local/bin (off-PATH on root FHS) or missing
|
||||
# entirely. Re-create them in the canonical link dir and prune
|
||||
# stale copies, so `hermes update` heals a previously-broken box
|
||||
# instead of silently leaving it broken.
|
||||
_nb_link_bundled_node
|
||||
_nb_ok "Node $(node --version) found (Hermes-managed)"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
|
||||
@@ -1288,3 +1288,48 @@ class TestEdgeCases:
|
||||
delete_profile("coder", yes=True)
|
||||
|
||||
assert get_active_profile() == "default"
|
||||
|
||||
|
||||
class TestWrapperDirLayoutAware:
|
||||
"""Profile-alias wrapper dir follows the canonical command-link layout (#38889)."""
|
||||
|
||||
def test_wrapper_dir_root_fhs(self, monkeypatch):
|
||||
import hermes_cli.profiles as profiles
|
||||
import hermes_constants
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
|
||||
assert profiles._get_wrapper_dir() == Path("/usr/local/bin")
|
||||
|
||||
def test_wrapper_dir_nonroot(self, tmp_path, monkeypatch):
|
||||
import hermes_cli.profiles as profiles
|
||||
import hermes_constants
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
|
||||
assert profiles._get_wrapper_dir() == tmp_path / ".local" / "bin"
|
||||
|
||||
def test_remove_wrapper_scans_all_dirs(self, tmp_path, monkeypatch):
|
||||
"""An alias in /usr/local/bin is removed even though _get_wrapper_dir
|
||||
would (in test) point at ~/.local/bin — remove must scan candidates."""
|
||||
import hermes_cli.profiles as profiles
|
||||
fake_usr_local = tmp_path / "usr_local_bin"
|
||||
fake_usr_local.mkdir()
|
||||
alias = fake_usr_local / "myprof"
|
||||
alias.write_text('#!/usr/bin/env bash\nexec hermes -p myprof "$@"\n')
|
||||
alias.chmod(0o755)
|
||||
monkeypatch.setattr(
|
||||
profiles, "_wrapper_candidate_dirs", lambda: [fake_usr_local]
|
||||
)
|
||||
assert profiles.remove_wrapper_script("myprof") is True
|
||||
assert not alias.exists()
|
||||
|
||||
def test_remove_wrapper_leaves_foreign_files(self, tmp_path, monkeypatch):
|
||||
"""A file that isn't our wrapper (no 'hermes -p') is left untouched."""
|
||||
import hermes_cli.profiles as profiles
|
||||
d = tmp_path / "bin"
|
||||
d.mkdir()
|
||||
foreign = d / "myprof"
|
||||
foreign.write_text("#!/bin/sh\necho not ours\n")
|
||||
monkeypatch.setattr(profiles, "_wrapper_candidate_dirs", lambda: [d])
|
||||
assert profiles.remove_wrapper_script("myprof") is False
|
||||
assert foreign.exists()
|
||||
|
||||
@@ -298,3 +298,66 @@ class TestSecureParentDir:
|
||||
assert len(called_with) == 1
|
||||
assert called_with[0] == (str(real_dir), 0o700)
|
||||
|
||||
|
||||
|
||||
class TestCommandLinkDir:
|
||||
"""Tests for the canonical command-link / bundled-node helpers (#38889)."""
|
||||
|
||||
def test_nonroot_returns_local_bin(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.delenv("PREFIX", raising=False)
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
|
||||
assert hermes_constants.command_link_dir() == tmp_path / ".local" / "bin"
|
||||
assert hermes_constants.command_link_display_dir() == "~/.local/bin"
|
||||
|
||||
def test_root_fhs_returns_usr_local_bin(self, monkeypatch):
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
|
||||
assert hermes_constants.command_link_dir() == Path("/usr/local/bin")
|
||||
assert hermes_constants.command_link_display_dir() == "/usr/local/bin"
|
||||
|
||||
def test_termux_returns_prefix_bin(self, monkeypatch):
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: True)
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
assert hermes_constants.command_link_dir() == Path(
|
||||
"/data/data/com.termux/files/usr/bin"
|
||||
)
|
||||
assert hermes_constants.command_link_display_dir() == "$PREFIX/bin"
|
||||
|
||||
def test_candidate_dirs_includes_both_on_linux(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
|
||||
monkeypatch.delenv("PREFIX", raising=False)
|
||||
dirs = hermes_constants.command_link_candidate_dirs()
|
||||
assert tmp_path / ".local" / "bin" in dirs
|
||||
assert Path("/usr/local/bin") in dirs
|
||||
|
||||
def test_candidate_dirs_deduped(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
|
||||
monkeypatch.delenv("PREFIX", raising=False)
|
||||
dirs = hermes_constants.command_link_candidate_dirs()
|
||||
assert len(dirs) == len({str(d) for d in dirs})
|
||||
|
||||
def test_bundled_node_bin_dir(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
assert hermes_constants.bundled_node_bin_dir() == tmp_path / "node" / "bin"
|
||||
|
||||
def test_find_node_prefers_path(self, monkeypatch):
|
||||
monkeypatch.setattr("shutil.which", lambda n: "/usr/bin/" + n)
|
||||
assert hermes_constants.find_node_executable("node") == "/usr/bin/node"
|
||||
|
||||
def test_find_node_falls_back_to_bundled(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("shutil.which", lambda n: None)
|
||||
node_bin = tmp_path / "node" / "bin"
|
||||
node_bin.mkdir(parents=True)
|
||||
(node_bin / "npm").write_text("#!/bin/sh\n")
|
||||
(node_bin / "npm").chmod(0o755)
|
||||
assert hermes_constants.find_node_executable("npm") == str(node_bin / "npm")
|
||||
|
||||
def test_find_node_returns_none_when_absent(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("shutil.which", lambda n: None)
|
||||
assert hermes_constants.find_node_executable("node") is None
|
||||
|
||||
213
tests/test_node_bootstrap_link_prune.py
Normal file
213
tests/test_node_bootstrap_link_prune.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Regression coverage for the #38889 node migration-heal + stale-prune.
|
||||
|
||||
The user-facing fix reviewers cared about most — re-running the installer (or
|
||||
``hermes update``) on a box whose node symlinks landed in the wrong (off-PATH)
|
||||
dir re-links node into the canonical command dir AND prunes the stale shadow
|
||||
copies — lives entirely in shell (``link_bundled_node`` in ``install.sh`` and
|
||||
``_nb_link_bundled_node`` in ``scripts/lib/node-bootstrap.sh``). Before this
|
||||
file it had no automated coverage at all (only a manual VM run).
|
||||
|
||||
These tests drive the sourceable ``node-bootstrap.sh`` helper directly. The FHS
|
||||
``/usr/local/bin`` target requires root, so to exercise the same relink+prune
|
||||
code path with a *writable* link dir we run in Termux mode (``$PREFIX/bin`` is
|
||||
the link dir), which makes ``~/.local/bin`` one of the scanned stale dirs. The
|
||||
prune logic, safety guards, idempotency, and the ``set -e`` hardening are
|
||||
identical across the FHS and Termux link dirs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
|
||||
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform.startswith("win") or shutil.which("bash") is None,
|
||||
reason="POSIX shell required to drive node-bootstrap.sh",
|
||||
)
|
||||
|
||||
|
||||
class _Layout(NamedTuple):
|
||||
home: Path
|
||||
hermes_home: Path
|
||||
node_bin: Path
|
||||
prefix: Path
|
||||
link_dir: Path
|
||||
local_bin: Path
|
||||
|
||||
|
||||
def _layout(tmp_path: Path) -> _Layout:
|
||||
"""The fixed dir layout these tests share.
|
||||
|
||||
Termux mode (PREFIX contains ``com.termux/files/usr``) makes the link dir
|
||||
``$PREFIX/bin``, so ``~/.local/bin`` is a *scanned, writable* stale dir —
|
||||
the only way to exercise the relink+prune without being root.
|
||||
"""
|
||||
home = tmp_path / "home"
|
||||
hermes_home = tmp_path / "hermes"
|
||||
prefix = tmp_path / "termux" / "com.termux" / "files" / "usr"
|
||||
return _Layout(
|
||||
home=home,
|
||||
hermes_home=hermes_home,
|
||||
node_bin=hermes_home / "node" / "bin",
|
||||
prefix=prefix,
|
||||
link_dir=prefix / "bin",
|
||||
local_bin=home / ".local" / "bin",
|
||||
)
|
||||
|
||||
|
||||
def _make_bundled_node(hermes_home: Path) -> Path:
|
||||
"""Create dummy <HERMES_HOME>/node/bin/{node,npm,npx} executables."""
|
||||
node_bin = hermes_home / "node" / "bin"
|
||||
node_bin.mkdir(parents=True)
|
||||
for name in ("node", "npm", "npx"):
|
||||
exe = node_bin / name
|
||||
exe.write_text("#!/bin/sh\necho dummy\n")
|
||||
exe.chmod(0o755)
|
||||
return node_bin
|
||||
|
||||
|
||||
def _run_nb_link(tmp_path: Path, *, extra: str = "") -> subprocess.CompletedProcess:
|
||||
"""Source node-bootstrap.sh in Termux mode and run _nb_link_bundled_node.
|
||||
|
||||
Runs under ``set -e`` so the prune's best-effort ``rm`` failures must not
|
||||
abort (the #38889 hardening); ``SENTINEL_OK`` after the call proves we
|
||||
returned normally.
|
||||
"""
|
||||
lay = _layout(tmp_path)
|
||||
lay.link_dir.mkdir(parents=True, exist_ok=True)
|
||||
lay.local_bin.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
env = {
|
||||
"HOME": str(lay.home),
|
||||
"PREFIX": str(lay.prefix),
|
||||
"HERMES_HOME": str(lay.hermes_home),
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
}
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
set -e
|
||||
source "{NODE_BOOTSTRAP}"
|
||||
{extra}
|
||||
_nb_link_bundled_node
|
||||
echo SENTINEL_OK
|
||||
"""
|
||||
)
|
||||
return subprocess.run(
|
||||
["bash", "-c", script], env=env, capture_output=True, text=True
|
||||
)
|
||||
|
||||
|
||||
def test_relinks_and_prunes_stale_hermes_shadows(tmp_path: Path) -> None:
|
||||
"""node/npm links into the bundle's node dir are pruned; the canonical link
|
||||
dir gets fresh links; non-hermes links and real files are left alone."""
|
||||
lay = _layout(tmp_path)
|
||||
node_bin = _make_bundled_node(lay.hermes_home)
|
||||
link_dir = lay.link_dir
|
||||
local_bin = lay.local_bin
|
||||
|
||||
# Simulate an old/broken install: hermes-owned shadow links in ~/.local/bin.
|
||||
local_bin.mkdir(parents=True, exist_ok=True)
|
||||
(local_bin / "node").symlink_to(node_bin / "node") # hermes → PRUNE
|
||||
(local_bin / "npm").write_text("#!/bin/sh\n") # real file → KEEP
|
||||
(local_bin / "npm").chmod(0o755)
|
||||
nvm_npx = tmp_path / "fake_nvm" / "bin" / "npx"
|
||||
nvm_npx.parent.mkdir(parents=True)
|
||||
nvm_npx.write_text("#!/bin/sh\n")
|
||||
(local_bin / "npx").symlink_to(nvm_npx) # user link → KEEP
|
||||
|
||||
result = _run_nb_link(tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "SENTINEL_OK" in result.stdout
|
||||
|
||||
# Canonical link dir now has all three pointing into the bundle.
|
||||
for name in ("node", "npm", "npx"):
|
||||
link = link_dir / name
|
||||
assert link.is_symlink(), f"{name} not linked into canonical dir"
|
||||
assert link.resolve() == (node_bin / name).resolve()
|
||||
|
||||
# Stale hermes shadow was pruned; the real file and the nvm link survived.
|
||||
assert not (local_bin / "node").exists() and not (local_bin / "node").is_symlink(), (
|
||||
"stale hermes-owned ~/.local/bin/node should have been pruned"
|
||||
)
|
||||
assert (local_bin / "npm").is_file() and not (local_bin / "npm").is_symlink(), (
|
||||
"a real binary must never be removed by the prune"
|
||||
)
|
||||
assert (local_bin / "npx").is_symlink() and (local_bin / "npx").resolve() == nvm_npx.resolve(), (
|
||||
"a user's nvm/fnm link must never be removed by the prune"
|
||||
)
|
||||
|
||||
|
||||
def test_idempotent_across_repeated_runs(tmp_path: Path) -> None:
|
||||
"""Running the heal twice converges to the same state (no thrash/dup)."""
|
||||
lay = _layout(tmp_path)
|
||||
node_bin = _make_bundled_node(lay.hermes_home)
|
||||
link_dir = lay.link_dir
|
||||
|
||||
first = _run_nb_link(tmp_path)
|
||||
assert first.returncode == 0, first.stderr
|
||||
# Second run with the canonical links already in place.
|
||||
second = _run_nb_link(tmp_path)
|
||||
assert second.returncode == 0, second.stderr
|
||||
assert "SENTINEL_OK" in second.stdout
|
||||
for name in ("node", "npm", "npx"):
|
||||
link = link_dir / name
|
||||
assert link.is_symlink()
|
||||
assert link.resolve() == (node_bin / name).resolve()
|
||||
|
||||
|
||||
def test_prune_failure_does_not_abort_under_set_e(tmp_path: Path) -> None:
|
||||
"""A non-removable stale shadow (read-only parent dir) must NOT abort the
|
||||
caller under ``set -e`` — the #38889 prune-abort hardening."""
|
||||
lay = _layout(tmp_path)
|
||||
node_bin = _make_bundled_node(lay.hermes_home)
|
||||
local_bin = lay.local_bin
|
||||
local_bin.mkdir(parents=True)
|
||||
(local_bin / "node").symlink_to(node_bin / "node") # hermes shadow to prune
|
||||
|
||||
# Make the stale dir read-only so unlinking the shadow fails with EACCES
|
||||
# (non-root cannot unlink in a dir without write perm).
|
||||
local_bin.chmod(0o555)
|
||||
try:
|
||||
result = _run_nb_link(tmp_path)
|
||||
finally:
|
||||
local_bin.chmod(0o755) # restore so tmp cleanup can proceed
|
||||
|
||||
assert result.returncode == 0, (
|
||||
"set -e abort regression (#38889): a failing best-effort prune must not "
|
||||
f"fail the caller.\nstdout={result.stdout}\nstderr={result.stderr}"
|
||||
)
|
||||
assert "SENTINEL_OK" in result.stdout
|
||||
|
||||
|
||||
def test_install_sh_prune_is_set_e_safe_static() -> None:
|
||||
"""Static guard for the same fix in install.sh's link_bundled_node (which
|
||||
can't be sourced standalone): the stale-prune rm must be guarded and the
|
||||
function must end with `return 0` so it never trips `set -e`."""
|
||||
text = INSTALL_SH.read_text()
|
||||
match = re.search(
|
||||
r"link_bundled_node\(\)\s*\{.*?\n\}",
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match is not None, "could not locate link_bundled_node() in install.sh"
|
||||
body = match.group(0)
|
||||
assert 'rm -f "$stale_dir/$name" 2>/dev/null || true' in body, (
|
||||
"link_bundled_node prune rm must be `2>/dev/null || true` so a failed "
|
||||
"unlink under `set -e` doesn't abort the installer (#38889)"
|
||||
)
|
||||
assert re.search(r"return 0\s*\n\}", body), (
|
||||
"link_bundled_node must end with `return 0` so a failing prune is never "
|
||||
"the function's exit status under `set -e` (#38889)"
|
||||
)
|
||||
Reference in New Issue
Block a user