diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 7586b439e8..fa14d4bf0d 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -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 : `` warning. - """ + """Run ``remove(path)`` per candidate, collecting the ones it reports removed; failures become + the shared ``Could not remove : `` 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 (``\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 + ``\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 ``\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 ``/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 ``/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 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 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\\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\\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 ``/profiles//`` and are swept by the rmtree - # below, but services + alias scripts live OUTSIDE the default root. + # 5a. Named profiles' homes live under /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 ``. - - 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 ``. 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 diff --git a/hermes_cli/update_abort_recovery.py b/hermes_cli/update_abort_recovery.py index 243efe10ba..1990148b65 100644 --- a/hermes_cli/update_abort_recovery.py +++ b/hermes_cli/update_abort_recovery.py @@ -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 ``/`` 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: ``/`` 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": [""]} @@ -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 diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 28cd70cd81..d61d12d199 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1,10 +1,9 @@ """Hermes update pipeline: dispatchers (``_cmd_update_impl``/``_cmd_update_check``) + git plumbing. Each concern lives in ``update_cmd_.py`` and is re-imported here so -``hermes_cli.update_cmd.`` keeps resolving (and stays monkeypatchable). -``_m()`` is the lazy ``hermes_cli.main`` handle kept for main-side test patches. -Imports are one-way: main -> update_cmd -> update_cmd_* (never the reverse at -import time; ``_m()`` resolves at call time, so no cycle). +``hermes_cli.update_cmd.`` keeps resolving (and stays monkeypatchable). Imports are +one-way: main -> update_cmd -> update_cmd_*; ``_m()`` resolves ``hermes_cli.main`` at call +time (no cycle, main-side test patches stay effective). """ import logging @@ -18,19 +17,15 @@ import time as _time from dataclasses import dataclass from pathlib import Path -# Re-exported: update_cmd_* modules import lazily from here so patches stick. -from hermes_cli.config import get_hermes_home # noqa: F401 +from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd) from hermes_cli.update_cmd_common import _best_effort from hermes_constants import get_default_hermes_root, venv_python_path -# Re-exported: main and the split modules address these via update_cmd (tests patch here). +# Re-exports: every split-module name stays reachable (and monkeypatchable) as update_cmd.. from hermes_cli.update_abort_recovery import ( # noqa: F401 _abort_recovery_is_complete, _qualified_serve_skips, _recover_gateway_restart_after_abort, _serve_unit_recovery_available, _surviving_pre_update_serve_runtimes, - _warn_stale_serve_runtimes, -) -# Re-exports from the split modules: every moved name stays reachable (and monkeypatchable) -# as ``hermes_cli.update_cmd.``; the split modules import origin-internal names lazily. + _warn_stale_serve_runtimes) from hermes_cli.update_cmd_windows import ( # noqa: F401 _HOLDER_VALUE_FLAGS_FALLBACK, _clear_windows_venv_holders_or_exit, _cold_start_windows_gateway_after_update, _desktop_owns_gateway_lifecycle, @@ -45,8 +40,7 @@ from hermes_cli.update_cmd_windows import ( # noqa: F401 _resume_windows_gateways_and_merge_outcome, _self_and_non_gateway_ancestor_pids, _serve_relaunch_commands, _start_windows_gateway_service, _stop_process_trees, _stop_windows_gateway_service, _venv_launcher_ancestors, - _wait_for_windows_update_gateway_exit, _write_update_planned_stop_marker, -) + _wait_for_windows_update_gateway_exit, _write_update_planned_stop_marker) from hermes_cli.update_cmd_fleet import ( # noqa: F401 _FLEET_RESTART_PENDING_NAME, _FRESH_RESTART_SUPERVISORS, _GatewayRestartOutcome, _apply_pending_fleet_restart_catchup, _clear_fleet_restart_pending_marker, @@ -63,25 +57,21 @@ from hermes_cli.update_cmd_fleet import ( # noqa: F401 _wait_for_service_active, _warn_gateway_restart_phase_aborted, _warn_incomplete_gateway_fleet_restart, _warn_pending_fleet_restart, _warn_pending_fleet_restart_on_startup, _write_fleet_restart_pending_marker, - _write_gateway_update_exit_code, -) + _write_gateway_update_exit_code) from hermes_cli.update_cmd_zip import ( # noqa: F401 _ZIP_PRESERVED_TOP_LEVEL, _ZIP_STAGING_ARTIFACT_SUFFIXES, _abort_zip_update_if_dirty_tree, _atomic_replace_dir, _commit_staged_replacements, _discard_staged, _is_zip_preserved_entry_status_line, _is_zip_staging_artifact_status_line, _stage_replacement, - _update_via_zip, _zip_overlay_block_reason, -) + _update_via_zip, _zip_overlay_block_reason) from hermes_cli.update_cmd_stash import ( # noqa: F401 _AUTOSTASH_NAME_PREFIX, _AUTOSTASH_WARN_AGE_DAYS, _discard_stashed_changes, _git_untracked_paths, _park_stashed_changes, _print_stash_cleanup_guidance, _reject_unsafe_stash_restore, _resolve_stash_selector, _restore_stashed_changes, _restored_python_paths, _stash_apply_failed_only_on_existing_untracked, - _stash_local_changes_if_needed, _warn_orphaned_update_autostashes, -) + _stash_local_changes_if_needed, _warn_orphaned_update_autostashes) from hermes_cli.update_cmd_config import ( # noqa: F401 _LAST_SIBLING_SNAPSHOTS, _check_and_apply_config_migration, _migrate_sibling_profile_configs, - _print_items, _reload_config_modules, _run_config_check_fresh, _run_migrate_config_fresh, -) + _print_items, _reload_config_modules, _run_config_check_fresh, _run_migrate_config_fresh) from hermes_cli.update_cmd_deps import ( # noqa: F401 _INSTALL_DEFINING_FILES, _SELF_LOCKING_NATIVE_MODULES, _UPDATE_CRITICAL_MODULES, _abort_dependency_sync_if_self_locked, _capture_active_lazy_features, @@ -96,8 +86,7 @@ from hermes_cli.update_cmd_deps import ( # noqa: F401 _sync_python_dependencies_after_pull, _update_node_dependencies, _upgrade_pip_before_lazy_refresh, _validate_critical_modules_import, _venv_core_imports_healthy, _venv_foreign_owned_paths, _web_build_toolchain_ready, - _web_toolchain_roots, -) + _web_toolchain_roots) from hermes_cli.update_cmd_git import ( # noqa: F401 OFFICIAL_REPO_URL, OFFICIAL_REPO_URLS, SKIP_UPSTREAM_PROMPT_FILE, _ORPHAN_RESCUE_REFS_TO_KEEP, _ORPHAN_RESCUE_REF_MAX_AGE_DAYS, _add_upstream_remote, _assess_parked_branch_switch, @@ -107,8 +96,7 @@ from hermes_cli.update_cmd_git import ( # noqa: F401 _normalize_managed_eol, _portable_git_candidates, _print_fetch_failure, _print_parked_branch_kept_notice, _print_parked_branch_skip_warning, _prune_orphan_rescue_refs, _should_skip_upstream_prompt, _sync_fork_with_upstream, - _sync_with_upstream_if_needed, -) + _sync_with_upstream_if_needed) from hermes_cli.update_cmd_maint import ( # noqa: F401 _PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _STALE_PURGE_PREFIXES, _STALE_PURGE_PROTECTED, _UPDATE_RUNTIME_RELOAD_MODULES, _clear_stale_sqlite_sidecars, @@ -121,48 +109,45 @@ from hermes_cli.update_cmd_maint import ( # noqa: F401 _resolve_pre_update_backup_mode, _restore_state_db_from_snapshot, _run_post_update_maintenance, _run_pre_update_backup, _sweep_bytecode_after_update, _update_complete_message, _verify_and_restore_one_state_db, - _verify_and_restore_state_dbs_post_update, -) + _verify_and_restore_state_dbs_post_update) logger = logging.getLogger(__name__) def _m(): """Lazy ``hermes_cli.main`` handle: keeps main-side test patches effective, import one-way.""" from hermes_cli import main - return main +def _updates_config() -> dict: + """The ``updates:`` config section (``{}`` when absent/malformed); may raise on config errors.""" + from hermes_cli.config import load_config + section = (load_config() or {}).get("updates", {}) + return section if isinstance(section, dict) else {} + + def _no_prompt_git_kwargs() -> dict: - """``subprocess.run`` kwargs for network git calls: GitHub answers anonymous fetches with - 401 during outages and git would then block forever on ``Username for ...``; disable only - the *prompt* (credential helpers/askpass still run) so it fails fast into ``_classify_fetch_failure``.""" + """``subprocess.run`` kwargs for network git: GitHub answers anonymous fetches with 401 during + outages and git would block forever on ``Username for ...``; disable only the *prompt* + (credential helpers/askpass still run) so it fails fast into ``_classify_fetch_failure``.""" env = dict(os.environ) env["GIT_TERMINAL_PROMPT"] = "0" env["GCM_INTERACTIVE"] = "Never" return {"stdin": subprocess.DEVNULL, "env": env} -# CLI-startup imports (+ web_server.py, the desktop backend a fresh Windows install -# launches) that must parse post-update; the syntax guard auto-rolls-back on failure. +# CLI-startup files (+ web_server.py, launched by a fresh Windows Desktop install) that must +# parse post-update; the syntax guard rolls back when one doesn't. _UPDATE_CRITICAL_FILES = ( - "hermes_cli/main.py", - "hermes_cli/config.py", - "hermes_cli/__init__.py", - "hermes_cli/web_server.py", - "cli.py", - "run_agent.py", - "model_tools.py", - "toolsets.py", - "hermes_constants.py", -) + "hermes_cli/main.py", "hermes_cli/config.py", "hermes_cli/__init__.py", + "hermes_cli/web_server.py", "cli.py", "run_agent.py", "model_tools.py", "toolsets.py", + "hermes_constants.py") def _record_update_step(step: str, ok: bool, detail: str = "") -> None: """Best-effort ``update_receipt.record_step``; the receipt must never break an update.""" with suppress(Exception): from hermes_cli.update_receipt import record_step - record_step(step, ok, detail) @@ -170,13 +155,9 @@ def _git_run(git_cmd, args, cwd=None, *, check=False, network=False): """Run git capturing utf-8 text (default cwd: checkout); ``network=True`` disables the terminal prompt so an HTTP 401 fails fast instead of hanging.""" return subprocess.run( - git_cmd + args, - cwd=_m().PROJECT_ROOT if cwd is None else cwd, - capture_output=True, - text=True, encoding="utf-8", errors="replace", - check=check, - **(_no_prompt_git_kwargs() if network else {}), - ) + git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True, + text=True, encoding="utf-8", errors="replace", check=check, + **(_no_prompt_git_kwargs() if network else {})) def _capture_head_sha(git_cmd, cwd) -> str | None: @@ -189,12 +170,10 @@ def _capture_head_sha(git_cmd, cwd) -> str | None: def _validate_python_files_syntax( - root, relpaths -) -> tuple[bool, str | None, str | None]: + root, relpaths) -> tuple[bool, str | None, str | None]: """Compile *relpaths* under *root* without writing bytecode into the tree.""" import py_compile import tempfile - root = Path(root) with tempfile.TemporaryDirectory(prefix="hermes-syntax-check-") as tmpdir: for relpath in relpaths: @@ -212,12 +191,9 @@ def _validate_python_files_syntax( def _validate_critical_files_syntax(root) -> tuple[bool, str | None, str | None]: - """Compile ``_UPDATE_CRITICAL_FILES`` -> ``(ok, failing_path, error_message)``. - - A syntax error there means the CLI can't bootstrap, so validate post-pull and - auto-roll-back. The .pyc goes to a temp dir, not ``__pycache__/``: avoids racing - concurrent test workers and leaving a stale pyc for a different interpreter. - """ + """Compile ``_UPDATE_CRITICAL_FILES`` -> ``(ok, failing_path, error_message)``. The .pyc goes + to a temp dir, not ``__pycache__/`` (no race with test workers, no stale pyc for another + interpreter).""" return _validate_python_files_syntax(root, _UPDATE_CRITICAL_FILES) @@ -227,7 +203,6 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) import json as _json import uuid as _uuid from hermes_constants import get_hermes_home # noqa: F811 (deliberate: constants variant) - home = get_hermes_home() prompt_path = home / ".update_prompt.json" response_path = home / ".update_response" @@ -280,20 +255,14 @@ def _called_process_error_is_git(exc: subprocess.CalledProcessError) -> bool: def _called_process_error_is_python_dep_install( - exc: subprocess.CalledProcessError, -) -> bool: + exc: subprocess.CalledProcessError) -> bool: """True when the failed subprocess was a uv/pip (or ensurepip) install.""" parts = [part.lower() for part in _called_process_error_cmd_parts(exc)] if not parts: return False exe = os.path.basename(parts[0].replace("\\", "/")) - if "ensurepip" in parts: - return True - if "install" in parts and ( - "pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"} - ): - return True - return False + return "ensurepip" in parts or ("install" in parts and ( + "pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"})) def _format_update_failure_stage(exc: subprocess.CalledProcessError) -> str: @@ -346,13 +315,11 @@ def _should_zip_fallback_on_update_error(exc: BaseException) -> bool: return ( isinstance(exc, subprocess.CalledProcessError) and _m()._is_windows() - and _called_process_error_is_git(exc) - ) + and _called_process_error_is_git(exc)) def _print_called_process_error_tail( - exc: subprocess.CalledProcessError, *, limit: int = 12 -) -> None: + exc: subprocess.CalledProcessError, *, limit: int = 12) -> None: """Print a captured stderr/stdout tail when the failing call recorded one.""" blob = exc.stderr or exc.stdout or "" if isinstance(blob, bytes): @@ -368,19 +335,14 @@ def _print_called_process_error_tail( def _invalidate_update_cache(): """Delete the update-check cache for ALL profiles: the repo is shared, so one profile's update makes every profile current and a stale "commits behind" banner would linger.""" - homes = [] default_home = get_default_hermes_root() - homes.append(default_home) profiles_root = default_home / "profiles" + homes = [default_home] if profiles_root.is_dir(): - for entry in profiles_root.iterdir(): - if entry.is_dir(): - homes.append(entry) + homes += [entry for entry in profiles_root.iterdir() if entry.is_dir()] for home in homes: with suppress(Exception): - cache_file = home / ".update_check" - if cache_file.exists(): - cache_file.unlink() + (home / ".update_check").unlink(missing_ok=True) def _write_marker_file(path: Path, *, label: str) -> None: @@ -405,38 +367,36 @@ def _write_lazy_refresh_incomplete_marker() -> None: def _format_concurrent_instances_message( - matches: list[tuple[int, str]], scripts_dir: Path -) -> str: + matches: list[tuple[int, str]], scripts_dir: Path) -> str: """Build a human-readable explanation + remediation hint for the user.""" shim = scripts_dir / "hermes.exe" - lines = ["✗ Another hermes.exe is running:"] - for pid, name in matches: - lines.append(f" PID {pid} {name}") - lines.append("") - lines.append(f" Updating now would fail to overwrite {shim} because") - lines.append(" Windows blocks REPLACE on a running executable.") - lines.append("") - lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and") - lines.append(" stop the gateway (`hermes gateway stop`) before retrying.") - lines.append("") + lines = [ + "✗ Another hermes.exe is running:", + *(f" PID {pid} {name}" for pid, name in matches), + "", + f" Updating now would fail to overwrite {shim} because", + " Windows blocks REPLACE on a running executable.", + "", + " Close Hermes Desktop, exit any open `hermes` REPLs, and", + " stop the gateway (`hermes gateway stop`) before retrying.", + ""] if matches: pid_args = " ".join(f"/PID {pid}" for pid, _ in matches) - lines.append(" If you've already closed everything and these PIDs are") - lines.append(" stale, terminate them directly, then retry the update:") - lines.append(f" taskkill {pid_args} /F") - lines.append("") - lines.append(" Override with `hermes update --force` if you've already") - lines.append(" confirmed those processes will not write to the venv.") + lines += [ + " If you've already closed everything and these PIDs are", + " stale, terminate them directly, then retry the update:", + f" taskkill {pid_args} /F", + ""] + lines += [ + " Override with `hermes update --force` if you've already", + " confirmed those processes will not write to the venv."] return "\n".join(lines) def _classify_concurrent_instance(pid: int) -> str: - """Classify ``pid`` as "gateway" / "non-gateway" / "unknown" (psutil can't read it). - - Uses ``_is_pausable_gateway``, the same matcher as the Desktop preflight exemption and - venv-holder guard, so "gateway" is exactly the set the downstream pause/restart machinery - stops. The gate treats "unknown" as non-gateway (better block than proceed blind). - """ + """Classify ``pid`` as "gateway" / "non-gateway" / "unknown" (psutil can't read it). Uses + ``_is_pausable_gateway`` (same matcher as the Desktop preflight and venv-holder guard) so + "gateway" is exactly what the pause/restart machinery stops; "unknown" gates as non-gateway.""" try: import psutil # noqa: PLC0415 except Exception: @@ -449,23 +409,14 @@ def _classify_concurrent_instance(pid: int) -> str: return "unknown" from hermes_cli._scan_venv_blockers import _is_pausable_gateway # noqa: PLC0415 - - cmdline = " ".join(cmdline_list or []) - if _is_pausable_gateway(cmdline): - return "gateway" - return "non-gateway" + return "gateway" if _is_pausable_gateway(" ".join(cmdline_list or [])) else "non-gateway" def _filter_non_gateway_concurrent_instances( - matches: list[tuple[int, str]], -) -> list[tuple[int, str]]: - """Drop gateway matches (the pause + post-update restart machinery handles them); - anything else (TUI, Desktop backend child, another REPL) has no pause path, so the gate aborts.""" - non_gateway: list[tuple[int, str]] = [] - for pid, name in matches: - if _classify_concurrent_instance(pid) != "gateway": - non_gateway.append((pid, name)) - return non_gateway + matches: list[tuple[int, str]]) -> list[tuple[int, str]]: + """Drop gateway matches (the pause + post-update restart machinery handles them); anything else + (TUI, Desktop backend child, another REPL) has no pause path, so the gate aborts.""" + return [(pid, name) for pid, name in matches if _classify_concurrent_instance(pid) != "gateway"] def _log_only_write(text: str) -> None: @@ -486,16 +437,8 @@ def _run_logged_subprocess(cmd, *, cwd=None, env=None): """Run ``cmd`` with combined output captured into update.log only; returns the ``CompletedProcess`` so the caller can surface the output on failure.""" result = subprocess.run( - cmd, - cwd=cwd, - env=env, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - ) + cmd, cwd=cwd, env=env, check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace") _log_only_write(result.stdout or "") return result @@ -507,9 +450,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Same marker-first admission gate as the apply path, so --check never reports git # state for an install whose real update mechanism is an image pull. from hermes_cli.update_contract import ( - evaluate_update_admission, - record_refusal_receipt, - ) + evaluate_update_admission, record_refusal_receipt) refusal = evaluate_update_admission(_m().PROJECT_ROOT) if refusal is not None: @@ -528,7 +469,6 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Interrupted fetches leave .git/*.lock behind ("File exists" forever); self-heal first. from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs - cleared = clear_stale_git_locks(_m().PROJECT_ROOT) for lock_path in cleared: print(f" (removed stale git lock: {lock_path})") @@ -543,24 +483,16 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # checkouts are shallow: a plain fetch would unshallow them and rev-list would report a # bogus huge "behind" count, so fetch --depth 1 and report presence-only. is_shallow = ( - _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() - == "true" - ) + _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true") depth_args = ["--depth", "1"] if is_shallow else [] - if branch == "main": - # Probe locally for an 'upstream' remote before a network fetch non-forks always fail. - has_upstream_remote = _git_run(git_cmd, ["remote", "get-url", "upstream"]).returncode == 0 - fetch_result = None - if has_upstream_remote: - print("→ Fetching from upstream...") - fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["upstream", branch], network=True) - if fetch_result is not None and fetch_result.returncode == 0: - compare_branch = f"upstream/{branch}" - else: - print("→ Fetching from origin...") - fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["origin", branch], network=True) - compare_branch = f"origin/{branch}" + # Probe locally for an 'upstream' remote before a network fetch non-forks always fail. + fetch_result = None + if branch == "main" and _git_run(git_cmd, ["remote", "get-url", "upstream"]).returncode == 0: + print("→ Fetching from upstream...") + fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["upstream", branch], network=True) + if fetch_result is not None and fetch_result.returncode == 0: + compare_branch = f"upstream/{branch}" else: print("→ Fetching from origin...") fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["origin", branch], network=True) @@ -583,33 +515,28 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): target_sha = _git_run(git_cmd, ["rev-parse", compare_branch]).stdout.strip() if head_sha and target_sha and head_sha == target_sha: print("✓ Already up to date.") - else: - from hermes_cli.banner import _github_compare_behind - from hermes_cli.config import recommended_update_command - - counted = _github_compare_behind(head_sha, target_sha) - if counted == 0: # local-ahead, not behind - print("✓ Already up to date.") - return - if counted is not None: - commits_word = "commit" if counted == 1 else "commits" - print(f"⚕ Update available: {counted} {commits_word} behind {compare_branch}.") - else: - print(f"⚕ Update available (behind {compare_branch}).") - print(f" Run '{recommended_update_command()}' to install.") + return + from hermes_cli.banner import _github_compare_behind + # counted == 0 means local-ahead, not behind; None means the API could not count. + _print_update_check_result(_github_compare_behind(head_sha, target_sha), compare_branch) return rev_result = _git_run(git_cmd, ["rev-list", f"HEAD..{compare_branch}", "--count"], check=True) - behind = int(rev_result.stdout.strip()) + _print_update_check_result(int(rev_result.stdout.strip()), compare_branch) + +def _print_update_check_result(behind: int | None, compare_branch: str) -> None: + """Report ``--check``'s verdict: up to date, N commits behind, or behind by an unknown count.""" if behind == 0: print("✓ Already up to date.") - else: + return + if behind is not None: commits_word = "commit" if behind == 1 else "commits" print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.") - from hermes_cli.config import recommended_update_command - - print(f" Run '{recommended_update_command()}' to install.") + else: + print(f"⚕ Update available (behind {compare_branch}).") + from hermes_cli.config import recommended_update_command + print(f" Run '{recommended_update_command()}' to install.") def _repair_current_checkout( @@ -622,14 +549,12 @@ def _repair_current_checkout( active_lazy_features, active_tool_dependencies, upstream_checked, - _windows_gateway_resume, -) -> bool: + _windows_gateway_resume) -> bool: """Already-up-to-date path: keep the managed runtime current, repair a broken venv. Returns whether the checkout can be reported complete.""" # "No new commits" != safe interpreter: uv can keep the same CPython patch while # python-build-standalone refreshes the embedded SQLite; keep the boundary hook here too. from hermes_cli.managed_uv import ensure_uv, update_managed_uv - runtime_repairs = [] update_managed_uv(repair_observer=runtime_repairs.append) ensure_uv(repair_observer=runtime_repairs.append) @@ -653,48 +578,26 @@ def _repair_current_checkout( _m()._abort_dependency_sync_if_self_locked(_windows_gateway_resume) _write_update_incomplete_marker() from hermes_cli.managed_uv import ensure_uv - repair_uv = ensure_uv() # Venv gone entirely (repair interrupted after the old one was moved aside): recreate. venv_python_missing = not ( - venv_python_path( - _m().PROJECT_ROOT / "venv", windows=_m()._is_windows() - ) - ).exists() + venv_python_path(_m().PROJECT_ROOT / "venv", windows=_m()._is_windows())).exists() if venv_python_missing and repair_uv: print("→ Recreating virtual environment...") subprocess.run([repair_uv, "venv", "venv"], cwd=_m().PROJECT_ROOT, check=False) if repair_uv: # Isolated from third-party UV env vars, like the other dependency syncs. from hermes_cli.managed_uv import managed_python_env - - repair_env = managed_python_env() + repair_prefix, repair_env = [repair_uv, "pip"], managed_python_env() repair_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") - _m()._install_python_dependencies_with_optional_fallback( - [repair_uv, "pip"], env=repair_env, group="all" - ) - _m()._refresh_active_lazy_features( - [repair_uv, "pip"], - env=repair_env, - features=active_lazy_features, - ) - _m()._restore_active_tool_dependencies( - active_tool_dependencies, - [repair_uv, "pip"], - env=repair_env, - ) else: - _m()._install_python_dependencies_with_optional_fallback( - [sys.executable, "-m", "pip"], group="all" - ) - _m()._refresh_active_lazy_features( - [sys.executable, "-m", "pip"], - features=active_lazy_features, - ) - _m()._restore_active_tool_dependencies( - active_tool_dependencies, - [sys.executable, "-m", "pip"], - ) + repair_prefix, repair_env = [sys.executable, "-m", "pip"], None + _m()._install_python_dependencies_with_optional_fallback( + repair_prefix, env=repair_env, group="all") + _m()._refresh_active_lazy_features( + repair_prefix, env=repair_env, features=active_lazy_features) + _m()._restore_active_tool_dependencies( + active_tool_dependencies, repair_prefix, env=repair_env) _m()._clear_update_incomplete_marker() healthy_after, detail_after = _venv_core_imports_healthy() if healthy_after: @@ -702,20 +605,16 @@ def _repair_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) # The hand-off child never reaches the commits-pulled rebuild; do it here. if _rebuild_desktop_after_update( - desktop_dir, - had_desktop_app_before_update=had_desktop_app_before_update, - ): + desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update): current_checkout_complete = _print_verified_update_completion("✓ Update complete!") else: current_checkout_complete = False _print_update_completion( "⚠ Update partially complete — the desktop app was " - "not rebuilt and is still on the previous build." - ) + "not rebuilt and is still on the previous build.") else: current_checkout_complete = False print(f"⚠ Venv still unhealthy after repair: {detail_after}") @@ -729,56 +628,38 @@ def _repair_current_checkout( completion_message=( "✓ Already up to date!" if upstream_checked - else "✓ Up to date with your fork (official repo not checked)." - ), - had_desktop_app_before_update=had_desktop_app_before_update, - ) + else "✓ Up to date with your fork (official repo not checked)."), + had_desktop_app_before_update=had_desktop_app_before_update) if runtime_repaired is not None and not _m()._is_windows(): print() print("⚠ Restart required to finish the managed Python runtime repair.") print( " Any running Hermes gateways, Desktop backends, or other " - "long-lived processes still use the previous runtime." - ) + "long-lived processes still use the previous runtime.") print(" Restart each of them to pick up the repaired runtime.") return current_checkout_complete def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: - """Fast-forward failed: merge on a custom branch (local commits survive) or reset --hard on - the same branch (rescue ref first when histories share no ancestor). ``sys.exit(1)`` on failure.""" + """Fast-forward failed: merge on a custom branch (local commits survive) or reset --hard on the + same branch (rescue ref first when histories share no ancestor). ``sys.exit(1)`` on failure.""" # Diverged. A custom branch (local commits atop origin/) also can't ff, # and reset --hard would discard that work: merge instead, stop on conflict. _cur_branch = (_git_run(git_cmd, ["branch", "--show-current"]).stdout or "").strip() if _cur_branch and _cur_branch != branch: print( f" ⚠ Checkout is on custom branch '{_cur_branch}' — " - f"merging origin/{branch} instead of resetting so local commits survive..." - ) + f"merging origin/{branch} instead of resetting so local commits survive...") # Best-effort safety tag as a recovery anchor. - subprocess.run( - git_cmd - + ["tag", f"pre-update-{_time.strftime('%Y%m%d-%H%M%S')}"], - cwd=_m().PROJECT_ROOT, - capture_output=True, - check=False, - ) + _git_run(git_cmd, ["tag", f"pre-update-{_time.strftime('%Y%m%d-%H%M%S')}"]) merge_result = _git_run(git_cmd, ["merge", "--no-edit", f"origin/{branch}"]) if merge_result.returncode != 0: - subprocess.run( - git_cmd + ["merge", "--abort"], - cwd=_m().PROJECT_ROOT, - capture_output=True, - check=False, - ) + _git_run(git_cmd, ["merge", "--abort"]) print( "✗ Merge conflict between local commits and upstream — " - "update stopped, nothing was changed." - ) + "update stopped, nothing was changed.") print( - f" Resolve manually: cd {_m().PROJECT_ROOT} && " - f"git merge origin/{branch}" - ) + f" Resolve manually: cd {_m().PROJECT_ROOT} && " f"git merge origin/{branch}") print(" Then re-run the update. Local work is untouched.") sys.exit(1) else: @@ -786,19 +667,14 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: # reset. Orphan divergence (no common ancestor: corrupted HEAD, re-init) would # lose the whole local graph, so park pre_pull_sha behind a rescue ref first. merge_base_result = _git_run(git_cmd, ["merge-base", "HEAD", f"origin/{branch}"]) - has_common_ancestor = bool( - merge_base_result.returncode == 0 - and merge_base_result.stdout.strip() - ) + has_common_ancestor = merge_base_result.returncode == 0 and merge_base_result.stdout.strip() if not has_common_ancestor and pre_pull_sha: from datetime import datetime as _dt, timezone - # SHA suffix so two updates in the same second get distinct refs. rescue_ref = ( f"refs/hermes-update-backups/orphan-{branch}-" f"{_dt.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" - f"-{pre_pull_sha[:12]}" - ) + f"-{pre_pull_sha[:12]}") update_ref_result = _git_run(git_cmd, ["update-ref", rescue_ref, pre_pull_sha]) if update_ref_result.returncode == 0: print( @@ -806,8 +682,7 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: f"origin/{branch} (orphan divergence) — backed up " f"current HEAD to {rescue_ref} before resetting. " f"This backup expires after " - f"{_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days." - ) + f"{_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days.") else: # update-ref failure is intentionally non-fatal, but never claim a backup exists. print( @@ -815,12 +690,9 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: f"origin/{branch} (orphan divergence) — attempted " f"to back up current HEAD to {rescue_ref} before " "resetting, but the backup write failed " - f"(pre-reset SHA was {pre_pull_sha})." - ) + f"(pre-reset SHA was {pre_pull_sha}).") _prune_orphan_rescue_refs(git_cmd, _m().PROJECT_ROOT, branch) - print( - " ⚠ Fast-forward not possible (history diverged), resetting to match remote..." - ) + print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...") reset_result = _git_run(git_cmd, ["reset", "--hard", f"origin/{branch}"]) if reset_result.returncode != 0: print(f"✗ Failed to reset to origin/{branch}.") @@ -870,8 +742,7 @@ def _pull_updates( prompt_for_restore, gw_input_fn, discard_local_changes, - keep_stash, -): + keep_stash): """Fast-forward onto ``origin/`` and settle the autostash. Divergence by shape: custom branch -> merge, same branch -> reset, orphan history -> rescue ref first; a post-pull syntax error in a critical file rolls back. Exits on failure; returns pre-pull SHA.""" @@ -906,8 +777,7 @@ def _pull_updates( _m().PROJECT_ROOT, auto_stash_ref, prompt_user=prompt_for_restore, - input_fn=gw_input_fn, - ) + input_fn=gw_input_fn) return pre_pull_sha @@ -927,51 +797,35 @@ class _CheckoutPlan: def _apply_parked_branch_guard( git_cmd, branch, current_branch, *, switch_branch, _windows_gateway_resume ) -> tuple[bool, bool, "str | None"]: - """Decide how a checkout parked on another branch is brought to *branch*. + """Decide how a checkout parked on another branch is brought to *branch* (stash-switch-pull- + switch-back used to "update" main while the running code stayed behind). - Returns ``(parked_branch_switched, in_place_update, switch_block_reason)``; ``sys.exit(1)`` - when the branch is dirty/unverifiable (code update SKIPPED) or the target is missing. + By branch contents + updates.parked_branch_strategy: fully merged -> switch back; + unmerged -> "switch" (default; loud "kept" notice) or "update_in_place" (merge origin/ + INTO the branch, checkout never moves; --switch-branch overrides once); dirty/unverifiable -> + touch nothing, warn, ``sys.exit(1)`` with the code update SKIPPED (also when the target is + missing). Returns ``(parked_branch_switched, in_place_update, switch_block_reason)``. """ switch_block_reason = None # only meaningful when parked_branch_switched - # Parked-branch guard (stash-switch-pull-switch-back used to "update" main while the - # running code stayed behind). By branch contents + updates.parked_branch_strategy: - # fully merged -> switch back to the target. - # unmerged: N -> "switch" (default): switch anyway with a loud "kept" notice; - # "update_in_place": merge origin/ INTO the branch (checkout - # never moves). --switch-branch overrides for one run. - # anything else -> dirty/unverifiable/opted out: touch nothing, warn, mark SKIPPED, stop. parked_branch_switched = False in_place_update = False if current_branch != branch and current_branch != "HEAD": switch_safe, switch_block_reason = _m()._assess_parked_branch_switch( - git_cmd, _m().PROJECT_ROOT, current_branch, branch - ) + git_cmd, _m().PROJECT_ROOT, current_branch, branch) if not switch_safe: _m()._print_parked_branch_skip_warning( - git_cmd, - _m().PROJECT_ROOT, - current_branch, - branch, - switch_block_reason, - ) + git_cmd, _m().PROJECT_ROOT, current_branch, branch, switch_block_reason) print() print( "⚠ Update finished — code update SKIPPED" - f"{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}" - ) + f"{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) if switch_block_reason.startswith("unmerged:"): _in_place_configured = False with _best_effort('Could not read updates.parked_branch_strategy: %s'): - from hermes_cli.config import load_config as _load_cfg - - _upd_cfg = (_load_cfg() or {}).get("updates", {}) _in_place_configured = ( - isinstance(_upd_cfg, dict) - and _upd_cfg.get("parked_branch_strategy", "switch") - == "update_in_place" - ) + _updates_config().get("parked_branch_strategy", "switch") == "update_in_place") if _in_place_configured and not switch_branch: # --branch typos used to surface via the checkout failing, which this path skips. verify_ref = _git_run(git_cmd, ["rev-parse", "--verify", "--quiet", f"origin/{branch}"]) @@ -981,21 +835,16 @@ def _apply_parked_branch_guard( in_place_update = True print( f" ℹ On branch '{current_branch}' — updating it in place from " - f"origin/{branch} (no branch switch; local commits preserved)." - ) + f"origin/{branch} (no branch switch; local commits preserved).") else: parked_branch_switched = True _m()._print_parked_branch_kept_notice( - current_branch, - branch, - switch_block_reason.split(":", 1)[1], - ) + current_branch, branch, switch_block_reason.split(":", 1)[1]) else: parked_branch_switched = True print( f" ⚠ Checkout was parked on '{current_branch}' " - f"(fully merged) — switching back to {branch}..." - ) + f"(fully merged) — switching back to {branch}...") return parked_branch_switched, in_place_update, switch_block_reason @@ -1009,15 +858,13 @@ def _prepare_checkout_for_update( gateway_mode, gw_input_fn, switch_branch, - _windows_gateway_resume, -): + _windows_gateway_resume): """Parked-branch guard, land on the target, stash, count new commits. Exits when the checkout is unsafe to move or the target is missing. ``commit_count`` is 0 when up to date, -1 when tips differ but the shallow count is unrecoverable.""" parked_branch_switched, in_place_update, switch_block_reason = _apply_parked_branch_guard( git_cmd, branch, current_branch, switch_branch=switch_branch, - _windows_gateway_resume=_windows_gateway_resume, - ) + _windows_gateway_resume=_windows_gateway_resume) if not in_place_update and current_branch != branch: if current_branch == "HEAD": @@ -1034,8 +881,7 @@ def _prepare_checkout_for_update( _m().PROJECT_ROOT, auto_stash_ref, prompt_user=False, - input_fn=gw_input_fn, - ) + input_fn=gw_input_fn) print(f"✗ Branch '{branch}' does not exist locally or on origin.") if track_result.stderr.strip(): print(f" {track_result.stderr.strip().splitlines()[0]}") @@ -1046,8 +892,7 @@ def _prepare_checkout_for_update( prompt_for_restore = ( auto_stash_ref is not None and not assume_yes - and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())) - ) + and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))) # On shallow checkouts `rev-list --count` can report the entire remote ancestry. The # zero/nonzero gate is still sound; treat the shallow NUMBER as unknown and recover it @@ -1056,12 +901,9 @@ def _prepare_checkout_for_update( commit_count = int(result.stdout.strip()) apply_is_shallow = ( - _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() - == "true" - ) + _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true") if commit_count > 0 and apply_is_shallow: from hermes_cli.banner import _github_compare_behind - head_sha = _git_run(git_cmd, ["rev-parse", "HEAD"]).stdout.strip() target_sha = _git_run(git_cmd, ["rev-parse", f"origin/{branch}"]).stdout.strip() counted = _github_compare_behind(head_sha, target_sha) @@ -1075,31 +917,18 @@ def _prepare_checkout_for_update( if commit_count == 0 and is_fork and branch == "main": pre_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) upstream_checked = _m()._sync_with_upstream_if_needed( - git_cmd, - _m().PROJECT_ROOT, - assume_yes=assume_yes, - input_fn=gw_input_fn, - ) + git_cmd, _m().PROJECT_ROOT, assume_yes=assume_yes, input_fn=gw_input_fn) post_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) if pre_sync_sha and post_sync_sha and pre_sync_sha != post_sync_sha: synced_count = _count_commits_between( - git_cmd, - _m().PROJECT_ROOT, - pre_sync_sha, - post_sync_sha, - ) + git_cmd, _m().PROJECT_ROOT, pre_sync_sha, post_sync_sha) # HEAD moving is proof of an update even if the count can't be read. commit_count = max(1, synced_count) return _CheckoutPlan( - auto_stash_ref=auto_stash_ref, - commit_count=commit_count, - in_place_update=in_place_update, - parked_branch_switched=parked_branch_switched, - prompt_for_restore=prompt_for_restore, - switch_block_reason=switch_block_reason, - upstream_checked=upstream_checked, - ) + auto_stash_ref=auto_stash_ref, commit_count=commit_count, in_place_update=in_place_update, + parked_branch_switched=parked_branch_switched, prompt_for_restore=prompt_for_restore, + switch_block_reason=switch_block_reason, upstream_checked=upstream_checked) @dataclass @@ -1126,10 +955,7 @@ def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions: # Captured before any pull so the completion line can report the transition. pre_update_version = _read_project_version() gw_input_fn = ( - (lambda prompt, default="": _gateway_prompt(prompt, default)) - if gateway_mode - else None - ) + (lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None) assume_yes = bool(getattr(args, "yes", False)) # --keep-stash (desktop updater): never re-apply the autostash; only when an update # landed — abort/no-op paths still restore since the tree is unchanged. @@ -1141,33 +967,18 @@ def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions: # Interactive terminals always stash-and-ask; only non-interactive updates consult # updates.non_interactive_local_changes (auto-restore vs discard). _non_interactive_update = ( - gateway_mode - or assume_yes - or not (sys.stdin.isatty() and sys.stdout.isatty()) - ) + gateway_mode or assume_yes or not (sys.stdin.isatty() and sys.stdout.isatty())) discard_local_changes = False if _non_interactive_update: - try: - from hermes_cli.config import load_config - - _update_cfg = (load_config() or {}).get("updates", {}) - if isinstance(_update_cfg, dict): - _mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower() - discard_local_changes = _mode == "discard" - except Exception as exc: - # A config read failure must never change the safe default. - logger.debug("Could not read updates.non_interactive_local_changes: %s", exc) - discard_local_changes = False + # A config read failure must never change the safe default. + with _best_effort("Could not read updates.non_interactive_local_changes: %s"): + _mode = str(_updates_config().get("non_interactive_local_changes", "stash")).lower() + discard_local_changes = _mode == "discard" return _UpdateOptions( active_lazy_features=active_lazy_features, - active_tool_dependencies=active_tool_dependencies, - pre_update_version=pre_update_version, - gw_input_fn=gw_input_fn, - assume_yes=assume_yes, - keep_stash=keep_stash, - switch_branch=switch_branch, - discard_local_changes=discard_local_changes, - ) + active_tool_dependencies=active_tool_dependencies, pre_update_version=pre_update_version, + gw_input_fn=gw_input_fn, assume_yes=assume_yes, keep_stash=keep_stash, + switch_branch=switch_branch, discard_local_changes=discard_local_changes) def _begin_update_receipt_and_plan(args): @@ -1177,7 +988,6 @@ def _begin_update_receipt_and_plan(args): # Structured receipt: record what this run discovers/does/skips so silent failures are diagnosable. with _best_effort('Update receipt unavailable: %s'): from hermes_cli.update_receipt import begin_update_receipt - begin_update_receipt() # Plan phase: snapshot runtimes/supervisors/version (read-only; probe failure records @@ -1185,9 +995,7 @@ def _begin_update_receipt_and_plan(args): _pre_update_plan = None with _best_effort('Update plan phase failed: %s'): from hermes_cli.update_inventory import ( - collect_runtime_inventory, - record_plan_in_receipt, - ) + collect_runtime_inventory, record_plan_in_receipt) _pre_update_plan = collect_runtime_inventory() record_plan_in_receipt(_pre_update_plan) @@ -1226,24 +1034,11 @@ def _prepare_git_command() -> tuple[bool, list, bool]: sys.exit(1) # Windows git can fail "unable to write loose object file: Invalid argument" (fs atomicity). - if sys.platform == "win32" and git_dir.exists(): - subprocess.run( - [ - "git", - "-c", - "windows.appendAtomically=false", - "config", - "windows.appendAtomically", - "false", - ], - cwd=_m().PROJECT_ROOT, - check=False, - capture_output=True, - ) - git_cmd = ["git"] if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] + if git_dir.exists(): + _git_run(git_cmd, ["config", "windows.appendAtomically", "false"]) # A broken Git-for-Windows trampoline refuses every call with a "BUG (fork bomb)" guard; # swap in a real binary up front so git survives instead of degrading to ZIP. git_cmd = _ensure_non_trampoline_git(git_cmd) @@ -1275,12 +1070,10 @@ def _verify_head_after_pull( print("✗ Code did not move — update was a no-op.") print( f" HEAD is pinned to {pre_pull_sha[:10]} (detached checkout); " - f"origin/{branch} advanced but the working tree stayed put." - ) + f"origin/{branch} advanced but the working tree stayed put.") print( " Reattach to the branch and retry: " - f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update" - ) + f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) @@ -1288,27 +1081,21 @@ def _verify_head_after_pull( # legitimate exception (origin/ merged INTO the checked-out branch). post_pull_branch = _git_run(git_cmd, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() if ( - not in_place_update - and post_pull_branch - and post_pull_branch not in {branch, "HEAD"} - ): + not in_place_update and post_pull_branch and post_pull_branch not in {branch, "HEAD"}): print() print( f"✗ Update pulled origin/{branch}, but the checkout is on " - f"'{post_pull_branch}' — not claiming success." - ) + f"'{post_pull_branch}' — not claiming success.") print( " Switch to the target branch and retry: " - f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update" - ) + f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) return post_pull_sha def _handle_update_called_process_error( - e, args, gateway_mode: bool, had_desktop_app_before_update: bool -) -> None: + e, args, gateway_mode: bool, had_desktop_app_before_update: bool) -> None: """Git/installer failure: ZIP-fallback when safe, else report and ``sys.exit(1)``.""" stage = _format_update_failure_stage(e) if _should_zip_fallback_on_update_error(e): @@ -1316,9 +1103,7 @@ def _handle_update_called_process_error( print("→ Falling back to ZIP download...") print() desktop_build_ok = _update_via_zip( - args, - had_desktop_app_before_update=had_desktop_app_before_update, - ) + args, had_desktop_app_before_update=had_desktop_app_before_update) if gateway_mode: _write_gateway_update_exit_code(desktop_build_ok) else: @@ -1327,18 +1112,14 @@ def _handle_update_called_process_error( if _called_process_error_is_python_dep_install(e): print( " The git update already finished. Re-downloading the source " - "ZIP cannot fix a dependency install error and would overwrite " - "local files." - ) + "ZIP cannot fix a dependency install error and would overwrite local files.") if _m()._is_windows(): print(" Retry through the venv interpreter:") print( ' venv\\Scripts\\python.exe -c ' - '"from hermes_cli.main import main; main()" update --yes' - ) + '"from hermes_cli.main import main; main()" update --yes') with suppress(Exception): from hermes_cli.update_receipt import finalize_update_receipt - finalize_update_receipt("failed") sys.exit(1) @@ -1357,54 +1138,38 @@ def _finish_already_up_to_date( had_desktop_app_before_update: bool, active_lazy_features, active_tool_dependencies, - _windows_gateway_resume, -) -> None: + _windows_gateway_resume) -> None: """"Already up to date" path: restore stash/branch, repair the checkout, catch up the fleet. ``sys.exit(1)`` when the repair is incomplete (after gateway exit code + partial receipt).""" - auto_stash_ref = _plan.auto_stash_ref - parked_branch_switched = _plan.parked_branch_switched - prompt_for_restore = _plan.prompt_for_restore - switch_block_reason = _plan.switch_block_reason - upstream_checked = _plan.upstream_checked _invalidate_update_cache() # Restore stash and switch back if we moved. EXCEPTION: a parked branch verified clean + # fully merged stays on the target — re-parking on the stale branch recreates the incident. - if auto_stash_ref is not None: + if _plan.auto_stash_ref is not None: _m()._restore_stashed_changes( - git_cmd, - _m().PROJECT_ROOT, - auto_stash_ref, - prompt_user=prompt_for_restore, - input_fn=gw_input_fn, - ) - if parked_branch_switched: - if switch_block_reason.startswith("unmerged:"): - _count = switch_block_reason.split(":", 1)[1] + git_cmd, _m().PROJECT_ROOT, _plan.auto_stash_ref, prompt_user=_plan.prompt_for_restore, + input_fn=gw_input_fn) + if _plan.parked_branch_switched: + if _plan.switch_block_reason.startswith("unmerged:"): + _count = _plan.switch_block_reason.split(":", 1)[1] print( f" ✓ Checkout was parked on '{current_branch}' — " f"switched back to {branch}; {_count} unmerged " - f"commit(s) kept on '{current_branch}'." - ) + f"commit(s) kept on '{current_branch}'.") else: print( f" ✓ Checkout was parked on '{current_branch}' (fully " - f"merged) — switched back to {branch}." - ) + f"merged) — switched back to {branch}.") elif current_branch not in {branch, "HEAD"}: _git_run(git_cmd, ["checkout", current_branch]) current_checkout_complete = _repair_current_checkout( - assume_yes=assume_yes, - gateway_mode=gateway_mode, - pre_update_snapshot_id=pre_update_snapshot_id, - desktop_dir=desktop_dir, + assume_yes=assume_yes, gateway_mode=gateway_mode, + pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=active_lazy_features, - active_tool_dependencies=active_tool_dependencies, - upstream_checked=upstream_checked, - _windows_gateway_resume=_windows_gateway_resume, - ) + active_tool_dependencies=active_tool_dependencies, upstream_checked=_plan.upstream_checked, + _windows_gateway_resume=_windows_gateway_resume) _m()._resume_windows_gateways_after_update(_windows_gateway_resume) # A prior pull may still owe the fleet a restart; catch up here too, BEFORE the exit # gate so a partial outcome can't strand the fleet on stale code. @@ -1414,7 +1179,6 @@ def _finish_already_up_to_date( _write_gateway_update_exit_code(False) with _best_effort('Update receipt finalize (current checkout) failed: %s'): from hermes_cli.update_receipt import finalize_update_receipt - finalize_update_receipt("partial") sys.exit(1) @@ -1425,12 +1189,7 @@ def _cmd_update_impl(args, gateway_mode: bool): opts = _resolve_update_options(args, gateway_mode) active_lazy_features = opts.active_lazy_features active_tool_dependencies = opts.active_tool_dependencies - pre_update_version = opts.pre_update_version - gw_input_fn = opts.gw_input_fn - assume_yes = opts.assume_yes - keep_stash = opts.keep_stash - switch_branch = opts.switch_branch - discard_local_changes = opts.discard_local_changes + gw_input_fn, assume_yes = opts.gw_input_fn, opts.assume_yes print("⚕ Updating Hermes Agent...") print() @@ -1443,13 +1202,11 @@ def _cmd_update_impl(args, gateway_mode: bool): _record_update_step( "pre_update_backup", pre_update_snapshot_id is not None, - f"snapshot={pre_update_snapshot_id}" if pre_update_snapshot_id else "disabled or failed", - ) + f"snapshot={pre_update_snapshot_id}" if pre_update_snapshot_id else "disabled or failed") _windows_gateway_resume = _m()._pause_windows_gateways_for_update() if _windows_gateway_resume: import atexit as _atexit - _atexit.register(_m()._resume_windows_gateways_after_update, _windows_gateway_resume) # Any venv python still running (typically the Desktop `hermes serve` backend) keeps .pyd @@ -1458,10 +1215,9 @@ def _cmd_update_impl(args, gateway_mode: bool): if _m()._is_windows() and not getattr(args, "force_venv", False): _clear_windows_venv_holders_or_exit(args, gateway_mode, _windows_gateway_resume) - # Self-lock deferral (this process IS the venv python and can't rewrite its own mapped - # .pyd) deliberately does NOT run here: pre-fetch it stranded users on the OLD checkout - # and eager cryptography imports made every Windows update an exit-2 loop. It runs via - # _abort_dependency_sync_if_self_locked() right before the dependency sync instead. + # Self-lock deferral deliberately does NOT run here (pre-fetch it stranded users on the OLD + # checkout and made every Windows update an exit-2 loop); it runs right before the + # dependency sync via _abort_dependency_sync_if_self_locked(). # After every fail-closed venv guard, before either path can remove the release tree. desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop" @@ -1472,9 +1228,7 @@ def _cmd_update_impl(args, gateway_mode: bool): if use_zip_update: try: desktop_build_ok = _update_via_zip( - args, - had_desktop_app_before_update=had_desktop_app_before_update, - ) + args, had_desktop_app_before_update=had_desktop_app_before_update) finally: _m()._resume_windows_gateways_after_update(_windows_gateway_resume) if gateway_mode: @@ -1487,7 +1241,6 @@ def _cmd_update_impl(args, gateway_mode: bool): # Self-heal abandoned .git/*.lock files (crashed fetch) or the fetch fails "File exists". from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs - cleared = clear_stale_git_locks(_m().PROJECT_ROOT) if cleared: print(" (removed stale git lock(s): %s)" % ", ".join(cleared)) @@ -1509,37 +1262,20 @@ def _cmd_update_impl(args, gateway_mode: bool): current_branch = result.stdout.strip() _plan = _prepare_checkout_for_update( - git_cmd, - branch, - current_branch, - is_fork=is_fork, - assume_yes=assume_yes, - gateway_mode=gateway_mode, - gw_input_fn=gw_input_fn, - switch_branch=switch_branch, - _windows_gateway_resume=_windows_gateway_resume, - ) - auto_stash_ref = _plan.auto_stash_ref + git_cmd, branch, current_branch, is_fork=is_fork, assume_yes=assume_yes, + gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, switch_branch=opts.switch_branch, + _windows_gateway_resume=_windows_gateway_resume) commit_count = _plan.commit_count - in_place_update = _plan.in_place_update - prompt_for_restore = _plan.prompt_for_restore if commit_count == 0: _finish_already_up_to_date( - git_cmd, - branch, - current_branch, - _plan, - assume_yes=assume_yes, - gateway_mode=gateway_mode, - gw_input_fn=gw_input_fn, - pre_update_snapshot_id=pre_update_snapshot_id, - desktop_dir=desktop_dir, + git_cmd, branch, current_branch, _plan, assume_yes=assume_yes, + gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, + pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, - _windows_gateway_resume=_windows_gateway_resume, - ) + _windows_gateway_resume=_windows_gateway_resume) return if commit_count > 0: @@ -1550,24 +1286,15 @@ def _cmd_update_impl(args, gateway_mode: bool): print("→ Pulling updates...") pre_pull_sha = _pull_updates( - git_cmd, - branch, - auto_stash_ref, - prompt_for_restore=prompt_for_restore, - gw_input_fn=gw_input_fn, - discard_local_changes=discard_local_changes, - keep_stash=keep_stash, - ) + git_cmd, branch, _plan.auto_stash_ref, prompt_for_restore=_plan.prompt_for_restore, + gw_input_fn=gw_input_fn, discard_local_changes=opts.discard_local_changes, + keep_stash=opts.keep_stash) _invalidate_update_cache() post_pull_sha = _verify_head_after_pull( - git_cmd, - branch, - pre_pull_sha, - in_place_update=in_place_update, - _windows_gateway_resume=_windows_gateway_resume, - ) + git_cmd, branch, pre_pull_sha, in_place_update=_plan.in_place_update, + _windows_gateway_resume=_windows_gateway_resume) # Gateways still serve pre-pull modules until the restart phase; an interrupt before a # completed restart leaves this marker so the next update catches up even when git is @@ -1579,43 +1306,30 @@ def _cmd_update_impl(args, gateway_mode: bool): if is_fork and branch == "main": _m()._sync_with_upstream_if_needed( - git_cmd, - _m().PROJECT_ROOT, - assume_yes=assume_yes, - input_fn=gw_input_fn, - ) + git_cmd, _m().PROJECT_ROOT, assume_yes=assume_yes, input_fn=gw_input_fn) # .[all], falling back to base + extras individually so one broken extra doesn't strip # the rest; the ownership preflight refuses first on foreign-owned (sudo-pip) venv files. _sync_python_dependencies_after_pull( - git_cmd, - branch, - pre_pull_sha, - active_lazy_features=active_lazy_features, + git_cmd, branch, pre_pull_sha, active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, - _windows_gateway_resume=_windows_gateway_resume, - ) + _windows_gateway_resume=_windows_gateway_resume) node_failures = _update_node_dependencies() _m()._build_web_ui(_m().PROJECT_ROOT / "web") desktop_build_ok = _rebuild_desktop_after_update( - desktop_dir, - had_desktop_app_before_update=had_desktop_app_before_update, - ) + desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update) print() print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") update_complete = _run_post_update_maintenance( - assume_yes=assume_yes, - gateway_mode=gateway_mode, + assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, had_desktop_app_before_update=had_desktop_app_before_update, - node_failures=node_failures, - desktop_build_ok=desktop_build_ok, - pre_update_version=pre_update_version, - ) + node_failures=node_failures, desktop_build_ok=desktop_build_ok, + pre_update_version=opts.pre_update_version) # Exit code *before* the restart: under --gateway this process lives in the gateway's # systemd cgroup and the systemctl-restart fallback SIGKILLs it (KillMode=mixed), so @@ -1626,12 +1340,9 @@ def _cmd_update_impl(args, gateway_mode: bool): _restart = _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode) _resume_windows_gateways_and_merge_outcome(_restart, _windows_gateway_resume, gateway_mode) _verify_fleet_after_update( - _restart, - _pre_update_plan=_pre_update_plan, - _windows_gateway_resume=_windows_gateway_resume, - node_failures=node_failures, - update_complete=update_complete, - ) + _restart, _pre_update_plan=_pre_update_plan, + _windows_gateway_resume=_windows_gateway_resume, node_failures=node_failures, + update_complete=update_complete) except _shim_quarantine_error_type() as e: # Strict quarantine refused BEFORE any installer ran — defer via marker, exit 2, no ZIP. diff --git a/hermes_cli/update_cmd_config.py b/hermes_cli/update_cmd_config.py index d4f5bb3273..0ca4d06206 100644 --- a/hermes_cli/update_cmd_config.py +++ b/hermes_cli/update_cmd_config.py @@ -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.`` -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.`` 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"}: diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index f6d90a9acc..9510dd7366 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -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.`` -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.`` 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)