From 54aa4db1de76a7c4bb02c8a7f7411727384b8fea Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Fri, 29 May 2026 05:57:33 -0400 Subject: [PATCH] fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall 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 removed the hermes wrapper but left these behind, so the user's default node/npm/npx stayed redirected after uninstall. Add remove_node_symlinks() and call it from run_uninstall. It removes ~/.local/bin/{node,npm,npx} only when each is a symlink resolving into the current Hermes home's node dir, so a link the user repointed at nvm or a real binary is never touched. Handles dangling links too. Closes #34536 --- hermes_cli/uninstall.py | 54 +++++++ .../test_uninstall_node_symlinks.py | 132 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 tests/hermes_cli/test_uninstall_node_symlinks.py diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 028b66575..430abd47b 100644 --- a/hermes_cli/uninstall.py +++ b/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...") diff --git a/tests/hermes_cli/test_uninstall_node_symlinks.py b/tests/hermes_cli/test_uninstall_node_symlinks.py new file mode 100644 index 000000000..316e6d646 --- /dev/null +++ b/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 + + +@pytest.fixture +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()