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:
@ -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...")
|
||||
|
||||
132
tests/hermes_cli/test_uninstall_node_symlinks.py
Normal file
132
tests/hermes_cli/test_uninstall_node_symlinks.py
Normal 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()
|
||||
Reference in New Issue
Block a user