Node/npm symlink conflicts with user version managers
NousResearch/hermes-agent · cluster 18158 · agent
Data as of
5 open items. Fix merged in #34835. Check it covers your case.
The short answer
- Known?
- Yes. 21 related items grouped here, 6 issues already closed.
- Fix event
- A fix was merged to main in PR #34835. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #34835. That is separate from the fix event. - Still open
- 2 issues and 3 PRs.
Next step: read #34835, then check whether your report is one of the 2 still open.
Read #34835Pick up a Lab taskIssues and fixes around the installer creating or leaving behind node/npm/npx symlinks that shadow or hijack user-managed Node installations from nvm, fnm, mise, asdf, and Homebrew. Related concerns include tool discovery failures when Hermes is launched non-interactively and PATH differs from the user's shell environment.
Open issues2
Open PRs3
Fixes on record
-
#34835 fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall
files:
hermes_cli/uninstall.py,pyproject.toml,tests/hermes_cli/test_uninstall_node_symlinks.pyShow the diff
-
hermes_cli/uninstall.py@@ -117,6 +117,49 @@ def remove_wrapper_script(): return removed +def remove_node_symlinks(hermes_home: Path) -> list: + """Remove the node/npm/npx symlinks the installer drops in ~/.local/bin. + + The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``) + creates:: + + ~/.local/bin/node -> $HERMES_HOME/node/bin/node + ~/.local/bin/npm -> $HERMES_HOME/node/bin/npm + ~/.local/bin/npx -> $HERMES_HOME/node/bin/npx + + 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. + """ + 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 + + # 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}") + + return removed + + def uninstall_gateway_service(): """Stop and uninstall the gateway service (systemd, launchd, Windows Scheduled Task / Startup folder) and kill any standalone gateway processes. @@ -594,6 +637,17 @@ def run_uninstall(args): log_success(f"Removed {wrapper}") else: log_info("No wrapper script found") + + # 3b. Remove node/npm/npx symlinks the installer left in ~/.local/bin + # (only when they still point into this Hermes home's node dir, so we + # never clobber an existing nvm / user-managed Node). + log_info("Removing Hermes-managed node/npm/npx symlinks...") + removed_node_links = remove_node_symlinks(hermes_home) + if removed_node_links: + for link in removed_node_links: + log_success(f"Removed {link}") + else: + log_info("No Hermes-managed node/npm/npx symlinks found") # 4. Remove installation directory (code) log_info("Removing installation directory...") -
pyproject.toml@@ -83,7 +83,7 @@ edge-tts = ["edge-tts==7.2.7"] modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] hindsight = ["hindsight-client==0.6.1"] -dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] +dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] cron = [] # croniter is now a core dependency; this extra kept for back-compat slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"] -
tests/hermes_cli/test_uninstall_node_symlinks.py@@ -0,0 +1,132 @@ +"""Tests for hermes_cli.uninstall.remove_node_symlinks. + +Regression for #34536: the POSIX installer drops node/npm/npx symlinks in +~/.local/bin pointing into $HERMES_HOME/node and prepends ~/.local/bin to +PATH, shadowing an existing nvm. Uninstall must remove those symlinks, but +only when they still resolve into the Hermes-managed node dir. +""" + +import os +from pathlib import Path + +import pytest + +import hermes_cli.uninstall as uninstall + + [redacted:email] +def fake_home(tmp_path, monkeypatch): + """Redirect Path.home() at the home both the installer-symlink target and + the ~/.local/bin links live under the same temp dir.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + (home / ".local" / "bin").mkdir(parents=True) + return home + + +def _make_hermes_node(hermes_home: Path) -> Path: + """Create a fake $HERMES_HOME/node/bin/{node,npm,npx} tree.""" + node_bin = hermes_home / "node" / "bin" + node_bin.mkdir(parents=True) + for name in ("node", "npm", "npx"): + (node_bin / name).write_text("#!/bin/sh\n") + (node_bin / name).chmod(0o755) + return node_bin + + +def test_removes_symlinks_pointing_into_hermes_node(fake_home): + hermes_home = fake_home / ".hermes" + node_bin = _make_hermes_node(hermes_home) + local_bin = fake_home / ".local" / "bin" + + for name in ("node", "npm", "npx"): + (local_bin / name).symlink_to(node_bin / name) + + 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 (local_bin / name).exists() + assert not (local_bin / name).is_symlink() + + +def test_leaves_unrelated_symlinks_untouched(fake_home): + """A node symlink the user repointed at nvm must survive uninstall.""" + hermes_home = fake_home / ".hermes" + _make_hermes_node(hermes_home) + local_bin = fake_home / ".local" / "bin" + + # Simulate nvm's node living elsewhere; user's ~/.local/bin/node -> nvm. + nvm_bin = fake_home / ".nvm" / "versions" / "node" / "v20.0.0" / "bin" + nvm_bin.mkdir(parents=True) + (nvm_bin / "node").write_text("#!/bin/sh\n") + (local_bin / "node").symlink_to(nvm_bin / "node") + + removed = uninstall.remove_node_symlinks(hermes_home) + + assert removed == [] + assert (local_bin / "node").is_symlink() + assert (local_bin / "node").resolve() == (nvm_bin / "node").resolve() + + +def test_leaves_real_binaries_untouched(fake_home): + """A real (non-symlink) binary in ~/.local/bin is never deleted.""" + hermes_home = fake_home / ".hermes" + _make_hermes_node(hermes_home) + local_bin = fake_home / ".local" / "bin" + + real_node = local_bin / "node" + real_node.write_text("#!/bin/sh\necho real\n") + real_node.chmod(0o755) + + removed = uninstall.remove_node_symlinks(hermes_home) + + assert removed == [] + assert real_node.exists() + assert not real_node.is_symlink() + + +def test_handles_missing_local_bin(fake_home): + """No symlinks present -> no-op, no error.""" + hermes_home = fake_home / ".hermes" + _make_hermes_node(hermes_home) + + assert uninstall.remove_node_symlinks(hermes_home) == [] + + +def test_removes_dangling_symlink_into_hermes_node(fake_home): + """A link into the Hermes node dir is removed even if the target file is + already gone (dangling) \u2014 the link still shadows PATH.""" + hermes_home = fake_home / ".hermes" + node_bin = hermes_home / "node" / "bin" + node_bin.mkdir(parents=True) + local_bin = fake_home / ".local" / "bin" + + # Create the symlink, then delete the target so it dangles. + (local_bin / "node").symlink_to(node_bin / "node") + assert (local_bin / "node").is_symlink() + + removed = uninstall.remove_node_symlinks(hermes_home) + + assert [p.name for p in removed] == ["node"] + assert not (local_bin / "node").is_symlink() + + +def test_only_some_links_present(fake_home): + """Removes the Hermes links that exist; ignores the ones that don't.""" + hermes_home = fake_home / ".hermes" + node_bin = _make_hermes_node(hermes_home) + local_bin = fake_home / ".local" / "bin" + + # Only npm and npx are Hermes-managed; node is a real user binary. + (local_bin / "npm").symlink_to(node_bin / "npm") + (local_bin / "npx").symlink_to(node_bin / "npx") + (local_bin / "node").write_text("#!/bin/sh\n") + + removed = uninstall.remove_node_symlinks(hermes_home) + + assert sorted(p.name for p in removed) == ["npm", "npx"] + assert (local_bin / "node").exists() + assert not (local_bin / "npm").is_symlink() + assert not (local_bin / "npx").is_symlink() -
uv.lock@@ -1629,6 +1629,7 @@ all = [ { name = "pytest-timeout" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "ruff" }, + { name = "setuptools" }, { name = "simple-term-menu" }, { name = "ty" }, { name = "uvicorn", extra = ["standard"] }, @@ -1659,6 +1660,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-timeout" }, { name = "ruff" }, + { name = "setuptools" }, { name = "ty" }, ] dingtalk = [ @@ -1864,6 +1866,7 @@ requires-dist = [ { name = "rich", specifier = "==14.3.3" }, { name = "ruamel-yaml", specifier = "==0.18.17" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = "==82.0.1" }, { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" }, { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" },
-
-
#38889 fix(installer): symlink bundled node/npm into command bin dir for FHS root installs
files:
hermes_cli/uninstall.py,scripts/install.sh,scripts/lib/node-bootstrap.sh,tests/hermes_cli/test_uninstall_node_symlinks.pyShow the diff
-
hermes_cli/uninstall.py@@ -9,6 +9,7 @@ import os import shutil import subprocess +import sys from pathlib import Path from hermes_constants import get_hermes_home @@ -117,45 +118,60 @@ def remove_wrapper_script(): return removed -def remove_node_symlinks(hermes_home: Path) -> list: - """Remove the node/npm/npx symlinks the installer drops in ~/.local/bin. +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 - The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``) - creates:: - ~/.local/bin/node -> $HERMES_HOME/node/bin/node - ~/.local/bin/npm -> $HERMES_HOME/node/bin/npm - ~/.local/bin/npx -> $HERMES_HOME/node/bin/npx +def remove_node_symlinks(hermes_home: Path) -> list: + """Remove the node/npm/npx symlinks the installer placed on PATH. - 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. + The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``) + symlinks node/npm/npx into the same directory as the ``hermes`` command: + + - ``/usr/local/bin/`` on root FHS installs (Linux, uid 0) + - ``$PREFIX/bin/`` on Termux + - ``~/.local/bin/`` otherwise (the common non-root case) + + 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 - - # 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}") + 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() + + 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 -
scripts/install.sh@@ -836,16 +836,20 @@ 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" + 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" export PATH="$HERMES_HOME/node/bin:$PATH" -
scripts/lib/node-bootstrap.sh@@ -44,6 +44,19 @@ _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. +_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" + fi +} + _nb_node_major() { local v v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) @@ -187,10 +200,12 @@ _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" + 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" export PATH="$HERMES_HOME/node/bin:$PATH" _nb_have_modern_node || return 1 -
tests/hermes_cli/test_uninstall_node_symlinks.py@@ -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()
-
-
#46085 fix(install): make `npm install -g` packages reachable on PATH
files:
scripts/install.sh,scripts/lib/node-bootstrap.sh,tests/test_install_sh_node_global_prefix.pyShow the diff
-
scripts/install.sh@@ -413,6 +413,25 @@ get_command_link_display_dir() { fi } +# Point a Hermes-managed Node's `npm install -g` at a directory that is on +# PATH. npm's default global prefix for a bundled Node is the Node dir itself, +# so global package binaries land in $HERMES_HOME/node/bin — which is NOT on +# PATH (only the command link dir is) and is wiped on every Node upgrade. +# Redirecting the prefix to the link dir's parent makes global bins resolve to +# the command link dir (node/npm/npx live there too, already on PATH) and +# survive upgrades. Scoped to the managed Node via its prefix-local global +# npmrc, so the user's other Node installs and their ~/.npmrc are untouched. +# Hermes's own global installs pass an explicit --prefix and are unaffected. +# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on +# every install run repairs pre-existing installs, not just fresh ones. +configure_managed_node_npm_prefix() { + [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 + local link_dir + link_dir="$(get_command_link_dir)" + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc" +} + get_hermes_command_path() { local link_dir link_dir="$(get_command_link_dir)" @@ -722,6 +741,11 @@ node_satisfies_build() { check_node() { log_info "Checking Node.js (for browser tools)..." + # Repair pre-existing Hermes-managed installs where `npm install -g` lands + # off PATH. No-op when there's no managed Node, so this is safe to run on + # every install — including re-runs that skip the Node (re)install below. + configure_managed_node_npm_prefix + if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then log_success "Node.js $(node --version) found" HAS_NODE=true @@ -851,6 +875,8 @@ install_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx" + configure_managed_node_npm_prefix + export PATH="$HERMES_HOME/node/bin:$PATH" local installed_ver -
scripts/lib/node-bootstrap.sh@@ -57,6 +57,19 @@ _nb_get_link_dir() { fi } +# Redirect a Hermes-managed Node's `npm install -g` to the command link dir +# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off +# PATH and wiped on every Node upgrade. Scoped to the managed Node via its +# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are +# untouched. Idempotent no-op when there's no managed npm. +_nb_configure_npm_prefix() { + [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 + local _link_dir + _link_dir="$(_nb_get_link_dir)" + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" +} + _nb_node_major() { local v v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) @@ -206,6 +219,9 @@ _nb_install_bundled_node() { 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" + + _nb_configure_npm_prefix + export PATH="$HERMES_HOME/node/bin:$PATH" _nb_have_modern_node || return 1 @@ -220,6 +236,10 @@ _nb_install_bundled_node() { ensure_node() { HERMES_NODE_AVAILABLE=false + # Repair pre-existing managed installs where `npm install -g` lands off + # PATH. No-op when there's no managed Node, so it's safe to run first. + _nb_configure_npm_prefix + if _nb_have_modern_node; then _nb_ok "Node $(node --version) found" HERMES_NODE_AVAILABLE=true -
tests/test_install_sh_node_global_prefix.py@@ -0,0 +1,55 @@ +"""Regression tests for the Hermes-managed Node's npm global prefix. + +When the installer falls back to a bundled Node under ``$HERMES_HOME/node``, +npm's default global prefix is that Node dir, so ``npm install -g <pkg>`` +drops the package binary in ``$HERMES_HOME/node/bin`` — which is NOT on PATH +(only the command link dir is) and is wiped on every Node upgrade. Users then +report "I can ``npm i -g`` but the package isn't usable on the command line". + +The fix redirects the bundled Node's global prefix to the command link dir's +parent (so global bins land in the already-on-PATH link dir alongside +node/npm/npx), scoped to the bundled Node via its prefix-local global npmrc. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" +NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh" + + +def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None: + text = INSTALL_SH.read_text() + + # The redirect must target the link dir's PARENT so global bins resolve to + # <parent>/bin == the command link dir (node/npm/npx live there and it is + # guaranteed on PATH by the installer's PATH setup). + assert "configure_managed_node_npm_prefix()" in text + assert 'printf \'prefix=%s\\n\' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + + +def test_install_sh_repairs_existing_managed_node_on_rerun() -> None: + """The redirect must run on every install (not just fresh Node installs), + so re-running the installer repairs pre-existing managed installs whose + Node is already up to date and would otherwise skip install_node.""" + text = INSTALL_SH.read_text() + + check_node_body = text.split("check_node()", 1)[1].split("\ninstall_node()", 1)[0] + assert "configure_managed_node_npm_prefix" in check_node_body + + # No-op guard so it's safe to call when there is no managed Node. + assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text + + +def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None: + text = NODE_BOOTSTRAP.read_text() + + assert "_nb_configure_npm_prefix()" in text + assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + + # Runs at the top of ensure_node so existing managed installs are repaired + # even when a modern Node is already present (early return path). + ensure_node_body = text.split("ensure_node()", 1)[1] + assert "_nb_configure_npm_prefix" in ensure_node_body + assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text
-
7 PRs closed without merging
From the close comments: done elsewhere 2 · closed without comment 1 · merged via another change 1 · partial fix 1 · other 2
- #18159 fix(doctor,browser): centralize tool PATH fallbacks (mise/asdf/nvm/Homebrew) · other
-
#18379 fix(install): prevent npm symlink from hijacking user global npm prefix · done elsewhere →
#38889,#46085,#18357 -
#34538 fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall · merged via another change →
54aa4db,#34835,#34536 - #45384 fix(installer): skip node/npm/npx symlinks for user installs (#45279) · other
-
#58099 fix(install): keep managed Node private for user installs · done elsewhere →
3d12e86,b63c138
Closed issues · 6
- Closed#18357[Bug]: Setup SABOTAGES computer integrits - npm global installs hijacked to ~/.hermes/node breaks other software
- Closed#34536Installer symlinks node/npm/npx into ~/.local/bin ahead of nvm, but uninstall leaves them behind
- Closed#38765[Bug]: After installation, my fnm node config is overwrited.
- Closed#45279PR #38889 still creates node/npm/npx shims in ~/.local/bin for user macOS installs, shadowing Homebrew/nvm
- Closed#57997[Bug]: Installing Hermes Desktop broke iTerm disk access and nvm usage
- Closed#63612[Bug]: Installer hijacks global node/npm/npx — GUI-launched install.sh misdetects Node as absent (launchd PATH), then ln -sf's Hermes-managed Node 22 over the user's ~/.local/bin
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.08: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab