mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(uninstall): offer to remove named profiles when uninstalling from default
When `hermes uninstall` runs from the default HERMES_HOME (~/.hermes)
and other named profiles exist under ~/.hermes/profiles/, show them in
the installation overview and prompt:
Also stop and remove these N profile(s)? [y/N]
If confirmed, for each named profile we:
1. Shell out to `python -m hermes_cli.main -p <name> gateway stop/uninstall`
to stop the gateway and remove its systemd unit or launchd plist
(service names + unit paths are derived from HERMES_HOME, so we
can't cleanly switch in-process)
2. Remove the ~/.local/bin/<name> alias wrapper (outside HERMES_HOME)
3. Wipe the profile's HERMES_HOME dir
Previously `hermes uninstall` was silently profile-scoped, leaving
zombie systemd units at ~/.config/systemd/user/hermes-gateway-<profile>.service
and zombie HERMES_HOMEs under ~/.hermes/profiles/ whenever a user
uninstalled from default with other profiles configured.
Prompt only appears when uninstalling from the default root. Uninstalling
from within a named profile stays profile-scoped as before.
This commit is contained in:
parent
3fe0d503b6
commit
79c5a381c5
1 changed files with 127 additions and 3 deletions
|
|
@ -204,6 +204,80 @@ def uninstall_gateway_service():
|
||||||
return stopped_something
|
return stopped_something
|
||||||
|
|
||||||
|
|
||||||
|
def _is_default_hermes_home(hermes_home: Path) -> bool:
|
||||||
|
"""Return True when ``hermes_home`` points at the default (non-profile) root."""
|
||||||
|
try:
|
||||||
|
from hermes_constants import get_default_hermes_root
|
||||||
|
return hermes_home.resolve() == get_default_hermes_root().resolve()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_named_profiles():
|
||||||
|
"""Return a list of ``ProfileInfo`` for every non-default profile, or ``[]``
|
||||||
|
if profile support is unavailable or nothing is installed beyond the
|
||||||
|
default root."""
|
||||||
|
try:
|
||||||
|
from hermes_cli.profiles import list_profiles
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return [p for p in list_profiles() if not getattr(p, "is_default", False)]
|
||||||
|
except Exception as e:
|
||||||
|
log_warn(f"Could not enumerate profiles: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _uninstall_profile(profile) -> None:
|
||||||
|
"""Fully uninstall a single named profile: stop its gateway service,
|
||||||
|
remove its alias wrapper, and wipe its HERMES_HOME directory.
|
||||||
|
|
||||||
|
We shell out to ``hermes -p <name> gateway stop|uninstall`` because
|
||||||
|
service names, unit paths, and plist paths are all derived from the
|
||||||
|
current HERMES_HOME and can't be easily switched in-process.
|
||||||
|
"""
|
||||||
|
import sys as _sys
|
||||||
|
name = profile.name
|
||||||
|
profile_home = profile.path
|
||||||
|
|
||||||
|
log_info(f"Uninstalling profile '{name}'...")
|
||||||
|
|
||||||
|
# 1. Stop and remove this profile's gateway service.
|
||||||
|
# Use `python -m hermes_cli.main` so we don't depend on a `hermes`
|
||||||
|
# wrapper that may be half-removed mid-uninstall.
|
||||||
|
hermes_invocation = [_sys.executable, "-m", "hermes_cli.main", "--profile", name]
|
||||||
|
for subcmd in ("stop", "uninstall"):
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
hermes_invocation + ["gateway", subcmd],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log_warn(f" Gateway {subcmd} timed out for '{name}'")
|
||||||
|
except Exception as e:
|
||||||
|
log_warn(f" Could not run gateway {subcmd} for '{name}': {e}")
|
||||||
|
|
||||||
|
# 2. Remove the wrapper alias script at ~/.local/bin/<name> (if any).
|
||||||
|
alias_path = getattr(profile, "alias_path", None)
|
||||||
|
if alias_path and alias_path.exists():
|
||||||
|
try:
|
||||||
|
alias_path.unlink()
|
||||||
|
log_success(f" Removed alias {alias_path}")
|
||||||
|
except Exception as e:
|
||||||
|
log_warn(f" Could not remove alias {alias_path}: {e}")
|
||||||
|
|
||||||
|
# 3. Wipe the profile's HERMES_HOME directory.
|
||||||
|
try:
|
||||||
|
if profile_home.exists():
|
||||||
|
shutil.rmtree(profile_home)
|
||||||
|
log_success(f" Removed {profile_home}")
|
||||||
|
except Exception as e:
|
||||||
|
log_warn(f" Could not remove {profile_home}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def run_uninstall(args):
|
def run_uninstall(args):
|
||||||
"""
|
"""
|
||||||
Run the uninstall process.
|
Run the uninstall process.
|
||||||
|
|
@ -214,7 +288,13 @@ def run_uninstall(args):
|
||||||
"""
|
"""
|
||||||
project_root = get_project_root()
|
project_root = get_project_root()
|
||||||
hermes_home = get_hermes_home()
|
hermes_home = get_hermes_home()
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
is_default_profile = _is_default_hermes_home(hermes_home)
|
||||||
|
named_profiles = _discover_named_profiles() if is_default_profile else []
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
|
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
|
||||||
print(color("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA, Colors.BOLD))
|
print(color("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA, Colors.BOLD))
|
||||||
|
|
@ -228,6 +308,13 @@ def run_uninstall(args):
|
||||||
print(f" Secrets: {hermes_home / '.env'}")
|
print(f" Secrets: {hermes_home / '.env'}")
|
||||||
print(f" Data: {hermes_home / 'cron/'}, {hermes_home / 'sessions/'}, {hermes_home / 'logs/'}")
|
print(f" Data: {hermes_home / 'cron/'}, {hermes_home / 'sessions/'}, {hermes_home / 'logs/'}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
if named_profiles:
|
||||||
|
print(color("Other profiles detected:", Colors.CYAN, Colors.BOLD))
|
||||||
|
for p in named_profiles:
|
||||||
|
running = " (gateway running)" if getattr(p, "gateway_running", False) else ""
|
||||||
|
print(f" • {p.name}{running}: {p.path}")
|
||||||
|
print()
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
print(color("Uninstall Options:", Colors.YELLOW, Colors.BOLD))
|
print(color("Uninstall Options:", Colors.YELLOW, Colors.BOLD))
|
||||||
|
|
@ -254,12 +341,40 @@ def run_uninstall(args):
|
||||||
return
|
return
|
||||||
|
|
||||||
full_uninstall = (choice == "2")
|
full_uninstall = (choice == "2")
|
||||||
|
|
||||||
|
# When doing a full uninstall from the default profile, also offer to
|
||||||
|
# remove any named profiles — stopping their gateway services, unlinking
|
||||||
|
# their alias wrappers, and wiping their HERMES_HOME dirs. Otherwise
|
||||||
|
# those leave zombie services and data behind.
|
||||||
|
remove_profiles = False
|
||||||
|
if full_uninstall and named_profiles:
|
||||||
|
print()
|
||||||
|
print(color("Other profiles will NOT be removed by default.", Colors.YELLOW))
|
||||||
|
print(f"Found {len(named_profiles)} named profile(s): " +
|
||||||
|
", ".join(p.name for p in named_profiles))
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
resp = input(color(
|
||||||
|
f"Also stop and remove these {len(named_profiles)} profile(s)? [y/N]: ",
|
||||||
|
Colors.BOLD
|
||||||
|
)).strip().lower()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print()
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
remove_profiles = resp in ("y", "yes")
|
||||||
|
|
||||||
# Final confirmation
|
# Final confirmation
|
||||||
print()
|
print()
|
||||||
if full_uninstall:
|
if full_uninstall:
|
||||||
print(color("⚠️ WARNING: This will permanently delete ALL Hermes data!", Colors.RED, Colors.BOLD))
|
print(color("⚠️ WARNING: This will permanently delete ALL Hermes data!", Colors.RED, Colors.BOLD))
|
||||||
print(color(" Including: configs, API keys, sessions, scheduled jobs, logs", Colors.RED))
|
print(color(" Including: configs, API keys, sessions, scheduled jobs, logs", Colors.RED))
|
||||||
|
if remove_profiles:
|
||||||
|
print(color(
|
||||||
|
f" Plus {len(named_profiles)} profile(s): " +
|
||||||
|
", ".join(p.name for p in named_profiles),
|
||||||
|
Colors.RED
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
print("This will remove the Hermes code but keep your configuration and data.")
|
print("This will remove the Hermes code but keep your configuration and data.")
|
||||||
|
|
||||||
|
|
@ -322,8 +437,17 @@ def run_uninstall(args):
|
||||||
log_warn(f"Could not fully remove {project_root}: {e}")
|
log_warn(f"Could not fully remove {project_root}: {e}")
|
||||||
log_info("You may need to manually remove it")
|
log_info("You may need to manually remove it")
|
||||||
|
|
||||||
# 5. Optionally remove ~/.hermes/ data directory
|
# 5. Optionally remove ~/.hermes/ data directory (and named profiles)
|
||||||
if full_uninstall:
|
if full_uninstall:
|
||||||
|
# 5a. Stop and remove each named profile's gateway service and
|
||||||
|
# alias wrapper. The profile HERMES_HOME dirs live under
|
||||||
|
# ``<default>/profiles/<name>/`` and will be swept away by the
|
||||||
|
# rmtree below, but services + alias scripts live OUTSIDE the
|
||||||
|
# default root and have to be cleaned up explicitly.
|
||||||
|
if remove_profiles and named_profiles:
|
||||||
|
for prof in named_profiles:
|
||||||
|
_uninstall_profile(prof)
|
||||||
|
|
||||||
log_info("Removing configuration and data...")
|
log_info("Removing configuration and data...")
|
||||||
try:
|
try:
|
||||||
if hermes_home.exists():
|
if hermes_home.exists():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue