mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
1 Commits
bb/coding-
...
ethie/desk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ed2bec40 |
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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...")
|
||||
|
||||
480
tests/hermes_cli/test_uninstall_desktop.py
Normal file
480
tests/hermes_cli/test_uninstall_desktop.py
Normal 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 == []
|
||||
Reference in New Issue
Block a user