refactor(hermes_cli): compact docstrings/comments in desktop/dashboard/web_build/tui_launch (WHY kept)
This commit is contained in:
@@ -77,10 +77,9 @@ def _run_probe(cmd: list[str], *, timeout: int) -> subprocess.CompletedProcess:
|
||||
def _restart_managed_dashboard_service(reason: str, unit: str = _DASHBOARD_SYSTEMD_UNIT) -> bool:
|
||||
"""Restart a systemd-managed dashboard instead of raw-killing its PID.
|
||||
|
||||
Returns True when a dashboard unit was found and handled (successfully or
|
||||
with a printed actionable failure). Returning True deliberately prevents the
|
||||
caller from falling back to ``os.kill``: systemd treats a direct SIGTERM of
|
||||
the main PID as a clean stop, so ``Restart=on-failure`` won't bring it back.
|
||||
True when a unit was found and handled (success or printed failure) — which
|
||||
deliberately stops the caller's ``os.kill`` fallback: systemd treats a direct
|
||||
SIGTERM as a clean stop, so ``Restart=on-failure`` won't bring it back.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return False
|
||||
@@ -216,12 +215,8 @@ def _try_restart_systemd_service(svc_name: str, cgroup_path: str | None = None)
|
||||
|
||||
|
||||
def _dashboard_cmdline_for_pid(pid: int) -> list[str] | None:
|
||||
"""The exact argv of a running process, when recoverable.
|
||||
|
||||
Linux: ``/proc/<pid>/cmdline`` (lossless). macOS: ``ps -o command=`` + shlex
|
||||
(best effort). Windows: None — taskkill /F gives no graceful window and the
|
||||
desktop app manages its own backend there.
|
||||
"""
|
||||
"""Exact argv of a running process: ``/proc/<pid>/cmdline`` (Linux), ``ps -o command=`` + shlex
|
||||
(macOS), None on Windows (no graceful taskkill window; Desktop manages its backend)."""
|
||||
if sys.platform == "win32":
|
||||
return None
|
||||
try:
|
||||
@@ -247,16 +242,10 @@ def _dashboard_cmdline_for_pid(pid: int) -> list[str] | None:
|
||||
|
||||
|
||||
def _respawn_dashboard_processes(commands: list[list[str]]) -> list[list[str]]:
|
||||
"""Best-effort respawn of manually-started dashboards after ``hermes update``.
|
||||
|
||||
Spawns each argv detached (new session, output appended to the profile's
|
||||
``logs/dashboard-restart.log``). Returns the commands that failed to spawn so
|
||||
the caller can print the manual hint. Callers must pre-filter via
|
||||
``_filter_dashboard_respawn_candidates`` (no Desktop ``--port 0`` backends,
|
||||
duplicates capped per profile).
|
||||
"""
|
||||
"""Respawn manually-started dashboards after ``hermes update``, detached, logging to
|
||||
``logs/dashboard-restart.log``; returns the argvs that failed to spawn. Callers pre-filter via
|
||||
``_filter_dashboard_respawn_candidates`` (no Desktop ``--port 0`` backends, capped per profile)."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
respawned: list[list[str]] = []
|
||||
failed: list[tuple[list[str], str]] = []
|
||||
log_path = get_hermes_home() / "logs" / "dashboard-restart.log"
|
||||
@@ -288,14 +277,8 @@ def _respawn_dashboard_processes(commands: list[list[str]]) -> list[list[str]]:
|
||||
|
||||
|
||||
class _UpdateOutputStream:
|
||||
"""Stream wrapper used during ``hermes update`` to survive terminal loss.
|
||||
|
||||
Every write is mirrored to an append-only log (``~/.hermes/logs/update.log``)
|
||||
and writes to the original stream that fail with ``BrokenPipeError`` /
|
||||
``OSError`` / ``ValueError`` stop the on-screen output instead of the update.
|
||||
With ``SIGHUP -> SIG_IGN`` from ``_install_hangup_protection`` this makes
|
||||
``hermes update`` safe in an SSH session that disconnects mid-install.
|
||||
"""
|
||||
"""stdout/stderr wrapper for ``hermes update``: mirrors to ``logs/update.log`` and, once the
|
||||
terminal vanishes (BrokenPipe/OSError/ValueError), drops screen output instead of the update."""
|
||||
|
||||
_BROKEN = (BrokenPipeError, OSError, ValueError)
|
||||
|
||||
@@ -348,17 +331,9 @@ class _UpdateOutputStream:
|
||||
|
||||
|
||||
def _install_hangup_protection(gateway_mode: bool = False):
|
||||
"""Protect ``cmd_update`` from SIGHUP and broken terminal pipes.
|
||||
|
||||
1. ``SIGHUP`` → ``SIG_IGN`` (preserved across exec, so pip/git children
|
||||
survive hangup too). ``SIGINT``/``SIGTERM`` are intentionally left alone —
|
||||
those are legitimate cancellation signals.
|
||||
2. ``sys.stdout``/``sys.stderr`` are wrapped to mirror to
|
||||
``~/.hermes/logs/update.log`` and absorb ``BrokenPipeError``.
|
||||
|
||||
No-op in gateway mode (already detached). Returns the state dict that
|
||||
``_finalize_update_output`` consumes on exit.
|
||||
"""
|
||||
"""Protect ``cmd_update`` from SIGHUP (→ SIG_IGN, inherited by pip/git children) and broken pipes
|
||||
(stdio wrapped in ``_UpdateOutputStream``). SIGINT/SIGTERM are left alone — legitimate cancels.
|
||||
No-op in gateway mode (already detached). Returns state for ``_finalize_update_output``."""
|
||||
state = {
|
||||
"prev_stdout": sys.stdout, "prev_stderr": sys.stderr, "log_file": None, "installed": False
|
||||
}
|
||||
@@ -379,7 +354,6 @@ def _install_hangup_protection(gateway_mode: bool = False):
|
||||
# Late-bound import so tests can monkeypatch
|
||||
# hermes_cli.config.get_hermes_home to simulate setup failure.
|
||||
from hermes_cli.config import get_hermes_home as _get_hermes_home
|
||||
|
||||
logs_dir = _get_hermes_home() / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = open(logs_dir / "update.log", "a", buffering=1, encoding="utf-8")
|
||||
@@ -428,7 +402,6 @@ def _report_dashboard_status() -> int:
|
||||
"""
|
||||
from hermes_cli.main import _dashboard_listening, _self
|
||||
from gateway.status import _pid_exists
|
||||
|
||||
live: list[tuple[int, str, str]] = []
|
||||
for pid, command in _self()._scan_dashboard_processes():
|
||||
runtime = _parse_dashboard_runtime(command)
|
||||
@@ -452,7 +425,6 @@ def _report_dashboard_status() -> int:
|
||||
def _dashboard_listening(host: str, port: int) -> bool:
|
||||
"""True when something accepts TCP connections at host:port (even a 401 proves a dashboard is up)."""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((_dashboard_probe_host(host), port), timeout=1.5):
|
||||
return True
|
||||
@@ -466,14 +438,12 @@ def _cancel(message: str = " Cancelled.") -> NoReturn:
|
||||
|
||||
|
||||
def _maybe_setup_dashboard_auth_interactively(args) -> None:
|
||||
"""Offer to configure dashboard auth when the gate engages and none exists.
|
||||
"""Offer to configure dashboard auth when the gate engages and no provider exists.
|
||||
|
||||
Called from ``cmd_dashboard`` just before ``start_server``, which fails closed
|
||||
when no ``DashboardAuthProvider`` is registered for a non-loopback bind or a
|
||||
non-loopback ``dashboard.public_url``. Prompt an interactive operator to set
|
||||
up the bundled password provider (or point at ``hermes dashboard register``
|
||||
for OAuth). No-op — leaving the fail-closed ``SystemExit`` as backstop — when
|
||||
the gate doesn't engage, a provider exists, or stdin/stdout isn't a TTY.
|
||||
``start_server`` fails closed for a non-loopback bind / ``dashboard.public_url``
|
||||
without a ``DashboardAuthProvider``; prompt an interactive operator first.
|
||||
No-op (fail-closed backstop stays) when the gate doesn't engage, a provider
|
||||
exists, or stdin/stdout isn't a TTY.
|
||||
"""
|
||||
host = getattr(args, "host", "127.0.0.1") or "127.0.0.1"
|
||||
|
||||
@@ -530,7 +500,6 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
|
||||
|
||||
import getpass
|
||||
import secrets
|
||||
|
||||
print()
|
||||
try:
|
||||
username = line_input(" Username [admin]: ").strip() or "admin"
|
||||
@@ -556,7 +525,6 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.plugins_cmd import ensure_basic_auth_plugin_enabled_in_config
|
||||
|
||||
cfg = load_config()
|
||||
basic = cfg.setdefault("dashboard", {}).setdefault("basic_auth", {})
|
||||
basic["username"] = username
|
||||
@@ -576,7 +544,6 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
|
||||
# Re-run plugin discovery so the provider registers before start_server's gate.
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins(force=True)
|
||||
except Exception as exc:
|
||||
print(f" ⚠ Plugin re-discovery failed ({exc}); the gate may still "
|
||||
@@ -686,15 +653,13 @@ def _is_electron_packaged_web_dist(path: str) -> bool:
|
||||
|
||||
|
||||
def _route_named_profile_dashboard(args, _headless_backend: bool, _ssh_owner_nonce: str, _token_file: str) -> None:
|
||||
"""Named-profile launches route to the single MACHINE dashboard.
|
||||
"""Route a named-profile launch to the single MACHINE dashboard (per-request ``?profile=`` scoping
|
||||
makes one server per profile pure fragmentation).
|
||||
|
||||
The dashboard manages every profile via per-request ``?profile=`` scoping, so
|
||||
one server per profile only fragments it. If the machine dashboard is already
|
||||
listening, open the browser at ``?profile=<name>`` and exit; otherwise re-exec
|
||||
as the machine dashboard pinned to ``-p default`` (so ``_apply_profile_override``
|
||||
can't re-route through the sticky active_profile file) with this profile
|
||||
preselected. ``--isolated`` opts out; Desktop pool backends (HERMES_DESKTOP=1)
|
||||
stay per-profile. Returns normally when no routing applies.
|
||||
Already listening → open ``?profile=<name>`` and exit; else re-exec pinned to
|
||||
``-p default`` (so ``_apply_profile_override`` can't re-route via the sticky
|
||||
active_profile file). ``--isolated`` opts out; Desktop pool backends
|
||||
(HERMES_DESKTOP=1) stay per-profile. Returns normally when no routing applies.
|
||||
"""
|
||||
from hermes_cli.main import _dashboard_listening
|
||||
try:
|
||||
@@ -773,12 +738,10 @@ def _route_named_profile_dashboard(args, _headless_backend: bool, _ssh_owner_non
|
||||
def _resolve_dashboard_web_dist(args, _headless_backend: bool) -> None:
|
||||
"""Build or validate the web UI dist before the server imports.
|
||||
|
||||
``serve`` sets HERMES_SERVE_HEADLESS so mount_spa() stays off even if a stray
|
||||
dist exists. Otherwise build unless HERMES_WEB_DIST or --skip-build says a
|
||||
dist is pre-built — then verify index.html exists (an unverified promise
|
||||
means the server starts and serves 404s). --skip-build on the default dist
|
||||
location gets ONE recovery build; a caller-managed HERMES_WEB_DIST cannot be
|
||||
populated and is written back expanded because web_server reads it raw.
|
||||
``serve`` sets HERMES_SERVE_HEADLESS so mount_spa() stays off. Otherwise build
|
||||
unless HERMES_WEB_DIST / --skip-build promise a dist — then verify index.html
|
||||
(else the server serves 404s). --skip-build on the default location gets ONE
|
||||
recovery build; a caller-managed HERMES_WEB_DIST can't be populated.
|
||||
"""
|
||||
from hermes_cli.main import PROJECT_ROOT, _build_web_ui
|
||||
skip_build = getattr(args, "skip_build", False)
|
||||
|
||||
@@ -47,13 +47,8 @@ def _desktop_stamp_path() -> Path:
|
||||
|
||||
|
||||
def _renderer_bundle_dir(desktop_dir: Path, *, source_mode: bool) -> Optional[Path]:
|
||||
"""The renderer ``dist`` directory a launch loads, when it is inspectable.
|
||||
|
||||
Source mode builds to ``apps/desktop/dist``. A packaged app ships the bundle
|
||||
inside ``app.asar`` and (``asarUnpack: dist/**``) beside it in
|
||||
``app.asar.unpacked``; only the unpacked copy is a real directory, and it is
|
||||
the one an interrupted replace tears.
|
||||
"""
|
||||
"""The renderer ``dist`` a launch loads: ``apps/desktop/dist`` in source mode, else the
|
||||
``app.asar.unpacked/dist`` copy (the only real directory, and the one an interrupted replace tears)."""
|
||||
from hermes_cli.main import _desktop_packaged_executable
|
||||
if source_mode:
|
||||
return desktop_dir / "dist"
|
||||
@@ -77,14 +72,12 @@ _MODULE_TAG = re.compile(r"""\btype=["']module["']|\brel=["']modulepreload["']""
|
||||
|
||||
|
||||
def _renderer_bundle_torn(dist_dir: Path) -> bool:
|
||||
"""True when ``index.html`` names hashed module files that aren't there.
|
||||
"""True when ``index.html`` names hashed module chunks that aren't there.
|
||||
|
||||
``index.html`` and the hashed chunks under ``assets/`` are ONE generation. An
|
||||
update that replaces the app while its files are locked can leave them from
|
||||
different generations; the app then dies on its first lazy import, and
|
||||
because the content stamp still matches the intact SOURCE tree the rebuild
|
||||
that would fix it is skipped. Conservative: an unreadable index, or one naming
|
||||
nothing checkable, is NOT torn — the missing-bundle guards own those cases.
|
||||
A replace interrupted by locked files leaves index and ``assets/`` from
|
||||
different generations; the app dies on its first lazy import while the
|
||||
SOURCE-tree stamp still matches, so no rebuild fixes it. Conservative: an
|
||||
unreadable index or one naming nothing checkable is NOT torn.
|
||||
"""
|
||||
try:
|
||||
html = (dist_dir / "index.html").read_text(encoding="utf-8", errors="replace")
|
||||
@@ -171,12 +164,8 @@ _DESKTOP_PREVIOUS_SUFFIX = ".previous"
|
||||
|
||||
|
||||
def _desktop_staging_dir(desktop_dir: Path) -> Path:
|
||||
"""Fresh, unique staging output dir: ``apps/desktop/.staging-<pid>-<ts>``.
|
||||
|
||||
A sibling of ``release/`` (same filesystem → the swap is a rename) but NOT
|
||||
inside it, so nothing globbing ``release/*-unpacked`` can mistake the
|
||||
half-built tree for the live app. Stale leftovers are swept first.
|
||||
"""
|
||||
"""Fresh staging dir ``apps/desktop/.staging-<pid>-<ts>``: a sibling of ``release/`` (same fs → the
|
||||
swap is a rename) but not inside it, so ``release/*-unpacked`` globs never see it. Sweeps leftovers."""
|
||||
for stale in desktop_dir.glob(f"{_DESKTOP_STAGING_PREFIX}*"):
|
||||
shutil.rmtree(stale, ignore_errors=True)
|
||||
return desktop_dir / f"{_DESKTOP_STAGING_PREFIX}{os.getpid()}-{int(_time_mod.time())}"
|
||||
@@ -193,13 +182,8 @@ def _desktop_unpacked_root(exe: Path, release_dir: Path) -> Path:
|
||||
|
||||
|
||||
def _swap_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Optional[Path]:
|
||||
"""Promote a VERIFIED staged pack over the live ``release/`` app.
|
||||
|
||||
``release/<unpacked>`` → ``.previous``, ``<staging>/<unpacked>`` →
|
||||
``release/<unpacked>``, then drop ``.previous``. The only window with no live
|
||||
app is between the two renames, and a failure there rolls back. Returns the
|
||||
live executable, or None (live app untouched or restored). Never raises.
|
||||
"""
|
||||
"""Promote a VERIFIED staged pack over ``release/`` by two renames (live → ``.previous``, staged →
|
||||
live); a failure between them rolls back. Returns the live exe or None (live app kept). Never raises."""
|
||||
staged_exe = _desktop_packaged_executable_in(staging_dir)
|
||||
if staged_exe is None:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
@@ -250,15 +234,10 @@ _MACHINE_ATTRIBUTE_USER_ENABLED = 0x00000001
|
||||
|
||||
|
||||
def _windows_native_machine_from_iswow64() -> Optional[str]:
|
||||
"""Ask IsWow64Process2 for the OS-native machine (None if unavailable/fail).
|
||||
|
||||
``restype``/``argtypes`` are bound to ``wintypes.HANDLE``: ctypes' default
|
||||
``c_int`` truncates the ``(HANDLE)-1`` pseudo-handle to ``0xFFFFFFFF`` and
|
||||
``IsWow64Process2`` then fails with ``ERROR_INVALID_HANDLE`` on Win64.
|
||||
"""
|
||||
"""IsWow64Process2's OS-native machine, or None. HANDLE types are bound explicitly: ctypes'
|
||||
default ``c_int`` truncates the ``(HANDLE)-1`` pseudo-handle → ``ERROR_INVALID_HANDLE`` on Win64."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetCurrentProcess.argtypes = []
|
||||
@@ -277,15 +256,10 @@ def _windows_native_machine_from_iswow64() -> Optional[str]:
|
||||
|
||||
|
||||
def _windows_user_runnable_pe_machines() -> Optional[set]:
|
||||
"""PE machines this host can run in user mode, via GetMachineTypeAttributes.
|
||||
|
||||
The only documented API that reports AMD64-on-ARM64 emulation support. None
|
||||
when unavailable (pre-Windows-11 build 22000) or nothing runnable, so callers
|
||||
fall back to name-based detection.
|
||||
"""
|
||||
"""PE machines this host runs in user mode via GetMachineTypeAttributes (the only API reporting
|
||||
AMD64-on-ARM64 emulation); None when unavailable (pre-Win11 22000) so callers fall back."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.GetMachineTypeAttributes.argtypes = [wintypes.USHORT, ctypes.POINTER(ctypes.c_int)]
|
||||
kernel32.GetMachineTypeAttributes.restype = ctypes.c_long
|
||||
@@ -302,15 +276,10 @@ def _windows_user_runnable_pe_machines() -> Optional[set]:
|
||||
|
||||
|
||||
def _windows_native_machine() -> str:
|
||||
"""The Windows host OS's NATIVE machine architecture, normalized upper.
|
||||
|
||||
``platform.machine()`` reports the PROCESS architecture, which lies under
|
||||
emulation (x64 Python on Windows-on-ARM reports AMD64). Probe order:
|
||||
``IsWow64Process2`` (the only API that tells the truth from an emulated
|
||||
process), ``PROCESSOR_ARCHITEW6432`` / ``PROCESSOR_ARCHITECTURE``, then
|
||||
``platform.machine()``. ``GetNativeSystemInfo`` is deliberately NOT used: it
|
||||
also returns emulated details under emulation.
|
||||
"""
|
||||
"""The Windows host's NATIVE machine, upper-cased: ``IsWow64Process2`` (the only API that tells
|
||||
the truth from an emulated x64 process on ARM64), then ``PROCESSOR_ARCHITEW6432`` /
|
||||
``PROCESSOR_ARCHITECTURE``, then ``platform.machine()`` (which lies under emulation).
|
||||
``GetNativeSystemInfo`` is NOT used: it also returns emulated details."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
name = _windows_native_machine_from_iswow64()
|
||||
@@ -327,12 +296,8 @@ def _windows_native_machine() -> str:
|
||||
|
||||
|
||||
def _expected_windows_pe_machines() -> set:
|
||||
"""PE machine values the current Windows host can natively load.
|
||||
|
||||
``GetMachineTypeAttributes`` first; else name-based: AMD64 runs x64 + x86
|
||||
(WOW64), ARM64 runs ARM64 + x64 (emulation), x86 runs only x86. Unknown
|
||||
machines return the permissive full set so the gate can never brick launch.
|
||||
"""
|
||||
"""PE machines this Windows host can load: ``GetMachineTypeAttributes``, else by name (AMD64 → x64+x86,
|
||||
ARM64 → ARM64+x64, x86 → x86). Unknown hosts get the full set so the gate can never brick launch."""
|
||||
from hermes_cli.main import _windows_native_machine
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
@@ -352,14 +317,9 @@ def _expected_windows_pe_machines() -> set:
|
||||
|
||||
|
||||
def _parse_pe_machine(path: Path) -> int:
|
||||
"""Parse ``path`` as a PE executable and return its COFF machine field.
|
||||
|
||||
Raises ``ValueError`` with a human-readable reason when the file is not a
|
||||
structurally complete PE (missing MZ/PE magic, truncated header, or section
|
||||
data past EOF — the truncated-download shape). Header walk only; cheap.
|
||||
"""
|
||||
"""COFF machine field of the PE at ``path``; ``ValueError`` with a readable reason when it is not a
|
||||
structurally complete PE (bad magic, truncated header, section data past EOF). Header walk only."""
|
||||
import struct
|
||||
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
except OSError as exc:
|
||||
@@ -451,14 +411,9 @@ def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:
|
||||
|
||||
|
||||
def _ensure_desktop_exe_launchable(desktop_dir: Path, packaged_executable: Optional[Path]) -> tuple:
|
||||
"""Windows post-build integrity gate for the self-update rebuild.
|
||||
|
||||
Returns ``(verified_exe_or_None, rolled_back)``: probe passed → ``(exe, False)``;
|
||||
corrupt/wrong-arch with backup restored → ``(old_exe, True)``; nothing
|
||||
restorable → ``(None, False)``. On failure the cached Electron zip is purged
|
||||
and the stamp invalidated so the retry-once rebuild pulls a fresh,
|
||||
SHASUM-verified download. No-op off Windows / with no executable.
|
||||
"""
|
||||
"""Windows post-build integrity gate → ``(verified_exe_or_None, rolled_back)``: pass →
|
||||
``(exe, False)``; corrupt with backup restored → ``(old_exe, True)``; nothing restorable →
|
||||
``(None, False)``. Failure purges the cached zip + stamp so the retry re-downloads."""
|
||||
from hermes_cli.main import _desktop_stamp_path, _purge_electron_build_cache
|
||||
if packaged_executable is None or sys.platform != "win32":
|
||||
return packaged_executable, False
|
||||
@@ -492,13 +447,9 @@ def _ensure_desktop_exe_launchable(desktop_dir: Path, packaged_executable: Optio
|
||||
|
||||
|
||||
def _electron_download_cache_dirs() -> list[Path]:
|
||||
"""Per-user Electron download cache directories for this OS (deduped, in priority order).
|
||||
|
||||
electron-builder's ``app-builder unpack-electron`` extracts Electron from a
|
||||
zip stored here (NOT from node_modules), so a corrupt zip here poisons the
|
||||
build. Honors the ``electron_config_cache`` / ``ELECTRON_CACHE`` overrides
|
||||
``@electron/get`` respects.
|
||||
"""
|
||||
"""Per-user Electron download caches (``electron_config_cache`` / ``ELECTRON_CACHE`` overrides
|
||||
first): ``unpack-electron`` extracts from a zip here, NOT node_modules, so a corrupt zip poisons
|
||||
the build."""
|
||||
home = Path.home()
|
||||
candidates: list[Path] = []
|
||||
override = os.environ.get("electron_config_cache") or os.environ.get("ELECTRON_CACHE")
|
||||
@@ -521,20 +472,15 @@ def _electron_download_cache_dirs() -> list[Path]:
|
||||
|
||||
|
||||
def _purge_electron_build_cache(desktop_dir: Path, release_dir: Optional[Path] = None) -> list[Path]:
|
||||
"""Clear the cached Electron download + half-written unpacked dir so the next pack restarts from scratch.
|
||||
"""Purge the cached Electron zips + half-written unpacked dir so the next pack restarts from scratch.
|
||||
|
||||
Root cause of ``ENOENT … rename 'linux-unpacked/electron'``: a corrupt zip in
|
||||
the per-user cache (resumed partial download with prepended junk, or a
|
||||
truncated write) unpacks to a tree MISSING the ``electron`` binary, and
|
||||
re-running repeats the same extraction forever. We deliberately do NOT
|
||||
validate the zip ourselves: stdlib ``zipfile`` tolerates exactly the
|
||||
concat-junk that ``@electron/get`` rejects, so a gate would never self-heal.
|
||||
Instead purge unconditionally and let the caller retry once; ``@electron/get``
|
||||
re-downloads with SHASUM verification (the real source of truth).
|
||||
|
||||
``release_dir`` lets a stage-and-swap caller point at its STAGING output so
|
||||
the purge never touches the live app. Never raises; returns removed paths
|
||||
(empty ⇒ nothing to clear, so no point retrying).
|
||||
A corrupt cached zip unpacks to a tree MISSING the ``electron`` binary
|
||||
(``ENOENT … rename``) and every rerun repeats it. Deliberately no self-rolled
|
||||
zip validation: stdlib ``zipfile`` tolerates exactly the concat-junk
|
||||
``@electron/get`` rejects, so a gate would never self-heal — purge
|
||||
unconditionally and let ``@electron/get``'s SHASUM check be the truth.
|
||||
``release_dir`` points a stage-and-swap caller at its STAGING output so the
|
||||
live app is never touched. Never raises; empty result ⇒ nothing to retry.
|
||||
"""
|
||||
from hermes_cli.main import _electron_download_cache_dirs
|
||||
removed: list[Path] = []
|
||||
@@ -568,12 +514,8 @@ _ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/"
|
||||
|
||||
|
||||
def _electron_dir(project_root: Path) -> Path:
|
||||
"""The Electron package directory the desktop workspace installs.
|
||||
|
||||
npm may keep workspace-only dev deps under ``apps/desktop/node_modules`` or
|
||||
hoist them to the root depending on version; ``apps/desktop/package.json``
|
||||
points ``electronDist`` at the workspace-local path, so prefer that.
|
||||
"""
|
||||
"""The installed Electron package dir: workspace-local ``apps/desktop/node_modules/electron`` (where
|
||||
``electronDist`` points) when present, else the root hoist npm sometimes uses instead."""
|
||||
desktop_local = project_root / "apps" / "desktop" / "node_modules" / "electron"
|
||||
if desktop_local.exists():
|
||||
return desktop_local
|
||||
@@ -620,7 +562,6 @@ def _redownload_electron_dist(project_root: Path, env: dict, *, mirror: Optional
|
||||
if not installer.is_file():
|
||||
return False
|
||||
from hermes_constants import find_node_executable, with_hermes_node_path
|
||||
|
||||
node = find_node_executable("node")
|
||||
if not node:
|
||||
return False
|
||||
@@ -652,14 +593,9 @@ def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool:
|
||||
|
||||
|
||||
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
|
||||
"""Terminate a running desktop app executing from this build's ``release`` dir (Windows only).
|
||||
|
||||
A running ``Hermes.exe`` holds an exclusive lock, so electron-builder's pack
|
||||
dies with ``Access is denied`` and the retry repeats it. POSIX lets you
|
||||
unlink a running binary, so this is a no-op off Windows. Only processes whose
|
||||
exe lives INSIDE this desktop's ``release`` tree are stopped. Never raises;
|
||||
returns the PIDs asked to stop.
|
||||
"""
|
||||
"""Terminate a running desktop app whose exe lives INSIDE this build's ``release`` tree (Windows
|
||||
only — its lock makes the pack die with ``Access is denied``; POSIX can unlink a running
|
||||
binary). Never raises; returns the PIDs asked to stop."""
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
try:
|
||||
@@ -713,7 +649,6 @@ def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
|
||||
def _desktop_macos_bundle_id(bundle: Path) -> Optional[str]:
|
||||
"""Return a bundle/framework CFBundleIdentifier for local macOS signing."""
|
||||
import plistlib
|
||||
|
||||
info = bundle / "Contents" / "Info.plist"
|
||||
if not info.exists() and bundle.suffix == ".framework":
|
||||
candidates = list(bundle.glob("Versions/*/Resources/Info.plist")) + list(
|
||||
@@ -732,18 +667,12 @@ def _desktop_macos_bundle_id(bundle: Path) -> Optional[str]:
|
||||
|
||||
|
||||
def _desktop_macos_local_signing_identity() -> Optional[str]:
|
||||
"""The opt-in keychain identity for local macOS desktop signing (``desktop.macos_signing_identity``).
|
||||
|
||||
A persistent code-signing cert (a self-signed one from Keychain Access is
|
||||
enough) gives the app a certificate-anchored Designated Requirement — the
|
||||
strongest way to keep TCC grants stable across rebuilds. Empty/unset keeps
|
||||
identifier-pinned ad-hoc signing.
|
||||
"""
|
||||
"""``desktop.macos_signing_identity`` — a persistent (even self-signed) code-signing cert anchors
|
||||
the Designated Requirement and keeps TCC grants stable across rebuilds. Unset → ad-hoc."""
|
||||
if sys.platform != "darwin":
|
||||
return None
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
desktop = load_config().get("desktop", {})
|
||||
if not isinstance(desktop, dict):
|
||||
return None
|
||||
@@ -766,13 +695,8 @@ def _codesign_verify(codesign: str, app: Path, **kwargs) -> subprocess.Completed
|
||||
|
||||
|
||||
def _desktop_macos_has_valid_real_signature(app: Path) -> bool:
|
||||
"""True when the bundle carries an intact non-ad-hoc (Team ID) signature.
|
||||
|
||||
Makes the relaunch fixup a no-op on properly signed/notarized builds even
|
||||
without CSC_LINK / APPLE_SIGNING_IDENTITY in the environment — clobbering a
|
||||
Developer ID signature with an ad-hoc one resets TCC grants. A STALE real
|
||||
signature fails --verify and returns False so the fixup can repair it.
|
||||
"""
|
||||
"""True when the bundle has an intact Team-ID signature, so the fixup never clobbers a notarized
|
||||
build with ad-hoc (resets TCC). A STALE real signature fails --verify → False → repairable."""
|
||||
codesign = shutil.which("codesign")
|
||||
if not codesign:
|
||||
return False
|
||||
@@ -789,16 +713,10 @@ def _desktop_macos_has_valid_real_signature(app: Path) -> bool:
|
||||
|
||||
|
||||
def _desktop_macos_local_codesign(app: Path, *, desktop_dir: Path, identity: str = "-") -> bool:
|
||||
"""Re-sign a local Desktop build so macOS TCC grants survive rebuilds.
|
||||
|
||||
A plain ``codesign --deep --sign -`` leaves a cdhash-only Designated
|
||||
Requirement (changes every rebuild → TCC re-prompts everything) and strips
|
||||
electron-builder's entitlements (breaks microphone/JIT under the hardened
|
||||
runtime). Instead sign inside-out (standalone Mach-O, nested frameworks/
|
||||
helpers, main bundle), preserving the repo's entitlement plists, and pin an
|
||||
identifier-based DR when ad-hoc. Raises on signing failure; True after
|
||||
strict verification passes.
|
||||
"""
|
||||
"""Sign a local build inside-out (Mach-O files, nested frameworks/helpers, main bundle) with the
|
||||
repo's entitlements and an identifier-pinned DR when ad-hoc — a plain ``--deep --sign -`` gives
|
||||
a cdhash-only DR (TCC re-prompts every rebuild) and strips the JIT/mic entitlements.
|
||||
Raises on signing failure; True after strict verification."""
|
||||
codesign = shutil.which("codesign")
|
||||
if not codesign:
|
||||
return False
|
||||
@@ -862,13 +780,9 @@ def _desktop_macos_local_codesign(app: Path, *, desktop_dir: Path, identity: str
|
||||
|
||||
|
||||
def _macos_legacy_adhoc_resign(codesign: str, app: Path) -> bool:
|
||||
"""Legacy deep ad-hoc re-sign fallback; NEVER deletes the safeStorage keychain item.
|
||||
|
||||
Deleting it would permanently orphan every credential encrypted under it,
|
||||
and this path is reached exactly when the entitlement-preserving signer
|
||||
failed, so there is no verified successor identity. The keychain prompt
|
||||
macOS shows instead is recoverable ("Always Allow"); deletion is not.
|
||||
"""
|
||||
"""Legacy deep ad-hoc re-sign; NEVER deletes the safeStorage keychain item (that would orphan every
|
||||
credential under it, and there is no verified successor identity here — the "Always Allow"
|
||||
prompt is recoverable, deletion is not)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[codesign, "--force", "--deep", "--sign", "-", str(app)], check=False, capture_output=True, text=True
|
||||
@@ -896,17 +810,15 @@ def _desktop_macos_relaunchable_fixup(
|
||||
desktop_dir: Path, *, publisher_signing_configured: Optional[bool] = None,
|
||||
release_dir: Optional[Path] = None,
|
||||
) -> bool:
|
||||
"""Make a locally-built macOS app survive in-place self-update without resetting TCC grants.
|
||||
"""Re-sign a locally-built macOS app so in-place self-update doesn't reset TCC grants.
|
||||
|
||||
An ad-hoc-signed .app has no stable Designated Requirement, so a rebuilt
|
||||
bundle (new cdhash) reports "Hermes is damaged" and loses every TCC grant.
|
||||
Clear quarantine xattrs, then re-sign with ``desktop.macos_signing_identity``
|
||||
when configured, else identifier-pinned ad-hoc, preserving entitlements. No-op
|
||||
when a publisher identity is configured (CSC_LINK / APPLE_SIGNING_IDENTITY —
|
||||
callers may pass the decision so a later dotenv load can't reverse it) or the
|
||||
bundle already carries an intact Developer ID signature. ``release_dir``
|
||||
signs the STAGED bundle before promotion. Falls back to the legacy deep
|
||||
ad-hoc sign. Never raises; True when no work was needed or signing verified.
|
||||
A rebuilt ad-hoc bundle (new cdhash, no stable Designated Requirement) reports
|
||||
"Hermes is damaged" and loses every grant. Clear quarantine xattrs, then sign
|
||||
with ``desktop.macos_signing_identity`` or identifier-pinned ad-hoc, keeping
|
||||
entitlements; legacy deep ad-hoc as fallback. No-op with a publisher identity
|
||||
(CSC_LINK / APPLE_SIGNING_IDENTITY; callers may pass the decision so a later
|
||||
dotenv load can't reverse it) or an intact Developer ID signature.
|
||||
``release_dir`` signs the STAGED bundle before promotion. Never raises.
|
||||
"""
|
||||
from hermes_cli.main import _desktop_macos_has_valid_real_signature, _desktop_macos_local_codesign, _desktop_macos_local_signing_identity
|
||||
if sys.platform != "darwin":
|
||||
@@ -947,13 +859,8 @@ def _desktop_macos_relaunchable_fixup(
|
||||
|
||||
|
||||
def _macos_codesigning_identity_valid(security: str, identity: str) -> bool:
|
||||
"""True when `identity` appears among VALID code-signing identities.
|
||||
|
||||
``find-identity`` without ``-v`` also lists certs macOS refuses to sign with
|
||||
(imported but never trusted for codeSign); only the ``-v`` listing proves
|
||||
codesign can use it. Both the idempotency probe and the success
|
||||
postcondition for ``--setup-tcc-identity``. Never raises.
|
||||
"""
|
||||
"""True when `identity` is among VALID (``-v``) code-signing identities — the plain listing also
|
||||
shows untrusted certs codesign refuses. Idempotency probe + postcondition. Never raises."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[security, "find-identity", "-v", "-p", "codesigning"], capture_output=True, text=True, check=False,
|
||||
@@ -1046,15 +953,10 @@ def _macos_create_signing_identity(
|
||||
|
||||
|
||||
def _desktop_macos_setup_tcc_identity(identity: str = "Hermes Local Signing") -> bool:
|
||||
"""Create/import a self-signed code-signing cert and configure Hermes to use it.
|
||||
|
||||
One-shot setup for ``hermes desktop --setup-tcc-identity``: creates the cert
|
||||
in the login keychain, grants ``codesign`` access, writes
|
||||
``desktop.macos_signing_identity`` to config.yaml, and re-signs the packaged
|
||||
app. TCC grants persist against the code-signing identity, not the path; a
|
||||
certificate-anchored identity is stable across rebuilds (the yabai/skhd
|
||||
mechanism). Idempotent; True on success or already configured. Never raises.
|
||||
"""
|
||||
"""``--setup-tcc-identity``: create/import a self-signed code-signing cert, point
|
||||
``desktop.macos_signing_identity`` at it and re-sign the packaged app. TCC grants follow the
|
||||
signing identity, so a certificate-anchored one is stable across rebuilds (the yabai/skhd
|
||||
mechanism). Idempotent; never raises."""
|
||||
from hermes_cli.main import PROJECT_ROOT, _desktop_macos_relaunchable_fixup, _desktop_packaged_executable
|
||||
if sys.platform != "darwin":
|
||||
print(" (--setup-tcc-identity is macOS-only; skipping)")
|
||||
@@ -1091,7 +993,6 @@ def _desktop_macos_setup_tcc_identity(identity: str = "Hermes Local Signing") ->
|
||||
# config.yaml, not .env — it's not a secret.
|
||||
try:
|
||||
from hermes_cli.config import set_config_value
|
||||
|
||||
set_config_value("desktop.macos_signing_identity", identity)
|
||||
print(f" → set desktop.macos_signing_identity = {identity!r}")
|
||||
except Exception as exc:
|
||||
@@ -1118,15 +1019,9 @@ def _desktop_macos_setup_tcc_identity(identity: str = "Hermes Local Signing") ->
|
||||
|
||||
|
||||
def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool:
|
||||
"""Stop electron-builder grabbing a random keychain identity on self-update.
|
||||
|
||||
The self-updater re-signs the .app on the end user's machine; with
|
||||
``CSC_IDENTITY_AUTO_DISCOVERY`` on, electron-builder signs the hardened-
|
||||
runtime bundle with whatever personal cert it finds, which stalls the sign
|
||||
step or clobbers a real notarized signature. Force ad-hoc for the local
|
||||
packaged rebuild instead. No-op for source runs, off-macOS, with a real
|
||||
identity configured, or when the caller pinned the flag. Mutates ``env``.
|
||||
"""
|
||||
"""Force ad-hoc signing for the local packaged rebuild: with ``CSC_IDENTITY_AUTO_DISCOVERY`` on,
|
||||
electron-builder grabs any personal keychain cert and stalls the sign step or clobbers a
|
||||
notarized signature. No-op for source runs, off-macOS, with a real identity, or when pinned."""
|
||||
if sys.platform != "darwin" or source_mode:
|
||||
return False
|
||||
if env.get("CSC_LINK") or env.get("APPLE_SIGNING_IDENTITY") or "CSC_IDENTITY_AUTO_DISCOVERY" in env:
|
||||
@@ -1136,14 +1031,9 @@ def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool:
|
||||
|
||||
|
||||
def _desktop_linux_needs_no_sandbox() -> bool:
|
||||
"""True when Chromium/Electron should bypass the Linux sandbox.
|
||||
|
||||
Ubuntu 23.10+ ``apparmor_restrict_unprivileged_userns`` breaks the userns
|
||||
sandbox unless the app ships a root-owned 4755 ``chrome-sandbox``; when we
|
||||
can't ``sudo chown/chmod`` it, fall back to ``--no-sandbox`` rather than
|
||||
hard-failing. Deliberately NOT True for root: Electron as root without a
|
||||
sandbox is a qualitatively riskier path and must stay an explicit choice.
|
||||
"""
|
||||
"""True when Electron should run ``--no-sandbox``: Ubuntu 23.10+ ``apparmor_restrict_unprivileged_userns``
|
||||
breaks the userns sandbox without a root-owned 4755 helper. Deliberately NOT True for root —
|
||||
Electron as root without a sandbox must stay an explicit choice."""
|
||||
if os.environ.get("ELECTRON_DISABLE_SANDBOX", 0) == "1":
|
||||
return True
|
||||
|
||||
@@ -1159,13 +1049,8 @@ def _desktop_linux_needs_no_sandbox() -> bool:
|
||||
|
||||
|
||||
def _desktop_linux_userns_sandbox_available() -> bool:
|
||||
"""True when Chromium's unprivileged user-namespace sandbox works.
|
||||
|
||||
Then Chromium never consults the setuid ``chrome-sandbox`` helper, so
|
||||
requiring it root-owned 4755 (and prompting for sudo) is unnecessary. Probe
|
||||
the real capability with ``unshare``; fails closed on hosts where user
|
||||
namespaces are disabled or AppArmor-restricted.
|
||||
"""
|
||||
"""True when the unprivileged userns sandbox works (probed with ``unshare``, fails closed) — then
|
||||
the setuid ``chrome-sandbox`` helper is never consulted and no sudo prompt is needed."""
|
||||
if sys.platform != "linux":
|
||||
return False
|
||||
unshare = shutil.which("unshare")
|
||||
@@ -1243,12 +1128,8 @@ def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool:
|
||||
|
||||
|
||||
def _desktop_linux_needs_disable_setuid_sandbox(packaged_executable: Path) -> bool:
|
||||
"""True when Chromium should skip the present-but-non-setuid helper.
|
||||
|
||||
A user-owned ``chrome-sandbox`` still makes Chromium abort with
|
||||
``setuid_sandbox_host`` even when the namespace sandbox works. Call only
|
||||
after ``_desktop_linux_sandbox_fixup`` succeeded via the userns path.
|
||||
"""
|
||||
"""True when a present, non-setuid ``chrome-sandbox`` would make Chromium abort with
|
||||
``setuid_sandbox_host`` despite a working userns sandbox (call after the fixup's userns path)."""
|
||||
if sys.platform != "linux":
|
||||
return False
|
||||
_sandbox, st = _sandbox_helper_lstat(packaged_executable)
|
||||
@@ -1259,15 +1140,9 @@ _LINUX_PASSWORD_STORES = frozenset({"gnome-libsecret", "kwallet", "kwallet5", "k
|
||||
|
||||
|
||||
def _detect_linux_password_store() -> str | None:
|
||||
"""Detect the Chromium password-store backend for the current Linux session.
|
||||
|
||||
safeStorage only reports encryption available when Chromium selects the right
|
||||
keychain backend, and its own detection routinely fails under ``hermes
|
||||
desktop`` (launcher env doesn't look like a desktop session). Probe order:
|
||||
KDE session env, GNOME Keyring control socket, D-Bus ping of
|
||||
org.freedesktop.secrets (any Secret Service, e.g. KeePassXC). None when no
|
||||
keychain daemon is reachable.
|
||||
"""
|
||||
"""Chromium password-store backend for this Linux session (KDE env → GNOME Keyring socket → D-Bus
|
||||
ping of org.freedesktop.secrets), or None. Chromium's own detection fails under the launcher
|
||||
env, and safeStorage then reports encryption unavailable."""
|
||||
kde_version = os.environ.get("KDE_SESSION_VERSION", "").strip()
|
||||
if kde_version:
|
||||
return {"6": "kwallet6", "5": "kwallet5"}.get(kde_version, "kwallet")
|
||||
@@ -1294,20 +1169,13 @@ def _detect_linux_password_store() -> str | None:
|
||||
|
||||
|
||||
def _desktop_launch_options() -> tuple[list[str], str, str, str]:
|
||||
"""Read `desktop.*` launch options from config.yaml.
|
||||
|
||||
Returns ``(electron_flags, disable_gpu, password_store, ozone_hint)``:
|
||||
``disable_gpu`` is "auto"/"1"/"0" (for HERMES_DESKTOP_DISABLE_GPU),
|
||||
``password_store`` is "auto" or a Chromium backend (unknown → "auto"),
|
||||
``ozone_hint`` is "auto"/"x11"/"wayland" (for ELECTRON_OZONE_PLATFORM_HINT).
|
||||
Any config error yields ``([], "auto", "auto", "auto")`` so a malformed
|
||||
config never blocks the launch.
|
||||
"""
|
||||
"""``desktop.*`` launch options: ``(electron_flags, disable_gpu "auto"/"1"/"0", password_store,
|
||||
ozone_hint "auto"/"x11"/"wayland")``; unknown values and config errors yield "auto"/[] so a
|
||||
malformed config never blocks the launch."""
|
||||
flags: list[str] = []
|
||||
disable_gpu = password_store = ozone_hint = "auto"
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
desktop_cfg = (load_config() or {}).get("desktop") or {}
|
||||
except Exception:
|
||||
return flags, disable_gpu, password_store, ozone_hint
|
||||
@@ -1347,7 +1215,6 @@ def _register_linux_desktop_entry() -> None:
|
||||
from hermes_cli.main import PROJECT_ROOT
|
||||
try:
|
||||
from hermes_cli.linux_desktop_entry import install_desktop_entry, is_supported
|
||||
|
||||
if not is_supported():
|
||||
return
|
||||
entry = install_desktop_entry(PROJECT_ROOT)
|
||||
@@ -1361,7 +1228,6 @@ def _install_desktop_workspace_deps(npm: str, env: dict) -> None:
|
||||
"""npm-install the desktop workspace; exits on a failure that isn't a repairable missing Electron dist."""
|
||||
from hermes_cli.main import PROJECT_ROOT, _run_npm_install_deterministic
|
||||
from hermes_constants import with_hermes_node_path
|
||||
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
# Managed Node on PATH so npm's child scripts that shell out to bare `node`
|
||||
# (e.g. electron-winstaller's select-7z-arch.js) resolve it even when the
|
||||
@@ -1389,14 +1255,11 @@ def _run_desktop_pack_with_recovery(
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run the desktop build; a packaged build with NO staged exe retries after an Electron re-download, then via mirror.
|
||||
|
||||
Gate on a MISSING packaged executable: that is the signature of the
|
||||
corrupt-download class (corrupt cached zip → partial unpack → ENOENT on
|
||||
rename). A late failure such as macOS code signing leaves the executable in
|
||||
place — redownloading can't repair it, so the retry would only add a slow,
|
||||
identical failure.
|
||||
A MISSING exe is the signature of the corrupt-download class; a late failure
|
||||
(e.g. macOS signing) leaves it in place and a redownload retry would only
|
||||
repeat the same slow failure.
|
||||
"""
|
||||
from hermes_cli.main import PROJECT_ROOT, _electron_dist_ok, _purge_electron_build_cache, _redownload_electron_dist, _stop_desktop_processes_locking_build
|
||||
|
||||
def _staged_exe() -> Optional[Path]:
|
||||
return _desktop_packaged_executable_in(staging_dir) if staging_dir else None
|
||||
|
||||
@@ -1463,14 +1326,9 @@ def _promote_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Path:
|
||||
|
||||
|
||||
def _build_desktop_app(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict) -> Optional[Path]:
|
||||
"""npm-install + build the desktop app; stage-and-swap the packaged tree.
|
||||
|
||||
Returns the freshly installed packaged executable (non-source mode) or None
|
||||
(source mode builds ``dist/`` in place). Exits on any unrecoverable build
|
||||
failure, leaving the previous packaged app untouched.
|
||||
"""
|
||||
"""npm-install + build the desktop app, stage-and-swapping the packaged tree. Returns the new
|
||||
packaged exe (None in source mode). Exits on unrecoverable failure with the previous app kept."""
|
||||
from hermes_cli.main import PROJECT_ROOT, _desktop_packaged_executable, _stop_desktop_processes_locking_build, _write_desktop_build_stamp
|
||||
|
||||
_install_desktop_workspace_deps(npm, env)
|
||||
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
@@ -1520,15 +1378,10 @@ def _build_desktop_app(desktop_dir: Path, *, source_mode: bool, npm: str, env: d
|
||||
|
||||
|
||||
def _desktop_launch_env(args: argparse.Namespace) -> tuple[dict, list[str]]:
|
||||
"""Child env for the Electron process plus the config-supplied extra Electron flags.
|
||||
|
||||
Config (``desktop.*``) is bridged to env vars the Electron/Chromium process
|
||||
already reads; an explicit env var still wins over config. Linux keychain
|
||||
backend for safeStorage: config wins over detection, env wins over both.
|
||||
"""
|
||||
"""Electron child env + config-supplied extra flags. ``desktop.*`` config is bridged to env vars
|
||||
Electron already reads; an explicit env var wins over config (and over keychain detection)."""
|
||||
from hermes_cli.main import _desktop_launch_options, _detect_linux_password_store
|
||||
from hermes_constants import with_hermes_node_path
|
||||
|
||||
# with_hermes_node_path() copies os.environ when called with no arg.
|
||||
env = with_hermes_node_path()
|
||||
if getattr(args, "fake_boot", False):
|
||||
|
||||
@@ -43,7 +43,6 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
|
||||
db = None
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
session = db.get_session(target)
|
||||
if not session:
|
||||
@@ -94,14 +93,9 @@ intersection comparison in :func:`_tui_need_npm_install` always catches.
|
||||
|
||||
|
||||
def _workspace_root(dir: Path) -> Path:
|
||||
"""The npm workspace root for *dir*.
|
||||
|
||||
If *dir* has a ``package.json`` but no ``package-lock.json`` and its parent has
|
||||
one, the parent is the workspace root (single lockfile + hoisted node_modules).
|
||||
Otherwise *dir* itself (standalone project or prebuilt-bundle layout). Shared
|
||||
by the install-need check, the TUI launcher and the web build so lockfile /
|
||||
node_modules resolution and ``npm install`` cwd can't diverge.
|
||||
"""
|
||||
"""The npm workspace root for *dir*: its parent when *dir* has ``package.json`` but the
|
||||
lockfile lives one level up (hoisted node_modules), else *dir* (standalone / prebuilt).
|
||||
Shared by the install check, TUI launcher and web build so their cwd can't diverge."""
|
||||
if (
|
||||
(dir / "package.json").is_file()
|
||||
and not (dir / "package-lock.json").is_file()
|
||||
@@ -141,19 +135,14 @@ def _termux_workspace_install_context(dir: Path, *, include_child_workspaces: bo
|
||||
|
||||
|
||||
def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]:
|
||||
"""Package-map keys reachable from the selected workspaces via npm resolution.
|
||||
"""Package-map keys reachable from the selected workspaces (*starts*: set or str) via npm resolution.
|
||||
|
||||
*starts* is the set of workspace keys the launch install scopes to (a str is
|
||||
accepted). ``devDependencies`` are followed for each of those (npm installs
|
||||
the dev toolchain of every workspace it selects) but not for transitive deps.
|
||||
Returns ``None`` when none of *starts* are in *packages* so callers fall back
|
||||
to the full-lockfile comparison. The shared root lock also lists every OTHER
|
||||
workspace's deps (``apps/desktop``, ``web``); comparing in full reported them
|
||||
as "missing" and reinstalled on every launch.
|
||||
|
||||
Keys follow npm's v3 ``packages`` map; dependency names resolve by walking up
|
||||
``node_modules`` ancestors (node resolution), and workspace symlinks
|
||||
(``link: true``) are followed to their real entry.
|
||||
``devDependencies`` are followed for each start (npm installs every selected
|
||||
workspace's dev toolchain) but not for transitive deps. None when no start is
|
||||
in *packages* so callers fall back to the full comparison — which would report
|
||||
every OTHER workspace's deps (``apps/desktop``, ``web``) as missing and
|
||||
reinstall on every launch. Names resolve by walking up ``node_modules``
|
||||
ancestors; ``link: true`` entries are followed to their real package.
|
||||
"""
|
||||
start_set = {starts} if isinstance(starts, str) else {s for s in starts if s}
|
||||
present = [s for s in start_set if s in packages]
|
||||
@@ -198,12 +187,8 @@ def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]:
|
||||
|
||||
|
||||
def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set:
|
||||
"""Lock-map keys for the workspaces the launch install scopes to.
|
||||
|
||||
Mirrors ``_make_tui_argv``: the ui-tui workspace, plus its child ``packages/*``
|
||||
on Termux. Each is a dev-included closure root (npm installs devDependencies
|
||||
of every selected workspace). Empty set when ui-tui isn't under *ws_root*.
|
||||
"""
|
||||
"""Lock-map keys the launch install scopes to: ui-tui, plus its child ``packages/*`` on Termux
|
||||
(each a dev-included closure root). Empty when ui-tui isn't under *ws_root*."""
|
||||
from hermes_cli.main import _is_termux_startup_environment
|
||||
try:
|
||||
keys = {tui_dir.relative_to(ws_root).as_posix()}
|
||||
@@ -221,16 +206,13 @@ def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set:
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||
|
||||
Prebuilt bundle (``dist/entry.js`` with no lockfile): nothing to install.
|
||||
Lockfile / ink / marker checks use the workspace root. The root lock is
|
||||
compared against npm's hidden ``node_modules/.package-lock.json`` by CONTENT
|
||||
(git checkouts bump mtimes without changing deps): an entry missing from the
|
||||
hidden lock → reinstall unless ``optional``/``peer``/``link`` or not under
|
||||
``node_modules/``; present in both → compare only the intersection of
|
||||
non-null fields after stripping ``_NPM_LOCK_RUNTIME_KEYS`` (the hidden lock
|
||||
omits or nulls many metadata fields; ``resolved``/``integrity`` are always in
|
||||
both). Extra hidden-only entries are ignored. Falls back to mtime when either
|
||||
lockfile is unparseable.
|
||||
Prebuilt bundle (``dist/entry.js``, no lockfile): nothing to install. The root
|
||||
lock is compared to npm's hidden ``node_modules/.package-lock.json`` by CONTENT
|
||||
(git bumps mtimes without changing deps): missing from hidden → reinstall
|
||||
unless ``optional``/``peer``/``link`` or outside ``node_modules/``; present in
|
||||
both → compare the intersection of non-null fields minus
|
||||
``_NPM_LOCK_RUNTIME_KEYS`` (``resolved``/``integrity`` are always in both).
|
||||
Hidden-only entries are ignored; unparseable lockfiles fall back to mtime.
|
||||
"""
|
||||
entry = root / "dist" / "entry.js"
|
||||
ws_root = _workspace_root(root)
|
||||
@@ -317,11 +299,8 @@ def _iter_tui_build_inputs(root: Path):
|
||||
|
||||
|
||||
def _tui_need_rebuild(root: Path) -> bool:
|
||||
"""True when ``dist/entry.js`` is missing or older than TUI inputs.
|
||||
|
||||
Rebuilding on every launch is a visible cold-start tax on slow Termux CPUs;
|
||||
``HERMES_TUI_FORCE_BUILD=1`` forces the old always-rebuild behaviour.
|
||||
"""
|
||||
"""True when ``dist/entry.js`` is missing or older than TUI inputs (Termux cold-start saver);
|
||||
``HERMES_TUI_FORCE_BUILD=1`` forces a rebuild."""
|
||||
force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower()
|
||||
if force in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
@@ -341,13 +320,8 @@ def _tui_need_rebuild(root: Path) -> bool:
|
||||
|
||||
|
||||
def _ensure_tui_node() -> None:
|
||||
"""Make sure `node` + `npm` are on PATH for the TUI.
|
||||
|
||||
If either is missing, source scripts/lib/node-bootstrap.sh and call
|
||||
`ensure_node` (fnm/nvm/proto/brew/bundled cascade), then prepend the resolved
|
||||
node's directory to PATH so shutil.which finds it in this process. No-op when
|
||||
both exist; ``HERMES_SKIP_NODE_BOOTSTRAP=1`` disables auto-install.
|
||||
"""
|
||||
"""Ensure `node` + `npm` are on PATH: else run node-bootstrap.sh `ensure_node` and prepend
|
||||
the resolved node dir to PATH. ``HERMES_SKIP_NODE_BOOTSTRAP=1`` disables auto-install."""
|
||||
from hermes_cli.main import PROJECT_ROOT
|
||||
if shutil.which("node") and shutil.which("npm"):
|
||||
return
|
||||
@@ -359,7 +333,6 @@ def _ensure_tui_node() -> None:
|
||||
return
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
hermes_home = str(get_hermes_home())
|
||||
try:
|
||||
# Helper logs to stderr; stdout carries `command -v node` — subshell PATH
|
||||
@@ -397,13 +370,8 @@ def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
|
||||
|
||||
|
||||
def _restore_tui_workspace(tui_dir: Path) -> bool:
|
||||
"""Try to restore a missing ``ui-tui/`` from git, returning True on success.
|
||||
|
||||
On Windows an antivirus / NTFS filter driver can leave tracked ``ui-tui/``
|
||||
files deleted after ``hermes update``; ``git restore`` puts them back.
|
||||
Best-effort: False when git is unavailable, this isn't a checkout, or the
|
||||
directory is still missing afterwards.
|
||||
"""
|
||||
"""Best-effort ``git restore`` of a missing ``ui-tui/`` (Windows AV/NTFS filters can delete
|
||||
tracked files after ``hermes update``); True when the directory exists afterwards."""
|
||||
git = shutil.which("git")
|
||||
if not git or not (tui_dir.parent / ".git").exists():
|
||||
return False
|
||||
@@ -418,12 +386,8 @@ def _restore_tui_workspace(tui_dir: Path) -> bool:
|
||||
|
||||
|
||||
def _ensure_tui_workspace(tui_dir: Path) -> None:
|
||||
"""Ensure ``ui-tui/`` exists before any npm/node subprocess uses it as cwd.
|
||||
|
||||
Otherwise ``subprocess.run(cwd=<missing>)`` crashes with ``NotADirectoryError``
|
||||
(``WinError 267``) instead of a usable message. Self-heal via ``git restore``
|
||||
first; abort with manual recovery steps only if that fails.
|
||||
"""
|
||||
"""Ensure ``ui-tui/`` exists before it is used as a subprocess cwd (else ``NotADirectoryError``
|
||||
/ ``WinError 267`` with no usable message): git-restore first, then abort with recovery steps."""
|
||||
if tui_dir.is_dir():
|
||||
return
|
||||
|
||||
@@ -456,18 +420,13 @@ def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]:
|
||||
|
||||
|
||||
def _tui_node_bin(bin: str) -> str:
|
||||
"""Resolve ``node``/``npm`` for the TUI launch, or exit with a hint.
|
||||
|
||||
``HERMES_NODE`` wins for node. ``find_node_executable()`` prefers the managed
|
||||
``$HERMES_HOME/node`` tree, which is not on PATH — a bare which() would say
|
||||
"node not found" on an install whose only Node is the one Hermes installed.
|
||||
"""
|
||||
"""Resolve ``node``/``npm`` for the TUI launch, or exit with a hint. ``HERMES_NODE`` wins for node;
|
||||
``find_node_executable()`` sees the managed ``$HERMES_HOME/node`` tree a bare which() misses."""
|
||||
if bin == "node":
|
||||
env_node = os.environ.get("HERMES_NODE")
|
||||
if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
|
||||
return env_node
|
||||
from hermes_constants import find_node_executable
|
||||
|
||||
path = find_node_executable(bin)
|
||||
if not path and bin == "node":
|
||||
try:
|
||||
@@ -506,11 +465,10 @@ def _run_tui_npm_build(npm: str, cwd: Path, failure_message: str) -> None:
|
||||
def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
|
||||
"""``npm install`` for the TUI workspace, with one EBADENGINE repair retry. Exits on failure.
|
||||
|
||||
``--workspace ui-tui`` avoids resolving apps/desktop (Electron + node-pty);
|
||||
omitted when ui-tui/ has its own lockfile (npm can't find a workspace named
|
||||
"ui-tui" inside ui-tui/). Termux scopes the install to ui-tui + its child
|
||||
packages. ``--include=dev``: the build toolchain lives in devDependencies and
|
||||
an inherited ``NODE_ENV=production`` / ``omit=dev`` would silently skip it.
|
||||
``--workspace ui-tui`` avoids resolving apps/desktop (Electron + node-pty) and
|
||||
is omitted when ui-tui/ has its own lockfile. ``--include=dev``: the build
|
||||
toolchain is in devDependencies and an inherited ``NODE_ENV=production`` /
|
||||
``omit=dev`` would silently skip it.
|
||||
"""
|
||||
npm = _tui_node_bin("npm")
|
||||
if not os.environ.get("HERMES_QUIET"):
|
||||
@@ -526,7 +484,6 @@ def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
|
||||
|
||||
def _run_tui_install() -> subprocess.CompletedProcess:
|
||||
from hermes_constants import with_hermes_node_path
|
||||
|
||||
# Managed tree first on PATH: if the EBADENGINE repair provisioned a
|
||||
# managed Node, npm's shebang/lifecycle scripts must resolve that node.
|
||||
return subprocess.run(
|
||||
@@ -541,7 +498,6 @@ def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
|
||||
# repair once (upgrade a managed npm in place, or provision a managed
|
||||
# runtime) and retry rather than dumping EBADENGINE at the user.
|
||||
from hermes_cli.npm_engine import maybe_repair_npm_engine
|
||||
|
||||
repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}")
|
||||
if repaired_npm:
|
||||
npm_install_cmd[0] = repaired_npm
|
||||
@@ -629,20 +585,16 @@ def _normalize_tui_toolsets(toolsets: object) -> list[str]:
|
||||
"""Normalize argparse/Fire-style toolset input for the TUI subprocess."""
|
||||
try:
|
||||
from hermes_cli.oneshot import _normalize_toolsets
|
||||
|
||||
return _normalize_toolsets(toolsets) or []
|
||||
except (AttributeError, ImportError):
|
||||
return _split_comma_items(toolsets, split_non_str=False) if toolsets else []
|
||||
|
||||
|
||||
def _read_cgroup_memory_limit() -> Optional[int]:
|
||||
"""Container memory limit in bytes, or None if unconstrained.
|
||||
"""Container memory limit in bytes, or None if unconstrained (v2 ``memory.max``, then v1).
|
||||
|
||||
V8 is NOT cgroup-aware: a flat ``--max-old-space-size=8192`` grows past a
|
||||
smaller container limit and the cgroup OOM-killer SIGKILLs Node (no JS
|
||||
handler, no breadcrumb — the user sees a bare ``stdin EOF``). Checks cgroup
|
||||
v2 ``memory.max`` then v1 ``memory.limit_in_bytes``; ``max`` or the v1
|
||||
near-INT64 "unlimited" sentinel means no limit.
|
||||
V8 is NOT cgroup-aware: a flat 8GB heap grows past a smaller container limit
|
||||
and the OOM-killer SIGKILLs Node with no breadcrumb (bare ``stdin EOF``).
|
||||
"""
|
||||
candidates = (
|
||||
"/sys/fs/cgroup/memory.max", # cgroup v2
|
||||
@@ -671,13 +623,9 @@ def _read_cgroup_memory_limit() -> Optional[int]:
|
||||
|
||||
|
||||
def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
|
||||
"""Pick a V8 ``--max-old-space-size`` (MB) that fits the container.
|
||||
|
||||
``default_mb`` when unconstrained or the box is large enough; otherwise ~75% of
|
||||
the cgroup limit (headroom for non-heap RSS and the Python gateway child in
|
||||
the same cgroup), floored at 1536MB when the container is > 2GB (below that
|
||||
V8 GC-thrashes). Never exceeds ``default_mb``.
|
||||
"""
|
||||
"""V8 ``--max-old-space-size`` (MB) that fits the container: ``default_mb`` when unconstrained,
|
||||
else 75% of the cgroup limit (headroom for non-heap RSS + the gateway child), floored at
|
||||
1536MB when the container is > 2GB (below that V8 GC-thrashes)."""
|
||||
from hermes_cli.main import _read_cgroup_memory_limit
|
||||
limit = _read_cgroup_memory_limit()
|
||||
if not limit:
|
||||
@@ -731,7 +679,6 @@ def _setup_tui_worktree() -> dict:
|
||||
wt_info = None
|
||||
try:
|
||||
from cli import _git_repo_root, _maintain_pack_health, _prune_stale_worktrees, _setup_worktree
|
||||
|
||||
repo = _git_repo_root()
|
||||
if repo:
|
||||
_prune_stale_worktrees(repo)
|
||||
@@ -760,7 +707,6 @@ def _launch_tui(
|
||||
tui_dir = PROJECT_ROOT / "ui-tui"
|
||||
|
||||
import tempfile
|
||||
|
||||
# TUI child is a hermes process: propagate the profile-home contract via
|
||||
# the single factory; keep secrets (the TUI/agent needs provider creds).
|
||||
from tools.environments.local import build_subprocess_env
|
||||
@@ -849,7 +795,6 @@ def _launch_tui(
|
||||
# preserve_inherited=False keeps --tui and other flags out of the subcommand.
|
||||
if code == 42:
|
||||
from hermes_cli.relaunch import relaunch
|
||||
|
||||
print()
|
||||
print("⚕ Launching update...")
|
||||
print()
|
||||
@@ -859,47 +804,34 @@ def _launch_tui(
|
||||
|
||||
|
||||
def _pin_kanban_board_env() -> None:
|
||||
"""Pin the active kanban board into ``HERMES_KANBAN_BOARD`` for the chat session.
|
||||
|
||||
Otherwise in-process ``kanban_*`` tools and shelled-out ``hermes kanban`` calls
|
||||
resolve the board on different paths (env pin vs the global ``kanban/current``
|
||||
file), and a concurrent ``boards switch`` can flip the file mid-turn.
|
||||
"""
|
||||
"""Pin the active kanban board into ``HERMES_KANBAN_BOARD`` so in-process tools and shelled-out
|
||||
``hermes kanban`` calls agree even if a concurrent ``boards switch`` flips the file mid-turn."""
|
||||
if os.environ.get("HERMES_KANBAN_BOARD"):
|
||||
return
|
||||
try:
|
||||
from hermes_cli.kanban_db import get_current_board
|
||||
|
||||
os.environ["HERMES_KANBAN_BOARD"] = get_current_board()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _sync_bundled_skills_quietly() -> None:
|
||||
"""Seed ``~/.hermes/skills/`` with the bundled skill library on first launch.
|
||||
|
||||
Manifest-based and idempotent (skipped skills cost milliseconds), so every
|
||||
first-interaction entrypoint may call it. Failures are swallowed: skills are
|
||||
an enhancement, not a hard dependency.
|
||||
"""
|
||||
"""Seed ``~/.hermes/skills/`` with the bundled library (idempotent, milliseconds when synced).
|
||||
Failures are swallowed: skills are an enhancement, not a hard dependency."""
|
||||
try:
|
||||
from tools.skills_sync import sync_skills
|
||||
|
||||
sync_skills(quiet=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_use_tui(args) -> bool:
|
||||
"""Decide whether to launch the TUI for a chat/bare invocation.
|
||||
|
||||
Precedence: ``--cli`` → classic; ``--tui`` → TUI; no TTY → classic;
|
||||
"""Decide whether to launch the TUI: ``--cli`` → classic; ``--tui`` → TUI; no TTY → classic;
|
||||
``HERMES_TUI=1`` → TUI; ``display.interface`` config; default classic.
|
||||
The TTY gate is load-bearing: ambient TUI preferences must never hijack a
|
||||
non-interactive invocation (kanban workers, cron, pipelines run
|
||||
``hermes chat -q`` on a pipe; the Ink no-TTY bail-out exits 0 and a kanban
|
||||
worker then dies with a protocol violation). An explicit ``--tui`` still gets
|
||||
the informative bail-out.
|
||||
|
||||
The TTY gate is load-bearing: ambient preferences must never hijack a piped
|
||||
``hermes chat -q`` (kanban workers, cron) — the Ink no-TTY bail-out exits 0 and
|
||||
the worker dies with a protocol violation. Explicit ``--tui`` still bails out.
|
||||
"""
|
||||
if getattr(args, "cli", False):
|
||||
return False
|
||||
@@ -914,7 +846,6 @@ def _resolve_use_tui(args) -> bool:
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
iface = (load_config().get("display", {}) or {}).get("interface", "cli")
|
||||
return isinstance(iface, str) and iface.strip().lower() == "tui"
|
||||
except Exception:
|
||||
|
||||
@@ -48,15 +48,11 @@ def _record_bytecode_fingerprint() -> None:
|
||||
|
||||
|
||||
def _sweep_stale_bytecode_if_checkout_changed() -> None:
|
||||
"""Clear ``__pycache__`` at launch when the checkout changed underneath us.
|
||||
"""Clear ``__pycache__`` at launch when the checkout fingerprint changed since the last sweep.
|
||||
|
||||
Stale-bytecode bug class: the checkout's ``.py`` files change (git pull inside
|
||||
``hermes update``, a manual pull, a ZIP update, a file-sync restore) while
|
||||
``__pycache__`` keeps bytecode from the previous revision. Update-time clears
|
||||
can't close it — ``hermes update`` runs the PRE-pull updater code, and manual
|
||||
pulls never run it — so every entry point compares the checkout fingerprint
|
||||
(cheap file reads, no git subprocess) against the last-validated stamp and
|
||||
sweeps once when they diverge. Never raises.
|
||||
Update-time clears can't close the stale-bytecode class: ``hermes update`` runs
|
||||
the PRE-pull updater code and manual pulls never run it. Cheap file reads, no
|
||||
git subprocess. Never raises.
|
||||
"""
|
||||
from hermes_cli.main import PROJECT_ROOT, _clear_bytecode_cache, _read_git_revision_fingerprint, _record_bytecode_fingerprint
|
||||
try:
|
||||
@@ -112,7 +108,6 @@ def _hash_source_tree(project_root: Path, tree_dir: Path) -> str:
|
||||
h.update(b"\0")
|
||||
|
||||
from pathspec import PathSpec
|
||||
|
||||
gitignore = project_root / ".gitignore"
|
||||
lines = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.is_file() else []
|
||||
spec = PathSpec.from_lines("gitignore", lines)
|
||||
@@ -218,13 +213,9 @@ def _run_with_idle_timeout(
|
||||
cmd: list[str], cwd: Path, *, idle_timeout_seconds: int = 180, indent: str = " ",
|
||||
env: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a subprocess that streams output, killing it after *idle_timeout_seconds* of silence.
|
||||
|
||||
A silent, captured ``npm run build`` on a low-memory host looks like a hang and
|
||||
users reboot mid-install. Stdout is streamed, and idle output terminates the
|
||||
process with a non-zero returncode (124 if terminate raced a clean exit).
|
||||
Returns merged stdout (text) and empty stderr; never raises on idle timeout.
|
||||
"""
|
||||
"""Stream a subprocess, killing it after *idle_timeout_seconds* of silence (a silent captured
|
||||
Vite build on a low-memory host looks like a hang and users reboot mid-install). Returns merged
|
||||
stdout, empty stderr, rc 124 if terminate raced a clean exit; never raises on idle timeout."""
|
||||
merged_chunks: list[str] = []
|
||||
last_output_ts = _time.monotonic()
|
||||
lock = threading.Lock()
|
||||
@@ -285,15 +276,10 @@ def _run_with_idle_timeout(
|
||||
|
||||
|
||||
def _nixos_build_env() -> dict[str, str] | None:
|
||||
"""Extra env for native module builds on NixOS, or None when not needed.
|
||||
|
||||
node-gyp's ``find-python.js`` does a bare PATH lookup for python3, which fails
|
||||
on NixOS outside a nix-shell. Tier 1: the hermes venv python3; tier 2: resolve
|
||||
via ``nix-shell`` (a self-contained Nix store binary, valid after the shell exits).
|
||||
"""
|
||||
"""``PYTHON=`` env for node-gyp on NixOS (bare PATH lookup fails outside nix-shell): the hermes
|
||||
venv python3, else a ``nix-shell``-resolved store path. None off NixOS / python3 on PATH."""
|
||||
from hermes_cli.main import PROJECT_ROOT
|
||||
import re
|
||||
|
||||
try:
|
||||
os_release = Path("/etc/os-release").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
@@ -327,14 +313,11 @@ def _run_npm_install_deterministic(
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Deterministic npm install that never mutates ``package-lock.json``.
|
||||
|
||||
``npm ci`` when a lockfile is present, else/on failure ``npm install --no-save``
|
||||
(lockfile may be out of sync on a WIP checkout; ``--no-save`` keeps the
|
||||
contract — a rewritten lockfile makes every future ``npm ci`` fail).
|
||||
``--include=dev`` is forced: callers are frontend builds whose toolchain is in
|
||||
devDependencies, and an inherited ``NODE_ENV=production`` / ``omit=dev`` would
|
||||
silently skip them (exit 0) and the build then dies with ``tsc: not found``.
|
||||
An npm outside the root ``engines.npm`` range fails every command identically,
|
||||
so that failure gets exactly one ``maybe_repair_npm_engine`` retry.
|
||||
``npm ci`` when a lockfile exists, else/on failure ``npm install --no-save``
|
||||
(a rewritten lockfile makes every future ``npm ci`` fail). ``--include=dev``
|
||||
is forced: an inherited ``NODE_ENV=production`` / ``omit=dev`` silently skips
|
||||
the build toolchain and the build dies with ``tsc: not found``. An npm outside
|
||||
``engines.npm`` fails every command, so it gets one engine-repair retry.
|
||||
"""
|
||||
# CI=1 no-ops unicode-animations' postinstall that animates to /dev/tty.
|
||||
run_env = _npm_lifecycle_env(env)
|
||||
@@ -355,14 +338,12 @@ def _run_npm_install_deterministic(
|
||||
return result
|
||||
|
||||
from hermes_cli.npm_engine import maybe_repair_npm_engine
|
||||
|
||||
repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}")
|
||||
if not repaired_npm:
|
||||
return result
|
||||
# A freshly provisioned managed npm resolves `node` from PATH — put the
|
||||
# managed tree first so it finds the managed Node, not a mismatched system one.
|
||||
from hermes_constants import with_hermes_node_path
|
||||
|
||||
run_env["PATH"] = with_hermes_node_path(run_env)["PATH"]
|
||||
return _attempt(repaired_npm)
|
||||
|
||||
@@ -404,14 +385,9 @@ def _missing_web_build_tool(output: str) -> str | None:
|
||||
|
||||
|
||||
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
"""Build the web UI frontend if npm is available, serializing across processes.
|
||||
|
||||
Concurrent dashboard boots used to each spawn ``npm install`` + ``vite build``
|
||||
over the same tree and starve each other. One process builds under an
|
||||
exclusive flock; the rest serve the existing dist (stale is acceptable) or,
|
||||
when none exists yet, block until the builder finishes. Staleness is checked
|
||||
once inside :func:`_do_build_web_ui`, after the lock is held.
|
||||
"""
|
||||
"""Build the web UI if npm is available, serialized across processes by flock: one builds, the
|
||||
rest serve the existing dist (stale is fine) or block until the first build exists. Staleness is
|
||||
checked inside :func:`_do_build_web_ui` after the lock is held."""
|
||||
if not (web_dir / "package.json").exists():
|
||||
return True
|
||||
try:
|
||||
@@ -449,14 +425,11 @@ def _relay_npm_output(result: subprocess.CompletedProcess) -> None:
|
||||
def _web_npm_install_context(web_dir: Path) -> tuple[Path, tuple[str, ...]]:
|
||||
"""``(cwd, workspace_args)`` for installing the web workspace's deps.
|
||||
|
||||
Scoped to ``--workspace web`` so the root ``apps/*`` glob never pulls desktop
|
||||
(Electron + node-pty) into a web build; no args when ``web/`` has its own
|
||||
lockfile (``_workspace_root`` returns web_dir and ``--workspace`` would fail).
|
||||
From the workspace root this must name the SAME closure as ``hermes update``'s
|
||||
``_update_node_dependencies()`` (ui-tui + web + root): ``npm ci`` deletes
|
||||
node_modules before reifying, so a narrower closure silently prunes what the
|
||||
update step just installed while still exiting 0. ui-tui is only named when
|
||||
present (prebuilt/partial checkouts lack it and npm fails hard otherwise).
|
||||
``--workspace web`` keeps desktop (Electron + node-pty) out of a web build; no
|
||||
args when ``web/`` has its own lockfile. From the root this must name the SAME
|
||||
closure as ``hermes update``'s ``_update_node_dependencies()`` (ui-tui + web +
|
||||
root): ``npm ci`` wipes node_modules first, so a narrower closure silently
|
||||
prunes what update just installed. ui-tui is named only when present.
|
||||
"""
|
||||
from hermes_cli.main import _is_termux_startup_environment
|
||||
if _is_termux_startup_environment():
|
||||
@@ -491,7 +464,6 @@ def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
from hermes_constants import with_hermes_node_path
|
||||
|
||||
npm = _resolve_node_runtime_npm()
|
||||
if not npm:
|
||||
if fatal:
|
||||
|
||||
Reference in New Issue
Block a user