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
This commit is contained in:
Bartok9
2026-05-29 05:57:33 -04:00
committed by Teknium
parent 2062a84000
commit 54aa4db1de
2 changed files with 186 additions and 0 deletions

View File

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

View File

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