Compare commits

...

2 Commits

Author SHA1 Message Date
alt-glitch
2ff73853ee fix(installer): heal off-PATH node on update/migration + harden node discovery
Follow-up to the FHS root-install node-PATH fix, addressing the high-risk
gaps a reviewer flagged: fresh-install passing does not mean an existing
broken install gets healed.

Migration repair (the #1 trap):
- node-bootstrap.sh ensure_node() and install.sh check_node() both
  early-returned when a bundled node already existed at HERMES_HOME/node/bin,
  only fixing the current shell PATH and never re-creating the /usr/local/bin
  symlinks. A previously-broken root box therefore stayed broken after
  `hermes update` / re-install.
- Both paths now call a shared link_bundled_node / _nb_link_bundled_node that
  idempotently re-creates the symlinks in the canonical command-link dir AND
  prunes stale links left in the other candidate dirs, so a migrated root
  install no longer keeps shadowing copies in ~/.local/bin (the #34536
  nvm-shadow class).

Parity (messy-middle edge case):
- _nb_get_link_dir() now mirrors resolve_install_layout()'s legacy-install
  carve-out: a root user with HERMES_HOME/hermes-agent/.git keeps ~/.local/bin,
  so the bootstrap path can no longer link node to a different dir than the
  installer placed the hermes command.

Canonical helper (kills the duplicated layout-logic root cause):
- hermes_constants now owns command_link_dir, command_link_display_dir,
  command_link_candidate_dirs, bundled_node_bin_dir, find_node_executable.
  doctor.py, profiles.py, uninstall.py, backup.py, main.py all consume it.

Doctor now catches this class of regression:
- new _resolve_node_for_doctor reports "Node.js installed but not on PATH"
  instead of a false "not found", verifies the /usr/local/bin symlink on
  root FHS, self-heals PATH for the rest of the run, and the npm-audit block
  no longer silently vanishes when npm is off-PATH.
- doctor command-link detection uses the canonical helper, so it no longer
  looks in ~/.local/bin on root FHS or creates a wrong duplicate symlink
  with --fix.

Profile-alias wrappers now land in the layout-aware dir (was hardcoded
~/.local/bin, off-PATH for root FHS); remove_wrapper_script and uninstall
scan all candidate dirs.

Defensive bundled-node fallback (find_node_executable) added to the dashboard
web-UI build, WhatsApp bridge, and LSP installer so an off-PATH bundled node
does not silently disable those features.

Tests: 9 new hermes_constants helper tests + 4 profiles wrapper-dir tests.
Verified on a throwaway VM: fresh-root install (node on PATH, dashboard
serves HTTP 200, tsc present) and the migration scenario (broken old layout
re-installed -> node restored to /usr/local/bin, stale ~/.local/bin pruned).
2026-06-04 15:19:31 +05:30
alt-glitch
6495027f60 fix(installer): symlink bundled node/npm into command bin dir for FHS root installs
Root installs on Linux (FHS layout, #15608) put the `hermes` command in
`/usr/local/bin` (on PATH) but symlinked the bundled node/npm/npx into
`~/.local/bin`, which isn't on PATH for a stock root shell. `node`/`npm`
were 'command not found' and `hermes dashboard` failed with 'npm is not
available' because its build-on-demand fallback couldn't find npm.

Fix: `install_node()` now symlinks into `get_command_link_dir()` — the same
helper the `hermes` command link already uses — so node/npm/npx land
wherever the command does (`/usr/local/bin` on FHS root, `~/.local/bin`
otherwise, `$PREFIX/bin` on Termux). Non-root and Termux installs are
unchanged.

Also fixes:
- `scripts/lib/node-bootstrap.sh`: adds `_nb_get_link_dir()` mirroring
  the same root/Termux/user logic for the standalone bootstrap path
  (used by `hermes update`, TUI node bootstrap, etc.)
- `hermes_cli/uninstall.py`: `remove_node_symlinks()` now checks all
  candidate directories (`~/.local/bin`, `/usr/local/bin`, `$PREFIX/bin`)
  so root FHS uninstalls don't leave orphan symlinks

Regression from #15608, which created the FHS path for the command but
left `install_node` pointed at the legacy user-local dir.
2026-06-04 13:34:42 +05:30
13 changed files with 652 additions and 88 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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()):

View File

@@ -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)"),

View File

@@ -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:

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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:

View File

@@ -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 ─────────────────────────────────────────────────────

View File

@@ -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"

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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