{"repo":"NousResearch/hermes-agent","public_id":"18158","display_label":"Node/npm symlink conflicts with user version managers","summary":"Issues 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.","subsystem":"agent","counts":{"members":21,"open_issues":2,"open_issues_distinct":2,"open_prs":3,"closed_issues":6,"merged_prs":3,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34835},"resolution":{"state":"partially_resolved","by":"pr-34835"},"freshness":{"state":"verified","last_verified_at":"2026-06-15T19:47:55+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.08076190476190474},"fix_edges":[{"issue_number":18357,"pr_number":38889,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":18357,"pr_number":46085,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":34536,"pr_number":34835,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":18158,"title":"hermes doctor / browser tool can't find Node when launched non-interactively (mise/asdf/nvm shims, Homebrew)","url":null,"state":"open"},{"kind":"issue","number":18357,"title":"[Bug]: Setup SABOTAGES computer integrits - npm global installs hijacked to ~/.hermes/node breaks other software","url":null,"state":"closed"},{"kind":"issue","number":34536,"title":"Installer symlinks node/npm/npx into ~/.local/bin ahead of nvm, but uninstall leaves them behind","url":null,"state":"closed"},{"kind":"issue","number":38765,"title":"[Bug]: After installation, my fnm node config is overwrited.","url":null,"state":"closed"},{"kind":"issue","number":45279,"title":"PR #38889 still creates node/npm/npx shims in ~/.local/bin for user macOS installs, shadowing Homebrew/nvm","url":null,"state":"closed"},{"kind":"issue","number":57997,"title":"[Bug]: Installing Hermes Desktop broke iTerm disk access and nvm usage","url":null,"state":"closed"},{"kind":"issue","number":63612,"title":"[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","url":null,"state":"closed"},{"kind":"issue","number":64538,"title":"Browser tool cannot discover agent-browser installed by fnm on Windows","url":null,"state":"open"},{"kind":"pr","number":18159,"title":"fix(doctor,browser): centralize tool PATH fallbacks (mise/asdf/nvm/Homebrew)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":18379,"title":"fix(install): prevent npm symlink from hijacking user global npm prefix","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38889","number":38889},{"kind":"pr","value":"#46085","number":46085},{"kind":"pr","value":"#18357","number":18357}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34538,"title":"fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"54aa4db1d","number":0},{"kind":"pr","value":"#34835","number":34835},{"kind":"pr","value":"#34536","number":34536}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34835,"title":"fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/uninstall.py","pyproject.toml","tests/hermes_cli/test_uninstall_node_symlinks.py"],"fix_hunks":[{"file":"hermes_cli/uninstall.py","hunks":["@@ -117,6 +117,49 @@ def remove_wrapper_script():\n     return removed\n \n \n+def remove_node_symlinks(hermes_home: Path) -> list:\n+    \"\"\"Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.\n+\n+    The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)\n+    creates::\n+\n+        ~/.local/bin/node -> $HERMES_HOME/node/bin/node\n+        ~/.local/bin/npm  -> $HERMES_HOME/node/bin/npm\n+        ~/.local/bin/npx  -> $HERMES_HOME/node/bin/npx\n+\n+    and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node\n+    manager such as nvm.  Symmetrically remove them on uninstall, but *only*\n+    when the link still resolves into this Hermes home's ``node`` directory.\n+    A link the user has since repointed at nvm (or anything else outside\n+    Hermes) is left untouched so we never break unrelated tooling.\n+    \"\"\"\n+    node_dir = (hermes_home / \"node\").resolve()\n+    removed = []\n+\n+    for name in (\"node\", \"npm\", \"npx\"):\n+        link = Path.home() / \".local\" / \"bin\" / name\n+        try:\n+            # Only act on symlinks — never delete a real binary the user put here.\n+            if not link.is_symlink():\n+                continue\n+\n+            # Resolve the link target and confirm it points into our node dir.\n+            # os.readlink + manual join handles broken (dangling) links too;\n+            # Path.resolve() on a dangling link still returns the target path.\n+            target = Path(os.readlink(link))\n+            if not target.is_absolute():\n+                target = (link.parent / target)\n+            target = target.resolve()\n+\n+            if target == node_dir or node_dir in target.parents:\n+                link.unlink()\n+                removed.append(link)\n+        except Exception as e:\n+            log_warn(f\"Could not remove {link}: {e}\")\n+\n+    return removed\n+\n+\n def uninstall_gateway_service():\n     \"\"\"Stop and uninstall the gateway service (systemd, launchd, Windows\n     Scheduled Task / Startup folder) and kill any standalone gateway processes.","@@ -594,6 +637,17 @@ def run_uninstall(args):\n             log_success(f\"Removed {wrapper}\")\n     else:\n         log_info(\"No wrapper script found\")\n+\n+    # 3b. Remove node/npm/npx symlinks the installer left in ~/.local/bin\n+    #     (only when they still point into this Hermes home's node dir, so we\n+    #     never clobber an existing nvm / user-managed Node).\n+    log_info(\"Removing Hermes-managed node/npm/npx symlinks...\")\n+    removed_node_links = remove_node_symlinks(hermes_home)\n+    if removed_node_links:\n+        for link in removed_node_links:\n+            log_success(f\"Removed {link}\")\n+    else:\n+        log_info(\"No Hermes-managed node/npm/npx symlinks found\")\n     \n     # 4. Remove installation directory (code)\n     log_info(\"Removing installation directory...\")"]},{"file":"pyproject.toml","hunks":["@@ -83,7 +83,7 @@ edge-tts = [\"edge-tts==7.2.7\"]\n modal = [\"modal==1.3.4\"]\n daytona = [\"daytona==0.155.0\"]\n hindsight = [\"hindsight-client==0.6.1\"]\n-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\"]\n+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\"]\n 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\"]\n cron = []  # croniter is now a core dependency; this extra kept for back-compat\n slack = [\"slack-bolt==1.27.0\", \"slack-sdk==3.40.1\", \"aiohttp==3.13.3\"]"]},{"file":"tests/hermes_cli/test_uninstall_node_symlinks.py","hunks":["@@ -0,0 +1,132 @@\n+\"\"\"Tests for hermes_cli.uninstall.remove_node_symlinks.\n+\n+Regression for #34536: the POSIX installer drops node/npm/npx symlinks in\n+~/.local/bin pointing into $HERMES_HOME/node and prepends ~/.local/bin to\n+PATH, shadowing an existing nvm. Uninstall must remove those symlinks, but\n+only when they still resolve into the Hermes-managed node dir.\n+\"\"\"\n+\n+import os\n+from pathlib import Path\n+\n+import pytest\n+\n+import hermes_cli.uninstall as uninstall\n+\n+\n[redacted:email]\n+def fake_home(tmp_path, monkeypatch):\n+    \"\"\"Redirect Path.home() at the home both the installer-symlink target and\n+    the ~/.local/bin links live under the same temp dir.\"\"\"\n+    home = tmp_path / \"home\"\n+    home.mkdir()\n+    monkeypatch.setattr(Path, \"home\", classmethod(lambda cls: home))\n+    (home / \".local\" / \"bin\").mkdir(parents=True)\n+    return home\n+\n+\n+def _make_hermes_node(hermes_home: Path) -> Path:\n+    \"\"\"Create a fake $HERMES_HOME/node/bin/{node,npm,npx} tree.\"\"\"\n+    node_bin = hermes_home / \"node\" / \"bin\"\n+    node_bin.mkdir(parents=True)\n+    for name in (\"node\", \"npm\", \"npx\"):\n+        (node_bin / name).write_text(\"#!/bin/sh\\n\")\n+        (node_bin / name).chmod(0o755)\n+    return node_bin\n+\n+\n+def test_removes_symlinks_pointing_into_hermes_node(fake_home):\n+    hermes_home = fake_home / \".hermes\"\n+    node_bin = _make_hermes_node(hermes_home)\n+    local_bin = fake_home / \".local\" / \"bin\"\n+\n+    for name in (\"node\", \"npm\", \"npx\"):\n+        (local_bin / name).symlink_to(node_bin / name)\n+\n+    removed = uninstall.remove_node_symlinks(hermes_home)\n+\n+    assert sorted(p.name for p in removed) == [\"node\", \"npm\", \"npx\"]\n+    for name in (\"node\", \"npm\", \"npx\"):\n+        assert not (local_bin / name).exists()\n+        assert not (local_bin / name).is_symlink()\n+\n+\n+def test_leaves_unrelated_symlinks_untouched(fake_home):\n+    \"\"\"A node symlink the user repointed at nvm must survive uninstall.\"\"\"\n+    hermes_home = fake_home / \".hermes\"\n+    _make_hermes_node(hermes_home)\n+    local_bin = fake_home / \".local\" / \"bin\"\n+\n+    # Simulate nvm's node living elsewhere; user's ~/.local/bin/node -> nvm.\n+    nvm_bin = fake_home / \".nvm\" / \"versions\" / \"node\" / \"v20.0.0\" / \"bin\"\n+    nvm_bin.mkdir(parents=True)\n+    (nvm_bin / \"node\").write_text(\"#!/bin/sh\\n\")\n+    (local_bin / \"node\").symlink_to(nvm_bin / \"node\")\n+\n+    removed = uninstall.remove_node_symlinks(hermes_home)\n+\n+    assert removed == []\n+    assert (local_bin / \"node\").is_symlink()\n+    assert (local_bin / \"node\").resolve() == (nvm_bin / \"node\").resolve()\n+\n+\n+def test_leaves_real_binaries_untouched(fake_home):\n+    \"\"\"A real (non-symlink) binary in ~/.local/bin is never deleted.\"\"\"\n+    hermes_home = fake_home / \".hermes\"\n+    _make_hermes_node(hermes_home)\n+    local_bin = fake_home / \".local\" / \"bin\"\n+\n+    real_node = local_bin / \"node\"\n+    real_node.write_text(\"#!/bin/sh\\necho real\\n\")\n+    real_node.chmod(0o755)\n+\n+    removed = uninstall.remove_node_symlinks(hermes_home)\n+\n+    assert removed == []\n+    assert real_node.exists()\n+    assert not real_node.is_symlink()\n+\n+\n+def test_handles_missing_local_bin(fake_home):\n+    \"\"\"No symlinks present -> no-op, no error.\"\"\"\n+    hermes_home = fake_home / \".hermes\"\n+    _make_hermes_node(hermes_home)\n+\n+    assert uninstall.remove_node_symlinks(hermes_home) == []\n+\n+\n+def test_removes_dangling_symlink_into_hermes_node(fake_home):\n+    \"\"\"A link into the Hermes node dir is removed even if the target file is\n+    already gone (dangling) \\u2014 the link still shadows PATH.\"\"\"\n+    hermes_home = fake_home / \".hermes\"\n+    node_bin = hermes_home / \"node\" / \"bin\"\n+    node_bin.mkdir(parents=True)\n+    local_bin = fake_home / \".local\" / \"bin\"\n+\n+    # Create the symlink, then delete the target so it dangles.\n+    (local_bin / \"node\").symlink_to(node_bin / \"node\")\n+    assert (local_bin / \"node\").is_symlink()\n+\n+    removed = uninstall.remove_node_symlinks(hermes_home)\n+\n+    assert [p.name for p in removed] == [\"node\"]\n+    assert not (local_bin / \"node\").is_symlink()\n+\n+\n+def test_only_some_links_present(fake_home):\n+    \"\"\"Removes the Hermes links that exist; ignores the ones that don't.\"\"\"\n+    hermes_home = fake_home / \".hermes\"\n+    node_bin = _make_hermes_node(hermes_home)\n+    local_bin = fake_home / \".local\" / \"bin\"\n+\n+    # Only npm and npx are Hermes-managed; node is a real user binary.\n+    (local_bin / \"npm\").symlink_to(node_bin / \"npm\")\n+    (local_bin / \"npx\").symlink_to(node_bin / \"npx\")\n+    (local_bin / \"node\").write_text(\"#!/bin/sh\\n\")\n+\n+    removed = uninstall.remove_node_symlinks(hermes_home)\n+\n+    assert sorted(p.name for p in removed) == [\"npm\", \"npx\"]\n+    assert (local_bin / \"node\").exists()\n+    assert not (local_bin / \"npm\").is_symlink()\n+    assert not (local_bin / \"npx\").is_symlink()"]},{"file":"uv.lock","hunks":["@@ -1629,6 +1629,7 @@ all = [\n     { name = \"pytest-timeout\" },\n     { name = \"pywinpty\", marker = \"sys_platform == 'win32'\" },\n     { name = \"ruff\" },\n+    { name = \"setuptools\" },\n     { name = \"simple-term-menu\" },\n     { name = \"ty\" },\n     { name = \"uvicorn\", extra = [\"standard\"] },","@@ -1659,6 +1660,7 @@ dev = [\n     { name = \"pytest-asyncio\" },\n     { name = \"pytest-timeout\" },\n     { name = \"ruff\" },\n+    { name = \"setuptools\" },\n     { name = \"ty\" },\n ]\n dingtalk = [","@@ -1864,6 +1866,7 @@ requires-dist = [\n     { name = \"rich\", specifier = \"==14.3.3\" },\n     { name = \"ruamel-yaml\", specifier = \"==0.18.17\" },\n     { name = \"ruff\", marker = \"extra == 'dev'\", specifier = \"==0.15.10\" },\n+    { name = \"setuptools\", marker = \"extra == 'dev'\", specifier = \"==82.0.1\" },\n     { name = \"simple-term-menu\", marker = \"extra == 'cli'\", specifier = \"==1.6.6\" },\n     { name = \"slack-bolt\", marker = \"extra == 'messaging'\", specifier = \"==1.27.0\" },\n     { name = \"slack-bolt\", marker = \"extra == 'slack'\", specifier = \"==1.27.0\" },\n"]}]},{"kind":"pr","number":38889,"title":"fix(installer): symlink bundled node/npm into command bin dir for FHS root installs","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/uninstall.py","scripts/install.sh","scripts/lib/node-bootstrap.sh","tests/hermes_cli/test_uninstall_node_symlinks.py"],"fix_hunks":[{"file":"hermes_cli/uninstall.py","hunks":["@@ -9,6 +9,7 @@\n import os\n import shutil\n import subprocess\n+import sys\n from pathlib import Path\n \n from hermes_constants import get_hermes_home","@@ -117,45 +118,60 @@ def remove_wrapper_script():\n     return removed\n \n \n-def remove_node_symlinks(hermes_home: Path) -> list:\n-    \"\"\"Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.\n+def _node_symlink_candidate_dirs() -> \"list[Path]\":\n+    \"\"\"Directories where the installer may have placed node/npm/npx symlinks.\"\"\"\n+    dirs: list[Path] = [Path.home() / \".local\" / \"bin\"]\n+    # Root FHS installs put links in /usr/local/bin.\n+    if sys.platform == \"linux\":\n+        dirs.append(Path(\"/usr/local/bin\"))\n+    # Termux installs put links in $PREFIX/bin.\n+    prefix = os.environ.get(\"PREFIX\", \"\")\n+    if prefix and \"com.termux\" in prefix:\n+        dirs.append(Path(prefix) / \"bin\")\n+    return dirs\n \n-    The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)\n-    creates::\n \n-        ~/.local/bin/node -> $HERMES_HOME/node/bin/node\n-        ~/.local/bin/npm  -> $HERMES_HOME/node/bin/npm\n-        ~/.local/bin/npx  -> $HERMES_HOME/node/bin/npx\n+def remove_node_symlinks(hermes_home: Path) -> list:\n+    \"\"\"Remove the node/npm/npx symlinks the installer placed on PATH.\n \n-    and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node\n-    manager such as nvm.  Symmetrically remove them on uninstall, but *only*\n-    when the link still resolves into this Hermes home's ``node`` directory.\n-    A link the user has since repointed at nvm (or anything else outside\n-    Hermes) is left untouched so we never break unrelated tooling.\n+    The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)\n+    symlinks node/npm/npx into the same directory as the ``hermes`` command:\n+\n+    - ``/usr/local/bin/`` on root FHS installs (Linux, uid 0)\n+    - ``$PREFIX/bin/`` on Termux\n+    - ``~/.local/bin/`` otherwise (the common non-root case)\n+\n+    We check all candidate directories so that uninstall works regardless of\n+    how the install was done (e.g. a root FHS install that placed links in\n+    ``/usr/local/bin``, or an older install that used ``~/.local/bin`` before\n+    the FHS fix).  Only symlinks that resolve into this Hermes home's ``node``\n+    directory are removed — links the user has repointed elsewhere (nvm, fnm,\n+    etc.) are left untouched.\n     \"\"\"\n     node_dir = (hermes_home / \"node\").resolve()\n     removed = []\n \n     for name in (\"node\", \"npm\", \"npx\"):\n-        link = Path.home() / \".local\" / \"bin\" / name\n-        try:\n-            # Only act on symlinks — never delete a real binary the user put here.\n-            if not link.is_symlink():\n-                continue\n-\n-            # Resolve the link target and confirm it points into our node dir.\n-            # os.readlink + manual join handles broken (dangling) links too;\n-            # Path.resolve() on a dangling link still returns the target path.\n-            target = Path(os.readlink(link))\n-            if not target.is_absolute():\n-                target = (link.parent / target)\n-            target = target.resolve()\n-\n-            if target == node_dir or node_dir in target.parents:\n-                link.unlink()\n-                removed.append(link)\n-        except Exception as e:\n-            log_warn(f\"Could not remove {link}: {e}\")\n+        for bin_dir in _node_symlink_candidate_dirs():\n+            link = bin_dir / name\n+            try:\n+                # Only act on symlinks — never delete a real binary the user put here.\n+                if not link.is_symlink():\n+                    continue\n+\n+                # Resolve the link target and confirm it points into our node dir.\n+                # os.readlink + manual join handles broken (dangling) links too;\n+                # Path.resolve() on a dangling link still returns the target path.\n+                target = Path(os.readlink(link))\n+                if not target.is_absolute():\n+                    target = (link.parent / target)\n+                target = target.resolve()\n+\n+                if target == node_dir or node_dir in target.parents:\n+                    link.unlink()\n+                    removed.append(link)\n+            except Exception as e:\n+                log_warn(f\"Could not remove {link}: {e}\")\n \n     return removed\n "]},{"file":"scripts/install.sh","hunks":["@@ -836,16 +836,20 @@ install_node() {\n         return 0\n     fi\n \n-    # Place into ~/.hermes/node/ and symlink binaries to ~/.local/bin/\n+    # Place into ~/.hermes/node/ and symlink binaries into the same bin dir\n+    # the hermes command uses (get_command_link_dir): /usr/local/bin for root\n+    # FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise.\n     rm -rf \"$HERMES_HOME/node\"\n     mkdir -p \"$HERMES_HOME\"\n     mv \"$extracted_dir\" \"$HERMES_HOME/node\"\n     rm -rf \"$tmp_dir\"\n \n-    mkdir -p \"$HOME/.local/bin\"\n-    ln -sf \"$HERMES_HOME/node/bin/node\" \"$HOME/.local/bin/node\"\n-    ln -sf \"$HERMES_HOME/node/bin/npm\"  \"$HOME/.local/bin/npm\"\n-    ln -sf \"$HERMES_HOME/node/bin/npx\"  \"$HOME/.local/bin/npx\"\n+    local node_link_dir\n+    node_link_dir=\"$(get_command_link_dir)\"\n+    mkdir -p \"$node_link_dir\"\n+    ln -sf \"$HERMES_HOME/node/bin/node\" \"$node_link_dir/node\"\n+    ln -sf \"$HERMES_HOME/node/bin/npm\"  \"$node_link_dir/npm\"\n+    ln -sf \"$HERMES_HOME/node/bin/npx\"  \"$node_link_dir/npx\"\n \n     export PATH=\"$HERMES_HOME/node/bin:$PATH\"\n "]},{"file":"scripts/lib/node-bootstrap.sh","hunks":["@@ -44,6 +44,19 @@ _nb_is_termux() {\n     [ -n \"${TERMUX_VERSION:-}\" ] || [[ \"${PREFIX:-}\" == *\"com.termux/files/usr\"* ]]\n }\n \n+# Where to symlink node/npm/npx so they land on PATH.\n+# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin,\n+# Termux → $PREFIX/bin, otherwise ~/.local/bin.\n+_nb_get_link_dir() {\n+    if _nb_is_termux && [ -n \"${PREFIX:-}\" ]; then\n+        echo \"$PREFIX/bin\"\n+    elif [ \"$(id -u)\" = 0 ] && [ \"$(uname -s)\" = \"Linux\" ]; then\n+        echo \"/usr/local/bin\"\n+    else\n+        echo \"$HOME/.local/bin\"\n+    fi\n+}\n+\n _nb_node_major() {\n     local v\n     v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)","@@ -187,10 +200,12 @@ _nb_install_bundled_node() {\n     mv \"$extracted\" \"$HERMES_HOME/node\"\n     rm -rf \"$tmp\"\n \n-    mkdir -p \"$HOME/.local/bin\"\n-    ln -sf \"$HERMES_HOME/node/bin/node\" \"$HOME/.local/bin/node\"\n-    ln -sf \"$HERMES_HOME/node/bin/npm\"  \"$HOME/.local/bin/npm\"\n-    ln -sf \"$HERMES_HOME/node/bin/npx\"  \"$HOME/.local/bin/npx\"\n+    local _link_dir\n+    _link_dir=\"$(_nb_get_link_dir)\"\n+    mkdir -p \"$_link_dir\"\n+    ln -sf \"$HERMES_HOME/node/bin/node\" \"$_link_dir/node\"\n+    ln -sf \"$HERMES_HOME/node/bin/npm\"  \"$_link_dir/npm\"\n+    ln -sf \"$HERMES_HOME/node/bin/npx\"  \"$_link_dir/npx\"\n     export PATH=\"$HERMES_HOME/node/bin:$PATH\"\n \n     _nb_have_modern_node || return 1"]},{"file":"tests/hermes_cli/test_uninstall_node_symlinks.py","hunks":["@@ -130,3 +130,37 @@ def test_only_some_links_present(fake_home):\n     assert (local_bin / \"node\").exists()\n     assert not (local_bin / \"npm\").is_symlink()\n     assert not (local_bin / \"npx\").is_symlink()\n+\n+\n+def test_removes_fhs_symlinks_in_usr_local_bin(fake_home, tmp_path, monkeypatch):\n+    \"\"\"Root FHS installs place node symlinks in /usr/local/bin.\n+\n+    We monkeypatch _node_symlink_candidate_dirs to return a temp dir standing\n+    in for /usr/local/bin so the test doesn't need real root privileges.\n+    \"\"\"\n+    hermes_home = fake_home / \".hermes\"\n+    node_bin = _make_hermes_node(hermes_home)\n+\n+    # Fake /usr/local/bin as a temp dir with our symlinks.\n+    fhs_bin = tmp_path / \"usr_local_bin\"\n+    fhs_bin.mkdir()\n+    for name in (\"node\", \"npm\", \"npx\"):\n+        (fhs_bin / name).symlink_to(node_bin / name)\n+\n+    # Ensure ~/.local/bin has NO symlinks (simulate pure FHS install).\n+    local_bin = fake_home / \".local\" / \"bin\"\n+    for name in (\"node\", \"npm\", \"npx\"):\n+        p = local_bin / name\n+        if p.exists() or p.is_symlink():\n+            p.unlink()\n+\n+    # Return only our fake FHS dir as a candidate.\n+    monkeypatch.setattr(\n+        uninstall, \"_node_symlink_candidate_dirs\", lambda: [fhs_bin]\n+    )\n+\n+    removed = uninstall.remove_node_symlinks(hermes_home)\n+\n+    assert sorted(p.name for p in removed) == [\"node\", \"npm\", \"npx\"]\n+    for name in (\"node\", \"npm\", \"npx\"):\n+        assert not (fhs_bin / name).is_symlink()\n"]}]},{"kind":"pr","number":45282,"title":"fix(install): skip node/npm/npx symlinks when system versions exist","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#45279","number":45279}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":45384,"title":"fix(installer): skip node/npm/npx symlinks for user installs (#45279)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":46085,"title":"fix(install): make `npm install -g` packages reachable on PATH","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/install.sh","scripts/lib/node-bootstrap.sh","tests/test_install_sh_node_global_prefix.py"],"fix_hunks":[{"file":"scripts/install.sh","hunks":["@@ -413,6 +413,25 @@ get_command_link_display_dir() {\n     fi\n }\n \n+# Point a Hermes-managed Node's `npm install -g` at a directory that is on\n+# PATH. npm's default global prefix for a bundled Node is the Node dir itself,\n+# so global package binaries land in $HERMES_HOME/node/bin — which is NOT on\n+# PATH (only the command link dir is) and is wiped on every Node upgrade.\n+# Redirecting the prefix to the link dir's parent makes global bins resolve to\n+# the command link dir (node/npm/npx live there too, already on PATH) and\n+# survive upgrades. Scoped to the managed Node via its prefix-local global\n+# npmrc, so the user's other Node installs and their ~/.npmrc are untouched.\n+# Hermes's own global installs pass an explicit --prefix and are unaffected.\n+# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on\n+# every install run repairs pre-existing installs, not just fresh ones.\n+configure_managed_node_npm_prefix() {\n+    [ -x \"$HERMES_HOME/node/bin/npm\" ] || return 0\n+    local link_dir\n+    link_dir=\"$(get_command_link_dir)\"\n+    mkdir -p \"$HERMES_HOME/node/etc\"\n+    printf 'prefix=%s\\n' \"$(dirname \"$link_dir\")\" > \"$HERMES_HOME/node/etc/npmrc\"\n+}\n+\n get_hermes_command_path() {\n     local link_dir\n     link_dir=\"$(get_command_link_dir)\"","@@ -722,6 +741,11 @@ node_satisfies_build() {\n check_node() {\n     log_info \"Checking Node.js (for browser tools)...\"\n \n+    # Repair pre-existing Hermes-managed installs where `npm install -g` lands\n+    # off PATH. No-op when there's no managed Node, so this is safe to run on\n+    # every install — including re-runs that skip the Node (re)install below.\n+    configure_managed_node_npm_prefix\n+\n     if command -v node &> /dev/null && node_satisfies_build \"$(node --version)\"; then\n         log_success \"Node.js $(node --version) found\"\n         HAS_NODE=true","@@ -851,6 +875,8 @@ install_node() {\n     ln -sf \"$HERMES_HOME/node/bin/npm\"  \"$node_link_dir/npm\"\n     ln -sf \"$HERMES_HOME/node/bin/npx\"  \"$node_link_dir/npx\"\n \n+    configure_managed_node_npm_prefix\n+\n     export PATH=\"$HERMES_HOME/node/bin:$PATH\"\n \n     local installed_ver"]},{"file":"scripts/lib/node-bootstrap.sh","hunks":["@@ -57,6 +57,19 @@ _nb_get_link_dir() {\n     fi\n }\n \n+# Redirect a Hermes-managed Node's `npm install -g` to the command link dir\n+# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off\n+# PATH and wiped on every Node upgrade. Scoped to the managed Node via its\n+# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are\n+# untouched. Idempotent no-op when there's no managed npm.\n+_nb_configure_npm_prefix() {\n+    [ -x \"$HERMES_HOME/node/bin/npm\" ] || return 0\n+    local _link_dir\n+    _link_dir=\"$(_nb_get_link_dir)\"\n+    mkdir -p \"$HERMES_HOME/node/etc\"\n+    printf 'prefix=%s\\n' \"$(dirname \"$_link_dir\")\" > \"$HERMES_HOME/node/etc/npmrc\"\n+}\n+\n _nb_node_major() {\n     local v\n     v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)","@@ -206,6 +219,9 @@ _nb_install_bundled_node() {\n     ln -sf \"$HERMES_HOME/node/bin/node\" \"$_link_dir/node\"\n     ln -sf \"$HERMES_HOME/node/bin/npm\"  \"$_link_dir/npm\"\n     ln -sf \"$HERMES_HOME/node/bin/npx\"  \"$_link_dir/npx\"\n+\n+    _nb_configure_npm_prefix\n+\n     export PATH=\"$HERMES_HOME/node/bin:$PATH\"\n \n     _nb_have_modern_node || return 1","@@ -220,6 +236,10 @@ _nb_install_bundled_node() {\n ensure_node() {\n     HERMES_NODE_AVAILABLE=false\n \n+    # Repair pre-existing managed installs where `npm install -g` lands off\n+    # PATH. No-op when there's no managed Node, so it's safe to run first.\n+    _nb_configure_npm_prefix\n+\n     if _nb_have_modern_node; then\n         _nb_ok \"Node $(node --version) found\"\n         HERMES_NODE_AVAILABLE=true"]},{"file":"tests/test_install_sh_node_global_prefix.py","hunks":["@@ -0,0 +1,55 @@\n+\"\"\"Regression tests for the Hermes-managed Node's npm global prefix.\n+\n+When the installer falls back to a bundled Node under ``$HERMES_HOME/node``,\n+npm's default global prefix is that Node dir, so ``npm install -g <pkg>``\n+drops the package binary in ``$HERMES_HOME/node/bin`` — which is NOT on PATH\n+(only the command link dir is) and is wiped on every Node upgrade. Users then\n+report \"I can ``npm i -g`` but the package isn't usable on the command line\".\n+\n+The fix redirects the bundled Node's global prefix to the command link dir's\n+parent (so global bins land in the already-on-PATH link dir alongside\n+node/npm/npx), scoped to the bundled Node via its prefix-local global npmrc.\n+\"\"\"\n+\n+from pathlib import Path\n+\n+\n+REPO_ROOT = Path(__file__).resolve().parent.parent\n+INSTALL_SH = REPO_ROOT / \"scripts\" / \"install.sh\"\n+NODE_BOOTSTRAP = REPO_ROOT / \"scripts\" / \"lib\" / \"node-bootstrap.sh\"\n+\n+\n+def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None:\n+    text = INSTALL_SH.read_text()\n+\n+    # The redirect must target the link dir's PARENT so global bins resolve to\n+    # <parent>/bin == the command link dir (node/npm/npx live there and it is\n+    # guaranteed on PATH by the installer's PATH setup).\n+    assert \"configure_managed_node_npm_prefix()\" in text\n+    assert 'printf \\'prefix=%s\\\\n\\' \"$(dirname \"$link_dir\")\" > \"$HERMES_HOME/node/etc/npmrc\"' in text\n+\n+\n+def test_install_sh_repairs_existing_managed_node_on_rerun() -> None:\n+    \"\"\"The redirect must run on every install (not just fresh Node installs),\n+    so re-running the installer repairs pre-existing managed installs whose\n+    Node is already up to date and would otherwise skip install_node.\"\"\"\n+    text = INSTALL_SH.read_text()\n+\n+    check_node_body = text.split(\"check_node()\", 1)[1].split(\"\\ninstall_node()\", 1)[0]\n+    assert \"configure_managed_node_npm_prefix\" in check_node_body\n+\n+    # No-op guard so it's safe to call when there is no managed Node.\n+    assert '[ -x \"$HERMES_HOME/node/bin/npm\" ] || return 0' in text\n+\n+\n+def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None:\n+    text = NODE_BOOTSTRAP.read_text()\n+\n+    assert \"_nb_configure_npm_prefix()\" in text\n+    assert 'printf \\'prefix=%s\\\\n\\' \"$(dirname \"$_link_dir\")\" > \"$HERMES_HOME/node/etc/npmrc\"' in text\n+\n+    # Runs at the top of ensure_node so existing managed installs are repaired\n+    # even when a modern Node is already present (early return path).\n+    ensure_node_body = text.split(\"ensure_node()\", 1)[1]\n+    assert \"_nb_configure_npm_prefix\" in ensure_node_body\n+    assert '[ -x \"$HERMES_HOME/node/bin/npm\" ] || return 0' in text\n"]}]},{"kind":"pr","number":58099,"title":"fix(install): keep managed Node private for user installs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3d12e86ef13","number":0},{"kind":"commit","value":"b63c138d78","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":63623,"title":"fix(installer): preserve user-managed Node commands","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":64545,"title":"fix(browser): discover fnm installs on Windows","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"e5db60e4dd30","number":0},{"kind":"pr","value":"#64538","number":64538},{"kind":"pr","value":"#64545","number":64545},{"kind":"pr","value":"#64545","number":64545},{"kind":"pr","value":"#64545","number":64545},{"kind":"pr","value":"#64538","number":64538},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#64538","number":64538},{"kind":"pr","value":"#64545","number":64545}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":99205,"title":"fix(install): restore user's fnm default Node version after bootstrap installs Hermes target","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38765","number":38765}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":111921,"title":"fix(installer): skip node/npm/npx symlinks for user installs","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#45282","number":45282},{"kind":"pr","value":"#45279","number":45279},{"kind":"pr","value":"#45384","number":45384}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}