refactor(hermes_cli): merge r3-24 group B

This commit is contained in:
Teknium
2026-09-02 21:55:03 -07:00
5 changed files with 567 additions and 1218 deletions
+127 -249
View File
@@ -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
+41 -96
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+33 -73
View File
@@ -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
View File
@@ -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)