Files
hermes-agent/hermes_cli/_install_repair.py
ethernet bbec973514 refactor(pm): pm owns the dependency-environment layout and interpreter paths
hermes_cli.runtime_paths (venv generations, selection, activation) moves to
pm.environments, and gains venv_bin_dir / venv_python / project_python. Every
in-tree caller asks pm for an interpreter now; pm no longer reaches back into
hermes_cli for its own environment layout (pm.packages, pm.extras, pm.ensure,
pm.paths imported hermes_cli.runtime_paths). The three open-coded
"Scripts/python.exe or bin/python" ladders in pm collapse onto venv_python.

hermes_constants.venv_python_path / venv_bin_dir and hermes_cli.runtime_paths
stay as frozen-updater-surface shims only (tests/compat/old_updater_surface.json).

To keep the boot path light, pm/__init__ resolves its facade lazily (PEP 562)
and pm.registry loads the built-in package definitions on first read instead of
at import: `import hermes_bootstrap` now loads pm + pm.environments only (25ms,
was 37ms with the eager facade dragging in the downloader). The stripped-payload
fixtures that ship only pre-import files keep working for the same reason.

Also restores two frozen-surface re-exports the F401 sweep dropped
(banner._github_compare_behind, cua_backend.resolve_cua_driver_cmd).
2026-09-18 20:02:36 -04:00

343 lines
14 KiB
Python

"""Repair Windows launchers and their registered PATH entries."""
from __future__ import annotations
import contextlib
import os
import sys
from pathlib import Path
def _sync_windows_cli_launchers(root: Path) -> list[Path]:
# Shim to stop the old updater doing work until relaunch. Copy no launchers.
return []
def _is_windows() -> bool:
return sys.platform == "win32"
#: Launcher command names install.ps1's Set-PathVariable exposes from the
#: managed binary dir (the default Hermes root's ``bin``, next to uv.exe)
#: on the user PATH. Keep in lockstep with WINDOWS_BIN_LAUNCHERS in
#: hermes_cli/_launchers.py and scripts/install.ps1.
_WINDOWS_BIN_LAUNCHERS = ("hermes", "hermes-acp")
def _normalize_windows_path(value) -> str:
"""Windows path equality key: backslashes, no trailing separator, lowered.
Lowercase via ``.lower()`` (what ``ntpath.normcase`` does) rather than
``os.path.normcase`` — that is an identity function on POSIX, and this
comparison must behave Windows-correct even when tests exercise the
Windows branch from another host (same rationale as
``venv_bin_dir(windows=...)``).
"""
return str(value).replace("/", "\\").rstrip("\\").lower()
def _windows_user_path_entries() -> list[str]:
"""User PATH entries from the registry — the value install.ps1 writes.
Falls back to the process PATH when the registry is unreadable. Only
called on Windows.
"""
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
raw, _kind = winreg.QueryValueEx(key, "Path")
value = os.path.expandvars(str(raw))
except (OSError, ImportError):
value = os.environ.get("PATH", "")
return [entry for entry in value.split(";") if entry.strip()]
def ensure_windows_bin_launchers(
root,
*,
windows: bool | None = None,
user_path_entries: list[str] | None = None,
) -> list[str]:
"""Re-stage the Windows ``hermes`` launchers when they vanish or when
they still boot through the venv.
On Windows, ``hermes`` resolves through staged launchers — never
``venv\\Scripts`` itself on PATH, which would shadow the user's
``python`` (#83797) — and under pm the launchers boot the pm STORE
python with ``PYTHONPATH=<repo>;<venv>/site-packages``, never the venv
interpreter (no-boot-through-venv; ``pyvenv.cfg`` is inert dead
config). The canonical launcher home is
the managed binary dir — the default Hermes root's ``bin``
(``%LOCALAPPDATA%\\hermes\\bin``, next to the managed uv) — which lives
OUTSIDE the git checkout so no git operation can ever touch it. It is
a per-machine dir shared by every profile: ``get_hermes_home()`` would
point inside ``profiles\\<name>`` under ``hermes -p``, so the anchor
here is :func:`hermes_constants.get_default_hermes_root`.
Earlier installer versions staged them at ``<checkout>\\bin`` instead —
inside the git working tree — where ``hermes update``'s pre-update
autostash (``git stash push --include-untracked``) swept them off disk;
once the desktop updater stopped re-applying stashes (``--keep-stash``)
nothing restored them and ``hermes`` stopped resolving in every new
terminal. That legacy location is re-staged too, during the transition,
for installs whose user PATH still resolves through it.
A name counts as present when an exe exists that does NOT boot the
venv interpreter — legacy copied-venv trampolines (detected by their
embedded interpreter path) and placeholder .cmd delegators are replaced
with a store-python launcher as soon as one can be minted.
Two targets, two gates, both failing toward inaction:
- canonical managed binary dir: only when *root* is the managed clone
(``root.parent == get_default_hermes_root()``), so source checkouts
elsewhere never gain launchers;
- legacy ``<root>\\bin``: only when that dir is on the user PATH
(registry value, process PATH as fallback), i.e. the install opted
into the old layout and still resolves through it.
Writes go through a staging name + ``os.replace`` so concurrent process
starts cannot tear a launcher. Never raises; returns the restored paths.
*windows* and *user_path_entries* are injectable for tests, same pattern
as ``hermes_constants.venv_bin_dir``.
"""
if windows is None:
windows = _is_windows()
if not windows:
return []
root = Path(root)
# Per-machine anchor: the DEFAULT Hermes root, not get_hermes_home() —
# under ``hermes -p <name>`` that returns ``profiles\\<name>``, which
# would fail the managed-clone gate below and silently skip the heal
# for profile users. The launcher dir serves the whole machine.
from hermes_constants import get_default_hermes_root
try:
home = Path(get_default_hermes_root())
except Exception:
return []
def _launcher_present(target: Path, name: str) -> bool:
return (target / f"{name}.exe").exists() or (target / f"{name}.cmd").exists()
# Only the launch producer knows the executable/boot contract. Old venv
# paths below identify obsolete artifacts; they never select dependencies.
from hermes_cli._launchers import (
ensure_install_launchers,
exe_is_venv_bound,
stage_launcher,
)
from hermes_constants import project_venv_dir
venv_dir = project_venv_dir(root)
def _needs_attention(target: Path, name: str) -> bool:
"""Missing, a placeholder .cmd, or a launcher that still boots the
venv interpreter — anything the store-python launcher should replace."""
exe = target / f"{name}.exe"
if not exe.exists():
return not ((target / f"{name}.cmd").is_file()
and _launcher_present(root / ".hermes" / "bin", name))
return exe_is_venv_bound(exe, venv_dir)
targets: list[Path] = []
restored: list[str] = []
# Canonical target — gate on the managed-clone shape. This runs at
# every hermes_cli.main process start (right after the profile
# override), so the healthy path must stay at a couple of stat calls.
if _normalize_windows_path(root.parent) == _normalize_windows_path(home):
canonical = home / "bin"
local = root / ".hermes" / "bin"
if any(not _launcher_present(local, name) for name in _WINDOWS_BIN_LAUNCHERS):
# Upgrade existing PM installs too: their healthy external launcher
# predates the exact-install command and may lack the runtime query.
try:
canonical.mkdir(parents=True, exist_ok=True)
restored.extend(ensure_install_launchers(root, canonical))
except OSError:
return []
if not restored and any(
_needs_attention(canonical, name) for name in _WINDOWS_BIN_LAUNCHERS
):
targets.append(canonical)
# Legacy transition target — the pre-migration in-checkout dir. Only
# re-staged while the user PATH still points at it (consent), compared
# as normalized literal strings: the installer wrote the long literal
# path, and realpath'ing arbitrary PATH entries could hang on dead
# network shares. An entry stored some other way (8.3 short path,
# subst drive) misses the re-stage, which fails safe: no-op.
legacy = root / "bin"
if any(_needs_attention(legacy, name) for name in _WINDOWS_BIN_LAUNCHERS):
if user_path_entries is None:
user_path_entries = _windows_user_path_entries()
configured = {_normalize_windows_path(entry) for entry in user_path_entries}
if _normalize_windows_path(legacy) in configured:
targets.append(legacy)
if not targets:
return restored
for target in targets:
try:
target.mkdir(parents=True, exist_ok=True)
except OSError:
continue
for name in _WINDOWS_BIN_LAUNCHERS:
if not _needs_attention(target, name):
# Already a store-python launcher (or a form this heal does
# not understand but that does not boot the venv): leave it.
continue
final = stage_launcher(name, root, target)
if final is not None:
# Windows resolves .exe before .cmd. A surviving venv-bound
# launcher would shadow the successfully staged fallback.
obsolete = target / f"{name}.exe"
if final.suffix == ".cmd" and exe_is_venv_bound(obsolete, venv_dir):
obsolete.unlink()
restored.append(str(final))
if restored:
# Guarded like everything else in this never-raises helper: a
# closed/broken stderr must not turn a successful heal into a crash.
with contextlib.suppress(OSError, ValueError):
print(
" ✓ Restored hermes launcher(s): " + ", ".join(restored),
file=sys.stderr,
)
return restored
def _read_user_path_raw() -> tuple[list[str], int]:
"""Raw (unexpanded) user PATH entries + registry value type.
Raw so a rewrite preserves ``%VARS%`` exactly as the user stored them
(same discipline as ``hermes_cli.uninstall``). Only called on Windows.
"""
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
try:
raw, kind = winreg.QueryValueEx(key, "Path")
except FileNotFoundError:
return [], winreg.REG_EXPAND_SZ
return [entry for entry in str(raw).split(";") if entry], int(kind)
def _write_user_path_raw(entries: list[str], kind: int) -> None:
"""Write the user PATH back, preserving the registry value type."""
import winreg
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE
) as key:
winreg.SetValueEx(key, "Path", 0, kind, ";".join(entries))
def migrate_windows_bin_path(
root,
*,
windows: bool | None = None,
read_user_path=None,
write_user_path=None,
) -> bool:
"""One-time PATH migration to the ``HERMES_HOME\\bin`` launcher layout.
Runs from the ``hermes update`` tail (and mirrors what install.ps1's
Set-PathVariable does on fresh installs/repairs, which never reach
existing installs — updates don't run install.ps1):
1. stage the launcher copies into the managed binary dir (via
:func:`ensure_windows_bin_launchers`);
2. verify both launchers are present there — otherwise STOP, leaving
the user PATH untouched (never strip a working entry before its
replacement is proven);
3. ensure the managed binary dir is on the user PATH (prepend);
4. strip the legacy entries: ``<root>\\bin`` (in-checkout launcher dir
the update autostash could sweep) and ``<root>\\venv\\Scripts``
(shadowed the user's ``python``, #83797).
The legacy ``<root>\\bin`` FILES are deliberately left in place: editor
and ACP configs that captured absolute launcher paths keep working
(the launchers run fine from there — only PATH resolution through a
dir git could sweep was the bug), and the dir is git-ignored so it
cannot dirty the tree.
Registry writes preserve the stored value type and raw ``%VARS%``.
Never raises; returns True when the canonical layout is in place.
*read_user_path*/*write_user_path* are injectable for tests.
"""
if windows is None:
windows = _is_windows()
if not windows:
return False
root = Path(root)
# Same per-machine anchor as ensure_windows_bin_launchers (see there).
from hermes_constants import get_default_hermes_root
from pm.environments import venv_bin_dir
try:
home = Path(get_default_hermes_root())
except Exception:
return False
if _normalize_windows_path(root.parent) != _normalize_windows_path(home):
return False # not the managed clone — nothing to migrate
ensure_windows_bin_launchers(root, windows=windows, user_path_entries=[])
home_bin = home / "bin"
if any(
not ((home_bin / f"{name}.exe").is_file() or (home_bin / f"{name}.cmd").is_file())
for name in _WINDOWS_BIN_LAUNCHERS
):
return False # staging incomplete — leave the PATH alone
if read_user_path is None:
read_user_path = _read_user_path_raw
if write_user_path is None:
write_user_path = _write_user_path_raw
try:
entries, kind = read_user_path()
except (OSError, ImportError):
return False
legacy_keys = {
_normalize_windows_path(root / "bin"),
# The pre-#83797 installer put the venv's Scripts dir itself on PATH,
# always at the literal `venv` layout (never `.venv`) — this strips
# that stale entry, so it must match what the installer wrote then,
# not where the venv lives now.
_normalize_windows_path(venv_bin_dir(root / "venv", windows=True)),
}
home_bin_key = _normalize_windows_path(home_bin)
def _entry_key(entry: str) -> str:
return _normalize_windows_path(os.path.expandvars(entry))
kept = [e for e in entries if _entry_key(e) not in legacy_keys]
have_home_bin = any(_entry_key(e) == home_bin_key for e in kept)
if not have_home_bin:
kept = [str(home_bin)] + kept
if kept != entries:
try:
write_user_path(kept, kind)
except (OSError, ImportError):
return False
with contextlib.suppress(OSError, ValueError):
print(
f" ✓ hermes launchers now resolve from {home_bin} "
"(legacy PATH entries removed)",
file=sys.stderr,
)
return True