Files
hermes-agent/hermes_cli/main_install_repair.py
T

1678 lines
66 KiB
Python

"""Install/update recovery: interrupted-install markers, lazy-refresh repair, Windows shim quarantine, dependency verification.
Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so
``hermes_cli.main.<name>`` keeps resolving (and monkeypatching) as before.
Names that stay in main are imported lazily inside the functions that use them
(call-time resolution keeps ``hermes_cli.main.<name>`` patches effective and
avoids an import cycle).
"""
import logging
import os
import shlex
import shutil
import subprocess
import sys
import threading
import time as _time
from pathlib import Path
from hermes_cli import _early_recovery as _early_recovery_mod
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.main")
def _load_installable_optional_extras(group: str = "all") -> list[str]:
"""Return optional extras referenced by a dependency group.
``group`` is usually ``all`` (desktop/server broad install) or
``termux-all`` (Termux-compatible broad install).
"""
from hermes_cli.main import PROJECT_ROOT
try:
import tomllib
with (PROJECT_ROOT / "pyproject.toml").open("rb") as handle:
project = tomllib.load(handle).get("project", {})
except Exception:
return []
optional_deps = project.get("optional-dependencies", {})
if not isinstance(optional_deps, dict):
return []
refs = optional_deps.get(group, [])
referenced: list[str] = []
for ref in refs:
if "[" in ref and "]" in ref:
name = ref.split("[", 1)[1].split("]", 1)[0]
if name in optional_deps:
referenced.append(name)
return referenced
# Install-scoped breadcrumbs live next to the venv (not under $HERMES_HOME)
# because the venv is shared across profiles.
#
# ``.update-incomplete`` — generic core ``.[all]`` install was interrupted.
# Cleared only after a confirmed full dependency reinstall/recovery.
#
# ``.lazy-refresh-incomplete`` — lazy-backend refresh phase may have corrupted
# packages. Cleared only after import-probe repair confirms healthy (not when
# probes are unavailable/indeterminate). Narrow lazy probes must NEVER clear
# the generic core marker (#58004 review).
def _update_marker_path() -> Path:
from hermes_cli.main import PROJECT_ROOT
return PROJECT_ROOT / ".update-incomplete"
def _lazy_refresh_marker_path() -> Path:
from hermes_cli.main import PROJECT_ROOT
return PROJECT_ROOT / ".lazy-refresh-incomplete"
def _pytest_owns_live_checkout(root: Path) -> bool:
"""True when running under pytest AND ``root`` is this checkout itself.
Tests that drive update/recovery without sandboxing ``PROJECT_ROOT``
must neither litter the live repo root with recovery breadcrumbs
(a leftover ``.lazy-refresh-incomplete`` / ``.update-incomplete``
false-arms recovery on the developer's next real launch) nor run a real
reinstall against the executing venv. Sandboxed tests point at a
tmp_path and are unaffected (same posture as
``managed_scope._under_pytest``)."""
return (
"PYTEST_CURRENT_TEST" in os.environ
and root == Path(__file__).resolve().parent.parent
)
def _clear_marker_file(path: Path, *, label: str) -> None:
"""Remove an update-recovery breadcrumb. Never raises."""
try:
path.unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.debug("Could not clear %s marker: %s", label, exc)
def _clear_update_incomplete_marker() -> None:
"""Remove the interrupted core-install breadcrumb. Never raises."""
from hermes_cli.main import _update_marker_path
_clear_marker_file(_update_marker_path(), label="update-incomplete")
def _clear_lazy_refresh_incomplete_marker() -> None:
"""Remove the interrupted lazy-refresh breadcrumb. Never raises."""
_clear_marker_file(_lazy_refresh_marker_path(), label="lazy-refresh-incomplete")
def _recover_from_interrupted_install() -> None:
"""Finish update work left half-done by a prior ``hermes update``.
Handles two independent breadcrumbs:
- ``.update-incomplete`` — core ``.[all]`` install interrupted. Recovers
via full quarantined reinstall. Never cleared by the narrow lazy-refresh
import probes alone.
- ``.lazy-refresh-incomplete`` — lazy-backend refresh may have corrupted
packages. Recovers via package-only import probes; cleared only when
probes confirm healthy/repaired (indeterminate keeps the marker).
Never raises: a recovery failure must not block launch. If it can't
self-heal it prints the manual command and leaves the relevant marker so
the next launch tries again.
Concurrency: markers live next to the shared venv, so a gateway start
plus a CLI launch (or two profiles starting at once) can both see them.
An ``O_EXCL`` lockfile ensures only one process runs recovery; the
others skip and let the winner clear markers.
Output: everything — our status lines AND the streamed pip/uv install
(which inherits fd 1) — is routed to stderr. Launches whose stdout is a
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
install noise on stdout.
"""
from hermes_cli.main import PROJECT_ROOT, _clear_update_incomplete_marker, _pytest_owns_live_checkout, _recover_core_update_marker_locked, _update_marker_path
if _pytest_owns_live_checkout(PROJECT_ROOT):
return
core_marker = _update_marker_path().exists()
lazy_marker = _lazy_refresh_marker_path().exists()
if not core_marker and not lazy_marker:
return
# Skip in managed/Docker installs and on PyPI installs with no git checkout:
# those don't run the source-tree update path, so a stray marker is not ours
# to act on. Just clear it.
if not (PROJECT_ROOT / "pyproject.toml").is_file():
_clear_update_incomplete_marker()
_clear_lazy_refresh_incomplete_marker()
return
# Single-flight guard: atomically claim the recovery lock. If another
# process holds it, skip — it is running the same reinstall into the same
# shared venv right now. A crashed holder leaves a stale lock; break it
# after an hour (well past any realistic install) so recovery can't be
# wedged forever.
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
except FileExistsError:
try:
if _time.time() - lock_path.stat().st_mtime > 3600:
lock_path.unlink()
except OSError:
pass
return
except OSError as exc:
# Couldn't create the lock (read-only fs, perms). Proceed unlocked —
# the install itself will surface the real problem.
logger.debug("Could not create install-recovery lock: %s", exc)
saved_stdout_fd = None
saved_sys_stdout = sys.stdout
try:
# Route Python-level prints AND subprocess-inherited fd 1 to stderr
# for the duration of recovery (see docstring: ACP stdout safety).
try:
saved_stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
saved_stdout_fd = None
sys.stdout = sys.stderr
if lazy_marker:
_recover_lazy_refresh_marker_locked()
if _update_marker_path().exists():
_recover_core_update_marker_locked()
finally:
sys.stdout = saved_sys_stdout
if saved_stdout_fd is not None:
try:
os.dup2(saved_stdout_fd, 1)
os.close(saved_stdout_fd)
except OSError:
pass
try:
lock_path.unlink()
except OSError:
pass
def _recover_lazy_refresh_marker_locked() -> None:
"""Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair."""
from hermes_cli.main import _default_venv_install_target, _repair_venv_via_import_probes
print(
"⚠ A previous lazy-backend refresh may have left the venv unhealthy — "
"running import-based package repair..."
)
install_prefix, install_env = _default_venv_install_target()
status = _repair_venv_via_import_probes(install_prefix, env=install_env)
if status in ("healthy", "repaired"):
_clear_lazy_refresh_incomplete_marker()
print("✓ Lazy-refresh venv recovery confirmed — install is healthy again.")
return
if status == "indeterminate":
print(
" ⚠ Import probes unavailable — cannot confirm venv health. "
"Leaving `.lazy-refresh-incomplete` for the next launch."
)
else:
print(
" ⚠ Lazy-refresh package repair incomplete. "
"Leaving `.lazy-refresh-incomplete` for the next launch."
)
print(" Recover manually with:")
all_specs = _lazy_refresh_repair_specs(
sorted(set(_LAZY_REFRESH_REPAIR_PACKAGES.values()))
)
print(
f" {' '.join(install_prefix)} install --force-reinstall "
+ " ".join(shlex.quote(s) for s in all_specs)
)
def _recover_core_update_marker_locked() -> None:
"""Heal ``.update-incomplete`` via full ``.[all]`` reinstall only.
Narrow lazy-refresh import probes are not sufficient proof that a generic
interrupted core install finished — a missing dep outside that probe set
would otherwise look healthy and clear the breadcrumb too early.
"""
from hermes_cli.main import PROJECT_ROOT, _clear_update_incomplete_marker, _default_venv_install_target, _repair_venv_via_import_probes
print(
"⚠ A previous `hermes update` was interrupted mid-install — "
"finishing dependency installation now..."
)
# Windows: a normal ``hermes.exe`` launch always has the launcher as an
# ancestor. Full editable reinstall uses quarantine so the live shim can
# still be replaced. Package-only import repair may help as first aid but
# must NEVER clear this core marker on its own (#58004 review).
self_locked = _windows_running_hermes_launcher_locked()
if self_locked:
install_prefix, install_env = _default_venv_install_target()
print(
" → Running from hermes.exe; applying package-only first aid, "
"then quarantined full reinstall (core marker stays until that "
"succeeds)..."
)
_repair_venv_via_import_probes(install_prefix, env=install_env)
try:
from hermes_cli import _install_repair as _ir
# ensure_uv bootstraps the installer itself when missing (the early
# pass's stdlib-only lookup cannot); keeping it here means the late
# path still self-heals a venv whose uv vanished mid-update.
from hermes_cli.managed_uv import ensure_uv
ensure_uv()
# Delegate the install itself to the shared stdlib executor so both
# this late path and the pre-import early pass run exactly the same
# reinstall. Called inside the same stdout→stderr redirect already
# established by _recover_from_interrupted_install, so
# run_core_install's own redirect nests harmlessly.
_ir.run_core_install(PROJECT_ROOT)
_clear_update_incomplete_marker()
print("✓ Dependency installation recovered — your install is healthy again.")
except Exception as exc:
# Leave the marker in place so the next launch retries. Give the user
# the exact manual recovery command in the meantime.
logger.debug("Interrupted-install recovery failed: %s", exc)
print("✗ Could not auto-recover the interrupted install.")
if self_locked:
print(
" Hermes is still running from the launcher that needs "
"replacing. Close other Hermes windows, restart from a "
"different terminal, then run:"
)
print(f' cd /d "{PROJECT_ROOT}"')
print(
f' "{sys.executable}" -m pip install -e ".[all]"'
)
else:
print(" Recover manually with:")
print(f" cd {PROJECT_ROOT}")
print(f" {sys.executable} -m ensurepip --upgrade")
print(f" {sys.executable} -m pip install -e '.[all]'")
def _norm_exe_path(path) -> str:
"""Case-folded resolved path, for comparing executables on Windows."""
try:
return str(Path(path).resolve()).lower()
except OSError:
return str(path).lower()
def _windows_shim_in_process_chain() -> Path | None:
"""The venv console shim this process runs from or under, if any.
``venv\\Scripts\\hermes.exe`` is a launcher that runs the interpreter with
the shim itself as its script, and that keeps the shim open — without
``FILE_SHARE_DELETE`` — for the whole process lifetime. So every
``hermes ...`` command holds its own shim, and an editable install run
from one can never rewrite it (#88838, #89599).
Two independent probes, because either can come up empty. Process
ancestry finds the launcher when it is a separate parent process, but
needs psutil. This process's own launch paths (``sys.argv[0]``,
``__main__.__file__``, the module spec origin) cover the rest — the
runpy/zipapp launch puts ``<shim>\\__main__.py`` there, which a plain
argv[0] check misses.
Candidates are intersected with the project venv's own shims, so a
``hermes.exe`` belonging to some other install never matches.
"""
from hermes_cli.main import _hermes_exe_shims, _is_windows, _venv_scripts_dir
if not _is_windows():
return None
scripts_dir = _venv_scripts_dir()
if scripts_dir is None:
return None
shims = {_norm_exe_path(shim): shim for shim in _hermes_exe_shims(scripts_dir)}
if not shims:
return None
def _match(candidate) -> Path | None:
path = Path(candidate)
if path.name.lower() == "__main__.py":
path = path.parent
return shims.get(_norm_exe_path(path))
candidates: list[str] = list(sys.argv[:1])
main_mod = sys.modules.get("__main__")
for attr in (getattr(main_mod, "__file__", None),
getattr(getattr(main_mod, "__spec__", None), "origin", None)):
if attr:
candidates.append(attr)
for candidate in candidates:
matched = _match(candidate)
if matched is not None:
return matched
try:
import psutil
me = psutil.Process()
for proc in [me] + list(me.parents()):
try:
matched = _match(proc.exe())
except Exception:
continue
if matched is not None:
return matched
except Exception:
return None
return None
def _windows_running_hermes_launcher_locked() -> bool:
"""True when a venv ``hermes*.exe`` shim is this process or an ancestor.
Best-effort: returns False when psutil is unavailable or inspection fails.
"""
from hermes_cli.main import _windows_shim_in_process_chain
return _windows_shim_in_process_chain() is not None
# Set on the re-exec'd child so it can never spawn another one.
_UPDATE_REEXEC_ENV = "HERMES_UPDATE_REEXEC"
def _reexec_dependency_sync_off_windows_shim() -> bool:
"""Hand the dependency sync to the venv interpreter, off the console shim.
Returns True when a child was spawned and the caller must exit at once,
releasing the shim before the child reaches ``pip install -e .``. Returns
False to continue the sync in-process.
Called at the dependency-sync boundary, NOT at the top of the command —
the same placement rule as the native-module deferral beside it, and for
the same reason (#86735): a hand-off that fires before the fetch detaches
every run, including the ``Already up to date!`` no-op that never touches
the venv at all, and it takes the interactive prompts with it. By the time
we reach here the code swap is done and every question — stash, branch
switch, config migration — has already been asked and answered in the
user's own console. Only the venv rewrite is left, and that is the single
step that genuinely cannot run from inside the shim.
``venv\\Scripts\\hermes.exe`` is a launcher that runs the interpreter with
the shim as its script and holds it open without ``FILE_SHARE_DELETE`` for
the whole command, so the quarantine rename is refused and uv fails to
replace it with os error 32 (#88838, #89599).
A child is required, and waiting on it cannot work: this process holds the
handle the child needs released, so a parent that waits deadlocks against
the work it is waiting for. Windows has no exec to escape with either.
The shell therefore returns while the install runs on; the child keeps the
console and prints its own result, and ``--gateway`` writes the true exit
code to ``.update_exit_code`` for the gateway watcher.
The child re-runs ``hermes update``, so the whole remaining flow — the
dependency sync and the node/web/lazy-refresh tail behind it — still
happens exactly once. ``_UPDATE_REEXEC_ENV`` marks it so it cannot spawn
another child, and so the "already up to date" early return does not
swallow the sync it was spawned to perform (the checkout is current by
now; that is the point).
The caller has already written ``.update-incomplete``, so a child that
dies mid-install is finished by the next launch's recovery instead of
leaving a half-synced venv. Anything that stops the hand-off (no venv
python, spawn refused) returns False and syncs in-process, where the
pre-existing os-error-32 path and its marker recovery still apply.
"""
from hermes_cli.main import _UPDATE_REEXEC_ENV, _windows_shim_in_process_chain
if os.environ.get(_UPDATE_REEXEC_ENV) == "1":
return False
shim = _windows_shim_in_process_chain()
if shim is None:
return False
from hermes_constants import venv_python_path
python_exe = venv_python_path(shim.parent.parent, windows=True)
cmd = [str(python_exe), "-m", "hermes_cli.main", *sys.argv[1:]]
if python_exe.is_file():
try:
subprocess.Popen(
cmd,
env={**os.environ, _UPDATE_REEXEC_ENV: "1"},
stdin=subprocess.DEVNULL,
)
print(
f"→ Windows: {shim.name} cannot replace itself while it runs; "
"finishing the dependency install under the venv Python."
)
print(
" The code update is already applied. The install continues "
"below and this shell returns right away."
)
return True
except OSError as exc:
logger.debug("Dependency-sync hand-off via %s failed: %s", python_exe, exc)
print(f" ⚠ Could not hand the dependency install off {shim.name}.")
print(" Continuing in-process; if it cannot replace the shim, run:")
print(f" {subprocess.list2cmdline(cmd)}")
return False
def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]:
"""Return ``(install_cmd_prefix, env)`` for the project venv when possible."""
from hermes_cli.main import PROJECT_ROOT, _is_termux_env
try:
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
except Exception:
uv_bin = None
if uv_bin:
from hermes_constants import project_venv_dir
venv_dir = project_venv_dir(PROJECT_ROOT) or PROJECT_ROOT / "venv"
env = {**os.environ, "VIRTUAL_ENV": str(venv_dir)}
if _is_termux_env(env):
env.pop("PYTHONPATH", None)
env.pop("PYTHONHOME", None)
return [uv_bin, "pip"], env
return [sys.executable, "-m", "pip"], None
def _run_install_with_heartbeat(
cmd: list[str],
*,
env: dict[str, str] | None = None,
heartbeat_interval_seconds: int = 30,
) -> None:
"""Run dependency install command with periodic heartbeat output.
Some resolvers/build backends (especially when compiling Rust/C extensions)
can stay quiet for minutes. Emit a simple elapsed-time heartbeat so users
know ``hermes update`` is still progressing even if pip/uv itself is silent.
"""
from hermes_cli.main import PROJECT_ROOT
done = threading.Event()
start = _time.time()
def _heartbeat() -> None:
# Wait first, then print, so short installs don't emit noise.
while not done.wait(heartbeat_interval_seconds):
elapsed = int(_time.time() - start)
print(
f" … still installing dependencies ({elapsed}s elapsed)"
" — compiling Rust/C extensions can take several minutes",
flush=True,
)
t = threading.Thread(target=_heartbeat, daemon=True)
t.start()
try:
subprocess.run(
cmd,
cwd=PROJECT_ROOT,
check=True,
env=env,
)
finally:
done.set()
t.join(timeout=0.2)
def _is_windows() -> bool:
return sys.platform == "win32"
def _venv_scripts_dir() -> Path | None:
"""Return the venv Scripts directory if we're running inside the project venv."""
from hermes_cli.main import PROJECT_ROOT, _is_windows
from hermes_constants import project_venv_dir, venv_bin_dir
venv_dir = project_venv_dir(PROJECT_ROOT)
if venv_dir is None:
return None
scripts = venv_bin_dir(venv_dir, windows=_is_windows())
return scripts if scripts.is_dir() else None
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
"""Entry-point shims that uv may try to rewrite during ``pip install -e .``.
On Windows these are .exe launchers generated by setuptools/uv. On POSIX
they're regular Python scripts which can be replaced atomically — no
self-replacement hazard exists outside Windows.
"""
from hermes_cli.main import _is_windows
if not _is_windows():
return []
names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"}
# The gateway shim is not a [project.scripts] entry point, but older
# update/install paths still rewrite and quarantine it.
names.add("hermes-gateway")
return [scripts_dir / f"{name}.exe" for name in sorted(names)]
def _quarantine_running_hermes_exe(
scripts_dir: Path, *, max_attempts: int = 4,
failed_out: list[str] | None = None,
) -> list[tuple[Path, Path]]:
"""Pre-empt Windows file lock on the running ``hermes.exe``.
Windows allows RENAMING a mapped/running executable (the kernel tracks the
file by handle, not path), but blocks DELETE/REPLACE while it's loaded. uv
needs to overwrite the entry-point shims during ``pip install -e .``;
when ``hermes update`` runs, ``hermes.exe`` IS the live process, and uv
fails with ``Access is denied. (os error 5)``.
We rename live shims to ``hermes.exe.old.<unix-ms>`` first. uv then writes
fresh shims at the original paths. The ``.old`` files are cleaned up on
the next hermes invocation by ``_cleanup_quarantined_exes``.
Rename can still fail when *another* process has opened the .exe without
``FILE_SHARE_DELETE`` — typically AV real-time scanners with transient
handles (recovers in <1s), or the Hermes Desktop backend child process
(won't recover until the user closes it). We mitigate:
1. Retry up to ``max_attempts`` times with exponential backoff
(100/250/500/1000 ms). Handles the AV-scanner case.
2. If all retries fail, print a clear warning naming the most likely
culprit (running Hermes Desktop / gateway / REPL).
The updater's own launcher is no longer one of those culprits: an update
started from ``hermes.exe`` re-runs itself under the venv Python before
reaching here (``_reexec_dependency_sync_off_windows_shim``).
Returns the list of (original, quarantined) pairs so the caller can roll
back if the install itself fails before uv writes a replacement.
``failed_out``: when provided, the names of shims whose rename failed on
every attempt are appended — callers that must not mutate a contended
venv (the update dependency sync, #87331) check it and refuse instead of
letting the install run into a half-broken state.
"""
from hermes_cli.main import _hermes_exe_shims, _is_windows
moved: list[tuple[Path, Path]] = []
if not _is_windows():
return moved
import time
stamp = int(time.time() * 1000)
# Backoff schedule: first attempt is immediate, subsequent ones sleep.
# 100ms / 250ms / 500ms covers the typical AV scanner re-scan window.
backoff_ms = [0, 100, 250, 500, 1000]
attempts = max(1, min(max_attempts, len(backoff_ms)))
for shim in _hermes_exe_shims(scripts_dir):
if not shim.exists():
continue
target = shim.with_suffix(shim.suffix + f".old.{stamp}")
last_exc: OSError | None = None
for attempt in range(attempts):
delay = backoff_ms[attempt] / 1000.0
if delay:
time.sleep(delay)
try:
shim.rename(target)
moved.append((shim, target))
last_exc = None
break
except OSError as e:
last_exc = e
continue
if last_exc is None:
continue
# Every rename failed. Deferring one to next boot via
# MOVEFILE_DELAY_UNTIL_REBOOT used to be the fallback here, but it
# cannot help: it needs elevation we don't have, and when it does
# land it frees nothing for the install running right now while
# queueing an operation that will move a later, freshly repaired shim
# aside at next boot. Report and let uv try its luck instead —
# sometimes its own retry handling pulls through.
print(
f" ⚠ Could not quarantine {shim.name} ({last_exc.__class__.__name__}: "
f"another process is holding it open)."
)
print(
" Close Hermes Desktop, exit other `hermes` REPLs, stop the "
"gateway, or pause AV scanning, then re-run `hermes update`."
)
if failed_out is not None:
failed_out.append(shim.name)
return moved
_PENDING_RENAME_KEY = r"SYSTEM\CurrentControlSet\Control\Session Manager"
_PENDING_RENAME_VALUE = "PendingFileRenameOperations"
def _filter_pending_shim_renames(
entries: list[str], shims: list[Path]
) -> tuple[list[str], int]:
"""Drop shim-quarantine pairs from a PendingFileRenameOperations value.
The value is a flat REG_MULTI_SZ of (source, target) pairs, and other
installers share it, so only pairs matching our own
``<shim>`` -> ``<shim>.old.<stamp>`` naming are removed. Returns the
entries to keep and how many pairs were dropped.
"""
import ntpath
def _norm(value: str) -> str:
path = str(value).lstrip("!")
if path.startswith("\\??\\"):
path = path[4:]
return ntpath.normcase(ntpath.normpath(path))
shim_paths = {_norm(str(shim)) for shim in shims}
kept: list[str] = []
removed = 0
for index in range(0, len(entries) - 1, 2):
source, target = entries[index], entries[index + 1]
source_norm = _norm(source)
if source_norm in shim_paths and _norm(target).startswith(f"{source_norm}.old."):
removed += 1
else:
kept.extend((source, target))
if len(entries) % 2:
kept.append(entries[-1])
return kept, removed
def _cleanup_pending_shim_renames(scripts_dir: Path) -> int:
"""Drop reboot renames older Hermes versions queued for our shims.
Hermes used to fall back to ``MoveFileExW(MOVEFILE_DELAY_UNTIL_REBOOT)``
when the quarantine rename failed. Those entries outlive the update that
queued them, so at the next boot they move away whatever now sits at the
shim path — including a shim a later repair just wrote. Needs elevation
to remove (same as it needed to create); a no-op otherwise.
"""
from hermes_cli.main import _filter_pending_shim_renames, _hermes_exe_shims, _is_windows
if not _is_windows():
return 0
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
_PENDING_RENAME_KEY,
0,
winreg.KEY_QUERY_VALUE | winreg.KEY_SET_VALUE,
) as key:
entries, value_type = winreg.QueryValueEx(key, _PENDING_RENAME_VALUE)
if value_type != winreg.REG_MULTI_SZ or not isinstance(entries, list):
return 0
kept, removed = _filter_pending_shim_renames(
entries, _hermes_exe_shims(scripts_dir)
)
if not removed:
return 0
if kept:
winreg.SetValueEx(key, _PENDING_RENAME_VALUE, 0, winreg.REG_MULTI_SZ, kept)
else:
winreg.DeleteValue(key, _PENDING_RENAME_VALUE)
return removed
except (OSError, ValueError):
return 0
def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None:
"""Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements.
This is the safety-critical direction. A failed *quarantine* only aborts an
update; a failed *restore* leaves the install with no ``hermes`` on PATH,
and therefore no way to run the command that would repair it (#75584). The
outbound rename already retries a lock, so this one must too rather than
swallow the first ``OSError`` in silence.
Delegates to the stdlib-only helper that the early-recovery copy in
``_install_repair`` also uses, so the two cannot drift apart.
"""
_early_recovery_mod.restore_quarantined_shims(moved)
class ShimQuarantineError(RuntimeError):
"""A live ``hermes*.exe`` shim could not be renamed aside (#87331).
Raised by :func:`_run_quarantined_install` in ``strict_quarantine`` mode
BEFORE the install command runs. A shim that cannot even be renamed means
another process holds the venv hard enough that the dependency sync would
die partway and strand the install half-updated — the update must refuse,
not warn-and-continue.
"""
def __init__(self, failed_shims: list[str]):
self.failed_shims = list(failed_shims)
super().__init__(
"could not quarantine live shim(s): " + ", ".join(self.failed_shims)
)
def _run_quarantined_install(
cmd: list[str],
*,
env: dict[str, str] | None = None,
scripts_dir: Path | None = None,
strict_quarantine: bool = False,
) -> None:
"""Run an editable install, quarantining the running ``hermes.exe`` first.
Any ``pip install -e .`` (or ``--reinstall``) rewrites the entry-point
shims, and on Windows the live ``hermes.exe`` is the running process —
pip can neither delete nor overwrite it, so without quarantine the shim
is left missing and ``hermes`` drops off PATH. This wraps
:func:`_run_install_with_heartbeat` with the same rename-out-of-the-way /
restore-on-failure dance that the primary install path uses, so EVERY
install that touches the shims is protected — including the
verification-repair reinstalls in
:func:`_verify_core_dependencies_installed`, which previously called
``_run_install_with_heartbeat`` directly and bypassed quarantine.
``strict_quarantine=True`` (the update dependency sync, #87331): a shim
whose rename failed every retry means a process is holding the venv
without ``FILE_SHARE_DELETE`` — the install WILL hit the same lock on
.pyd files and strand the venv between versions. Roll the successful
renames back and raise :class:`ShimQuarantineError` WITHOUT running the
install. Non-strict callers (post-sync entry-point repair) keep the old
warn-and-try behavior: their venv is already mutated, so refusing buys
nothing.
Off-Windows (``scripts_dir is None``) this is a thin pass-through.
"""
from hermes_cli.main import ShimQuarantineError, _quarantine_running_hermes_exe, _restore_quarantined_exes, _run_install_with_heartbeat
moved: list[tuple[Path, Path]] = []
failed: list[str] = []
if scripts_dir is not None:
moved = _quarantine_running_hermes_exe(scripts_dir, failed_out=failed)
if strict_quarantine and failed:
_restore_quarantined_exes(moved)
raise ShimQuarantineError(failed)
try:
_run_install_with_heartbeat(cmd, env=env)
finally:
# Restore shims when the installer didn't write replacements — on
# FAILURE (install died before the entry-points step) and on SUCCESS
# too: uv audits an already-satisfied editable install as a no-op and
# rewrites no entry points, which would otherwise leave the shims
# quarantined aside and `hermes` missing from PATH after a green
# install (#75584). _restore_quarantined_exes skips any shim the
# installer actually replaced, so this never clobbers fresh output.
# Errors are not swallowed — the finally re-raises whatever escaped.
if scripts_dir is not None:
_restore_quarantined_exes(moved)
# A quarantine file younger than this may belong to an update running RIGHT
# NOW in another process, whose restore step still needs it. Deleting one
# mid-flight destroys the only copy of that shim.
_QUARANTINE_GRACE_SECONDS = 15 * 60
def _quarantine_stamp_ms(stale: Path) -> int | None:
"""The ``.old.<unix-ms>`` stamp in a quarantine filename, or ``None``.
``None`` means the name was not produced by
:func:`_quarantine_running_hermes_exe`. We neither rescue nor delete those:
the sweep should not destroy files whose provenance it cannot establish, and
they are not ours to put back.
Parsed from the NAME rather than ``st_mtime`` because ``rename`` preserves
the original shim's mtime, which records when uv wrote the shim — days
earlier, in general — not when it was quarantined.
"""
try:
return int(stale.name.rsplit(".old.", 1)[1])
except (IndexError, ValueError):
return None
def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None:
"""Sweep — and where necessary RESCUE — ``hermes.exe.old.*`` from updates.
Called early on every hermes invocation. Two cases the old unconditional
``unlink()`` got wrong, both ending with ``hermes`` gone from PATH:
1. **Orphan rescue.** If ``hermes.exe`` is missing while
``hermes.exe.old.*`` is present, that .old file is the ONLY surviving
copy of the shim — an update died, or its restore failed, between
the rename and uv writing a replacement (#75584). Deleting it converts a
one-rename recovery into a full reinstall. Put it back instead, through
the same retry-and-report helper the update-time restore uses.
2. **Concurrency.** A fresh quarantine file may belong to an update in
flight in another process (the desktop update button racing a shell
``hermes update`` does exactly this). Leave anything inside the grace
window alone; a later run sweeps it.
Silent no-op on non-Windows, when there is nothing to do, or on
file-locked / permission errors.
"""
from hermes_cli.main import _QUARANTINE_GRACE_SECONDS, _cleanup_pending_shim_renames, _is_windows, _quarantine_stamp_ms, _venv_scripts_dir
if not _is_windows():
return
if scripts_dir is None:
scripts_dir = _venv_scripts_dir()
if scripts_dir is None:
return
_cleanup_pending_shim_renames(scripts_dir)
now = _time.time()
try:
candidates = [
(stamp, stale)
for stale, stamp in (
(p, _quarantine_stamp_ms(p)) for p in scripts_dir.glob("*.exe.old.*")
)
if stamp is not None
]
except OSError:
return
# Newest first by PARSED stamp. Sorting the raw filenames lexicographically
# only tracks recency while every stamp shares a digit width: a stray
# ``.old.999`` sorts above a 13-digit epoch-ms stamp and would be the copy
# rescued onto the live shim name.
candidates.sort(key=lambda pair: pair[0], reverse=True)
for stamp, stale in candidates:
try:
original = stale.with_name(stale.name.rsplit(".old.", 1)[0])
if not original.exists():
# Orphan rescue: this is the last copy of the shim, so it gets
# the retry ladder and the recovery message, not a bare rename.
_early_recovery_mod.restore_quarantined_shims([(original, stale)])
continue
if now - stamp / 1000.0 < _QUARANTINE_GRACE_SECONDS:
continue # may be a live quarantine from a concurrent update
stale.unlink()
except OSError:
pass # still locked or in use — try again next run
# Import probes for venv corruption after a failed lazy ``uv pip install``.
# Metadata can look fine while ``.py`` files were removed mid-install (#57828).
# Canonical tables live in the stdlib-only ``_early_recovery`` module (which
# also probes/repairs BEFORE this module's third-party imports can run) so the
# early and full recovery layers can never drift apart.
_LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
_early_recovery_mod.LAZY_REFRESH_IMPORT_PROBES
)
_LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = (
_early_recovery_mod.LAZY_REFRESH_REPAIR_PACKAGES
)
def _run_package_only_install(
cmd: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Run a package-only pip/uv install without quarantining entry-point shims.
``pip install --upgrade pip`` and ``--force-reinstall <pkg>`` do not
rewrite ``hermes.exe``. The editable-install quarantine path would rename
shims without uv recreating them on Windows (#57828).
"""
from hermes_cli.main import _run_install_with_heartbeat
_run_install_with_heartbeat(cmd, env=env)
def _lazy_refresh_repair_specs(packages: list[str]) -> list[str]:
"""Map repair package names to their declared pin specs in pyproject.toml."""
from hermes_cli.main import PROJECT_ROOT
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover
return packages
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return packages
try:
with open(pyproject, "rb") as f:
raw_deps = tomllib.load(f).get("project", {}).get("dependencies", []) or []
except Exception as exc:
logger.debug("lazy refresh repair spec lookup failed: %s", exc)
return packages
name_to_spec: dict[str, str] = {}
try:
from packaging.requirements import Requirement # type: ignore
for spec in raw_deps:
try:
req = Requirement(spec)
name_to_spec[req.name.lower()] = spec.split(";", 1)[0].strip()
except Exception:
continue
except Exception:
for spec in raw_deps:
head = spec.split(";", 1)[0].strip()
bare = head
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
if op in bare:
bare = bare.split(op, 1)[0]
break
key = bare.strip().split("[", 1)[0].strip().lower()
if key:
name_to_spec[key] = head
return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages]
def _detect_broken_lazy_refresh_imports(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> list[str] | None:
"""Probe lazy-refresh packages via real imports.
Returns:
- ``[]`` when probes ran and every package imported cleanly
- ``[dist, ...]`` when probes ran and some packages failed
- ``None`` when the probe could not run (missing venv Python, subprocess
failure, non-zero probe exit) — this is *indeterminate*, not healthy
"""
from hermes_cli.main import _resolve_install_target_python
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
if venv_python is None:
return None
probe_lines = "\n".join(
f" ({mod!r}, {attr!r})," for mod, attr in _LAZY_REFRESH_IMPORT_PROBES
)
check_script = (
"import os\n"
"import sys\n"
"probes = [\n"
f"{probe_lines}\n"
"]\n"
"broken = []\n"
"for mod, attr in probes:\n"
" try:\n"
" imported = __import__(mod)\n"
" if not hasattr(imported, attr):\n"
" broken.append(mod)\n"
" elif mod == 'certifi':\n"
" # The module can import cleanly while cacert.pem is\n"
" # missing/corrupt (brew Python upgrade, interrupted venv\n"
" # rebuild) - every TLS call then fails (#29866).\n"
" bundle = imported.where()\n"
" if not os.path.isfile(bundle) or os.path.getsize(bundle) < 1024:\n"
" broken.append(mod)\n"
" except Exception:\n"
" broken.append(mod)\n"
"print('\\n'.join(broken))\n"
)
try:
result = subprocess.run(
[str(venv_python), "-c", check_script],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
check=False,
env=env,
)
except Exception as exc:
logger.debug("lazy refresh import probe failed: %s", exc)
return None
if result.returncode != 0:
logger.debug(
"lazy refresh import probe exited %s: %s",
result.returncode,
(result.stderr or "")[:200],
)
return None
broken_modules = [
line.strip() for line in result.stdout.splitlines() if line.strip()
]
packages: list[str] = []
seen: set[str] = set()
for mod in broken_modules:
pkg = _LAZY_REFRESH_REPAIR_PACKAGES.get(mod)
if pkg and pkg not in seen:
seen.add(pkg)
packages.append(pkg)
return packages
def _repair_broken_lazy_refresh_imports(
install_cmd_prefix: list[str],
packages: list[str],
*,
env: dict[str, str] | None = None,
) -> bool:
"""Force-reinstall ``packages`` and re-probe imports. Never raises."""
from hermes_cli.main import _detect_broken_lazy_refresh_imports, _run_package_only_install
if not packages:
return True
specs = _lazy_refresh_repair_specs(packages)
try:
_run_package_only_install(
install_cmd_prefix + ["install", "--force-reinstall", *specs],
env=env,
)
except subprocess.CalledProcessError as exc:
logger.warning("lazy refresh venv repair failed: %s", exc)
return False
after = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env)
# Indeterminate re-probe is not confirmed success.
return after == []
def _repair_venv_via_import_probes(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> str:
"""Probe imports and force-reinstall any broken lazy-refresh packages.
Uses real ``import`` checks (not distribution metadata) so a venv where
METADATA remains but ``.py`` files were wiped mid-install is still
detected (#57828). Package-only reinstall — never rewrites ``hermes.exe``.
Never raises. Returns one of:
- ``"healthy"`` — probes ran and found nothing broken
- ``"repaired"`` — probes found breakage and force-reinstall confirmed clean
- ``"failed"`` — probes found breakage and repair did not confirm clean
- ``"indeterminate"`` — probes could not run; do NOT treat as healthy
"""
from hermes_cli.main import _detect_broken_lazy_refresh_imports, _repair_broken_lazy_refresh_imports
broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env)
if broken is None:
print(
" ⚠ Import probes unavailable — cannot confirm venv package health."
)
return "indeterminate"
if not broken:
return "healthy"
print(
" → Detected corrupted venv packages via import probes: "
f"{', '.join(broken)}; repairing..."
)
if _repair_broken_lazy_refresh_imports(
install_cmd_prefix, broken, env=env
):
print(" ✓ Venv repair succeeded")
return "repaired"
manual = " ".join(
shlex.quote(s) for s in _lazy_refresh_repair_specs(broken)
)
print(" ⚠ Venv repair incomplete. Run manually, then `hermes update`:")
print(
f" {' '.join(install_cmd_prefix)} install --force-reinstall {manual}"
)
return "failed"
def _is_uv_command(install_cmd_prefix: list[str]) -> bool:
"""True when the install command is a uv/uvx invocation.
Handles a bare uv binary (``uv`` / ``uvx``, any extension), a path to
one, and ``python -m uv`` / ``python -m uvx`` — the naive basename check
misses the module form and launcher wrappers whose name does not contain
"uv".
"""
if not install_cmd_prefix:
return False
first = str(install_cmd_prefix[0]).lower()
if "uv" in Path(first).name:
return True
# python -m uv / python -m uvx
if len(install_cmd_prefix) >= 3 and first.endswith(("python", "python.exe")):
return install_cmd_prefix[1] == "-m" and install_cmd_prefix[2] in (
"uv",
"uvx",
)
return False
def _insert_python_pin(args: list[str]) -> list[str]:
"""Insert ``--python <sys.executable>`` into a uv command line.
If the caller already passed ``--python``, its value wins (uv's last-wins
semantics are ambiguous; the explicit caller intent should not be
overridden by the fallback pin).
"""
if "--python" in args:
return args
return [args[0], "--python", str(sys.executable), *args[1:]]
def _interpreter_scripts_dir() -> Path | None:
"""Scripts/bin directory of the running interpreter (sys.executable).
Used when pinning an install to ``sys.executable`` on a site-packages
install where ``PROJECT_ROOT / "venv"`` does not exist: the entry-point
shims uv rewrites live next to the interpreter, not under a project venv.
Layout comes from the canonical ``venv_bin_dir`` helper (#76105 —
hand-rolling Scripts/bin is lint-tested against).
"""
from hermes_cli.main import _is_windows
from hermes_constants import venv_bin_dir
exe = Path(sys.executable)
# sys.executable lives IN the bin/Scripts dir; its parent.parent is the
# env root venv_bin_dir derives from.
cand = venv_bin_dir(exe.parent.parent, windows=_is_windows())
if cand.is_dir():
return cand
return exe.parent if exe.parent.is_dir() else None
def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
group: str = "all",
) -> None:
"""Install base deps plus as many optional extras as the environment supports.
By default this targets ``.[all]``; Termux callers can pass
``group='termux-all'`` to use the curated Android-compatible profile.
On Windows, pre-renames live ``hermes.exe`` / ``hermes-gateway.exe`` shims
in the venv Scripts dir before each install attempt so uv can write fresh
copies (Windows blocks REPLACE on a running .exe but allows RENAME). See
``_quarantine_running_hermes_exe`` for the rationale.
When ``env`` carries a ``VIRTUAL_ENV`` that does not exist (a pip /
site-packages install whose ``PROJECT_ROOT`` is the interpreter's
``site-packages`` directory, where ``PROJECT_ROOT / "venv"`` is never
created), ``uv pip`` fails with ``Failed to inspect Python interpreter from
active virtual environment`` before doing any work. Pin the install at the
running interpreter instead so the update/recovery path succeeds on those
installs (#71510 fixed the ZIP path, #83335 fixed lazy-deps; this closes the
shared helper for the remaining callers).
"""
from hermes_cli.main import _insert_python_pin, _interpreter_scripts_dir, _is_windows, _load_installable_optional_extras, _run_quarantined_install, _venv_scripts_dir, _verify_console_scripts_installed, _verify_core_dependencies_installed
scripts_dir = _venv_scripts_dir() if _is_windows() else None
# A pip / site-packages install has no PROJECT_ROOT/venv; the caller still
# passes VIRTUAL_ENV=PROJECT_ROOT/venv, which does not exist. uv would fail
# before installing anything ("Failed to inspect Python interpreter from
# active virtual environment"). Detect the stale pointer and pin the target
# interpreter explicitly instead of trusting the nonexistent venv.
pin_python = False
if (
env
and env.get("VIRTUAL_ENV")
and not Path(env["VIRTUAL_ENV"]).is_dir()
and install_cmd_prefix
and _is_uv_command(install_cmd_prefix)
):
# Only uv needs the explicit pin; pip resolves the target from
# sys.executable itself and has no --python flag.
pin_python = True
env = {**env}
env.pop("VIRTUAL_ENV", None)
# When we pin to sys.executable, the entry-point shims that uv will
# rewrite live in that interpreter's Scripts/bin directory, NOT in
# PROJECT_ROOT/venv (which does not exist on a site-packages install).
# Quarantining the wrong dir means the running hermes.exe stays locked
# on Windows and the install fails exactly like the original bug. Only
# override when the venv-derived dir is missing; otherwise keep it.
if scripts_dir is None and _is_windows():
scripts_dir = _interpreter_scripts_dir()
def _install(args: list[str]) -> None:
if pin_python:
args = _insert_python_pin(args)
# strict_quarantine: this is the UPDATE dependency sync. A shim that
# cannot be renamed aside proves a hard venv hold; running uv anyway
# is how installs strand half-updated (#87331). ShimQuarantineError
# propagates to the update's sync boundary, which defers via the
# update-incomplete marker instead of mutating a contended venv.
_run_quarantined_install(
install_cmd_prefix + args, env=env, scripts_dir=scripts_dir,
strict_quarantine=True,
)
try:
_install(["install", "-e", f".[{group}]"])
_verify_console_scripts_installed(install_cmd_prefix, env=env)
return
except subprocess.CalledProcessError:
print(
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
)
_install(["install", "-e", "."])
failed_extras: list[str] = []
installed_extras: list[str] = []
for extra in _load_installable_optional_extras(group=group):
try:
_install(["install", "-e", f".[{extra}]"])
installed_extras.append(extra)
except subprocess.CalledProcessError:
failed_extras.append(extra)
if installed_extras:
print(
f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}"
)
if failed_extras:
print(
f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}"
)
# Belt-and-suspenders: verify every declared core dependency from
# pyproject.toml's [project.dependencies] is actually importable in the
# target venv. uv's incremental resolver has — in the wild — produced
# partial installs where a newly added base dep (e.g. ``pathspec``)
# silently fails to land on top of a half-stale venv, and the only
# symptom is a downstream subprocess crashing with ModuleNotFoundError
# hours later inside ``hermes update``'s desktop-rebuild or skill-sync
# stage. Reinstall with --reinstall to force resolution if anything is
# missing, then re-verify so the failure surfaces here instead of
# downstream.
_verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)
_verify_console_scripts_installed(install_cmd_prefix, env=env)
def _load_console_script_names() -> list[str]:
"""Return ``[project.scripts]`` entry-point names from pyproject.toml."""
from hermes_cli.main import PROJECT_ROOT
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover
return []
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return []
try:
with open(pyproject, "rb") as f:
data = tomllib.load(f)
scripts = data.get("project", {}).get("scripts", {}) or {}
return [str(name) for name in scripts if name]
except Exception as e:
logger.debug("console script verification: failed to read pyproject.toml: %s", e)
return []
def _verify_console_scripts_installed(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Ensure every declared console_script shim exists on disk after install.
On Windows, ``uv pip install -e .`` can register ``hermes.exe`` in the
wheel RECORD while the file never lands on disk — typically when the live
``hermes.exe`` shim is locked during ``hermes update``, or when uv/distlib
skips a launcher write. The symptom is ``hermes-agent.exe`` and
``hermes-acp.exe`` present but ``hermes.exe`` missing, so ``hermes`` drops
off PATH even though the install reported success (issue #52931).
If any shim is missing we reinstall with ``--reinstall -e .`` under the
same quarantine dance as the primary install path, then re-check.
"""
from hermes_cli.main import _is_windows, _run_quarantined_install, _venv_scripts_dir
if not _is_windows():
return
scripts_dir = _venv_scripts_dir()
if scripts_dir is None:
return
names = _load_console_script_names()
if not names:
return
def _missing() -> list[str]:
return [
name
for name in names
if not (scripts_dir / f"{name}.exe").is_file()
]
missing = _missing()
if not missing:
return
print(
f" ⚠ Verification: {len(missing)} console script(s) missing on disk: "
f"{', '.join(missing)}"
)
print(" → Reinstalling entry points with --reinstall...")
try:
_run_quarantined_install(
install_cmd_prefix + ["install", "--reinstall", "-e", "."],
env=env,
scripts_dir=scripts_dir,
)
except subprocess.CalledProcessError as e:
logger.warning("console script verification: repair install failed: %s", e)
print(
" ⚠ Entry point repair failed; try `hermes update --force` after "
"closing other hermes processes."
)
return
still_missing = _missing()
if still_missing:
print(
f" ⚠ Still missing after repair: {', '.join(still_missing)}. "
"Workaround: python -m hermes_cli.main <command>"
)
else:
print(" ✓ All console entry points restored")
def _verify_core_dependencies_installed(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
group: str = "all",
) -> None:
"""Check that every base dep from pyproject.toml is importable; if not, retry.
Reads ``pyproject.toml`` directly (so we don't trust the venv's stale
metadata), filters out deps gated by ``;`` environment markers that don't
apply to this platform, and runs ``importlib.metadata.version()`` in the
venv interpreter for each one. If anything is missing we reinstall the
base group with ``--reinstall`` to force uv to re-resolve, then check
again. We treat the final state as a warning rather than a hard failure
so a single broken-on-PyPI dep can't block an otherwise-successful
update — but the warning makes the partial install visible at the spot
that caused it, instead of hours later in a downstream subprocess.
"""
from hermes_cli.main import PROJECT_ROOT, _is_windows, _resolve_install_target_python, _run_install_with_heartbeat, _run_quarantined_install, _venv_scripts_dir
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover — Python < 3.11 unsupported but be safe
return
pyproject = PROJECT_ROOT / "pyproject.toml"
if not pyproject.is_file():
return
try:
with open(pyproject, "rb") as f:
data = tomllib.load(f)
raw_deps = data.get("project", {}).get("dependencies", []) or []
except Exception as e:
logger.debug("dep verification: failed to read pyproject.toml: %s", e)
return
# Parse each "name OP version ; marker" string into (dist_name, marker_obj).
# We use packaging.requirements when available (it ships with pip/uv envs),
# falling back to a naive split that's good enough for the canonical
# ``name==version[; marker]`` style this repo uses.
deps: list[tuple[str, "object | None"]] = []
try:
from packaging.requirements import Requirement # type: ignore
for spec in raw_deps:
try:
req = Requirement(spec)
deps.append((req.name, req.marker))
except Exception:
continue
except Exception:
for spec in raw_deps:
head = spec.split(";", 1)[0]
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
if op in head:
head = head.split(op, 1)[0]
break
name = head.strip().split("[", 1)[0].strip()
if name:
deps.append((name, None))
# Apply environment markers to drop deps that don't apply on this platform
# (e.g. ``ptyprocess ; sys_platform != 'win32'`` is correctly skipped on
# Windows). Without markers we'd false-positive every cross-platform exclusion.
applicable: list[str] = []
for name, marker in deps:
if marker is None:
applicable.append(name)
continue
try:
if marker.evaluate(): # type: ignore[union-attr]
applicable.append(name)
except Exception:
applicable.append(name)
if not applicable:
return
# Run the check inside the venv Python — sys.executable here may be the
# outer Python that drove ``hermes update``, not the venv we just wrote
# to. The uv install_cmd_prefix encodes which environment we targeted
# (either ``[uv, pip]`` with VIRTUAL_ENV in env, or
# ``[sys.executable, -m, pip]`` for the in-process Python); resolve the
# right interpreter for the verification.
venv_python = _resolve_install_target_python(install_cmd_prefix, env)
if venv_python is None:
return
def _missing_deps() -> list[str]:
check_script = (
"import importlib.metadata as md, sys\n"
"missing=[]\n"
"for name in sys.argv[1:]:\n"
" try: md.version(name)\n"
" except md.PackageNotFoundError: missing.append(name)\n"
"print('\\n'.join(missing))\n"
)
try:
result = subprocess.run(
[str(venv_python), "-c", check_script, *applicable],
capture_output=True,
text=True, encoding="utf-8", errors="replace",
check=False,
env=env,
)
except Exception as e:
logger.debug("dep verification: subprocess failed: %s", e)
return []
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
missing = _missing_deps()
if not missing:
return
print(
f" ⚠ Verification: {len(missing)} declared dep(s) missing after install: "
f"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}"
)
print(" → Reinstalling base group with --reinstall to repair...")
# Reinstall base group with --reinstall so uv re-resolves from scratch
# against the current pyproject. We don't pass ``[{group}]`` here on
# purpose — the missing dep is in *base* deps; rerunning the full all-
# extras install can cost minutes and trips on whatever optional extra
# was already broken upstream. Base is fast and is what's actually wrong.
#
# Quarantine the running ``hermes.exe`` first: ``--reinstall -e .``
# rewrites the entry-point shims, and on Windows pip can't overwrite the
# live launcher, which would leave ``hermes`` off PATH.
scripts_dir = _venv_scripts_dir() if _is_windows() else None
repair_args = ["install", "--reinstall", "-e", "."]
try:
_run_quarantined_install(
install_cmd_prefix + repair_args, env=env, scripts_dir=scripts_dir
)
except subprocess.CalledProcessError as e:
logger.warning("dep verification: repair install failed: %s", e)
print(" ⚠ Repair install failed; check `hermes update` output above.")
return
still_missing = _missing_deps()
if not still_missing:
print(" ✓ All declared core dependencies now installed")
return
# Last-ditch: install each remaining missing dep with its pin directly.
# Useful when uv's resolver thinks the env is satisfied but the on-disk
# package metadata says otherwise (rare but observed).
name_to_spec = {}
for spec in raw_deps:
head = spec.split(";", 1)[0].strip()
bare = head
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
if op in bare:
bare = bare.split(op, 1)[0]
break
name_to_spec[bare.strip().split("[", 1)[0].strip()] = head
specs = [name_to_spec.get(n, n) for n in still_missing]
print(
f" → Force-installing remaining missing dep(s): {', '.join(specs)}"
)
try:
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "--reinstall", *specs], env=env
)
except subprocess.CalledProcessError as e:
logger.warning("dep verification: per-package repair failed: %s", e)
print(
f" ⚠ Could not install: {', '.join(still_missing)}. "
"Run `hermes update --force` after closing other hermes processes."
)
return
final_missing = _missing_deps()
if final_missing:
print(
f" ⚠ Still missing after repair: {', '.join(final_missing)}. "
"Run `hermes update --force` after closing other hermes processes."
)
else:
print(" ✓ All declared core dependencies now installed")
def _resolve_install_target_python(
install_cmd_prefix: list[str], env: dict[str, str] | None
) -> Path | None:
"""Figure out which Python interpreter the install just targeted.
``_install_python_dependencies_with_optional_fallback`` is called with
either ``[uv, pip]`` (and a ``VIRTUAL_ENV`` env var pointing at the
target venv) or ``[sys.executable, -m, pip]`` (the in-process Python).
The verification step needs the *resulting* environment's Python so
``importlib.metadata`` queries the right site-packages.
"""
from hermes_cli.main import _is_windows
if env and "VIRTUAL_ENV" in env:
from hermes_constants import venv_python_path
venv_root = Path(env["VIRTUAL_ENV"])
candidate = venv_python_path(venv_root, windows=_is_windows())
if candidate.exists():
return candidate
# Fallback: assume install_cmd_prefix[0] is the python interpreter (the
# ``[sys.executable, -m, pip]`` shape). Skip if it looks like ``uv``.
if install_cmd_prefix:
first = Path(install_cmd_prefix[0])
if first.exists() and "uv" not in first.name.lower():
return first
return None
def _is_termux_env(env: dict[str, str] | None = None) -> bool:
from hermes_cli.main import _is_termux_startup_environment
return _is_termux_startup_environment(env)
def _is_windows_npm_path(npm_path: str) -> bool:
"""Return True if ``npm_path`` points at a Windows npm shim.
On WSL the Windows install dir is exposed through the ``/mnt/c`` drive
mount and PATH interop, so ``shutil.which("npm")`` can hand back
``/mnt/c/Program Files/nodejs/npm`` (or the ``npm.cmd`` / ``npm.exe``
shim). Those are detected here by their ``.exe``/``.cmd``/``.bat``
suffix, a ``/mnt/`` drive-mount prefix, or an embedded backslash (a UNC
path). Callers use this only on a POSIX host — on native Windows an
``npm.cmd`` shim is the correct executable.
"""
low = npm_path.lower()
return (
low.endswith((".exe", ".cmd", ".bat"))
or low.startswith("/mnt/")
or "\\" in npm_path
)
def _resolve_node_runtime_npm() -> str | None:
"""Resolve an npm executable that belongs to the host's Node runtime.
On WSL/Linux ``shutil.which("npm")`` may resolve a Windows npm exposed
through PATH interop. Running that Windows npm against the Linux checkout
operates over ``\\wsl.localhost\\...`` UNC paths and fails with EISDIR /
symlink errors in symlink-heavy trees like ``ui-tui`` (#30271). Refuse a
Windows npm on a POSIX host and re-scan PATH (skipping ``/mnt/*`` interop
entries) for a Linux-native npm. Returns the npm path, or ``None`` when
no suitable npm is reachable.
"""
from hermes_cli.main import _is_windows
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
# On native Windows the platform npm (``npm.cmd``) is exactly what we
# want — only reject Windows shims when we're a POSIX/WSL process.
if _is_windows():
return npm
if not npm:
return None
if not _is_windows_npm_path(npm):
return npm
# The first resolution was a Windows npm. Re-scan PATH skipping the
# ``/mnt/*`` Windows drive mounts WSL injects, so a Linux-native npm that
# came later on PATH is still found.
for directory in os.environ.get("PATH", "").split(os.pathsep):
if not directory or directory.lower().startswith("/mnt/"):
continue
candidate = shutil.which("npm", path=directory)
if candidate and not _is_windows_npm_path(candidate):
return candidate
return None
def _resolve_update_branch(args) -> str:
"""Normalize ``args.branch`` into a non-empty branch name.
Centralizes the "default to main, accept --branch override, treat empty
or whitespace-only values as the default" parsing so every consumer of
``--branch`` (check path, git-update path, ZIP-fallback path) agrees on
the same answer.
"""
return (getattr(args, "branch", None) or "main").strip() or "main"