refactor(hermes_cli): merge r3-24 group B
This commit is contained in:
+127
-249
@@ -52,10 +52,8 @@ def _confirm_yes(text: str) -> bool:
|
||||
|
||||
|
||||
def _remove_each(candidates, remove) -> list:
|
||||
"""Run ``remove(path)`` for each candidate; collect those it reports removed (truthy).
|
||||
|
||||
Failures are downgraded to the shared ``Could not remove <path>: <err>`` warning.
|
||||
"""
|
||||
"""Run ``remove(path)`` per candidate, collecting the ones it reports removed; failures become
|
||||
the shared ``Could not remove <path>: <err>`` warning."""
|
||||
removed = []
|
||||
for path in candidates:
|
||||
try:
|
||||
@@ -75,7 +73,8 @@ _SHELL_RC_NAMES = (".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile"
|
||||
|
||||
|
||||
def _strip_hermes_path_lines(content: str) -> str:
|
||||
"""Drop the ``# Hermes Agent`` marker (+ its PATH line) and any hermes PATH line; squash blank runs."""
|
||||
"""Drop the ``# Hermes Agent`` marker (+ its PATH line) and any hermes PATH line; squash blank
|
||||
runs."""
|
||||
new_lines = []
|
||||
skip_next = False
|
||||
for line in content.split('\n'):
|
||||
@@ -105,14 +104,10 @@ def remove_path_from_shell_configs():
|
||||
new_content = _strip_hermes_path_lines(content)
|
||||
if new_content != content:
|
||||
from utils import atomic_write_text
|
||||
|
||||
# This is the user's own shell rc, not a Hermes-owned file, and nothing here backs
|
||||
# it up. A bare write_text() truncates it before the new content lands, so a crash
|
||||
# or SIGINT mid-write leaves an empty/truncated ~/.zshrc -- and the enclosing
|
||||
# `except Exception` downgrades that to a warning, so the next login just starts a
|
||||
# bare shell. atomic_replace also resolves a symlinked rc file (dotfiles repos keep
|
||||
# the symlink). preserve_mode keeps the rc's permission bits (normally 0644) and
|
||||
# owner (sudo-run uninstalls) instead of mkstemp's 0600/root.
|
||||
# The user's own rc, never backed up: a bare write_text() truncates before the new
|
||||
# content lands, and a crash mid-write (downgraded to a warning below) would leave
|
||||
# an empty ~/.zshrc. Atomic replace also follows a symlinked rc (dotfiles repos);
|
||||
# preserve_mode keeps its 0644 bits and owner (sudo-run uninstalls).
|
||||
atomic_write_text(config_path, new_content, preserve_mode=True)
|
||||
removed_from.append(config_path)
|
||||
except Exception as e:
|
||||
@@ -123,18 +118,17 @@ def remove_path_from_shell_configs():
|
||||
def remove_wrapper_script():
|
||||
"""Remove the hermes wrapper script if it exists."""
|
||||
def _unlink_ours(wrapper: Path) -> bool:
|
||||
# Check if it's our wrapper (contains hermes_cli reference)
|
||||
# Only our wrapper (contains a hermes_cli / hermes-agent reference).
|
||||
content = wrapper.read_text(encoding="utf-8")
|
||||
if 'hermes_cli' in content or 'hermes-agent' in content:
|
||||
wrapper.unlink()
|
||||
return True
|
||||
return False
|
||||
if 'hermes_cli' not in content and 'hermes-agent' not in content:
|
||||
return False
|
||||
wrapper.unlink()
|
||||
return True
|
||||
|
||||
candidates = (
|
||||
bin_dir / name
|
||||
for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin"))
|
||||
for name in ("hermes", "hermes-acp", "hermes-agent")
|
||||
)
|
||||
for name in ("hermes", "hermes-acp", "hermes-agent"))
|
||||
return _remove_each((w for w in candidates if w.exists()), _unlink_ours)
|
||||
|
||||
|
||||
@@ -150,44 +144,30 @@ def _node_symlink_candidate_dirs() -> "list[Path]":
|
||||
|
||||
|
||||
def remove_node_symlinks(hermes_home: Path) -> list:
|
||||
"""Remove the node/npm/npx symlinks the installer placed on PATH.
|
||||
|
||||
- ``/usr/local/bin/`` on root FHS installs (Linux, uid 0) - ``$PREFIX/bin/`` on Termux -
|
||||
``~/.local/bin/`` otherwise (the common non-root case)
|
||||
|
||||
We check all candidate directories so that uninstall works regardless of how the install was
|
||||
done (e.g. a root FHS install that placed links in ``/usr/local/bin``, or an older install that
|
||||
used ``~/.local/bin`` before the FHS fix).
|
||||
"""
|
||||
"""Remove the node/npm/npx symlinks the installer placed on PATH. Every candidate dir is
|
||||
checked (``/usr/local/bin`` root FHS, ``$PREFIX/bin`` Termux, ``~/.local/bin`` otherwise / older
|
||||
installs) so uninstall works regardless of how the install was done."""
|
||||
node_dir = (hermes_home / "node").resolve()
|
||||
|
||||
def _unlink_ours(link: Path) -> bool:
|
||||
# Only act on symlinks — never delete a real binary the user put here.
|
||||
if not link.is_symlink():
|
||||
return False
|
||||
# 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()
|
||||
return True
|
||||
return False
|
||||
# os.readlink + manual join handles dangling links too (Path.resolve() on a dangling
|
||||
# link still returns the target path); the link must point into OUR node dir.
|
||||
target = (link.parent / os.readlink(link)).resolve()
|
||||
if target != node_dir and node_dir not in target.parents:
|
||||
return False
|
||||
link.unlink()
|
||||
return True
|
||||
|
||||
candidates = (bin_dir / name for name in ("node", "npm", "npx") for bin_dir in _node_symlink_candidate_dirs())
|
||||
return _remove_each(candidates, _unlink_ours)
|
||||
|
||||
|
||||
def uninstall_gateway_service():
|
||||
"""Stop/uninstall the gateway service on every platform and kill standalone gateways.
|
||||
|
||||
Delegates to the gateway module: systemd user+system units (Linux), launchd (macOS),
|
||||
Scheduled Task + Startup folder (Windows), plus standalone ``hermes gateway run`` processes.
|
||||
Termux/Android has no systemd, so only the process kill applies there.
|
||||
"""
|
||||
"""Kill standalone gateways, then remove the per-platform service (systemd user+system /
|
||||
launchd / Scheduled Task + Startup folder). Termux/Android has neither: only the kill applies."""
|
||||
import platform
|
||||
stopped_something = False
|
||||
|
||||
@@ -206,24 +186,18 @@ def uninstall_gateway_service():
|
||||
return stopped_something
|
||||
|
||||
# 2. Per-platform service removal (systemd / launchd / Scheduled Task).
|
||||
step = _GATEWAY_SERVICE_REMOVERS.get(platform.system())
|
||||
if step is not None:
|
||||
remover, warn_label = step
|
||||
remover, warn_label = _GATEWAY_SERVICE_REMOVERS.get(platform.system(), (None, ""))
|
||||
if remover is not None:
|
||||
try:
|
||||
stopped_something = remover() or stopped_something
|
||||
except Exception as e:
|
||||
log_warn(f"{warn_label}: {e}")
|
||||
|
||||
return stopped_something
|
||||
|
||||
|
||||
def _remove_systemd_gateway() -> bool:
|
||||
"""Linux: uninstall systemd services (both user and system scopes)."""
|
||||
from hermes_cli.gateway import (
|
||||
get_systemd_unit_path,
|
||||
get_service_name,
|
||||
_systemctl_cmd,
|
||||
)
|
||||
from hermes_cli.gateway import _systemctl_cmd, get_service_name, get_systemd_unit_path
|
||||
svc_name = get_service_name()
|
||||
removed_any = False
|
||||
|
||||
@@ -254,20 +228,15 @@ def _remove_launchd_gateway() -> bool:
|
||||
plist_path = get_launchd_plist_path()
|
||||
if not plist_path.exists():
|
||||
return False
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)],
|
||||
capture_output=True, check=False)
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, check=False)
|
||||
plist_path.unlink()
|
||||
log_success(f"Removed macOS gateway service ({plist_path})")
|
||||
return True
|
||||
|
||||
|
||||
def _remove_windows_gateway() -> bool:
|
||||
"""Windows: uninstall Scheduled Task + Startup-folder entry.
|
||||
|
||||
The gateway_windows module already knows how to locate and remove both code paths (schtasks
|
||||
/Delete + .cmd unlink) and how to stop any running detached pythonw gateway process. We call
|
||||
into it so the uninstall logic stays in exactly one place.
|
||||
"""
|
||||
"""Windows: uninstall Scheduled Task + Startup-folder entry via ``gateway_windows`` (it owns
|
||||
schtasks /Delete, the .cmd unlink and stopping the detached pythonw gateway)."""
|
||||
from hermes_cli import gateway_windows
|
||||
if not any(probe() for probe in (
|
||||
gateway_windows.is_installed, gateway_windows.is_task_registered, gateway_windows.is_startup_entry_installed,
|
||||
@@ -290,49 +259,22 @@ def _remove_windows_gateway() -> bool:
|
||||
_GATEWAY_SERVICE_REMOVERS = {
|
||||
"Linux": (_remove_systemd_gateway, "Could not check systemd gateway services"),
|
||||
"Darwin": (_remove_launchd_gateway, "Could not remove launchd gateway service"),
|
||||
"Windows": (_remove_windows_gateway, "Could not check Windows gateway service"),
|
||||
}
|
||||
"Windows": (_remove_windows_gateway, "Could not check Windows gateway service")}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Windows-specific uninstall helpers
|
||||
# ============================================================================
|
||||
#
|
||||
# The installer (``scripts/install.ps1``) does four Windows-only things that
|
||||
# ``remove_path_from_shell_configs`` / ``remove_wrapper_script`` don't cover:
|
||||
#
|
||||
# 1. Sets User-scope env vars ``HERMES_HOME`` and ``HERMES_GIT_BASH_PATH``
|
||||
# via ``[Environment]::SetEnvironmentVariable(..., "User")``. These
|
||||
# don't live in ~/.bashrc — they're in the Windows registry at
|
||||
# HKCU\Environment.
|
||||
# 2. Prepends to User-scope ``PATH`` (same registry location) entries
|
||||
# like ``%LOCALAPPDATA%\hermes\git\cmd``, ``%LOCALAPPDATA%\hermes\git\bin``,
|
||||
# ``%LOCALAPPDATA%\hermes\git\usr\bin``, ``%LOCALAPPDATA%\hermes\node``.
|
||||
# Again not in any rc file — only accessible via the registry or the
|
||||
# .NET [Environment] API.
|
||||
# 3. Downloads PortableGit to ``%LOCALAPPDATA%\hermes\git\`` and Node to
|
||||
# ``%LOCALAPPDATA%\hermes\node\`` as user-scoped, isolated copies.
|
||||
# These are ~200MB combined and serve no purpose after uninstall.
|
||||
# 4. On the ``hermes dashboard`` + gateway paths, drops files into
|
||||
# ``%LOCALAPPDATA%\hermes\gateway-service\`` and sometimes
|
||||
# ``%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`` — the
|
||||
# latter is handled by ``gateway_windows.uninstall()`` already.
|
||||
#
|
||||
# Running a PowerShell one-liner per operation is overkill and fragile on
|
||||
# locked-down machines (Constrained Language Mode, restricted ExecutionPolicy).
|
||||
# Direct registry writes via ``winreg`` work without spawning any subprocess
|
||||
# and apply immediately for new shells (SendMessage WM_SETTINGCHANGE would
|
||||
# be nicer but requires ctypes and buys us nothing — the user will log out
|
||||
# or open a new terminal anyway).
|
||||
# Windows-specific helpers. install.ps1 does four things no rc file covers: (1) User-scope env
|
||||
# vars HERMES_HOME / HERMES_GIT_BASH_PATH in the registry (HKCU\Environment); (2) User-scope PATH
|
||||
# entries there too (%LOCALAPPDATA%\hermes\git\{cmd,bin,usr\bin}, ...\hermes\node); (3) PortableGit
|
||||
# + Node copies under %LOCALAPPDATA%\hermes\ (~200MB, useless after uninstall); (4) the
|
||||
# gateway-service dir (the Startup-folder entry is handled by gateway_windows.uninstall()).
|
||||
# Direct winreg writes are used instead of PowerShell one-liners: no subprocess, and they work
|
||||
# under Constrained Language Mode / restricted ExecutionPolicy. New shells see them immediately
|
||||
# (WM_SETTINGCHANGE would need ctypes and buys nothing — the user opens a new terminal anyway).
|
||||
|
||||
|
||||
def _hermes_path_markers(hermes_home: Path, *, include_managed_bin: bool = False) -> list[str]:
|
||||
"""Path-entry substrings that identify Hermes-owned User-PATH entries.
|
||||
|
||||
``include_managed_bin`` adds the managed binary dir (``<root>\bin``, holding the hermes
|
||||
launchers and the managed uv) — only wanted when that dir is about to be deleted (full uninstall
|
||||
from the default root), so a keep-data uninstall leaves the still-working managed uv resolvable.
|
||||
"""
|
||||
"""Prefixes identifying Hermes-owned User-PATH entries. ``include_managed_bin`` adds
|
||||
``<root>\bin`` (launchers + managed uv) — only when that dir is about to be deleted (full
|
||||
uninstall from the default root), so a keep-data uninstall keeps the working uv resolvable."""
|
||||
root = str(hermes_home).rstrip("\\/")
|
||||
# Match on prefix so sub-entries (git\cmd, git\bin, git\usr\bin, node, etc.) all get swept.
|
||||
subs = ("hermes-agent", "git", "node", "venv") + (("bin",) if include_managed_bin else ())
|
||||
@@ -340,13 +282,8 @@ def _hermes_path_markers(hermes_home: Path, *, include_managed_bin: bool = False
|
||||
|
||||
|
||||
def remove_path_from_windows_registry(hermes_home: Path, *, include_managed_bin: bool = False) -> list[str]:
|
||||
"""Strip Hermes-owned entries from User-scope PATH in the registry.
|
||||
|
||||
``include_managed_bin`` adds ``<hermes_home>\bin`` (the managed binary dir holding the hermes
|
||||
launchers and the managed uv) to the sweep. Only pass it when that dir is actually being deleted
|
||||
— full uninstall from the default root — so a keep-data uninstall leaves the still-working
|
||||
managed uv resolvable.
|
||||
"""
|
||||
"""Strip Hermes-owned entries from User-scope PATH in the registry (see ``_hermes_path_markers``
|
||||
for what ``include_managed_bin`` adds and when to pass it)."""
|
||||
markers = tuple(m.lower() for m in _hermes_path_markers(hermes_home, include_managed_bin=include_managed_bin))
|
||||
|
||||
def edit(winreg, key, removed):
|
||||
@@ -384,11 +321,9 @@ def remove_hermes_env_vars_windows() -> list[str]:
|
||||
|
||||
|
||||
def _edit_user_environment(edit, *, warn_label: str) -> list[str]:
|
||||
"""Open HKCU\\Environment read/write and run ``edit(winreg, key, removed)``.
|
||||
|
||||
Returns whatever ``edit`` appended to ``removed`` — even when a later registry call raised, so
|
||||
callers report exactly what was touched. ``[]`` off-Windows (no ``winreg``).
|
||||
"""
|
||||
"""Open HKCU\\Environment read/write and run ``edit(winreg, key, removed)``. Returns what
|
||||
``edit`` appended to ``removed`` even if a later registry call raised (callers report exactly
|
||||
what was touched); ``[]`` off-Windows (no ``winreg``)."""
|
||||
removed: list[str] = []
|
||||
try:
|
||||
import winreg
|
||||
@@ -407,22 +342,15 @@ def remove_portable_tooling_windows(hermes_home: Path) -> list[Path]:
|
||||
"""Delete PortableGit and Node installs the Windows installer created under
|
||||
``%LOCALAPPDATA%\\hermes\\``. Only called on full uninstall; they're
|
||||
isolated from any system Git / Node so they cannot break other tools."""
|
||||
def _rmtree(target: Path) -> bool:
|
||||
shutil.rmtree(target, ignore_errors=False)
|
||||
return True
|
||||
|
||||
targets = (hermes_home / sub for sub in ("git", "node", "gateway-service"))
|
||||
return _remove_each((t for t in targets if t.exists()), _rmtree)
|
||||
return _remove_each((t for t in targets if t.exists()), lambda t: shutil.rmtree(t) or True)
|
||||
|
||||
|
||||
def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]:
|
||||
"""Delete the ``hermes`` launchers install.ps1 staged in the managed ``bin`` dir.
|
||||
|
||||
Every uninstall mode deletes the checkout, so launchers pointing at ``<checkout>/venv``
|
||||
would dangle and error, which reads worse than command-not-found; the managed uv is kept for
|
||||
keep-data reinstalls. A launcher that is this process's own trampoline is locked against
|
||||
deletion but not rename, so it is renamed aside with a non-executable suffix instead.
|
||||
"""
|
||||
"""Delete the ``hermes`` launchers install.ps1 staged in the managed ``bin`` dir: every mode
|
||||
deletes the checkout, so a launcher pointing at ``<checkout>/venv`` would dangle (worse than
|
||||
command-not-found); the managed uv stays for keep-data reinstalls. Our own running trampoline is
|
||||
locked against deletion but not rename, so it is renamed aside with a non-executable suffix."""
|
||||
if windows is None:
|
||||
windows = _is_windows()
|
||||
if not windows:
|
||||
@@ -432,7 +360,6 @@ def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]:
|
||||
# startup heal stage into this dir.
|
||||
from hermes_cli._install_repair import _WINDOWS_BIN_LAUNCHERS
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
bin_dir = get_default_hermes_root() / "bin"
|
||||
except Exception as e:
|
||||
log_warn(f"Could not locate the managed binary dir: {e}")
|
||||
@@ -478,27 +405,19 @@ def _discover_named_profiles():
|
||||
|
||||
|
||||
def _uninstall_profile(profile) -> None:
|
||||
"""Fully uninstall a named profile: stop its gateway, remove its alias, wipe its home.
|
||||
|
||||
Shells out to ``hermes -p <name> gateway stop|uninstall`` because service names and unit/
|
||||
plist paths derive from the current HERMES_HOME and can't easily be switched in-process.
|
||||
"""
|
||||
"""Fully uninstall a named profile: stop its gateway, remove its alias, wipe its home. Shells
|
||||
out to ``hermes -p <name> gateway stop|uninstall`` because service names / unit paths derive
|
||||
from the current HERMES_HOME and can't be switched in-process."""
|
||||
name = profile.name
|
||||
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.
|
||||
# 1. Gateway service, via `python -m hermes_cli.main` (the `hermes` wrapper may be half-gone).
|
||||
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, encoding='utf-8', errors='replace',
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
hermes_invocation + ["gateway", subcmd], capture_output=True, text=True,
|
||||
encoding='utf-8', errors='replace', timeout=60, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
log_warn(f" Gateway {subcmd} timed out for '{name}'")
|
||||
except Exception as e:
|
||||
@@ -518,17 +437,10 @@ def _uninstall_profile(profile) -> None:
|
||||
|
||||
|
||||
def run_gui_uninstall(args):
|
||||
"""GUI-only uninstall: remove the Chat GUI, leave the agent + data intact.
|
||||
|
||||
Mirrors ``hermes uninstall --gui``. Removes the desktop app's built artifacts, the packaged app
|
||||
bundle (best-effort), and the Electron userData dir — nothing under ``$HERMES_HOME``
|
||||
config/sessions/.env, and never the Python agent or its venv.
|
||||
"""
|
||||
"""``hermes uninstall --gui``: remove the desktop app's built artifacts, packaged bundle
|
||||
(best-effort) and Electron userData — never config/sessions/.env, the agent or its venv."""
|
||||
from hermes_cli.gui_uninstall import (
|
||||
agent_is_installed,
|
||||
gui_install_summary,
|
||||
uninstall_gui,
|
||||
)
|
||||
agent_is_installed, gui_install_summary, uninstall_gui)
|
||||
|
||||
hermes_home = get_hermes_home()
|
||||
summary = gui_install_summary(hermes_home)
|
||||
@@ -574,48 +486,34 @@ def run_gui_uninstall(args):
|
||||
|
||||
|
||||
def run_uninstall(args):
|
||||
"""Run the uninstall process.
|
||||
|
||||
Full uninstall removes code and ``~/.hermes/``; keep-data removes only the code so the
|
||||
configs, data and logs survive a reinstall.
|
||||
"""
|
||||
"""Interactive/``--yes`` uninstall: full removes code and ``~/.hermes/``; keep-data removes only
|
||||
the code so configs, data and logs survive a reinstall."""
|
||||
project_root = get_project_root()
|
||||
hermes_home = get_hermes_home()
|
||||
|
||||
full_flag = bool(getattr(args, "full", False))
|
||||
if bool(getattr(args, "dry_run", False)):
|
||||
_print_uninstall_dry_run(
|
||||
project_root=project_root,
|
||||
hermes_home=hermes_home,
|
||||
full_uninstall=bool(getattr(args, "full", False)),
|
||||
)
|
||||
project_root=project_root, hermes_home=hermes_home, full_uninstall=full_flag)
|
||||
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.
|
||||
# Named profiles (only when uninstalling from the default root) are offered for cleanup 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 []
|
||||
|
||||
# Non-interactive fast path (``--yes``): no prompts. ``--full`` selects a
|
||||
# full wipe (code + ~/.hermes data); otherwise keep-data. Named profiles
|
||||
# are NOT auto-removed here — that's a destructive, surprising default for
|
||||
# an unattended run, so it stays opt-in to the interactive flow. This is
|
||||
# the path the desktop app's detached cleanup script uses for its
|
||||
# lite/full modes.
|
||||
# ``--yes`` (the desktop app's detached cleanup script): no prompts; ``--full`` = full wipe.
|
||||
# Named profiles are NOT auto-removed here — too destructive a default for an unattended run.
|
||||
if bool(getattr(args, "yes", False)):
|
||||
_perform_uninstall(
|
||||
project_root=project_root,
|
||||
hermes_home=hermes_home,
|
||||
full_uninstall=bool(getattr(args, "full", False)),
|
||||
remove_profiles=False,
|
||||
named_profiles=named_profiles,
|
||||
)
|
||||
project_root=project_root, hermes_home=hermes_home, full_uninstall=full_flag,
|
||||
remove_profiles=False, named_profiles=named_profiles)
|
||||
return
|
||||
|
||||
print()
|
||||
_print_box("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA)
|
||||
print()
|
||||
|
||||
|
||||
# Show what will be affected
|
||||
print(color("Current Installation:", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Code: {project_root}")
|
||||
@@ -641,21 +539,19 @@ def run_uninstall(args):
|
||||
print()
|
||||
print(" 3) " + color("Cancel", Colors.CYAN) + " - Don't uninstall")
|
||||
print()
|
||||
|
||||
|
||||
choice = _prompt(color("Select option [1/2/3]: ", Colors.BOLD))
|
||||
if choice is None:
|
||||
return
|
||||
|
||||
|
||||
if choice in {"3", "c", "cancel", "q", "quit", "n", "no"}:
|
||||
_cancelled()
|
||||
return
|
||||
|
||||
|
||||
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.
|
||||
# Full uninstall from the default profile: offer to remove named profiles too (gateway
|
||||
# services, alias wrappers, HERMES_HOME dirs) — otherwise they leave zombie services behind.
|
||||
remove_profiles = False
|
||||
n_profiles = len(named_profiles)
|
||||
profile_names = ", ".join(p.name for p in named_profiles)
|
||||
@@ -678,18 +574,14 @@ def run_uninstall(args):
|
||||
print(color(f" Plus {n_profiles} profile(s): {profile_names}", Colors.RED))
|
||||
else:
|
||||
print("This will remove the Hermes code but keep your configuration and data.")
|
||||
|
||||
|
||||
print()
|
||||
if not _confirm_yes("to confirm"):
|
||||
return
|
||||
|
||||
_perform_uninstall(
|
||||
project_root=project_root,
|
||||
hermes_home=hermes_home,
|
||||
full_uninstall=full_uninstall,
|
||||
remove_profiles=remove_profiles,
|
||||
named_profiles=named_profiles,
|
||||
)
|
||||
project_root=project_root, hermes_home=hermes_home, full_uninstall=full_uninstall,
|
||||
remove_profiles=remove_profiles, named_profiles=named_profiles)
|
||||
|
||||
|
||||
def _print_uninstall_dry_run(*, project_root: Path, hermes_home: Path, full_uninstall: bool) -> None:
|
||||
@@ -716,13 +608,13 @@ def _print_uninstall_dry_run(*, project_root: Path, hermes_home: Path, full_unin
|
||||
|
||||
|
||||
def _remove_step(label: str, remove, success_fmt: str, none_msg: str) -> None:
|
||||
"""Announce ``label``, run ``remove()``, then log one success line per removed item (or ``none_msg``)."""
|
||||
"""Announce ``label``, run ``remove()``, then log one success line per removed item (or
|
||||
``none_msg``)."""
|
||||
log_info(label)
|
||||
removed = remove()
|
||||
if removed:
|
||||
for item in removed:
|
||||
log_success(success_fmt.format(item))
|
||||
else:
|
||||
for item in removed:
|
||||
log_success(success_fmt.format(item))
|
||||
if not removed:
|
||||
log_info(none_msg)
|
||||
|
||||
|
||||
@@ -744,32 +636,25 @@ def _perform_uninstall(
|
||||
hermes_home: Path,
|
||||
full_uninstall: bool,
|
||||
remove_profiles: bool,
|
||||
named_profiles: list,
|
||||
) -> None:
|
||||
"""Execute the uninstall steps; shared by the interactive and ``--yes`` paths.
|
||||
|
||||
Order: stop gateway -> strip PATH (rc files + Windows registry) -> remove wrapper and node
|
||||
symlinks -> remove desktop Chat GUI artifacts -> delete the checkout -> (Windows) remove
|
||||
PortableGit/Node -> optionally wipe ``$HERMES_HOME`` and named profiles.
|
||||
"""
|
||||
named_profiles: list) -> None:
|
||||
"""The uninstall steps shared by the interactive and ``--yes`` paths: stop gateway -> strip PATH
|
||||
(rc files + Windows registry) -> wrapper/launchers/node symlinks -> Chat GUI artifacts -> delete
|
||||
the checkout -> (Windows) PortableGit/Node -> optionally ``$HERMES_HOME`` and named profiles."""
|
||||
print()
|
||||
print(color("Uninstalling...", Colors.CYAN, Colors.BOLD))
|
||||
print()
|
||||
|
||||
|
||||
# 1. Stop and uninstall gateway service + kill standalone processes
|
||||
log_info("Checking for running gateway...")
|
||||
if not uninstall_gateway_service():
|
||||
log_info("No gateway service or processes found")
|
||||
|
||||
# 2-3b. PATH entries (POSIX rc files, then the Windows User-scope registry), wrapper script,
|
||||
# Windows launchers, node/npm/npx symlinks. Windows notes: hermes_home is %VAR%-expanded so
|
||||
# marker matching runs against fully resolved paths (install.ps1 writes literal
|
||||
# C:\Users\<u>\AppData\Local\hermes\git\cmd, not %LOCALAPPDATA%); the managed binary dir
|
||||
# (hermes\bin: launchers + managed uv) leaves the PATH only when the full wipe below is
|
||||
# about to delete it, so keep-data mode keeps the still-working uv resolvable; the launchers
|
||||
# themselves always go — both modes delete the checkout, so a surviving launcher would
|
||||
# dangle and error, worse than command-not-found. Symlinks are removed only when they still
|
||||
# point into this Hermes home's node dir (never clobber nvm / user-managed Node).
|
||||
|
||||
# 2-3b. PATH entries (rc files, then the Windows User registry), wrapper, Windows launchers,
|
||||
# node symlinks. Windows: hermes_home is %VAR%-expanded because install.ps1 writes literal
|
||||
# C:\Users\<u>\AppData\Local\hermes\git\cmd; hermes\bin (launchers + managed uv) leaves the
|
||||
# PATH only when the full wipe below deletes it (keep-data keeps uv resolvable), while the
|
||||
# launchers themselves always go (see remove_windows_bin_launchers). Symlinks go only when
|
||||
# they still point into this home's node dir (never clobber nvm / user-managed Node).
|
||||
windows = _is_windows()
|
||||
sweep_managed_bin = windows and full_uninstall and _is_default_hermes_home(hermes_home)
|
||||
for on_this_platform, label, remove, success_fmt, none_msg in (
|
||||
@@ -790,11 +675,9 @@ def _perform_uninstall(
|
||||
if on_this_platform:
|
||||
_remove_step(label, remove, success_fmt, none_msg)
|
||||
|
||||
# 3c. Desktop Chat GUI artifacts (built renderer/release, node_modules, packaged app bundle,
|
||||
# Electron userData). Both CLI flows remove the agent code, so the GUI — just another
|
||||
# consumer of the same checkout — goes with it. uninstall_gui() never touches config /
|
||||
# sessions / .env, so it's safe in keep-data mode; the packaged app + Electron userData
|
||||
# live OUTSIDE HERMES_HOME, so the full-uninstall rmtree below would not reach them.
|
||||
# 3c. Desktop Chat GUI artifacts: both flows remove the agent code, so the GUI goes with it.
|
||||
# uninstall_gui() never touches config/sessions/.env (safe in keep-data mode); the packaged
|
||||
# app + Electron userData live OUTSIDE HERMES_HOME, so the rmtree below wouldn't reach them.
|
||||
log_info("Removing desktop Chat GUI artifacts...")
|
||||
try:
|
||||
from hermes_cli.gui_uninstall import uninstall_gui
|
||||
@@ -807,20 +690,18 @@ def _perform_uninstall(
|
||||
log_info("Removing installation directory...")
|
||||
_rmtree_step(project_root)
|
||||
|
||||
# 4b. Windows-only installer artifacts that are NOT user data (PortableGit, bundled Node,
|
||||
# gateway-service dir): install tooling under HERMES_HOME, safe to remove in keep-data mode.
|
||||
# 4b. Windows installer tooling under HERMES_HOME (PortableGit, Node, gateway-service) is not
|
||||
# user data: safe to remove in keep-data mode too.
|
||||
if windows:
|
||||
_remove_step(
|
||||
"Removing Windows installer artifacts (PortableGit, Node, gateway-service)...",
|
||||
lambda: remove_portable_tooling_windows(hermes_home), "Removed {}",
|
||||
"No Windows installer artifacts to remove",
|
||||
)
|
||||
|
||||
"No Windows installer artifacts to remove")
|
||||
|
||||
# 5. Optionally remove ~/.hermes/ data directory (and named profiles)
|
||||
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 are swept by the rmtree
|
||||
# below, but services + alias scripts live OUTSIDE the default root.
|
||||
# 5a. Named profiles' homes live under <default>/profiles/ (swept by the rmtree below),
|
||||
# but their services + alias scripts live OUTSIDE the default root.
|
||||
if remove_profiles:
|
||||
for prof in named_profiles:
|
||||
_uninstall_profile(prof)
|
||||
@@ -839,23 +720,27 @@ def _perform_uninstall(
|
||||
print(f" {hermes_home}/")
|
||||
print()
|
||||
print("To reinstall later with your existing settings:")
|
||||
if windows:
|
||||
print(color(" iex (irm https://hermes-agent.nousresearch.com/install.ps1)", Colors.DIM))
|
||||
else:
|
||||
print(color(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", Colors.DIM))
|
||||
print(color(_REINSTALL_HINT[windows], Colors.DIM))
|
||||
print()
|
||||
|
||||
if windows:
|
||||
print(color("Open a new terminal (PowerShell / Windows Terminal) to pick up", Colors.YELLOW))
|
||||
print(color("the updated User PATH and environment variables.", Colors.YELLOW))
|
||||
else:
|
||||
print(color("Reload your shell to complete the process:", Colors.YELLOW))
|
||||
print(" source ~/.bashrc # or ~/.zshrc")
|
||||
for line, col in _RELOAD_HINT[windows]:
|
||||
print(color(line, col) if col else line)
|
||||
print()
|
||||
print("Thank you for using Hermes Agent! ⚕")
|
||||
print()
|
||||
|
||||
|
||||
_REINSTALL_HINT = {
|
||||
True: " iex (irm https://hermes-agent.nousresearch.com/install.ps1)",
|
||||
False: " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"}
|
||||
# windows -> [(line, color or None)]
|
||||
_RELOAD_HINT = {
|
||||
True: [("Open a new terminal (PowerShell / Windows Terminal) to pick up", Colors.YELLOW),
|
||||
("the updated User PATH and environment variables.", Colors.YELLOW)],
|
||||
False: [("Reload your shell to complete the process:", Colors.YELLOW),
|
||||
(" source ~/.bashrc # or ~/.zshrc", None)]}
|
||||
|
||||
|
||||
class _UninstallArgs:
|
||||
"""Lightweight args namespace for the module entrypoint below."""
|
||||
|
||||
@@ -867,21 +752,14 @@ class _UninstallArgs:
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
"""Module entrypoint: ``python -m hermes_cli.uninstall --mode <gui|lite|full>``.
|
||||
|
||||
This module imports only stdlib + ``hermes_constants`` + ``hermes_cli.colors`` (and lazily
|
||||
``hermes_cli.gui_uninstall``), so it runs fine under a bare system Python with no site-packages
|
||||
from the venv.
|
||||
"""
|
||||
"""``python -m hermes_cli.uninstall --mode <gui|lite|full>``. Imports only stdlib +
|
||||
``hermes_constants`` + ``hermes_cli.colors`` (lazily ``gui_uninstall``), so it runs under a
|
||||
bare system Python without the venv."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(prog="python -m hermes_cli.uninstall")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["gui", "lite", "full"],
|
||||
required=True,
|
||||
help="gui = Chat GUI only; lite = GUI + agent, keep data; full = everything",
|
||||
)
|
||||
"--mode", choices=["gui", "lite", "full"], required=True,
|
||||
help="gui = Chat GUI only; lite = GUI + agent, keep data; full = everything")
|
||||
args = _UninstallArgs(mode=parser.parse_args(argv).mode)
|
||||
(run_gui_uninstall if args.gui else run_uninstall)(args)
|
||||
return 0
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Fresh-process recovery after the update's in-process restart phase aborts.
|
||||
|
||||
``hermes update`` performs its fleet restart in the interpreter that started before ``git pull``.
|
||||
|
||||
It is deliberately a separate owner from ``update_cmd``: the abort path has its own vocabulary
|
||||
(``verified`` / ``relaunch_attempted`` / ``failed``, serve units, survivors) and its own fail-closed
|
||||
contract, and the update monolith must not grow another authority surface (review on #96235).
|
||||
"""
|
||||
"""Fresh-process recovery after the update's in-process restart phase aborts (the fleet restart
|
||||
runs in the interpreter that started before ``git pull``). Deliberately a separate owner from
|
||||
``update_cmd``: its own vocabulary (``verified`` / ``relaunch_attempted`` / ``failed``, serve units,
|
||||
survivors) and its own fail-closed contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,17 +21,11 @@ def _serve_unit_recovery_available() -> bool:
|
||||
|
||||
|
||||
def _surviving_pre_update_serve_runtimes(plan) -> list[dict]:
|
||||
"""Pre-update serve/dashboard runtimes that are STILL the same process.
|
||||
|
||||
Identity is the process incarnation ``(pid, create_time)``, never the PID alone.
|
||||
``ledger_entries()`` re-verifies that pair on every read and prunes anything that is gone, so a
|
||||
live entry is a real process — but the numeric PID can be reused, and a serve that was restarted
|
||||
correctly can come back on the same number.
|
||||
|
||||
Anything still here after the recovery pass is a live runtime on the pre-update code generation,
|
||||
which is precisely the unsafe state #92145 reports. Fail closed on missing evidence: an
|
||||
unreadable ledger, or an incarnation neither side can produce, counts the runtime as surviving.
|
||||
"""
|
||||
"""Pre-update serve/dashboard runtimes that are STILL the same process — i.e. live on the
|
||||
pre-update code generation. Identity is the incarnation ``(pid, create_time)``, never the PID
|
||||
alone: ``ledger_entries()`` prunes dead entries, but a correctly restarted serve can come back
|
||||
on the same number. Fail closed on missing evidence (unreadable ledger, no incarnation on either
|
||||
side): the runtime counts as surviving."""
|
||||
planned: dict[int, dict] = {}
|
||||
try:
|
||||
for runtime in getattr(plan, "runtimes", ()) or ():
|
||||
@@ -59,12 +49,10 @@ def _surviving_pre_update_serve_runtimes(plan) -> list[dict]:
|
||||
return []
|
||||
try:
|
||||
from hermes_cli.process_identity import ledger_entries
|
||||
|
||||
live: dict[int, float | None] = {
|
||||
entry["pid"]: _numeric(entry.get("create_time"))
|
||||
for entry in ledger_entries()
|
||||
if entry.get("purpose") in ("serve", "dashboard") and isinstance(entry.get("pid"), int)
|
||||
}
|
||||
if entry.get("purpose") in ("serve", "dashboard") and isinstance(entry.get("pid"), int)}
|
||||
except Exception as exc:
|
||||
logger.debug("Serve/dashboard survivor probe failed: %s", exc)
|
||||
live = None
|
||||
@@ -77,8 +65,7 @@ def _surviving_pre_update_serve_runtimes(plan) -> list[dict]:
|
||||
if (
|
||||
planned_created is not None
|
||||
and live_created is not None
|
||||
and abs(float(live_created) - float(planned_created)) >= 2.0
|
||||
):
|
||||
and abs(float(live_created) - float(planned_created)) >= 2.0):
|
||||
# Same number, different process: the pre-update runtime is gone
|
||||
# and something new registered under its PID. Not a survivor.
|
||||
continue
|
||||
@@ -96,11 +83,8 @@ def _without_incarnation(row: dict) -> dict:
|
||||
|
||||
|
||||
def _qualified_serve_skips(skip_units) -> list[dict]:
|
||||
"""Scope-qualify the units the aborted phase already settled.
|
||||
|
||||
``restarted_scoped_units`` records ``<scope>/<unit>`` because ``hermes-serve.service`` can exist
|
||||
in BOTH the user and the system manager, and those are two different processes.
|
||||
"""
|
||||
"""Scope-qualify the units the aborted phase already settled: ``<scope>/<unit>`` because
|
||||
``hermes-serve.service`` can exist in BOTH the user and system manager (two processes)."""
|
||||
rows: list[dict] = []
|
||||
for entry in sorted(skip_units or ()):
|
||||
scope, sep, unit = str(entry).partition("/")
|
||||
@@ -116,17 +100,10 @@ def _recover_gateway_restart_after_abort(
|
||||
*,
|
||||
gateway_mode: bool,
|
||||
skip_profiles: set[str] | None = None,
|
||||
skip_units: set[str] | None = None,
|
||||
) -> dict[str, list]:
|
||||
"""Retry supervised gateway restarts from a clean Python process.
|
||||
|
||||
``hermes update`` normally performs the fleet restart in the interpreter that started before
|
||||
``git pull``.
|
||||
|
||||
Only profiles classified as supervisor-owned by the pre-update inventory are handed off.
|
||||
"""
|
||||
skip_units: set[str] | None = None) -> dict[str, list]:
|
||||
"""Retry supervised gateway restarts from a clean Python process (the in-process restart ran
|
||||
in the pre-``git pull`` interpreter). Only inventory-classified supervisor-owned profiles."""
|
||||
from hermes_cli.update_cmd import _gateway_recovery_partition
|
||||
|
||||
candidates, skipped = _gateway_recovery_partition(plan, skip_profiles=skip_profiles)
|
||||
profiles = sorted(candidates)
|
||||
recover_serve = _serve_unit_recovery_available()
|
||||
@@ -139,8 +116,7 @@ def _recover_gateway_restart_after_abort(
|
||||
"relaunch_attempted": relaunch_attempted,
|
||||
"failed": failed,
|
||||
"skipped": skipped,
|
||||
"serve_units": dict(_empty_serve) if serve_units is None else serve_units,
|
||||
}
|
||||
"serve_units": dict(_empty_serve) if serve_units is None else serve_units}
|
||||
|
||||
if not profiles and not recover_serve:
|
||||
return _result([], [], [], [])
|
||||
@@ -154,11 +130,9 @@ def _recover_gateway_restart_after_abort(
|
||||
for marker in ("_HERMES_GATEWAY", "HERMES_GATEWAY", "HERMES_GATEWAY_MODE"):
|
||||
env.pop(marker, None)
|
||||
|
||||
# A gateway-triggered update may run inside the gateway's systemd cgroup.
|
||||
# Put the recovery process in a transient user scope before it asks systemd
|
||||
# to restart that gateway, otherwise KillMode can terminate the recovery
|
||||
# process together with the old service. If systemd-run is unavailable,
|
||||
# fail closed rather than pretending the in-cgroup child is independent.
|
||||
# A gateway-triggered update may run inside the gateway's systemd cgroup: put the recovery in a
|
||||
# transient user scope or KillMode terminates it with the old service. No systemd-run -> fail
|
||||
# closed rather than pretend the in-cgroup child is independent.
|
||||
if gateway_mode and sys.platform == "linux":
|
||||
systemd_run = shutil.which("systemd-run")
|
||||
if not systemd_run:
|
||||
@@ -172,27 +146,20 @@ def _recover_gateway_restart_after_abort(
|
||||
"profiles": profiles,
|
||||
"supervisors": candidates,
|
||||
"serve_units": {
|
||||
"recover": recover_serve,
|
||||
"skip": _qualified_serve_skips(skip_units),
|
||||
},
|
||||
}
|
||||
),
|
||||
"recover": recover_serve, "skip": _qualified_serve_skips(skip_units)}}),
|
||||
"capture_output": True,
|
||||
"text": True,
|
||||
"encoding": "utf-8",
|
||||
"errors": "replace",
|
||||
"check": False,
|
||||
"env": env,
|
||||
# Gateway profiles run sequentially at up to 90s each; the serve pass
|
||||
# adds its own restart + settle budget on top, so give the child room
|
||||
# for both rather than killing a recovery that was working.
|
||||
"timeout": max(180, 30 + 90 * len(profiles) + (150 if recover_serve else 0)),
|
||||
}
|
||||
# Gateway profiles run sequentially at up to 90s each, plus the serve pass's own
|
||||
# restart + settle budget — don't kill a recovery that was working.
|
||||
"timeout": max(180, 30 + 90 * len(profiles) + (150 if recover_serve else 0))}
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = (
|
||||
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
)
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0))
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
|
||||
@@ -222,17 +189,11 @@ def _recover_gateway_restart_after_abort(
|
||||
and isinstance(raw_serve.get("verified"), list)
|
||||
and isinstance(raw_serve.get("failed"), list)
|
||||
and all(
|
||||
isinstance(unit, str)
|
||||
for unit in (*raw_serve["verified"], *raw_serve["failed"])
|
||||
)
|
||||
):
|
||||
isinstance(unit, str) for unit in (*raw_serve["verified"], *raw_serve["failed"]))):
|
||||
serve_units = {
|
||||
"verified": sorted(raw_serve["verified"]),
|
||||
"failed": sorted(raw_serve["failed"]),
|
||||
}
|
||||
"verified": sorted(raw_serve["verified"]), "failed": sorted(raw_serve["failed"])}
|
||||
elif recover_serve:
|
||||
# An unreadable serve block cannot be read as "nothing to do": the
|
||||
# units it describes are exactly the ones that host tui_gateway and
|
||||
# An unreadable serve block is not "nothing to do": those units host tui_gateway and
|
||||
# may still be serving the pre-update generation.
|
||||
logger.warning("Fresh recovery returned an invalid serve-unit result")
|
||||
serve_units = {"verified": [], "failed": ["<unreadable>"]}
|
||||
@@ -245,8 +206,7 @@ def _recover_gateway_restart_after_abort(
|
||||
not all(isinstance(bucket, list) for bucket in buckets)
|
||||
or any(not isinstance(profile, str) for profile in reported)
|
||||
or set(reported) != set(profiles)
|
||||
or len(reported) != len(set(reported))
|
||||
):
|
||||
or len(reported) != len(set(reported))):
|
||||
logger.warning("Fresh gateway restart recovery returned incomplete profiles")
|
||||
return _all_failed()
|
||||
|
||||
@@ -256,54 +216,39 @@ def _recover_gateway_restart_after_abort(
|
||||
(relaunch_attempted, " ⚠ Relaunch attempted in a fresh process but not"
|
||||
" supervisor-verified (check these gateways manually): "),
|
||||
(serve_units["verified"], " ✓ Restarted serve unit(s) in a fresh process (new main PID observed): "),
|
||||
(serve_units["failed"], " ⚠ Could not verify a replacement for serve unit(s): "),
|
||||
):
|
||||
(serve_units["failed"], " ⚠ Could not verify a replacement for serve unit(s): ")):
|
||||
if names:
|
||||
print(text + ", ".join(names))
|
||||
return _result(profiles, verified, relaunch_attempted, failed, serve_units)
|
||||
|
||||
|
||||
def _warn_stale_serve_runtimes(rows) -> None:
|
||||
"""Name the serve/dashboard processes that survived on pre-update code.
|
||||
|
||||
``hermes serve`` hosts ``tui_gateway.server``; if its unit was never restarted it keeps the
|
||||
pre-pull ``sys.modules`` graph, every chat turn fails with an ``ImportError`` for a symbol
|
||||
that imports fine on disk, and no gateway row reveals the culprit. Print the PIDs and the
|
||||
exact command that fixes it.
|
||||
"""
|
||||
"""Name the serve/dashboard processes still on pre-update code: ``hermes serve`` hosts
|
||||
``tui_gateway.server``, and an un-restarted unit keeps the pre-pull ``sys.modules`` graph so
|
||||
every chat turn fails with an ``ImportError`` no gateway row explains. Print PIDs + the fixing
|
||||
command."""
|
||||
if not rows:
|
||||
return
|
||||
print(
|
||||
" ⚠ These serve/dashboard processes still run pre-update code"
|
||||
" (they started before the checkout changed):"
|
||||
)
|
||||
" (they started before the checkout changed):")
|
||||
for row in rows:
|
||||
supervisor = row.get("supervisor") or "unknown"
|
||||
print(
|
||||
f" pid {row.get('pid')} — {row.get('kind')}"
|
||||
f" (profile {row.get('profile') or 'default'}, {supervisor})"
|
||||
)
|
||||
f" (profile {row.get('profile') or 'default'}, {supervisor})")
|
||||
print(
|
||||
" Restart them before using Hermes again, e.g."
|
||||
" `systemctl --user restart hermes-serve.service`"
|
||||
" or by relaunching `hermes serve` / the Desktop app."
|
||||
)
|
||||
" or by relaunching `hermes serve` / the Desktop app.")
|
||||
|
||||
|
||||
def _abort_recovery_is_complete(
|
||||
*,
|
||||
planned_gateway_profiles,
|
||||
covered_gateway_profiles,
|
||||
recovery_result,
|
||||
stale_runtime_rows,
|
||||
*, planned_gateway_profiles, covered_gateway_profiles, recovery_result, stale_runtime_rows
|
||||
) -> bool:
|
||||
"""May a fresh-process recovery clear the incomplete flag?
|
||||
|
||||
Only when EVERY inventoried runtime family is accounted for.
|
||||
|
||||
``planned_gateway_profiles`` being empty is deliberately NOT completeness: with no gateway leg
|
||||
to prove, the caller's own fail-closed contract (``_restart_phase_failure_is_incomplete``) plus
|
||||
the stale-runtime rows decide the outcome.
|
||||
"""May a fresh-process recovery clear the incomplete flag? Only when EVERY inventoried runtime
|
||||
family is accounted for. Empty ``planned_gateway_profiles`` is deliberately NOT completeness:
|
||||
with no gateway leg to prove, ``_restart_phase_failure_is_incomplete`` + the stale rows decide.
|
||||
"""
|
||||
if not planned_gateway_profiles:
|
||||
return False
|
||||
|
||||
+212
-501
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
"""Post-``hermes update`` config-schema migration for the active profile and every sibling.
|
||||
|
||||
Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.<name>``
|
||||
still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle; patches hold).
|
||||
"""
|
||||
Names are re-imported by ``update_cmd`` (``hermes_cli.update_cmd.<name>`` resolves/monkeypatches);
|
||||
origin helpers are imported lazily."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
@@ -15,24 +13,18 @@ logger = logging.getLogger("hermes_cli.update_cmd")
|
||||
|
||||
|
||||
def _reload_config_modules() -> None:
|
||||
"""Force-reload modules from disk after git pull.
|
||||
|
||||
``hermes update`` runs in the PRE-pull process, so cached ``config_defaults`` /
|
||||
``config`` / ``config_migrations`` hold OLD code and ``check_config_version()``
|
||||
reports "up to date" despite a newer pulled version with a migration to run.
|
||||
Also reloads ``_subprocess_compat`` / ``dashboard_procs`` so the later dashboard
|
||||
cleanup sees symbols the update added instead of dying with ImportError.
|
||||
"""
|
||||
"""Force-reload config modules after git pull: the updater is the PRE-pull process, so the
|
||||
cached modules hold OLD code and ``check_config_version()`` would report "up to date" despite a
|
||||
pulled migration. ``_subprocess_compat`` / ``dashboard_procs`` reload too so the later dashboard
|
||||
cleanup sees symbols the update added."""
|
||||
import importlib
|
||||
|
||||
importlib.invalidate_caches()
|
||||
for mod_name in (
|
||||
"hermes_cli.config_defaults",
|
||||
"hermes_cli.config",
|
||||
"hermes_cli.config_migrations",
|
||||
"hermes_cli._subprocess_compat",
|
||||
"hermes_cli.dashboard_procs",
|
||||
):
|
||||
"hermes_cli.dashboard_procs"):
|
||||
mod = sys.modules.get(mod_name)
|
||||
if mod is not None:
|
||||
try:
|
||||
@@ -42,44 +34,36 @@ def _reload_config_modules() -> None:
|
||||
|
||||
|
||||
def _run_config_check_fresh() -> tuple:
|
||||
"""Return ``(current_ver, latest_ver)`` using freshly-reloaded modules (see ``_reload_config_modules``)."""
|
||||
"""Return ``(current_ver, latest_ver)`` using freshly-reloaded modules (see
|
||||
``_reload_config_modules``)."""
|
||||
from hermes_cli.update_cmd import _reload_config_modules
|
||||
_reload_config_modules()
|
||||
from hermes_cli.config import check_config_version
|
||||
|
||||
return check_config_version()
|
||||
|
||||
|
||||
def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) -> dict:
|
||||
"""Run config migration using freshly-reloaded modules (see ``_reload_config_modules``); returns results dict."""
|
||||
"""Run config migration using freshly-reloaded modules (see ``_reload_config_modules``); returns
|
||||
results dict."""
|
||||
from hermes_cli.update_cmd import _reload_config_modules
|
||||
_reload_config_modules()
|
||||
from hermes_cli.config import migrate_config
|
||||
|
||||
return migrate_config(interactive=interactive, quiet=quiet)
|
||||
|
||||
|
||||
def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]:
|
||||
"""Migrate every SIBLING profile's config.yaml (the shared checkout serves all profiles;
|
||||
migrating only the active one left siblings on configs the new code couldn't read).
|
||||
|
||||
Per sibling home (active one skipped — caller already did it): scope reads/writes via the
|
||||
context-local HERMES_HOME override (thread-safe — never ``os.environ``) and run the
|
||||
NON-INTERACTIVE quiet migration; prompt-requiring settings wait for that profile's own
|
||||
interactive session. Returns ``[(profile_name, from_version, to_version), ...]`` for
|
||||
profiles actually migrated. Never raises; a failing profile is skipped (its startup
|
||||
migration remains the fallback).
|
||||
"""
|
||||
siblings were left on configs the new code couldn't read). Per sibling (active skipped): scope
|
||||
via the context-local HERMES_HOME override (never ``os.environ``) and run the NON-INTERACTIVE
|
||||
quiet migration — prompt-requiring settings wait for that profile's own session. Returns
|
||||
``[(name, from_version, to_version), ...]``; never raises (a failing profile falls back to its
|
||||
startup migration)."""
|
||||
from hermes_cli.update_cmd import _run_config_check_fresh, _run_migrate_config_fresh
|
||||
migrated: list[tuple[str, int, int]] = []
|
||||
with _best_effort('Sibling profile enumeration failed: %s'):
|
||||
from hermes_constants import (
|
||||
get_process_hermes_home,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override)
|
||||
from hermes_cli.profiles import _get_profiles_root, _PROFILE_ID_RE
|
||||
|
||||
active_home = get_process_hermes_home()
|
||||
root = _get_profiles_root()
|
||||
if not root.is_dir():
|
||||
@@ -117,15 +101,13 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None:
|
||||
# restore from the pre-update snapshot if jobs went missing.
|
||||
try:
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
|
||||
cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id)
|
||||
if cron_restore:
|
||||
print()
|
||||
print(
|
||||
" ⚠️ cron/jobs.json lost jobs during this update — "
|
||||
f"restored {cron_restore['job_count']} job(s) from "
|
||||
f"pre-update snapshot {cron_restore['snapshot_id']}."
|
||||
)
|
||||
f"pre-update snapshot {cron_restore['snapshot_id']}.")
|
||||
except Exception as exc:
|
||||
# Never let the cron safety net break an otherwise-good update.
|
||||
logger.debug("Cron jobs auto-restore check failed: %s", exc)
|
||||
@@ -134,15 +116,13 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None:
|
||||
# moa:; restore only those protected keys from the same pre-update snapshot.
|
||||
try:
|
||||
from hermes_cli.backup import restore_config_model_settings_if_rewritten
|
||||
|
||||
cfg_restore = restore_config_model_settings_if_rewritten(pre_update_snapshot_id)
|
||||
if cfg_restore:
|
||||
print()
|
||||
print(
|
||||
" ⚠️ config.yaml user model settings were rewritten during "
|
||||
f"this update — restored {', '.join(cfg_restore['keys'])} "
|
||||
f"from pre-update snapshot {cfg_restore['snapshot_id']}."
|
||||
)
|
||||
f"from pre-update snapshot {cfg_restore['snapshot_id']}.")
|
||||
except Exception as exc:
|
||||
# Never let the config safety net break an otherwise-good update.
|
||||
logger.debug("Config model-settings auto-restore check failed: %s", exc)
|
||||
@@ -150,53 +130,42 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None:
|
||||
# Same cron-jobs safety net per sibling profile against ITS OWN pre-update snapshot.
|
||||
with _best_effort('Sibling cron auto-restore check failed: %s'):
|
||||
from hermes_cli.backup import restore_cron_jobs_all_profiles
|
||||
|
||||
for _restored in restore_cron_jobs_all_profiles(
|
||||
_LAST_SIBLING_SNAPSHOTS
|
||||
):
|
||||
_LAST_SIBLING_SNAPSHOTS):
|
||||
print()
|
||||
print(
|
||||
f" ⚠️ Profile '{_restored['profile']}': cron/jobs.json "
|
||||
f"lost jobs during this update — restored "
|
||||
f"{_restored['job_count']} job(s) from pre-update "
|
||||
f"snapshot {_restored['snapshot_id']}."
|
||||
)
|
||||
f"snapshot {_restored['snapshot_id']}.")
|
||||
|
||||
# Same config model-settings safety net for sibling profiles.
|
||||
with _best_effort('Sibling config auto-restore check failed: %s'):
|
||||
from hermes_cli.backup import restore_config_model_settings_all_profiles
|
||||
|
||||
for _cfg_restored in restore_config_model_settings_all_profiles(
|
||||
_LAST_SIBLING_SNAPSHOTS
|
||||
):
|
||||
_LAST_SIBLING_SNAPSHOTS):
|
||||
print()
|
||||
print(
|
||||
f" ⚠️ Profile '{_cfg_restored['profile']}': config.yaml "
|
||||
f"user model settings were rewritten during this update — "
|
||||
f"restored {', '.join(_cfg_restored['keys'])} from "
|
||||
f"pre-update snapshot {_cfg_restored['snapshot_id']}."
|
||||
)
|
||||
f"pre-update snapshot {_cfg_restored['snapshot_id']}.")
|
||||
|
||||
|
||||
def _check_and_apply_config_migration(
|
||||
*,
|
||||
assume_yes: bool = False,
|
||||
gateway_mode: bool = False,
|
||||
pre_update_snapshot_id: str | None = None,
|
||||
) -> None:
|
||||
"""Check/apply config migrations on an update completion path.
|
||||
|
||||
Must use freshly-reloaded modules (see ``_reload_config_modules``) and run on EVERY
|
||||
completion path (post-pull, venv-repair retry, Node-deps repair on ``commit_count == 0``)
|
||||
so an interrupted update that already pulled code doesn't strand an old config version.
|
||||
"""
|
||||
pre_update_snapshot_id: str | None = None) -> None:
|
||||
"""Check/apply config migrations with freshly-reloaded modules. Runs on EVERY completion path
|
||||
(post-pull, venv-repair, Node-deps repair on ``commit_count == 0``) so an interrupted update
|
||||
that already pulled code doesn't strand an old config version."""
|
||||
from hermes_cli.update_cmd import (
|
||||
_gateway_prompt,
|
||||
_migrate_sibling_profile_configs,
|
||||
_reload_config_modules,
|
||||
_run_config_check_fresh,
|
||||
_run_migrate_config_fresh,
|
||||
)
|
||||
_run_migrate_config_fresh)
|
||||
print()
|
||||
print("→ Checking configuration for new options...")
|
||||
|
||||
@@ -204,9 +173,7 @@ def _check_and_apply_config_migration(
|
||||
_reload_config_modules()
|
||||
|
||||
from hermes_cli.config import (
|
||||
get_missing_env_vars,
|
||||
get_missing_config_fields,
|
||||
)
|
||||
get_missing_env_vars, get_missing_config_fields)
|
||||
|
||||
# A config-check failure must not break an otherwise-successful update.
|
||||
try:
|
||||
@@ -257,29 +224,22 @@ def _check_and_apply_config_migration(
|
||||
elif gateway_mode:
|
||||
response = (
|
||||
_gateway_prompt(
|
||||
"Would you like to configure new options now? [Y/n]", "n"
|
||||
)
|
||||
"Would you like to configure new options now? [Y/n]", "n")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
.lower())
|
||||
elif not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
print(" ℹ Non-interactive session — applying safe config migrations.")
|
||||
response = "auto"
|
||||
else:
|
||||
try:
|
||||
response = (
|
||||
input("Would you like to configure them now? [Y/n]: ")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
response = (input("Would you like to configure them now? [Y/n]: ").strip().lower())
|
||||
except EOFError:
|
||||
response = "n"
|
||||
except UnicodeDecodeError:
|
||||
# Non-UTF-8 locales / embedded terminals can make input() raise this.
|
||||
print(
|
||||
" ⚠ Could not read input (encoding issue). Skipping. "
|
||||
"Run 'hermes config migrate' manually to configure."
|
||||
)
|
||||
"Run 'hermes config migrate' manually to configure.")
|
||||
response = "n"
|
||||
|
||||
if response in {"", "y", "yes", "auto"}:
|
||||
|
||||
+154
-299
@@ -1,8 +1,6 @@
|
||||
"""Post-``hermes update`` dependency sync: venv preflight, editable reinstall, lazy refresh, npm/Desktop rebuilds, self-lock deferral.
|
||||
|
||||
Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.<name>``
|
||||
still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle; patches hold).
|
||||
"""
|
||||
"""Post-``hermes update`` dependency sync: venv preflight, editable reinstall, lazy refresh,
|
||||
npm/Desktop rebuilds, self-lock deferral. Names are re-imported by ``update_cmd`` (so
|
||||
``hermes_cli.update_cmd.<name>`` resolves/monkeypatches); origin helpers are imported lazily."""
|
||||
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
@@ -19,7 +17,6 @@ from hermes_constants import venv_python_path
|
||||
# Log-record parity with the origin module.
|
||||
logger = logging.getLogger("hermes_cli.update_cmd")
|
||||
|
||||
|
||||
# Files defining the editable install; a pull touching none of them cannot invalidate it.
|
||||
_INSTALL_DEFINING_FILES = "pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.in", "uv.lock"
|
||||
|
||||
@@ -27,11 +24,10 @@ _INSTALL_DEFINING_FILES = "pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.i
|
||||
def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool:
|
||||
"""True when the pulled commits cannot have invalidated the editable install.
|
||||
|
||||
``uv pip install -e .`` always rewrites console-script shims; on Windows that rewrite is
|
||||
why ``hermes.exe`` must be quarantined (lost race = ``os error 32``), so skip it when it
|
||||
provably changes nothing. Safe because the editable finder uses a *static* module list:
|
||||
only a new top-level module/package (needing a ``pyproject.toml`` diff, like deps and
|
||||
scripts) can stale it. Fails closed: unresolvable pre-pull SHA or failed diff -> False.
|
||||
``uv pip install -e .`` always rewrites console-script shims (on Windows that is why
|
||||
``hermes.exe`` must be quarantined; lost race = ``os error 32``), so skip it when it provably
|
||||
changes nothing. Safe because the editable finder uses a *static* module list: only a
|
||||
``pyproject.toml`` diff can stale it. Fails closed: no pre-pull SHA or failed diff -> False.
|
||||
"""
|
||||
if not pre_pull_sha:
|
||||
return False
|
||||
@@ -42,13 +38,10 @@ def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool
|
||||
+ list(_INSTALL_DEFINING_FILES),
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return not result.stdout.strip()
|
||||
return result.returncode == 0 and not result.stdout.strip()
|
||||
|
||||
|
||||
# Modules imported on every startup. Unlike _UPDATE_CRITICAL_FILES (only parsed) these are
|
||||
@@ -57,22 +50,19 @@ _UPDATE_CRITICAL_MODULES = "hermes_cli.main", "run_agent", "model_tools", "tools
|
||||
|
||||
|
||||
def _critical_module_import_failures(
|
||||
root, *, report_runtime_errors: bool = False
|
||||
) -> dict[str, tuple[str, str]]:
|
||||
root, *, report_runtime_errors: bool = False) -> dict[str, tuple[str, str]]:
|
||||
"""Import each ``_UPDATE_CRITICAL_MODULES`` entry in a subprocess; return failures in probe order.
|
||||
|
||||
Syntax validation only *parses*, so a partially-updated tree (reachable via the Windows
|
||||
ZIP copy loop) parses yet dies with ``ImportError: cannot import name``. A subprocess
|
||||
keeps the half-updated tree's import side effects out of the updater's ``sys.modules``;
|
||||
uses the venv interpreter when present since ``hermes update`` may run under another Python.
|
||||
Generic import-time exceptions are tolerated by default (may depend on local config);
|
||||
``report_runtime_errors=True`` exposes them so two checkout states can be compared.
|
||||
Syntax validation only *parses*: a partially-updated tree (Windows ZIP copy loop) parses yet
|
||||
dies with ``ImportError: cannot import name``. The subprocess (venv interpreter when present —
|
||||
the updater may run under another Python) keeps its import side effects out of our
|
||||
``sys.modules``. Generic import-time exceptions are tolerated (may depend on local config)
|
||||
unless ``report_runtime_errors=True``.
|
||||
"""
|
||||
from hermes_cli.update_cmd import _UPDATE_CRITICAL_MODULES, _m
|
||||
from hermes_constants import FIRST_PARTY_MODULE_ROOTS
|
||||
|
||||
import secrets
|
||||
|
||||
marker = f"__HERMES_IMPORT_HEALTH_{secrets.token_hex(16)}__"
|
||||
probe = (
|
||||
"import importlib, json, sys\n"
|
||||
@@ -99,9 +89,7 @@ def _critical_module_import_failures(
|
||||
tuple(sorted(FIRST_PARTY_MODULE_ROOTS)),
|
||||
report_runtime_errors,
|
||||
report_runtime_errors,
|
||||
marker,
|
||||
)
|
||||
)
|
||||
marker))
|
||||
try:
|
||||
interpreter = sys.executable
|
||||
with suppress(Exception):
|
||||
@@ -109,57 +97,38 @@ def _critical_module_import_failures(
|
||||
if venv_python.exists():
|
||||
interpreter = str(venv_python)
|
||||
result = subprocess.run(
|
||||
[interpreter, "-c", probe],
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
)
|
||||
[interpreter, "-c", probe], cwd=str(root), capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"critical-module probe": (
|
||||
"TimeoutExpired",
|
||||
"timed out before reporting import health",
|
||||
)
|
||||
}
|
||||
return _probe_failure("TimeoutExpired", "timed out before reporting import health")
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
# Can't run the probe — don't block the update on our own tooling.
|
||||
return {}
|
||||
output = result.stdout or ""
|
||||
if marker not in output:
|
||||
return {
|
||||
"critical-module probe": (
|
||||
"ProbeTerminated",
|
||||
"terminated before reporting import health "
|
||||
f"(exit code {result.returncode})",
|
||||
)
|
||||
}
|
||||
return _probe_failure(
|
||||
"ProbeTerminated",
|
||||
f"terminated before reporting import health (exit code {result.returncode})")
|
||||
try:
|
||||
import json
|
||||
|
||||
failures = json.loads(output.rsplit(marker, 1)[1])
|
||||
if not isinstance(failures, list) or any(
|
||||
not isinstance(item, list)
|
||||
or len(item) != 3
|
||||
or not all(isinstance(value, str) for value in item)
|
||||
for item in failures
|
||||
):
|
||||
for item in failures):
|
||||
raise ValueError("invalid import-health payload")
|
||||
return {str(module): (str(kind), str(detail)) for module, kind, detail in failures}
|
||||
except (TypeError, ValueError):
|
||||
return {
|
||||
"critical-module probe": (
|
||||
"MalformedPayload",
|
||||
"reported malformed import health data",
|
||||
)
|
||||
}
|
||||
return _probe_failure("MalformedPayload", "reported malformed import health data")
|
||||
|
||||
|
||||
def _probe_failure(kind: str, detail: str) -> dict[str, tuple[str, str]]:
|
||||
"""Failure row for the probe itself (as opposed to a module it imported)."""
|
||||
return {"critical-module probe": (kind, detail)}
|
||||
|
||||
|
||||
def _validate_critical_modules_import(
|
||||
root, *, report_runtime_errors: bool = False
|
||||
) -> tuple[bool, str | None, str | None]:
|
||||
root, *, report_runtime_errors: bool = False) -> tuple[bool, str | None, str | None]:
|
||||
"""Return the first critical-module import failure, if any."""
|
||||
failures = _critical_module_import_failures(root, report_runtime_errors=report_runtime_errors)
|
||||
if failures:
|
||||
@@ -172,8 +141,7 @@ def _npm_bin_exists(bin_dir: Path, name: str) -> bool:
|
||||
"""True when an npm bin shim for *name* exists (POSIX or Windows)."""
|
||||
return any(
|
||||
(bin_dir / candidate).exists()
|
||||
for candidate in (name, f"{name}.cmd", f"{name}.ps1", f"{name}.exe")
|
||||
)
|
||||
for candidate in (name, f"{name}.cmd", f"{name}.ps1", f"{name}.exe"))
|
||||
|
||||
|
||||
def _web_build_toolchain_ready(*roots: Path) -> bool:
|
||||
@@ -182,12 +150,9 @@ def _web_build_toolchain_ready(*roots: Path) -> bool:
|
||||
bin_dirs = [
|
||||
bin_dir
|
||||
for bin_dir in (root / "node_modules" / ".bin" for root in roots)
|
||||
if bin_dir.is_dir()
|
||||
]
|
||||
if bin_dir.is_dir()]
|
||||
return bool(bin_dirs) and all(
|
||||
any(_npm_bin_exists(bin_dir, tool) for bin_dir in bin_dirs)
|
||||
for tool in ("tsc", "vite")
|
||||
)
|
||||
any(_npm_bin_exists(bin_dir, tool) for bin_dir in bin_dirs) for tool in ("tsc", "vite"))
|
||||
|
||||
|
||||
def _web_toolchain_roots(web_dir: Path) -> tuple[Path, ...]:
|
||||
@@ -202,32 +167,22 @@ def _ensure_venv_pip(pip_cmd: list, python_exe: str) -> None:
|
||||
from hermes_cli.update_cmd import _m
|
||||
try:
|
||||
subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
pip_cmd + ["--version"], cwd=_m().PROJECT_ROOT, check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError:
|
||||
subprocess.run(
|
||||
[python_exe, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
check=True)
|
||||
|
||||
|
||||
def _upgrade_pip_before_lazy_refresh(
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
|
||||
"""Upgrade pip before lazy refreshes: older pip can fail setuptools source builds and
|
||||
leave a partially-written venv. Never raises."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
try:
|
||||
_m()._run_package_only_install(
|
||||
install_cmd_prefix + ["install", "--upgrade", "pip"],
|
||||
env=env,
|
||||
)
|
||||
install_cmd_prefix + ["install", "--upgrade", "pip"], env=env)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.debug("pip upgrade before lazy refresh failed: %s", exc)
|
||||
|
||||
@@ -236,7 +191,6 @@ def _capture_active_lazy_features() -> list[str]:
|
||||
"""Snapshot active lazy backends before a managed runtime is replaced."""
|
||||
try:
|
||||
from tools import lazy_deps
|
||||
|
||||
return lazy_deps.active_features()
|
||||
except Exception as exc:
|
||||
logger.debug("Could not snapshot active lazy features: %s", exc)
|
||||
@@ -247,7 +201,6 @@ def _capture_active_tool_dependencies() -> list[str]:
|
||||
"""Snapshot Python dependencies installed explicitly through ``hermes tools``."""
|
||||
try:
|
||||
from hermes_cli import tools_config
|
||||
|
||||
return tools_config.active_restorable_python_tool_dependencies()
|
||||
except Exception as exc:
|
||||
logger.debug("Could not snapshot active Hermes Tools dependencies: %s", exc)
|
||||
@@ -255,13 +208,10 @@ def _capture_active_tool_dependencies() -> list[str]:
|
||||
|
||||
|
||||
def _restore_active_tool_dependencies(
|
||||
dependencies: list[str],
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
dependencies: list[str], install_cmd_prefix: list[str], *, env: dict[str, str] | None = None
|
||||
) -> None:
|
||||
"""Restore allowlisted ``hermes tools`` dependencies (from a pre-rebuild probe) into a rebuilt venv.
|
||||
Never raises: a failed optional tool must not block the update, but must be reported."""
|
||||
"""Restore allowlisted ``hermes tools`` dependencies (from a pre-rebuild probe) into a rebuilt
|
||||
venv. Never raises: a failed optional tool must not block the update, but must be reported."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
if not dependencies:
|
||||
return
|
||||
@@ -287,12 +237,10 @@ def _restore_active_tool_dependencies(
|
||||
"-c",
|
||||
"import importlib.util,sys; "
|
||||
"raise SystemExit(0 if importlib.util.find_spec(sys.argv[1]) else 1)",
|
||||
module_name,
|
||||
],
|
||||
module_name],
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
check=False)
|
||||
if probe.returncode == 0:
|
||||
continue
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
@@ -310,9 +258,7 @@ def _restore_active_tool_dependencies(
|
||||
for name, install_args in missing:
|
||||
try:
|
||||
_m()._run_package_only_install(
|
||||
install_cmd_prefix + ["install", *install_args, "--quiet"],
|
||||
env=env,
|
||||
)
|
||||
install_cmd_prefix + ["install", *install_args, "--quiet"], env=env)
|
||||
restored.append(name)
|
||||
except Exception as exc:
|
||||
# Best-effort: surface failures without aborting the update.
|
||||
@@ -321,24 +267,23 @@ def _restore_active_tool_dependencies(
|
||||
if restored:
|
||||
print(f" ✓ {len(restored)} restored: {', '.join(restored)}")
|
||||
for name, reason in failed:
|
||||
if len(reason) > 200:
|
||||
reason = reason[:200] + "..."
|
||||
print(f" ⚠ {name} failed to restore: {reason}")
|
||||
print(f" ⚠ {name} failed to restore: {_clip(reason)}")
|
||||
|
||||
|
||||
def _clip(reason: str, limit: int = 200) -> str:
|
||||
"""Bound a failure reason for the one-line report."""
|
||||
return reason if len(reason) <= limit else reason[:limit] + "..."
|
||||
|
||||
|
||||
def _refresh_active_lazy_features(
|
||||
install_cmd_prefix: list[str] | None = None,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
features: list[str] | None = None,
|
||||
) -> bool:
|
||||
"""Refresh previously-activated lazy backends (cold ones untouched) after a code update.
|
||||
|
||||
The core install never touches ``lazy_deps`` backends, so a bumped :data:`LAZY_DEPS` pin
|
||||
would otherwise leave them stale forever. Returns True when the venv is safe to use
|
||||
(refreshed, nothing active, or import repair succeeded); False when a failed lazy install
|
||||
left broken core imports repair couldn't fix. Never raises.
|
||||
"""
|
||||
features: list[str] | None = None) -> bool:
|
||||
"""Refresh previously-activated lazy backends (cold ones untouched): the core install never
|
||||
touches them, so a bumped :data:`LAZY_DEPS` pin would leave them stale forever. Returns True
|
||||
when the venv is safe (refreshed / nothing active / import repair succeeded), False when a
|
||||
failed lazy install left broken core imports repair couldn't fix. Never raises."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
try:
|
||||
from tools import lazy_deps
|
||||
@@ -346,15 +291,13 @@ def _refresh_active_lazy_features(
|
||||
logger.debug("Lazy refresh skipped (import failed): %s", exc)
|
||||
return True
|
||||
|
||||
if features is None:
|
||||
active = features
|
||||
if active is None:
|
||||
try:
|
||||
active = lazy_deps.active_features()
|
||||
except Exception as exc:
|
||||
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
|
||||
return True
|
||||
else:
|
||||
active = features
|
||||
|
||||
if not active:
|
||||
return True
|
||||
|
||||
@@ -392,10 +335,7 @@ def _refresh_active_lazy_features(
|
||||
return True
|
||||
|
||||
for feature, status in failed:
|
||||
reason = status.split(": ", 1)[-1]
|
||||
if len(reason) > 200:
|
||||
reason = reason[:200] + "..."
|
||||
print(f" ⚠ {feature} failed to refresh: {reason}")
|
||||
print(f" ⚠ {feature} failed to refresh: {_clip(status.split(': ', 1)[-1])}")
|
||||
|
||||
if install_cmd_prefix is None:
|
||||
print(" ⚠ Lazy refresh failed; rerun `hermes update` once resolved.")
|
||||
@@ -417,15 +357,11 @@ def _refresh_active_lazy_features(
|
||||
|
||||
|
||||
def _refresh_active_memory_provider_dependencies() -> None:
|
||||
"""Refresh pip deps for the configured external memory provider.
|
||||
|
||||
Bridge packages live in each provider's ``plugin.yaml``, not Hermes extras or ``LAZY_DEPS``,
|
||||
so the core reinstall can strip/downgrade them; re-run the ACTIVE provider's install after
|
||||
core install and lazy refresh so its writes land last. Never raises.
|
||||
"""
|
||||
"""Refresh pip deps for the configured external memory provider: its bridge packages live in
|
||||
``plugin.yaml`` (not Hermes extras / ``LAZY_DEPS``), so the core reinstall can strip them;
|
||||
re-run the ACTIVE provider's install last so its writes land last. Never raises."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc:
|
||||
logger.debug("Memory provider refresh skipped (config load failed): %s", exc)
|
||||
@@ -464,10 +400,7 @@ def _is_android_python() -> bool:
|
||||
|
||||
|
||||
def _install_psutil_android_compat(
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
|
||||
"""Install psutil on Android by patching its platform detection: setup gates Linux sources on
|
||||
``sys.platform.startswith('linux')`` but Termux reports ``'android'`` though the Linux path
|
||||
compiles fine. Only this attempt's build tree is patched. Stopgap until psutil PR 2762 ships."""
|
||||
@@ -475,7 +408,6 @@ def _install_psutil_android_compat(
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from hermes_cli.psutil_android import PSUTIL_URL, prepare_patched_psutil_sdist
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
archive = tmp_path / "psutil.tar.gz"
|
||||
@@ -483,9 +415,7 @@ def _install_psutil_android_compat(
|
||||
src_root = prepare_patched_psutil_sdist(archive, tmp_path)
|
||||
|
||||
_m()._run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)],
|
||||
env=env,
|
||||
)
|
||||
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)], env=env)
|
||||
|
||||
|
||||
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
@@ -493,7 +423,6 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
PATH uv; else wheel-only ``pip install uv`` so the Rust crate is never source-built."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
from hermes_cli.managed_uv import resolve_uv
|
||||
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return existing
|
||||
@@ -509,22 +438,17 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
result = subprocess.run(
|
||||
pip_cmd + ["install", "uv", "--only-binary", ":all:"],
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
check=False,
|
||||
)
|
||||
check=False)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return resolve_uv() or shutil.which("uv")
|
||||
|
||||
|
||||
def _npm_manifest_paths() -> tuple[Path, ...]:
|
||||
"""Manifests whose changes must defeat the update-skip.
|
||||
|
||||
The lockfile alone isn't enough: a package.json can be edited without running npm, and
|
||||
`hermes update` is the step expected to sync node_modules. Workspaces come from the root
|
||||
`workspaces` globs so a new one can't escape the key; every workspace manifest counts
|
||||
(desktop too) because the single lockfile spans the whole graph. Falls back to root
|
||||
manifests only if package.json is unreadable.
|
||||
"""
|
||||
"""Manifests whose changes must defeat the update-skip. The lockfile alone isn't enough (a
|
||||
package.json can be edited without running npm); workspaces come from the root ``workspaces``
|
||||
globs so a new one can't escape the key, and every workspace counts (desktop too) because the
|
||||
single lockfile spans the whole graph. Root manifests only if package.json is unreadable."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
root_pkg = _m().PROJECT_ROOT / "package.json"
|
||||
paths = [_m().PROJECT_ROOT / "package-lock.json", root_pkg]
|
||||
@@ -541,7 +465,8 @@ def _npm_manifest_paths() -> tuple[Path, ...]:
|
||||
|
||||
|
||||
def _npm_manifests_digest() -> str | None:
|
||||
"""Combined sha256 over lockfile + all workspace package.json; None when lockfile missing (never skip)."""
|
||||
"""Combined sha256 over lockfile + all workspace package.json; None when lockfile missing (never
|
||||
skip)."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
if not (_m().PROJECT_ROOT / "package-lock.json").exists():
|
||||
return None
|
||||
@@ -566,13 +491,10 @@ def _npm_lockfile_changed(hermes_root: Path) -> bool:
|
||||
# Never skip when the web toolchain never landed, or later updates build on a half-installed tree.
|
||||
web_dir = _m().PROJECT_ROOT / "web"
|
||||
if (web_dir / "package.json").is_file() and not _web_build_toolchain_ready(
|
||||
*_web_toolchain_roots(web_dir)
|
||||
):
|
||||
*_web_toolchain_roots(web_dir)):
|
||||
return True
|
||||
try:
|
||||
# Key the cache by PROJECT_ROOT so parallel worktrees don't collide.
|
||||
cache_key = hashlib.sha256(str(_m().PROJECT_ROOT).encode()).hexdigest()[:12]
|
||||
cache_file = hermes_root / f".npm_lock_hash_{cache_key}"
|
||||
cache_file = _npm_lock_cache_file(hermes_root)
|
||||
if not cache_file.exists():
|
||||
return True
|
||||
return cache_file.read_text(encoding="utf-8").strip() != current
|
||||
@@ -580,15 +502,19 @@ def _npm_lockfile_changed(hermes_root: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _record_npm_lockfile_hash(hermes_root: Path) -> None:
|
||||
def _npm_lock_cache_file(hermes_root: Path) -> Path:
|
||||
"""Per-checkout cache path: keyed by PROJECT_ROOT so parallel worktrees don't collide."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
cache_key = hashlib.sha256(str(_m().PROJECT_ROOT).encode()).hexdigest()[:12]
|
||||
return hermes_root / f".npm_lock_hash_{cache_key}"
|
||||
|
||||
|
||||
def _record_npm_lockfile_hash(hermes_root: Path) -> None:
|
||||
digest = _npm_manifests_digest()
|
||||
if digest is None:
|
||||
return
|
||||
try:
|
||||
cache_key = hashlib.sha256(str(_m().PROJECT_ROOT).encode()).hexdigest()[:12]
|
||||
cache_file = hermes_root / f".npm_lock_hash_{cache_key}"
|
||||
cache_file.write_text(digest, encoding="utf-8")
|
||||
_npm_lock_cache_file(hermes_root).write_text(digest, encoding="utf-8")
|
||||
except OSError:
|
||||
logger.debug("Could not write npm lockfile hash cache")
|
||||
|
||||
@@ -600,21 +526,15 @@ def _repair_node_deps_on_current_checkout(
|
||||
gateway_mode: bool = False,
|
||||
pre_update_snapshot_id: str | None = None,
|
||||
completion_message: str = "✓ Already up to date!",
|
||||
had_desktop_app_before_update: bool = False,
|
||||
) -> bool:
|
||||
"""Repair Node deps on the ``commit_count == 0`` path.
|
||||
|
||||
A current checkout doesn't imply healthy Node deps (a failed npm install says "re-run
|
||||
hermes update" but the early return skipped the refresh). ``_update_node_dependencies``
|
||||
self-gates on the hash recorded only after a SUCCESSFUL install, so this is a cheap
|
||||
no-op when healthy and a real repair otherwise.
|
||||
"""
|
||||
had_desktop_app_before_update: bool = False) -> bool:
|
||||
"""Repair Node deps on the ``commit_count == 0`` path: a failed npm install says "re-run hermes
|
||||
update" but the early return used to skip the refresh. ``_update_node_dependencies`` self-gates
|
||||
on the hash recorded only after a SUCCESSFUL install, so this is a cheap no-op when healthy."""
|
||||
from hermes_cli.update_cmd import (
|
||||
_check_and_apply_config_migration,
|
||||
_m,
|
||||
_rebuild_desktop_after_update,
|
||||
_update_node_dependencies,
|
||||
)
|
||||
_update_node_dependencies)
|
||||
node_failures = _update_node_dependencies()
|
||||
if node_failures:
|
||||
print(f" ⚠ Node.js refresh failed for: {', '.join(node_failures)}")
|
||||
@@ -626,19 +546,16 @@ def _repair_node_deps_on_current_checkout(
|
||||
_check_and_apply_config_migration(
|
||||
assume_yes=assume_yes,
|
||||
gateway_mode=gateway_mode,
|
||||
pre_update_snapshot_id=pre_update_snapshot_id,
|
||||
)
|
||||
pre_update_snapshot_id=pre_update_snapshot_id)
|
||||
# A current checkout can still owe a Desktop rebuild (e.g. the Windows hand-off child
|
||||
# never reaches the commits-pulled rebuild). Self-gates on the build stamp.
|
||||
if not _rebuild_desktop_after_update(
|
||||
_m().PROJECT_ROOT / "apps" / "desktop",
|
||||
had_desktop_app_before_update=had_desktop_app_before_update,
|
||||
):
|
||||
had_desktop_app_before_update=had_desktop_app_before_update):
|
||||
# Retry hint already printed; withhold success rather than claim completion.
|
||||
print_completion(
|
||||
"⚠ Update partially complete — the desktop app was not rebuilt "
|
||||
"and is still on the previous build."
|
||||
)
|
||||
"and is still on the previous build.")
|
||||
return False
|
||||
return bool(print_completion(completion_message))
|
||||
|
||||
@@ -655,24 +572,18 @@ def _update_node_dependencies() -> list[str]:
|
||||
# Only a Windows npm reachable from WSL: flag loudly — skipping silently leaves
|
||||
# deps stale, running it would corrupt the tree.
|
||||
from hermes_constants import is_wsl
|
||||
|
||||
path_npm = shutil.which("npm")
|
||||
if is_wsl() and path_npm and _m()._is_windows_npm_path(path_npm):
|
||||
print("→ Updating Node.js dependencies...")
|
||||
print(" ⚠ Skipped: only a Windows npm is reachable from this WSL shell.")
|
||||
print(" Install Node.js inside the WSL distro (nvm, or your distro's")
|
||||
print(" package manager), then re-run `hermes update`.")
|
||||
failed = []
|
||||
if any(
|
||||
(_m().PROJECT_ROOT / workspace / "package.json").exists()
|
||||
for workspace in ("ui-tui", "web")
|
||||
):
|
||||
failed.append("ui-tui, web workspaces")
|
||||
return failed
|
||||
has_workspace = any(
|
||||
(_m().PROJECT_ROOT / ws / "package.json").exists() for ws in ("ui-tui", "web"))
|
||||
return ["ui-tui, web workspaces"] if has_workspace else []
|
||||
return []
|
||||
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
# node_modules is shared by every profile on this checkout: one per-checkout cache.
|
||||
shared_hermes_root = get_default_hermes_root()
|
||||
|
||||
@@ -704,43 +615,30 @@ def _update_node_dependencies() -> list[str]:
|
||||
"--workspace", "ui-tui", "--workspace", "web",
|
||||
# Root devDependencies (shared ESLint config) would otherwise be pruned by the
|
||||
# scoped install; apps/desktop stays excluded since it is never named above.
|
||||
"--include-workspace-root",
|
||||
]
|
||||
"--include-workspace-root"]
|
||||
|
||||
from hermes_constants import with_hermes_node_path
|
||||
|
||||
nixos_env = with_hermes_node_path(_m()._nixos_build_env())
|
||||
|
||||
# capture_output=False is deliberate: postinstall scripts print download progress and
|
||||
# capturing makes a long download look hung.
|
||||
result = _m()._run_npm_install_deterministic(
|
||||
npm,
|
||||
_m().PROJECT_ROOT,
|
||||
extra_args=tuple(install_args),
|
||||
capture_output=False,
|
||||
env=nixos_env,
|
||||
)
|
||||
npm, _m().PROJECT_ROOT, extra_args=tuple(install_args), capture_output=False, env=nixos_env)
|
||||
if result.returncode == 0:
|
||||
_record_npm_lockfile_hash(shared_hermes_root)
|
||||
print(" ✓ ui-tui, web workspaces installed (desktop skipped)")
|
||||
failures: list[str] = []
|
||||
else:
|
||||
print(" ⚠ npm install failed")
|
||||
stderr = (result.stderr or "").strip() if result.stderr else ""
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
failures = _partial_update_failure("ui-tui, web workspaces")
|
||||
|
||||
return failures
|
||||
return []
|
||||
print(" ⚠ npm install failed")
|
||||
stderr = (result.stderr or "").strip()
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
return _partial_update_failure("ui-tui, web workspaces")
|
||||
|
||||
|
||||
def _venv_core_imports_healthy() -> tuple[bool, str]:
|
||||
"""Probe the venv (in ITS interpreter — updater may run under another Python) for core imports.
|
||||
|
||||
Catches a half-updated venv where the checkout is current but a dependency sync died
|
||||
partway; otherwise "Already up to date!" never re-syncs. Returns ``(healthy, detail)``.
|
||||
Never raises; unknown states report healthy so a probe failure can't force reinstalls.
|
||||
"""
|
||||
"""Probe the venv (in ITS interpreter — the updater may run under another Python) for core
|
||||
imports, catching a half-updated venv that "Already up to date!" would otherwise never re-sync.
|
||||
Returns ``(healthy, detail)``; never raises, unknown states report healthy."""
|
||||
from hermes_cli.update_cmd import _m
|
||||
venv_dir = _m().PROJECT_ROOT / "venv"
|
||||
venv_python = venv_python_path(venv_dir, windows=_m()._is_windows())
|
||||
@@ -748,9 +646,7 @@ def _venv_core_imports_healthy() -> tuple[bool, str]:
|
||||
# No venv: normal for a dev checkout (healthy), but on a MANAGED install (bootstrap
|
||||
# stamp or `.update-incomplete`) the venv IS the install — absence means an interrupted repair.
|
||||
managed_markers = (
|
||||
_m().PROJECT_ROOT / ".hermes-bootstrap-complete",
|
||||
_m()._update_marker_path(),
|
||||
)
|
||||
_m().PROJECT_ROOT / ".hermes-bootstrap-complete", _m()._update_marker_path())
|
||||
if any(m.exists() for m in managed_markers):
|
||||
return False, f"venv python missing ({venv_python})"
|
||||
return True, ""
|
||||
@@ -764,16 +660,11 @@ def _venv_core_imports_healthy() -> tuple[bool, str]:
|
||||
"for m in mods:\n"
|
||||
" try: importlib.import_module(m)\n"
|
||||
" except Exception as e: missing.append(f'{m}: {e}')\n"
|
||||
"print('\\n'.join(missing))\n"
|
||||
)
|
||||
"print('\\n'.join(missing))\n")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(venv_python), "-c", check],
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
timeout=60,
|
||||
cwd=_m().PROJECT_ROOT,
|
||||
)
|
||||
[str(venv_python), "-c", check], capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", timeout=60, cwd=_m().PROJECT_ROOT)
|
||||
except Exception as exc:
|
||||
logger.debug("venv health probe failed to run: %s", exc)
|
||||
return True, ""
|
||||
@@ -797,8 +688,7 @@ def _venv_core_imports_healthy() -> tuple[bool, str]:
|
||||
# Keys are ``sys.modules`` prefixes; values are ``(display name, PyPI dist)``.
|
||||
_SELF_LOCKING_NATIVE_MODULES: dict[str, tuple[str, str]] = {
|
||||
"cryptography.hazmat.bindings._rust": ("cryptography (_rust.pyd)", "cryptography"),
|
||||
"yaml._yaml": ("PyYAML (_yaml.pyd)", "pyyaml"),
|
||||
}
|
||||
"yaml._yaml": ("PyYAML (_yaml.pyd)", "pyyaml")}
|
||||
|
||||
|
||||
def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
|
||||
@@ -809,7 +699,6 @@ def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
|
||||
from hermes_cli.update_cmd import _m
|
||||
try:
|
||||
from importlib import metadata as _ilmd
|
||||
|
||||
installed = _ilmd.version(dist_name)
|
||||
except Exception:
|
||||
return True # not installed → the sync will definitely install it
|
||||
@@ -819,7 +708,6 @@ def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import Version
|
||||
|
||||
pyproject = _m().PROJECT_ROOT / "pyproject.toml"
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
project = data.get("project") or {}
|
||||
@@ -857,17 +745,12 @@ def _detect_self_loaded_native_modules() -> list[str]:
|
||||
from hermes_cli.update_cmd import _m
|
||||
if not _m()._is_windows():
|
||||
return []
|
||||
found = []
|
||||
for prefix, (display, dist) in _SELF_LOCKING_NATIVE_MODULES.items():
|
||||
if prefix not in sys.modules:
|
||||
continue
|
||||
# Defer ONLY on a CONFIRMED rewrite; unknown fails OPEN (PyYAML is in every process, so
|
||||
# unknown-as-at-risk always fires). A missed deferral only yields the mid-sync os error 5
|
||||
# that marker recovery already handles — far less harmful than an update that never runs.
|
||||
if _m()._dependency_sync_would_rewrite(dist) is not True:
|
||||
continue
|
||||
found.append(display)
|
||||
return sorted(set(found))
|
||||
# Defer ONLY on a CONFIRMED rewrite; unknown fails OPEN (PyYAML is in every process, so
|
||||
# unknown-as-at-risk always fires). A missed deferral only yields the mid-sync os error 5
|
||||
# that marker recovery already handles — far less harmful than an update that never runs.
|
||||
return sorted({
|
||||
display for prefix, (display, dist) in _SELF_LOCKING_NATIVE_MODULES.items()
|
||||
if prefix in sys.modules and _m()._dependency_sync_would_rewrite(dist) is True})
|
||||
|
||||
|
||||
def _abort_dependency_sync_if_self_locked(gateway_resume=None) -> None:
|
||||
@@ -880,14 +763,14 @@ def _abort_dependency_sync_if_self_locked(gateway_resume=None) -> None:
|
||||
locked = _m()._detect_self_loaded_native_modules()
|
||||
if locked:
|
||||
_m()._defer_update_for_self_lock(locked)
|
||||
if gateway_resume is not None:
|
||||
_m()._resume_windows_gateways_after_update(gateway_resume)
|
||||
sys.exit(2)
|
||||
|
||||
if _m()._reexec_dependency_sync_off_windows_shim():
|
||||
if gateway_resume is not None:
|
||||
_m()._resume_windows_gateways_after_update(gateway_resume)
|
||||
sys.exit(0)
|
||||
exit_code = 2
|
||||
elif _m()._reexec_dependency_sync_off_windows_shim():
|
||||
exit_code = 0
|
||||
else:
|
||||
return
|
||||
if gateway_resume is not None:
|
||||
_m()._resume_windows_gateways_after_update(gateway_resume)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def _defer_update_for_self_lock(loaded: list[str]) -> None:
|
||||
@@ -912,13 +795,11 @@ def _desktop_app_present(desktop_dir: Path) -> bool:
|
||||
from hermes_cli.update_cmd import _m
|
||||
return (
|
||||
_m()._desktop_packaged_executable(desktop_dir) is not None
|
||||
or _m()._desktop_dist_exists(desktop_dir)
|
||||
)
|
||||
or _m()._desktop_dist_exists(desktop_dir))
|
||||
|
||||
|
||||
def _rebuild_desktop_after_update(
|
||||
desktop_dir: Path, *, had_desktop_app_before_update: bool
|
||||
) -> bool:
|
||||
desktop_dir: Path, *, had_desktop_app_before_update: bool) -> bool:
|
||||
"""Rebuild an installed Desktop app when its source or artifact changed. Returns ``False``
|
||||
only when a rebuild was attempted and failed (caller withholds ``✓ Update complete!`` and
|
||||
writes a failing ``.update_exit_code`` in gateway mode); every other outcome is ``True``."""
|
||||
@@ -929,19 +810,16 @@ def _rebuild_desktop_after_update(
|
||||
if not (
|
||||
(desktop_dir / "package.json").exists()
|
||||
and _m()._resolve_node_runtime_npm()
|
||||
and has_desktop_app
|
||||
):
|
||||
and has_desktop_app):
|
||||
return True
|
||||
|
||||
print("→ Checking if desktop app needs rebuilding...")
|
||||
# Check the content-hash stamp IN-PROCESS first (the subprocess spends ~1-3 s importing the
|
||||
# CLI to reach the same check). Update never passes --source, so source_mode=False.
|
||||
# Any pre-check error falls through to the subprocess.
|
||||
skip_desktop_build = False
|
||||
try:
|
||||
skip_desktop_build = not _m()._desktop_build_needed(
|
||||
desktop_dir, _m().PROJECT_ROOT, source_mode=False
|
||||
)
|
||||
desktop_dir, _m().PROJECT_ROOT, source_mode=False)
|
||||
except Exception:
|
||||
skip_desktop_build = False
|
||||
if skip_desktop_build:
|
||||
@@ -953,22 +831,18 @@ def _rebuild_desktop_after_update(
|
||||
# rebuild window), then surface the tail. Put Hermes-managed Node on PATH: the desktop
|
||||
# updater chain loses shell PATH customizations, so a bare-PATH child hits `node: not found`.
|
||||
from hermes_constants import with_hermes_node_path
|
||||
|
||||
build_env = with_hermes_node_path()
|
||||
build_result = _m()._run_logged_subprocess(
|
||||
desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env
|
||||
)
|
||||
desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env)
|
||||
if build_result.returncode != 0:
|
||||
build_result = _m()._run_logged_subprocess(
|
||||
desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env
|
||||
)
|
||||
desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env)
|
||||
if build_result.returncode != 0:
|
||||
print(" ⚠ Desktop build failed (run `hermes desktop` to retry)")
|
||||
tail = "\n".join((build_result.stdout or "").strip().splitlines()[-15:])
|
||||
if tail:
|
||||
print(tail)
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
|
||||
print(f" Full build log: {_dhh()}/logs/update.log")
|
||||
return False
|
||||
print(" ✓ Desktop app up to date")
|
||||
@@ -985,15 +859,12 @@ def _path_uid(path) -> Optional[int]:
|
||||
|
||||
|
||||
def _venv_foreign_owned_paths(venv_root, limit: int = 5) -> list:
|
||||
"""Bounded scan for venv entries not owned by the current user; returns up to ``limit``
|
||||
``(path_str, uid)`` tuples.
|
||||
"""Up to ``limit`` ``(path_str, uid)`` venv entries not owned by the current user.
|
||||
|
||||
A venv touched by ``sudo pip``/``sudo hermes`` has root-owned files, and a later normal
|
||||
update dies mid-mutation with ``venv/bin/hermes`` already deleted — never mutate a venv we
|
||||
can't safely mutate. Deliberately BOUNDED (venv root, ``venv/bin``, top-level of first
|
||||
site-packages, children of each ``*.dist-info``; ~2000 stats max). POSIX-only: ``[]`` on
|
||||
Windows and as root. Swallows per-entry ``OSError``; ``[]`` on any structural surprise —
|
||||
must NEVER raise or add noticeable latency.
|
||||
A venv touched by ``sudo pip``/``sudo hermes`` dies mid-update with ``venv/bin/hermes`` already
|
||||
deleted — never mutate a venv we can't safely mutate. Deliberately BOUNDED (venv root,
|
||||
``venv/bin``, first site-packages top level, ``*.dist-info`` children; ~2000 stats). POSIX-only:
|
||||
``[]`` on Windows and as root; ``[]`` on any surprise — must NEVER raise or add latency.
|
||||
"""
|
||||
from hermes_cli.update_cmd import _path_uid
|
||||
try:
|
||||
@@ -1076,8 +947,7 @@ def _sync_python_dependencies_after_pull(
|
||||
*,
|
||||
active_lazy_features,
|
||||
active_tool_dependencies,
|
||||
_windows_gateway_resume,
|
||||
):
|
||||
_windows_gateway_resume):
|
||||
"""Reinstall Python deps for the pulled checkout. Order matters: ownership preflight ->
|
||||
self-lock deferral -> core marker -> ``.[all]`` -> bytecode sweep -> lazy/tool refresh (own
|
||||
marker) -> memory-provider deps -> critical-import probe (warn only; stale bytecode self-heals)."""
|
||||
@@ -1086,8 +956,7 @@ def _sync_python_dependencies_after_pull(
|
||||
_sweep_bytecode_after_update,
|
||||
_validate_critical_modules_import,
|
||||
_write_lazy_refresh_incomplete_marker,
|
||||
_write_update_incomplete_marker,
|
||||
)
|
||||
_write_update_incomplete_marker)
|
||||
_refuse_update_if_venv_foreign_owned(_m().PROJECT_ROOT)
|
||||
# Self-lock deferral: if THIS process holds a native extension the sync must rewrite, defer
|
||||
# NOW (after the code swap) so only the install is pending for the next launch's marker.
|
||||
@@ -1101,12 +970,12 @@ def _sync_python_dependencies_after_pull(
|
||||
else:
|
||||
print("→ Updating Python dependencies...")
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
|
||||
# `uv self update` if we already have a managed uv.
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin = ensure_uv()
|
||||
|
||||
# sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors.
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
@@ -1115,36 +984,26 @@ def _sync_python_dependencies_after_pull(
|
||||
if uv_bin:
|
||||
# managed_python_env() isolation so a third-party UV_PYTHON_INSTALL_DIR can't hijack uv.
|
||||
from hermes_cli.managed_uv import managed_python_env
|
||||
|
||||
uv_env = managed_python_env()
|
||||
uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv")
|
||||
if _m()._is_termux_env(uv_env):
|
||||
uv_env.pop("PYTHONPATH", None)
|
||||
uv_env.pop("PYTHONHOME", None)
|
||||
install_group = "termux-all"
|
||||
print(" → Termux detected: using uv + curated termux-all optional profile...")
|
||||
if not deps_current:
|
||||
if _m()._is_termux_env(uv_env) and _is_android_python():
|
||||
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
|
||||
_install_psutil_android_compat([uv_bin, "pip"], env=uv_env)
|
||||
_m()._install_python_dependencies_with_optional_fallback(
|
||||
[uv_bin, "pip"], env=uv_env, group=install_group
|
||||
)
|
||||
install_prefix, lazy_env = [uv_bin, "pip"], managed_python_env()
|
||||
lazy_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv")
|
||||
termux_note = " → Termux detected: using uv + curated termux-all optional profile..."
|
||||
else:
|
||||
# sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors.
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
_ensure_venv_pip(pip_cmd, sys.executable)
|
||||
if _m()._is_termux_env():
|
||||
install_group = "termux-all"
|
||||
print(" → Termux detected: using curated termux-all optional profile...")
|
||||
if not deps_current:
|
||||
if _m()._is_termux_env() and _is_android_python():
|
||||
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
|
||||
_install_psutil_android_compat(pip_cmd)
|
||||
_m()._install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
|
||||
|
||||
install_prefix = [uv_bin, "pip"] if uv_bin else pip_cmd
|
||||
lazy_env = uv_env if uv_bin else None
|
||||
install_prefix, lazy_env = pip_cmd, None
|
||||
termux_note = " → Termux detected: using curated termux-all optional profile..."
|
||||
is_termux = _m()._is_termux_env(lazy_env)
|
||||
if is_termux:
|
||||
if lazy_env is not None:
|
||||
lazy_env.pop("PYTHONPATH", None)
|
||||
lazy_env.pop("PYTHONHOME", None)
|
||||
install_group = "termux-all"
|
||||
print(termux_note)
|
||||
if not deps_current:
|
||||
if is_termux and _is_android_python():
|
||||
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
|
||||
_install_psutil_android_compat(install_prefix, env=lazy_env)
|
||||
_m()._install_python_dependencies_with_optional_fallback(
|
||||
install_prefix, env=lazy_env, group=install_group)
|
||||
|
||||
if deps_current:
|
||||
# Verification normally runs inside the skipped install; run it here so a wrong skip
|
||||
@@ -1168,17 +1027,13 @@ def _sync_python_dependencies_after_pull(
|
||||
|
||||
# Clear the lazy marker only when refresh/repair is confirmed healthy.
|
||||
lazy_ok = _m()._refresh_active_lazy_features(
|
||||
install_prefix,
|
||||
env=lazy_env,
|
||||
features=active_lazy_features,
|
||||
)
|
||||
install_prefix, env=lazy_env, features=active_lazy_features)
|
||||
if lazy_ok:
|
||||
_m()._clear_lazy_refresh_incomplete_marker()
|
||||
else:
|
||||
print(
|
||||
" ⚠ Lazy-refresh recovery incomplete — run `hermes` again "
|
||||
"to finish import-based venv repair."
|
||||
)
|
||||
"to finish import-based venv repair.")
|
||||
|
||||
_m()._restore_active_tool_dependencies(active_tool_dependencies, install_prefix, env=lazy_env)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user