"""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.`` 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.`` 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 ``\\__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.`` 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 ```` -> ``.old.`` 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.`` 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 `` 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 `` 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 " ) 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"