mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 20:58:51 +08:00
Compare commits
2 Commits
bb/update-
...
fix/node-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff73853ee | ||
|
|
6495027f60 |
@@ -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
|
||||
|
||||
@@ -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,75 @@ 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")
|
||||
if not fhs_link.exists():
|
||||
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 fhs_link.resolve() != bundled.resolve():
|
||||
check_warn(
|
||||
"/usr/local/bin/node points to the wrong target",
|
||||
f"(→ {fhs_link.resolve()}, 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 +1259,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 +1314,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 +1443,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 +1523,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
|
||||
@@ -11384,11 +11422,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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -399,27 +416,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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ Provides options for:
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -117,45 +118,58 @@ def remove_wrapper_script():
|
||||
return removed
|
||||
|
||||
|
||||
def _node_symlink_candidate_dirs() -> "list[Path]":
|
||||
"""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:
|
||||
"""Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
|
||||
"""Remove the node/npm/npx symlinks the installer placed on PATH.
|
||||
|
||||
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
|
||||
creates::
|
||||
symlinks node/npm/npx into the same directory as the ``hermes`` command:
|
||||
|
||||
~/.local/bin/node -> $HERMES_HOME/node/bin/node
|
||||
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
|
||||
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
|
||||
- ``/usr/local/bin/`` on root FHS installs (Linux, uid 0)
|
||||
- ``$PREFIX/bin/`` on Termux
|
||||
- ``~/.local/bin/`` otherwise (the common non-root case)
|
||||
|
||||
and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
|
||||
manager such as nvm. Symmetrically remove them on uninstall, but *only*
|
||||
when the link still resolves into this Hermes home's ``node`` directory.
|
||||
A link the user has since repointed at nvm (or anything else outside
|
||||
Hermes) is left untouched so we never break unrelated tooling.
|
||||
We check all candidate directories so that uninstall works regardless of
|
||||
how the install was done (e.g. a root FHS install that placed links in
|
||||
``/usr/local/bin``, or an older install that used ``~/.local/bin`` before
|
||||
the FHS fix). Only symlinks that resolve into this Hermes home's ``node``
|
||||
directory are removed — links the user has repointed elsewhere (nvm, fnm,
|
||||
etc.) are left untouched.
|
||||
"""
|
||||
node_dir = (hermes_home / "node").resolve()
|
||||
removed = []
|
||||
|
||||
for name in ("node", "npm", "npx"):
|
||||
link = Path.home() / ".local" / "bin" / name
|
||||
try:
|
||||
# Only act on symlinks — never delete a real binary the user put here.
|
||||
if not link.is_symlink():
|
||||
continue
|
||||
for bin_dir in _node_symlink_candidate_dirs():
|
||||
link = bin_dir / name
|
||||
try:
|
||||
# Only act on symlinks — never delete a real binary the user put here.
|
||||
if not link.is_symlink():
|
||||
continue
|
||||
|
||||
# Resolve the link target and confirm it points into our node dir.
|
||||
# os.readlink + manual join handles broken (dangling) links too;
|
||||
# Path.resolve() on a dangling link still returns the target path.
|
||||
target = Path(os.readlink(link))
|
||||
if not target.is_absolute():
|
||||
target = (link.parent / target)
|
||||
target = target.resolve()
|
||||
# Resolve the link target and confirm it points into our node dir.
|
||||
# os.readlink + manual join handles broken (dangling) links too;
|
||||
# Path.resolve() on a dangling link still returns the target path.
|
||||
target = Path(os.readlink(link))
|
||||
if not target.is_absolute():
|
||||
target = (link.parent / target)
|
||||
target = target.resolve()
|
||||
|
||||
if target == node_dir or node_dir in target.parents:
|
||||
link.unlink()
|
||||
removed.append(link)
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove {link}: {e}")
|
||||
if target == node_dir or node_dir in target.parents:
|
||||
link.unlink()
|
||||
removed.append(link)
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove {link}: {e}")
|
||||
|
||||
return removed
|
||||
|
||||
@@ -458,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,145 @@ 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.
|
||||
|
||||
Mirrors ``resolve_install_layout()`` in ``scripts/install.sh``: root (uid 0)
|
||||
on Linux uses ``/usr/local/lib/hermes-agent`` for code and ``/usr/local/bin``
|
||||
for the command link. We detect it the same way the installer's own runtime
|
||||
guard does (``_ensure_fhs_path_guard`` in main.py): Linux + uid 0 + a command
|
||||
link present at ``/usr/local/bin/hermes`` OR code at
|
||||
``/usr/local/lib/hermes-agent``. Falling back to the uid check alone keeps
|
||||
this correct *during* an install before the symlink exists.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
return False
|
||||
try:
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
# Confirm it's actually the FHS layout (not a root user who installed into
|
||||
# ~/.local/bin anyway). 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 markers yet (e.g. mid-install): for a Linux root user the installer
|
||||
# defaults 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,31 @@ 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
|
||||
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
install_node() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Installing Node.js via pkg..."
|
||||
@@ -836,16 +867,15 @@ install_node() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Place into ~/.hermes/node/ and symlink binaries to ~/.local/bin/
|
||||
# Place into ~/.hermes/node/ and symlink binaries into the same bin dir
|
||||
# the hermes command uses (get_command_link_dir): /usr/local/bin for root
|
||||
# FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise.
|
||||
rm -rf "$HERMES_HOME/node"
|
||||
mkdir -p "$HERMES_HOME"
|
||||
mv "$extracted_dir" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx"
|
||||
link_bundled_node
|
||||
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
|
||||
|
||||
@@ -44,6 +44,63 @@ _nb_is_termux() {
|
||||
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
||||
}
|
||||
|
||||
# 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() sets to FALSE for a root user who has a LEGACY
|
||||
# install at $HERMES_HOME/hermes-agent/.git (those keep ~/.local/bin). We
|
||||
# mirror that here so the bootstrap path (hermes update) can't diverge from the
|
||||
# installer and link node into a different dir than the hermes command.
|
||||
_nb_get_link_dir() {
|
||||
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo "$PREFIX/bin"
|
||||
return
|
||||
fi
|
||||
if [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
|
||||
# Root on Linux: FHS layout UNLESS a legacy git install exists, matching
|
||||
# resolve_install_layout() in install.sh.
|
||||
if [ -d "${HERMES_HOME:-$HOME/.hermes}/hermes-agent/.git" ]; then
|
||||
echo "$HOME/.local/bin"
|
||||
else
|
||||
echo "/usr/local/bin"
|
||||
fi
|
||||
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
|
||||
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
_nb_node_major() {
|
||||
local v
|
||||
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
|
||||
@@ -187,10 +244,8 @@ _nb_install_bundled_node() {
|
||||
mv "$extracted" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp"
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/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
|
||||
@@ -214,6 +269,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()
|
||||
|
||||
@@ -130,3 +130,37 @@ def test_only_some_links_present(fake_home):
|
||||
assert (local_bin / "node").exists()
|
||||
assert not (local_bin / "npm").is_symlink()
|
||||
assert not (local_bin / "npx").is_symlink()
|
||||
|
||||
|
||||
def test_removes_fhs_symlinks_in_usr_local_bin(fake_home, tmp_path, monkeypatch):
|
||||
"""Root FHS installs place node symlinks in /usr/local/bin.
|
||||
|
||||
We monkeypatch _node_symlink_candidate_dirs to return a temp dir standing
|
||||
in for /usr/local/bin so the test doesn't need real root privileges.
|
||||
"""
|
||||
hermes_home = fake_home / ".hermes"
|
||||
node_bin = _make_hermes_node(hermes_home)
|
||||
|
||||
# Fake /usr/local/bin as a temp dir with our symlinks.
|
||||
fhs_bin = tmp_path / "usr_local_bin"
|
||||
fhs_bin.mkdir()
|
||||
for name in ("node", "npm", "npx"):
|
||||
(fhs_bin / name).symlink_to(node_bin / name)
|
||||
|
||||
# Ensure ~/.local/bin has NO symlinks (simulate pure FHS install).
|
||||
local_bin = fake_home / ".local" / "bin"
|
||||
for name in ("node", "npm", "npx"):
|
||||
p = local_bin / name
|
||||
if p.exists() or p.is_symlink():
|
||||
p.unlink()
|
||||
|
||||
# Return only our fake FHS dir as a candidate.
|
||||
monkeypatch.setattr(
|
||||
uninstall, "_node_symlink_candidate_dirs", lambda: [fhs_bin]
|
||||
)
|
||||
|
||||
removed = uninstall.remove_node_symlinks(hermes_home)
|
||||
|
||||
assert sorted(p.name for p in removed) == ["node", "npm", "npx"]
|
||||
for name in ("node", "npm", "npx"):
|
||||
assert not (fhs_bin / name).is_symlink()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user