Compare commits

...

1 Commits

Author SHA1 Message Date
ethernet
05ed2bec40 feat(cli): add desktop-only uninstall (hermes uninstall --desktop)
Add a focused uninstall path that removes only the Electron desktop app
and its artifacts, leaving the CLI, gateway, configs, and data intact.

- New `--desktop` flag and interactive menu option 3 route to a
  desktop-only flow (`_run_desktop_uninstall`).
- `remove_desktop_app` removes in-tree build artifacts (release/, dist/,
  node_modules/) + the desktop-build-stamp.json.
- External artifacts (/Applications/Hermes.app, Dock pin, Electron
  userData) are removed via a single shared helper,
  `_remove_desktop_external_artifacts`, reused by the standard flow.
- Managed installs (NixOS/Homebrew) remain a no-op via is_managed().

Tests cover per-platform userData resolution, artifact removal,
process-kill delegation, dispatch routing (--desktop flag + menu 3),
and the desktop confirm/cancel flow.
2026-06-03 02:54:14 -04:00
3 changed files with 844 additions and 3 deletions

View File

@@ -14769,6 +14769,11 @@ Examples:
action="store_true",
help="Full uninstall - remove everything including configs and data",
)
uninstall_parser.add_argument(
"--desktop",
action="store_true",
help="Uninstall only the desktop app (Electron app, built artifacts, Dock pin)",
)
uninstall_parser.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
)

View File

@@ -397,6 +397,263 @@ def remove_portable_tooling_windows(hermes_home: Path) -> list[Path]:
return removed
# ============================================================================
# Desktop app uninstall helpers
# ============================================================================
#
# The desktop app (apps/desktop) is an Electron app built and optionally
# installed alongside the CLI. It leaves artifacts in several places:
#
# macOS:
# /Applications/Hermes.app (auto-moved on first launch)
# Dock tile (pinned on first launch)
# ~/Library/Application Support/Hermes/ (Electron userData)
#
# Windows:
# %APPDATA%\Hermes\ (Electron userData)
#
# All platforms (inside the install dir):
# apps/desktop/release/ (electron-builder output)
# apps/desktop/dist/ (Vite renderer build)
# apps/desktop/node_modules/ (desktop-only deps, ~150MB)
#
# HERMES_HOME:
# desktop-build-stamp.json (content-hash skip stamp)
#
# The root node_modules/ is NOT removed — `npm ci` in the install dir
# rebuilds it cleanly, and the TUI also depends on it.
def _desktop_dir(project_root: Path) -> Path:
"""Return the apps/desktop directory inside the install root."""
return project_root / "apps" / "desktop"
def _electron_user_data_dir() -> Path:
"""Return the platform-specific Electron userData directory for Hermes Desktop.
Electron uses ``app.getPath('userData')`` which resolves to:
- macOS: ~/Library/Application Support/Hermes
- Windows: %APPDATA%\\Hermes
- Linux: ~/.config/Hermes (XDG_CONFIG_HOME if set)
"""
import sys
home = Path.home()
if sys.platform == "darwin":
return home / "Library" / "Application Support" / "Hermes"
elif sys.platform == "win32":
appdata = os.environ.get("APPDATA")
if appdata:
return Path(appdata) / "Hermes"
return home / "AppData" / "Roaming" / "Hermes"
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
config_base = Path(xdg) if xdg else (home / ".config")
return config_base / "Hermes"
def _unpin_from_dock() -> bool:
"""Remove the Hermes tile from the macOS Dock.
Best-effort: mirrors the pin logic in main.cjs (``maybePinToDock``).
We scan ``com.apple.dock persistent-apps`` for a file-reference URL
pointing at ``/Applications/Hermes.app/`` and remove matching entries.
Returns True if a tile was removed.
"""
import sys
if sys.platform != "darwin":
return False
try:
# Read current Dock tiles — property-list encoded via ``defaults read``.
result = subprocess.run(
["defaults", "read", "com.apple.dock", "persistent-apps"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
return False
current = result.stdout
# The Dock stores tiles as file-reference URLs like:
# file:///Applications/Hermes.app/
# We look for that pattern and nuke the whole <dict> containing it.
hermes_marker = "Hermes.app"
if hermes_marker not in current:
return False
# Use PlistBuddy to find and delete matching entries.
# PlistBuddy is more reliable than trying to re-serialize the defaults
# output ourselves. Probe indices until out-of-bounds — no need for
# a separate count query; the PlistBuddy Print call fails on an
# invalid index, which is our loop termination condition.
removed = False
idx = 0
while True:
entry = subprocess.run(
["/usr/libexec/PlistBuddy", "-c",
f"Print :persistent-apps:{idx}",
"~/Library/Preferences/com.apple.dock.plist"],
capture_output=True, text=True, check=False,
)
if entry.returncode != 0:
break # out of bounds
if hermes_marker in entry.stdout:
subprocess.run(
["/usr/libexec/PlistBuddy", "-c",
f"Delete :persistent-apps:{idx}",
"~/Library/Preferences/com.apple.dock.plist"],
capture_output=True, text=True, check=False,
)
removed = True
# Don't increment — the array shifted down
else:
idx += 1
if removed:
# Force cfprefsd to flush our PlistBuddy edit back to disk before we
# restart the Dock. We wrote com.apple.dock.plist directly, so the
# running cfprefsd still holds the pre-delete copy in memory; a plain
# `defaults read` makes it reload from disk. Skip this and `killall
# Dock` races cfprefsd, reloads the stale (still-pinned) prefs, and
# the tile reappears. (Mirrors the flush before the pin in main.cjs.)
subprocess.run(
["defaults", "read", "com.apple.dock", "persistent-apps"],
capture_output=True, check=False,
)
subprocess.run(["killall", "Dock"], capture_output=True, check=False)
return removed
except Exception as e:
log_warn(f"Could not unpin Hermes from Dock: {e}")
return False
def _kill_desktop_process() -> None:
"""Kill any running Hermes desktop (Electron) app process.
Safe to call from the CLI uninstaller — the desktop binary is named
``Hermes`` (macOS / Linux AppImage) or ``Hermes.exe`` (Windows), while
the CLI itself runs as ``python3``. We never kill ourselves.
Best-effort: if the process isn't running or the kill tool is
unavailable, we silently continue (the rmtree below will still
succeed for any files the dead process isn't holding open).
"""
import sys
try:
if sys.platform == "darwin":
# macOS: the Electron app binary inside Hermes.app is named
# "Hermes" (CFBundleExecutable). The CLI runs as python3.
subprocess.run(["killall", "Hermes"], capture_output=True, check=False)
elif sys.platform == "win32":
subprocess.run(
["taskkill", "/F", "/IM", "Hermes.exe"],
capture_output=True, check=False,
)
elif sys.platform.startswith("linux"):
# Heuristic for non-NixOS Linux: match the electron-builder
# output path. On NixOS the whole uninstall command is a
# no-op (see run_uninstall), so this pattern is only ever
# evaluated on conventional installs.
subprocess.run(
["pkill", "-f", "apps/desktop/release/linux-unpacked/Hermes"],
capture_output=True, check=False,
)
except Exception:
pass # not running or kill tool unavailable
def _remove_desktop_external_artifacts(
project_root: Path, hermes_home: Path
) -> list[str]:
"""Remove desktop app artifacts that live OUTSIDE the install dir and
HERMES_HOME: the macOS ``/Applications/Hermes.app`` bundle, its Dock pin,
and the Electron userData directory.
Returns a list of human-readable descriptions of what was removed.
This is the shared external-cleanup path used by both the desktop-only
uninstall (``remove_desktop_app``, which adds the in-tree artifacts on top)
and the standard full/keep-data flow (whose ``rmtree(project_root)`` /
``rmtree(hermes_home)`` already sweep the in-tree pieces but never touch
these external ones).
"""
import sys
removed: list[str] = []
# Kill the desktop process first — on macOS the .app bundle can't be
# removed while the binary inside it is running.
_kill_desktop_process()
# macOS: /Applications/Hermes.app and Dock pin
if sys.platform == "darwin":
app_bundle = Path("/Applications/Hermes.app")
if app_bundle.is_dir():
try:
shutil.rmtree(app_bundle)
log_success(f"Removed {app_bundle}")
removed.append("/Applications/Hermes.app")
except Exception as e:
log_warn(f"Could not remove {app_bundle}: {e}")
if _unpin_from_dock():
log_success("Removed Hermes from the Dock")
removed.append("Dock tile")
# Electron userData (outside both project_root and hermes_home)
user_data = _electron_user_data_dir()
if user_data.exists():
try:
shutil.rmtree(user_data)
log_success(f"Removed Electron userData ({user_data})")
removed.append(f"Electron userData ({user_data})")
except Exception as e:
log_warn(f"Could not remove Electron userData ({user_data}): {e}")
return removed
def remove_desktop_app(project_root: Path, hermes_home: Path) -> list[str]:
"""Remove the Hermes desktop app and all its artifacts.
Returns a list of human-readable descriptions of what was removed.
This does NOT remove the root node_modules/ (the TUI uses it too),
the CLI install, or any user data in HERMES_HOME other than the
desktop build stamp.
"""
removed: list[str] = []
desktop = _desktop_dir(project_root)
# ── External artifacts (kill process, .app bundle, Dock pin, userData) ──
# Shared with the standard uninstall flow — the single owner of every
# removal target that lives outside the install dir / HERMES_HOME.
removed.extend(_remove_desktop_external_artifacts(project_root, hermes_home))
# ── Built artifacts inside install dir ────────────────────────
for subdir in ("release", "dist", "node_modules"):
target = desktop / subdir
if target.exists():
try:
shutil.rmtree(target)
label = f"apps/desktop/{subdir}/"
log_success(f"Removed {target}")
removed.append(label)
except Exception as e:
log_warn(f"Could not remove {target}: {e}")
# ── Desktop build stamp in HERMES_HOME ────────────────────────
stamp = hermes_home / "desktop-build-stamp.json"
if stamp.exists():
try:
stamp.unlink()
log_success(f"Removed {stamp}")
removed.append("desktop-build-stamp.json")
except Exception as e:
log_warn(f"Could not remove {stamp}: {e}")
return removed
def _is_windows() -> bool:
import sys
return sys.platform == "win32"
@@ -476,6 +733,71 @@ def _uninstall_profile(profile) -> None:
log_warn(f" Could not remove {profile_home}: {e}")
def _run_desktop_uninstall(project_root: Path, hermes_home: Path, args) -> None:
"""Run the desktop-only uninstall flow.
This is a focused uninstall that only removes the Electron desktop app
and its artifacts — the CLI, gateway, configs, and data are untouched.
"""
skip_confirm = getattr(args, "yes", False) or getattr(args, "skip_confirm", False)
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
print(color("│ ⚕ Hermes Desktop Uninstaller │", Colors.MAGENTA, Colors.BOLD))
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA, Colors.BOLD))
print()
print(color("This will remove:", Colors.CYAN, Colors.BOLD))
import sys
if sys.platform == "darwin":
print(" • /Applications/Hermes.app")
print(" • Dock pin (if present)")
print(" • apps/desktop/release/ (Electron app bundle)")
print(" • apps/desktop/dist/ (Vite renderer)")
print(" • apps/desktop/node_modules/ (desktop deps)")
print(" • desktop-build-stamp.json")
print(" • Electron userData (desktop settings)")
print()
print(color("The CLI, gateway, and all configs/data will be preserved.", Colors.GREEN))
print()
if not skip_confirm:
try:
confirm = input(f"Type '{color('yes', Colors.YELLOW)}' to confirm: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
print("Cancelled.")
return
if confirm != "yes":
print()
print("Uninstall cancelled.")
return
print()
print(color("Uninstalling desktop app...", Colors.CYAN, Colors.BOLD))
print()
removed = remove_desktop_app(project_root, hermes_home)
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN, Colors.BOLD))
print(color("│ ✓ Desktop Uninstall Complete! │", Colors.GREEN, Colors.BOLD))
print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN, Colors.BOLD))
print()
if removed:
print(color("Removed:", Colors.CYAN))
for item in removed:
print(f"{item}")
print()
print("To reinstall the desktop app later:")
print(color(" hermes gui", Colors.DIM))
print()
print("Thank you for using Hermes Agent! ⚕")
print()
def run_uninstall(args):
"""
Run the uninstall process.
@@ -483,10 +805,30 @@ def run_uninstall(args):
Options:
- Full uninstall: removes code + ~/.hermes/ (configs, data, logs)
- Keep data: removes code but keeps ~/.hermes/ for future reinstall
- Desktop only: removes only the desktop app (Electron, Dock pin, built artifacts)
"""
# ── Managed installs (NixOS, Homebrew, etc.) ────────────────────
# The package manager owns every file — our uninstaller has nothing
# to remove and would only break the managed layout. Bail early.
from hermes_cli.config import is_managed, get_managed_update_command
if is_managed():
managed_cmd = get_managed_update_command() or "your package manager"
print()
print(color("⚠ Uninstall is not available for managed installs.", Colors.RED, Colors.BOLD))
print(color(" Hermes is managed by your system package manager.", Colors.YELLOW))
print(color(f" To remove it: {managed_cmd}", Colors.YELLOW))
print()
return
project_root = get_project_root()
hermes_home = get_hermes_home()
# ── Desktop-only fast path ────────────────────────────────────
desktop_only = getattr(args, "desktop", False)
if desktop_only:
_run_desktop_uninstall(project_root, hermes_home, args)
return
# Detect named profiles when uninstalling from the default root —
# offer to clean them up too instead of leaving zombie HERMES_HOMEs
# and systemd units behind.
@@ -523,21 +865,28 @@ def run_uninstall(args):
print(" 2) " + color("Full uninstall", Colors.RED) + " - Remove everything including all data")
print(" (Warning: This deletes all configs, sessions, and logs permanently)")
print()
print(" 3) " + color("Cancel", Colors.CYAN) + " - Don't uninstall")
print(" 3) " + color("Desktop only", Colors.CYAN) + " - Remove only the desktop app")
print(" (Removes Electron app, Dock pin, and built artifacts; keeps CLI + data)")
print()
print(" 4) " + color("Cancel", Colors.CYAN) + " - Don't uninstall")
print()
try:
choice = input(color("Select option [1/2/3]: ", Colors.BOLD)).strip()
choice = input(color("Select option [1/2/3/4]: ", Colors.BOLD)).strip()
except (KeyboardInterrupt, EOFError):
print()
print("Cancelled.")
return
if choice == "3" or choice.lower() in {"c", "cancel", "q", "quit", "n", "no"}:
if choice == "4" or choice.lower() in {"c", "cancel", "q", "quit", "n", "no"}:
print()
print("Uninstall cancelled.")
return
if choice == "3":
_run_desktop_uninstall(project_root, hermes_home, args)
return
full_uninstall = (choice == "2")
# When doing a full uninstall from the default profile, also offer to
@@ -648,6 +997,13 @@ def run_uninstall(args):
log_success(f"Removed {link}")
else:
log_info("No Hermes-managed node/npm/npx symlinks found")
# 3c. Remove desktop app artifacts that live OUTSIDE the install dir
# and HERMES_HOME (the .app bundle in /Applications, the Dock tile,
# and Electron userData). The install dir's apps/desktop/ subtree is
# removed by step 4; these external ones need separate cleanup.
log_info("Removing desktop app artifacts...")
_remove_desktop_external_artifacts(project_root, hermes_home)
# 4. Remove installation directory (code)
log_info("Removing installation directory...")

View File

@@ -0,0 +1,480 @@
"""Tests for hermes_cli.uninstall desktop-app removal functions.
Covers:
- ``_kill_desktop_process``: per-platform process killing
- ``remove_desktop_app``: full desktop-only uninstall
- ``_remove_desktop_external_artifacts``: external-only cleanup used by
the standard uninstall flow
- ``_electron_user_data_dir``: platform-specific userData resolution
- ``run_uninstall``: managed-install no-op
"""
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
import hermes_cli.uninstall as uninstall
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_install(tmp_path):
"""Create a fake project root with apps/desktop subtree and hermes_home."""
project_root = tmp_path / "hermes-agent"
project_root.mkdir()
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# apps/desktop with release/, dist/, node_modules/
desktop = project_root / "apps" / "desktop"
desktop.mkdir(parents=True)
(desktop / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS").mkdir(parents=True)
(desktop / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes").write_text("#!bin")
(desktop / "dist" / "index.html").parent.mkdir(parents=True, exist_ok=True)
(desktop / "dist" / "index.html").write_text("<html></html>")
(desktop / "node_modules" / "electron" / "package.json").parent.mkdir(parents=True, exist_ok=True)
(desktop / "node_modules" / "electron" / "package.json").write_text('{"name":"electron"}')
# desktop-build-stamp.json in hermes_home
(hermes_home / "desktop-build-stamp.json").write_text('{"contentHash":"abc123"}')
return project_root, hermes_home
@pytest.fixture
def fake_home(tmp_path, monkeypatch):
"""Redirect Path.home() so Electron userData dir lands in tmp."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
return home
# ---------------------------------------------------------------------------
# _electron_user_data_dir
# ---------------------------------------------------------------------------
class TestElectronUserDataDir:
def test_macos(self, fake_home, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
result = uninstall._electron_user_data_dir()
assert result == fake_home / "Library" / "Application Support" / "Hermes"
def test_linux(self, fake_home, monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
result = uninstall._electron_user_data_dir()
assert result == fake_home / ".config" / "Hermes"
def test_linux_xdg(self, fake_home, monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
xdg = fake_home / "xdg-config"
xdg.mkdir()
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
result = uninstall._electron_user_data_dir()
assert result == xdg / "Hermes"
def test_windows_appdata(self, fake_home, monkeypatch):
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setenv("APPDATA", str(fake_home / "AppData" / "Roaming"))
result = uninstall._electron_user_data_dir()
assert result == Path(os.environ["APPDATA"]) / "Hermes"
def test_windows_no_appdata(self, fake_home, monkeypatch):
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.delenv("APPDATA", raising=False)
result = uninstall._electron_user_data_dir()
assert result == fake_home / "AppData" / "Roaming" / "Hermes"
# ---------------------------------------------------------------------------
# remove_desktop_app
# ---------------------------------------------------------------------------
class TestRemoveDesktopApp:
def test_removes_built_artifacts(self, fake_install, fake_home, monkeypatch):
"""All three apps/desktop subdirs + stamp are removed."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
removed = uninstall.remove_desktop_app(project_root, hermes_home)
desktop = project_root / "apps" / "desktop"
assert not (desktop / "release").exists()
assert not (desktop / "dist").exists()
assert not (desktop / "node_modules").exists()
assert not (hermes_home / "desktop-build-stamp.json").exists()
# Should report what was removed
assert "apps/desktop/release/" in removed
assert "apps/desktop/dist/" in removed
assert "apps/desktop/node_modules/" in removed
assert "desktop-build-stamp.json" in removed
def test_no_artifacts_is_noop(self, tmp_path, fake_home, monkeypatch):
"""If nothing exists, function returns empty list without error."""
monkeypatch.setattr(sys, "platform", "linux")
project_root = tmp_path / "empty-agent"
project_root.mkdir()
hermes_home = tmp_path / ".hermes-empty"
hermes_home.mkdir()
removed = uninstall.remove_desktop_app(project_root, hermes_home)
assert removed == []
def test_partial_artifacts(self, fake_install, fake_home, monkeypatch):
"""Only some artifacts exist — removes what's there, skips the rest."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
# Delete release and dist before running — only node_modules and stamp remain
desktop = project_root / "apps" / "desktop"
import shutil
shutil.rmtree(desktop / "release")
shutil.rmtree(desktop / "dist")
removed = uninstall.remove_desktop_app(project_root, hermes_home)
assert "apps/desktop/release/" not in removed
assert "apps/desktop/dist/" not in removed
assert "apps/desktop/node_modules/" in removed
assert "desktop-build-stamp.json" in removed
def test_removes_electron_user_data(self, fake_install, fake_home, monkeypatch):
"""Electron userData directory is removed when present."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
# Create fake Electron userData
user_data = fake_home / ".config" / "Hermes"
user_data.mkdir(parents=True)
(user_data / "connection.json").write_text("{}")
(user_data / "composer-images").mkdir()
(user_data / "dock-pinned.json").write_text("{}")
removed = uninstall.remove_desktop_app(project_root, hermes_home)
assert not user_data.exists()
assert any("Electron userData" in r for r in removed)
def test_macos_removes_app_bundle(self, fake_install, fake_home, monkeypatch):
"""On macOS, /Applications/Hermes.app is removed."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "darwin")
# We can't actually write to /Applications. Patch is_dir to return True
# ONLY for the Hermes.app bundle — patching it globally would make every
# other Path.is_dir() check in this code path (and any future one) lie,
# which is exactly the kind of silent false positive the review guide
# warns about. Scope the truthiness to the one instance we care about.
app_bundle = Path("/Applications/Hermes.app")
real_is_dir = Path.is_dir
def fake_is_dir(self):
if self == app_bundle:
return True
return real_is_dir(self)
with patch.object(Path, "is_dir", fake_is_dir), \
patch("shutil.rmtree") as mock_rmtree:
removed = uninstall.remove_desktop_app(project_root, hermes_home)
# rmtree should have been called for /Applications/Hermes.app
rmtree_args = [call[0][0] for call in mock_rmtree.call_args_list]
assert app_bundle in rmtree_args
def test_preserves_desktop_source(self, fake_install, fake_home, monkeypatch):
"""Source files in apps/desktop/src/ are NOT removed."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
# Add source files that should survive
desktop = project_root / "apps" / "desktop"
(desktop / "src").mkdir(exist_ok=True)
(desktop / "src" / "App.tsx").write_text("// source")
(desktop / "package.json").write_text('{"name":"hermes-desktop"}')
uninstall.remove_desktop_app(project_root, hermes_home)
# Source and package.json should still exist
assert (desktop / "src" / "App.tsx").exists()
assert (desktop / "package.json").exists()
def test_preserves_root_node_modules(self, fake_install, fake_home, monkeypatch):
"""Root node_modules/ is NOT removed (TUI depends on it)."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
root_nm = project_root / "node_modules"
root_nm.mkdir()
(root_nm / "ink" / "package.json").parent.mkdir(parents=True, exist_ok=True)
(root_nm / "ink" / "package.json").write_text('{"name":"ink"}')
uninstall.remove_desktop_app(project_root, hermes_home)
assert root_nm.exists()
assert (root_nm / "ink" / "package.json").exists()
# ---------------------------------------------------------------------------
# _remove_desktop_external_artifacts
# ---------------------------------------------------------------------------
class TestRemoveDesktopExternalArtifacts:
def test_removes_electron_user_data(self, fake_install, fake_home, monkeypatch):
"""Electron userData is removed by the external-artifacts helper."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
user_data = fake_home / ".config" / "Hermes"
user_data.mkdir(parents=True)
(user_data / "connection.json").write_text("{}")
uninstall._remove_desktop_external_artifacts(project_root, hermes_home)
assert not user_data.exists()
def test_no_user_data_is_noop(self, fake_install, fake_home, monkeypatch):
"""If no Electron userData exists, no error."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
# Should not raise
uninstall._remove_desktop_external_artifacts(project_root, hermes_home)
def test_does_not_touch_install_dir(self, fake_install, fake_home, monkeypatch):
"""Internal artifacts (apps/desktop/*) are NOT removed by the external helper."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
uninstall._remove_desktop_external_artifacts(project_root, hermes_home)
desktop = project_root / "apps" / "desktop"
# These should all still exist — external helper only touches
# /Applications, Dock, and userData
assert (desktop / "release").exists()
assert (desktop / "dist").exists()
assert (desktop / "node_modules").exists()
assert (hermes_home / "desktop-build-stamp.json").exists()
# ---------------------------------------------------------------------------
# _unpin_from_dock (macOS-only, best-effort)
# ---------------------------------------------------------------------------
class TestUnpinFromDock:
def test_noop_on_linux(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
assert uninstall._unpin_from_dock() is False
def test_noop_on_windows(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "win32")
assert uninstall._unpin_from_dock() is False
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS-only")
def test_macos_no_hermes_in_dock(self, monkeypatch):
"""If Hermes.app is not in the Dock output, return False."""
with patch("subprocess.run") as mock_run:
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = "some other apps"
assert uninstall._unpin_from_dock() is False
# ---------------------------------------------------------------------------
# _kill_desktop_process
# ---------------------------------------------------------------------------
class TestKillDesktopProcess:
def test_macos_uses_killall(self, monkeypatch):
"""On macOS, killall Hermes is called (targets Electron binary, not CLI)."""
monkeypatch.setattr(sys, "platform", "darwin")
with patch("subprocess.run") as mock_run:
uninstall._kill_desktop_process()
mock_run.assert_called_once_with(
["killall", "Hermes"], capture_output=True, check=False,
)
def test_windows_uses_taskkill(self, monkeypatch):
"""On Windows, taskkill /F /IM Hermes.exe is called."""
monkeypatch.setattr(sys, "platform", "win32")
with patch("subprocess.run") as mock_run:
uninstall._kill_desktop_process()
mock_run.assert_called_once_with(
["taskkill", "/F", "/IM", "Hermes.exe"],
capture_output=True, check=False,
)
def test_linux_uses_pkill(self, monkeypatch):
"""On Linux, pkill targets the desktop app path."""
monkeypatch.setattr(sys, "platform", "linux")
with patch("subprocess.run") as mock_run:
uninstall._kill_desktop_process()
mock_run.assert_called_once_with(
["pkill", "-f", "apps/desktop/release/linux-unpacked/Hermes"],
capture_output=True, check=False,
)
def test_exception_is_swallowed(self, monkeypatch):
"""If the kill command raises, the function does not propagate."""
monkeypatch.setattr(sys, "platform", "darwin")
with patch("subprocess.run", side_effect=OSError("nope")):
uninstall._kill_desktop_process() # should not raise
def test_is_called_by_remove_desktop_app(self, fake_install, fake_home, monkeypatch):
"""remove_desktop_app delegates to _kill_desktop_process."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
with patch.object(uninstall, "_kill_desktop_process") as mock_kill:
uninstall.remove_desktop_app(project_root, hermes_home)
mock_kill.assert_called_once()
def test_is_called_by_external_artifacts(self, fake_install, fake_home, monkeypatch):
"""_remove_desktop_external_artifacts delegates to _kill_desktop_process."""
project_root, hermes_home = fake_install
monkeypatch.setattr(sys, "platform", "linux")
with patch.object(uninstall, "_kill_desktop_process") as mock_kill:
uninstall._remove_desktop_external_artifacts(project_root, hermes_home)
mock_kill.assert_called_once()
# ---------------------------------------------------------------------------
# run_uninstall — managed-install no-op
# ---------------------------------------------------------------------------
class TestRunUninstallManagedNoop:
def test_managed_install_bails_early(self, monkeypatch, capsys):
"""When is_managed() is True, run_uninstall prints an error and returns."""
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: True)
monkeypatch.setattr("hermes_cli.config.get_managed_update_command",
lambda: "nix-env -e hermes")
# Provide a minimal args namespace
args = type("Args", (), {"desktop": False, "yes": False})()
uninstall.run_uninstall(args)
output = capsys.readouterr().out
assert "not available for managed installs" in output
assert "nix-env -e hermes" in output
def test_unmanaged_install_proceeds(self, monkeypatch, capsys):
"""When is_managed() is False, run_uninstall continues past the check."""
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
# Patch both path helpers so the uninstaller gets past them
# without hitting the real FS or an interactive prompt.
monkeypatch.setattr(uninstall, "get_project_root",
lambda: Path("/tmp/nope"))
monkeypatch.setattr(uninstall, "get_hermes_home",
lambda: Path("/tmp/nope-hh"))
args = type("Args", (), {"desktop": False, "yes": False})()
# The interactive menu will try to read stdin; feed it "4" (cancel).
monkeypatch.setattr("builtins.input", lambda _: "4")
uninstall.run_uninstall(args)
output = capsys.readouterr().out
# Should NOT have the managed-install error — it got past that gate.
assert "not available for managed installs" not in output
# ---------------------------------------------------------------------------
# run_uninstall — desktop-only dispatch (the actual entry points users hit)
# ---------------------------------------------------------------------------
class TestRunUninstallDesktopDispatch:
"""Cover the two routes into the desktop-only flow: the ``--desktop`` flag
fast-path and interactive menu choice "3". These guard against the kind of
off-by-one that the cancel-option renumber (3 -> 4) could introduce.
"""
@pytest.fixture
def unmanaged(self, monkeypatch):
"""Get past the managed-install gate with stub path helpers."""
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
monkeypatch.setattr(uninstall, "get_project_root",
lambda: Path("/tmp/nope"))
monkeypatch.setattr(uninstall, "get_hermes_home",
lambda: Path("/tmp/nope-hh"))
def test_desktop_flag_routes_to_desktop_uninstall(self, unmanaged, monkeypatch):
"""``hermes uninstall --desktop`` calls remove_desktop_app and never
touches the standard (code/data) removal flow."""
spy = []
monkeypatch.setattr(uninstall, "remove_desktop_app",
lambda pr, hh: spy.append((pr, hh)) or [])
# If the standard flow were entered it would prompt; make that explode
# so a mis-route is a hard failure rather than a hang.
monkeypatch.setattr("builtins.input",
lambda _: pytest.fail("standard flow should not prompt"))
args = type("Args", (), {"desktop": True, "yes": True})()
uninstall.run_uninstall(args)
assert spy == [(Path("/tmp/nope"), Path("/tmp/nope-hh"))]
def test_menu_choice_3_routes_to_desktop_uninstall(self, unmanaged, monkeypatch):
"""Interactive menu choice "3" routes to the desktop-only flow."""
spy = []
monkeypatch.setattr(uninstall, "remove_desktop_app",
lambda pr, hh: spy.append((pr, hh)) or [])
# First prompt = menu choice "3"; second = the desktop confirm "yes".
answers = iter(["3", "yes"])
monkeypatch.setattr("builtins.input", lambda _: next(answers))
args = type("Args", (), {"desktop": False, "yes": False})()
uninstall.run_uninstall(args)
assert spy == [(Path("/tmp/nope"), Path("/tmp/nope-hh"))]
class TestRunDesktopUninstall:
"""The desktop-only flow's own confirm/cancel handling."""
def test_skips_confirm_with_yes(self, monkeypatch):
spy = []
monkeypatch.setattr(uninstall, "remove_desktop_app",
lambda pr, hh: spy.append((pr, hh)) or [])
monkeypatch.setattr("builtins.input",
lambda _: pytest.fail("should not prompt when --yes"))
args = type("Args", (), {"yes": True})()
uninstall._run_desktop_uninstall(Path("/p"), Path("/h"), args)
assert spy == [(Path("/p"), Path("/h"))]
def test_cancels_on_non_yes(self, monkeypatch, capsys):
spy = []
monkeypatch.setattr(uninstall, "remove_desktop_app",
lambda pr, hh: spy.append((pr, hh)) or [])
monkeypatch.setattr("builtins.input", lambda _: "no")
args = type("Args", (), {"yes": False})()
uninstall._run_desktop_uninstall(Path("/p"), Path("/h"), args)
# remove_desktop_app must NOT run when the user declines.
assert spy == []
assert "cancel" in capsys.readouterr().out.lower()
def test_cancels_on_eof(self, monkeypatch):
"""Ctrl-D / EOF at the confirm prompt cancels cleanly."""
spy = []
monkeypatch.setattr(uninstall, "remove_desktop_app",
lambda pr, hh: spy.append((pr, hh)) or [])
def raise_eof(_):
raise EOFError
monkeypatch.setattr("builtins.input", raise_eof)
args = type("Args", (), {"yes": False})()
uninstall._run_desktop_uninstall(Path("/p"), Path("/h"), args)
assert spy == []