"""Web UI (dashboard frontend) build: content-hash stamps, npm install/build with idle timeout, bytecode sweep. 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 hashlib import json import os import shutil import subprocess import sys import threading import time as _time from pathlib import Path from hermes_cli.main_tui_launch import ( _npm_lifecycle_env, _termux_workspace_install_context, _workspace_root, ) # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.main") # Stamp file recording the checkout fingerprint the bytecode cache was last # validated against. Lives next to the checkout (NOT in HERMES_HOME) because # __pycache__ is per-checkout state shared by every profile. _BYTECODE_FINGERPRINT_FILE = ".bytecode-fingerprint" def _record_bytecode_fingerprint() -> None: """Persist the current checkout fingerprint after a bytecode sweep. Never raises. A failed write just means the next launch re-sweeps — safe, merely redundant. """ from hermes_cli.main import PROJECT_ROOT, _BYTECODE_FINGERPRINT_FILE, _read_git_revision_fingerprint try: fingerprint = _read_git_revision_fingerprint(PROJECT_ROOT) if not fingerprint: return stamp_path = PROJECT_ROOT / _BYTECODE_FINGERPRINT_FILE tmp_path = stamp_path.with_name(stamp_path.name + ".tmp") tmp_path.write_text(fingerprint, encoding="utf-8") tmp_path.replace(stamp_path) except OSError as exc: logger.debug("Could not record bytecode fingerprint: %s", exc) def _sweep_stale_bytecode_if_checkout_changed() -> None: """Clear ``__pycache__`` at launch when the checkout changed underneath us. The stale-bytecode bug class (issues #6207, #60242; Dhruv's WhatsApp ``cannot import name 'parse_model_flags_detailed'`` report) has one shared shape: the checkout's ``.py`` files change (git pull inside ``hermes update``, a manual ``git pull``, a ZIP update, a file-sync restore) while ``__pycache__`` retains bytecode from the previous revision, and a later process trusts the stale ``.pyc`` instead of the fresh source. Update-time clears alone can never close this class: ``hermes update`` always executes the PRE-pull updater code, so any hardening added to it only takes effect one update late, and manual ``git pull`` never runs the updater at all. This launch-time guard closes the loop: every ``hermes`` entry point compares the checkout fingerprint (cheap file reads, no git subprocess) against the last-validated stamp and sweeps the bytecode cache once when they diverge. Never raises — a failure here must not block launch. """ from hermes_cli.main import PROJECT_ROOT, _BYTECODE_FINGERPRINT_FILE, _clear_bytecode_cache, _read_git_revision_fingerprint, _record_bytecode_fingerprint try: fingerprint = _read_git_revision_fingerprint(PROJECT_ROOT) if not fingerprint: return # non-git install — the ZIP update path clears explicitly stamp_path = PROJECT_ROOT / _BYTECODE_FINGERPRINT_FILE try: recorded = stamp_path.read_text(encoding="utf-8").strip() except OSError: recorded = "" if recorded == fingerprint: return removed = _clear_bytecode_cache(PROJECT_ROOT) if removed: logger.info( "Checkout changed since last launch (%s -> %s): cleared %d stale __pycache__ director%s", recorded or "unknown", fingerprint, removed, "y" if removed == 1 else "ies", ) _record_bytecode_fingerprint() except Exception as exc: logger.debug("Stale-bytecode launch sweep failed: %s", exc) def _web_ui_build_needed(web_dir: Path) -> bool: """Return True if the web UI dist is missing or its source content changed. Uses a SHA-256 content hash of the web source tree (the same approach ``_desktop_build_needed()`` already uses for the Electron build), NOT mtime comparison. ``git checkout`` / ``git pull`` / ``hermes update`` rewrite source mtimes without changing content, which made the old mtime check unreliable in both directions: it could skip a rebuild when source had genuinely changed (serving a stale dashboard) and force a rebuild when nothing had. A content hash is stable across mtime churn. The dashboard source lives under ``web/`` but Vite outputs to ``hermes_cli/web_dist/`` (per vite.config.ts outDir), NOT ``web/dist/``, so the dist directory is never part of the hashed source tree. """ project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent dist_dir = project_root / "hermes_cli" / "web_dist" sentinel = dist_dir / ".vite" / "manifest.json" if not sentinel.exists(): sentinel = dist_dir / "index.html" if not sentinel.exists(): return True stamp_file = _web_ui_stamp_path() if not stamp_file.is_file(): return True try: stamp_data = json.loads(stamp_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return True if not isinstance(stamp_data, dict): return True saved_hash = stamp_data.get("contentHash") if not saved_hash: return True return _compute_web_ui_content_hash(project_root, web_dir) != saved_hash def _compute_web_ui_content_hash(project_root: Path, web_dir: Path) -> str: """Return a SHA-256 hex digest of the web UI source tree. Covers ``web_dir`` (the dashboard frontend source) plus the root ``package.json`` / ``package-lock.json`` (workspace config that determines dependency resolution). Mirrors ``_compute_desktop_content_hash()``: ignored paths (``node_modules/``, ``dist/``, ``*.pyc``, ...) are skipped via the repo-root ``.gitignore`` so build output never feeds back into its own staleness check. """ h = hashlib.sha256() def _hash_file(path: Path) -> None: rel = str(path.relative_to(project_root)) h.update(rel.encode()) h.update(b"\0") try: with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) except OSError: pass h.update(b"\0") from pathspec import PathSpec gitignore = project_root / ".gitignore" lines: list[str] = [] if gitignore.is_file(): lines = gitignore.read_text(encoding="utf-8").splitlines() spec = PathSpec.from_lines("gitignore", lines) # Root workspace config (single package-lock.json covers all workspaces). for name in ("package.json", "package-lock.json"): p = project_root / name if p.is_file(): rel = str(p.relative_to(project_root)) if not spec.match_file(rel): _hash_file(p) # Walk the web source tree, pruning ignored directories in-place so we # never descend into node_modules/ or a stray dist/. Sort filenames for # a deterministic, order-independent digest. for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True): dirnames[:] = [ d for d in dirnames if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root))) ] for fn in sorted(filenames): fp = Path(dirpath) / fn rel = str(fp.relative_to(project_root)) if not spec.match_file(rel): _hash_file(fp) return h.hexdigest() def _web_ui_stamp_path() -> Path: """Return the path to the web UI build stamp file under $HERMES_HOME.""" from hermes_constants import get_hermes_home return get_hermes_home() / "web-ui-build-stamp.json" def _write_web_ui_build_stamp(project_root: Path, web_dir: Path) -> None: """Write the web UI build stamp after a successful build.""" stamp_file = _web_ui_stamp_path() try: stamp_file.parent.mkdir(parents=True, exist_ok=True) from datetime import datetime, timezone stamp_data = { "contentHash": _compute_web_ui_content_hash(project_root, web_dir), "builtAt": datetime.now(timezone.utc).isoformat(), } stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8") except Exception as exc: # Never let stamp-writing block or fail a build. logger.debug("Failed to write web UI build stamp: %s", exc) 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, with an idle-output timeout. Issue #33788: ``npm run build`` (Vite) was invoked with ``capture_output=True`` and no timeout. On low-memory hosts (notably WSL2 with the default 4 GB cap) the build can stall or sit silent for minutes; users see a frozen terminal, assume the update is hung, and reboot — leaving the editable install in a half-state with the ``hermes`` launcher present but ``hermes_cli`` not importable. This helper fixes both halves: stdout is streamed (so the user sees progress), and if no bytes have appeared on stdout/stderr for ``idle_timeout_seconds``, the process is terminated and the call returns with a non-zero ``returncode``. The caller's existing stale-dist fallback (#23817) takes over from there. Returns a ``CompletedProcess`` with merged stdout (text), empty stderr, and an integer returncode. Never raises on idle timeout — propagation of failure is via the returncode. """ merged_chunks: list[str] = [] last_output_ts = _time.monotonic() lock = threading.Lock() try: proc = subprocess.Popen( cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, env=env, ) except OSError as exc: # E.g. npm not on PATH between the which() check and now. return subprocess.CompletedProcess(cmd, 127, stdout="", stderr=str(exc)) def _reader() -> None: nonlocal last_output_ts assert proc.stdout is not None for line in proc.stdout: try: print(f"{indent}{line.rstrip()}", flush=True) except UnicodeEncodeError: # Windows cp1252 fallback — same pattern as _say(). enc = getattr(sys.stdout, "encoding", None) or "ascii" safe = line.rstrip().encode(enc, errors="replace").decode(enc, errors="replace") print(f"{indent}{safe}", flush=True) with lock: merged_chunks.append(line) last_output_ts = _time.monotonic() reader_thread = threading.Thread(target=_reader, daemon=True) reader_thread.start() idle_killed = False while True: try: rc = proc.wait(timeout=5) break except subprocess.TimeoutExpired: with lock: idle = _time.monotonic() - last_output_ts if idle > idle_timeout_seconds: idle_killed = True proc.terminate() try: rc = proc.wait(timeout=3) except subprocess.TimeoutExpired: proc.kill() rc = proc.wait() break # Drain reader so we don't leak the stdout file descriptor. reader_thread.join(timeout=2) combined = "".join(merged_chunks) if idle_killed: msg = ( f"\n ⚠ Build produced no output for {idle_timeout_seconds}s — terminated.\n" " Common causes: out-of-memory on a low-RAM host (WSL/container),\n" " a stuck Node process, or an antivirus scan stalling I/O.\n" ) combined += msg # Force a non-zero rc even if terminate() raced with a clean exit. if rc == 0: rc = 124 # GNU `timeout` convention return subprocess.CompletedProcess(cmd, rc, stdout=combined, stderr="") def _nixos_build_env() -> dict[str, str] | None: """Return extra env vars for native module builds on NixOS. On NixOS, python3 is typically not on the system PATH (it lives in the Nix store and only enters PATH inside a nix-shell or when explicitly installed as a system package). node-gyp uses Python to compile native addons like ``node-pty`` and its ``find-python.js`` does a bare ``PATH`` lookup — which fails on NixOS. Two-tier resolution: 1. Fast path — the hermes venv's python3 (present in managed installs) 2. Fallback — resolves the absolute python3 path via ``nix-shell`` Returns an env dict suitable for ``subprocess.run(env=...)`` or ``None`` when we are not on NixOS or python3 is already 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: return None if not re.search(r"^ID=nixos$", os_release, re.M): return None # python3 already on PATH — nothing to do if shutil.which("python3"): return None # Tier 1: fast path — hermes venv python3, no nix-shell overhead for venv_name in ("venv", ".venv"): venv_python = PROJECT_ROOT / venv_name / "bin" / "python3" if venv_python.exists(): return {**os.environ, "PYTHON": str(venv_python)} # Tier 2: nix-shell fallback — resolves the absolute python3 path once. # Slower (~2–5 s for the nix-shell eval) but always works, even without # a hermes venv (pip / non-managed / bare-git installs). The resolved # path is a self-contained Nix store binary (all deps via RPATH) so it # stays valid even after the nix-shell exits. try: result = subprocess.run( ["nix-shell", "-p", "python3", "--run", "which python3"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, timeout=15, ) if result.returncode == 0: python3_path = result.stdout.strip() if python3_path and Path(python3_path).exists(): return {**os.environ, "PYTHON": python3_path} except Exception: pass # nix-shell not available — caller will get None return None def _run_npm_install_deterministic( npm: str, cwd: Path, *, extra_args: tuple[str, ...] = (), capture_output: bool = True, env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess: """Run a deterministic npm install that does not mutate ``package-lock.json``. Prefers ``npm ci`` (strict, lockfile-preserving) when a lockfile is present; falls back to ``npm install`` only if ``npm ci`` fails (e.g. lockfile out of sync on a WIP checkout). Without this, ``npm install`` on npm ≥ 10 silently rewrites committed lockfiles (stripping ``"peer": true`` etc.), which leaves the working tree dirty and causes the next ``hermes update`` to stash the lockfile — repeatedly. ``--include=dev`` is forced on every invocation: the callers are frontend builds (web UI / TUI / desktop workspaces), and those builds need the dev toolchain (``tsc``, ``vite``, ``electron-builder`` — all ``devDependencies``). If the caller's environment has ``NODE_ENV=production`` (or npm config ``omit=dev``) — which leaks in from a shell profile, a container image, or the bundled TUI launcher that sets ``NODE_ENV=production`` on its subprocess env — npm silently omits devDependencies (exit 0, no error), so the build toolchain never installs and the subsequent build dies with ``tsc: command not found`` (exit 127). The flag overrides both the env var and npm config, unlike scrubbing ``NODE_ENV`` from the environment which only fixes the env-leak case. ``--no-save`` on the ``npm install`` fallback keeps it true to this function's contract: never mutate ``package-lock.json``. Without it, an out-of-sync lockfile gets rewritten by the fallback, which drifts the committed lockfile and makes every future ``npm ci`` fail — a self-reinforcing cycle where web devDeps never install and a stale dist is served on every update (PR #65595). """ # unicode-animations' postinstall animates to /dev/tty (bypasses # --silent/capture_output). It no-ops when CI is set — same as the TUI # install path and nix/lib.nix npm ci hooks. run_env = _npm_lifecycle_env(env) def _run(cmd: list[str]) -> subprocess.CompletedProcess: return _run_npm_watching_for_engine_failure( cmd, cwd=cwd, env=run_env, capture_output=capture_output, ) def _attempt(npm_exe: str) -> subprocess.CompletedProcess: lockfile = cwd / "package-lock.json" if lockfile.exists(): ci_result = _run([npm_exe, "ci", "--include=dev", *extra_args]) if ci_result.returncode == 0: return ci_result # Fall through to `npm install` — lockfile may be out of sync on a # WIP fork/branch, or `npm ci` may not be available on very old npm. return _run([npm_exe, "install", "--no-save", "--include=dev", *extra_args]) result = _attempt(npm) if result.returncode == 0: return result # An npm outside the root package.json's `engines.npm` range fails every # command here identically (the `npm install` fallback included), so the # failure is worth exactly one repair attempt. `maybe_repair_npm_engine` # returns the npm to retry with — the same one after an in-place upgrade # of a Hermes-managed install, or a freshly provisioned managed npm when # the failing npm belongs to the user's own toolchain. from hermes_cli.npm_engine import maybe_repair_npm_engine combined = f"{result.stdout or ''}\n{result.stderr or ''}" repaired_npm = maybe_repair_npm_engine(npm, combined) if not repaired_npm: return result # The repaired npm may be a freshly provisioned managed one whose shebang # and lifecycle scripts resolve `node` from PATH — put the managed tree # first so they find the managed Node, not the 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) def _run_npm_watching_for_engine_failure( cmd: list[str], *, cwd: Path, env: dict[str, str], capture_output: bool, ) -> subprocess.CompletedProcess: """Run *cmd*, always retaining stderr so ``EBADENGINE`` stays detectable. ``capture_output=False`` callers stream npm's progress live and would otherwise hand back a ``CompletedProcess`` with ``stderr=None``, leaving the engine-failure recovery nothing to read. Tee stderr instead: each line is forwarded to this process's stderr as it arrives (so live output is unchanged) and accumulated for the caller. """ if capture_output: return subprocess.run( cmd, cwd=cwd, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) captured: list[str] = [] with subprocess.Popen( cmd, cwd=cwd, env=env, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", ) as proc: if proc.stderr is not None: for line in proc.stderr: captured.append(line) sys.stderr.write(line) sys.stderr.flush() returncode = proc.wait() return subprocess.CompletedProcess(cmd, returncode, None, "".join(captured)) def _missing_web_build_tool(output: str) -> str | None: """Return the build tool a failed ``npm run build`` could not resolve. Each shell words this differently: ``sh: 1: tsc: not found`` (dash), ``vite: command not found`` (bash/zsh), and ``'tsc' is not recognized as an internal or external command`` (cmd.exe). """ lowered = output.lower() for tool in ("tsc", "vite"): if any( phrase in lowered for phrase in ( f"{tool}: not found", f"{tool}: command not found", f"'{tool}' is not recognized", ) ): return tool return 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 (e.g. the desktop app's retry loop after a readiness timeout) used to each spawn their own ``npm install`` + ``vite build`` over the same tree; the parallel builds starved each other, none finished, the dist sentinel never advanced, and every new boot re-triggered the build. One process builds under an exclusive flock; the rest serve the existing dist (stale is acceptable) or, when no dist exists yet, block until the builder finishes. Staleness is checked once, inside :func:`_do_build_web_ui`, after the lock is held — so a process that queued behind the builder skips the rebuild, and the (os.walk-based) check runs at most once per boot. """ if not (web_dir / "package.json").exists(): return True try: import fcntl except ImportError: # Windows: no flock — fall through to the unserialized build. return _do_build_web_ui(web_dir, fatal=fatal) project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent dist_index = project_root / "hermes_cli" / "web_dist" / "index.html" try: lock_file = open(project_root / ".web_ui_build.lock", "a", encoding="utf-8") except OSError: return _do_build_web_ui(web_dir, fatal=fatal) try: try: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: if dist_index.exists(): # Another process is already building — serve the current # dist instead of piling a second build onto the same tree. return True # No dist at all (first-ever build): wait for the builder. fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) return _do_build_web_ui(web_dir, fatal=fatal) finally: lock_file.close() def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: """Build the web UI frontend if npm is available. Args: web_dir: Path to the dashboard frontend source directory. fatal: If True, print error guidance and return False on failure instead of a soft warning (used by ``hermes web``). Returns True if the build succeeded or was skipped (no package.json). """ from hermes_cli.main import _is_termux_startup_environment, _resolve_node_runtime_npm, _run_npm_install_deterministic, _run_with_idle_timeout, _web_ui_build_needed, _write_web_ui_build_stamp if not (web_dir / "package.json").exists(): return True if not _web_ui_build_needed(web_dir): return True # Console-encoding-safe print: Windows consoles default to cp1252 # (or similar) and will raise UnicodeEncodeError on arrow / check # glyphs unless PYTHONIOENCODING=utf-8 is set. Routing every print # in this function through _say() with errors="replace" keeps the # build path usable on a stock `py -m hermes_cli.main web` invocation. def _say(text: str) -> None: try: print(text) except UnicodeEncodeError: encoding = getattr(sys.stdout, "encoding", None) or "ascii" print(text.encode(encoding, errors="replace").decode(encoding, errors="replace")) from hermes_constants import with_hermes_node_path npm = _resolve_node_runtime_npm() if not npm: if fatal: _say("Web UI frontend not built and npm is not available.") _say("Install Node.js, then run: cd web && npm install && npm run build") return not fatal build_env = _npm_lifecycle_env(with_hermes_node_path()) _say("→ Building web UI...") def _relay(result: "subprocess.CompletedProcess") -> None: """Print captured npm output so users can see *why* a step failed. Windows users hitting `rm -rf` / `cp -r` errors (or any other sync-assets / Vite failure) would otherwise see only ``Web UI build failed`` with no hint of the underlying cause, because the npm calls run with ``capture_output=True``. """ for blob in (result.stdout, result.stderr): if not blob: continue text = blob.decode("utf-8", errors="replace").rstrip() if isinstance(blob, bytes) else blob.rstrip() if text: _say(text) npm_cwd = _workspace_root(web_dir) # Scope the install to the web workspace only so that the full workspace # graph (including apps/desktop with its Electron + node-pty deps) is never # resolved here. Without --workspace the root package.json's apps/* glob # would pull in desktop on every web build. See #38772. # When web/ has its own package-lock.json, _workspace_root() returns # web_dir itself and --workspace would fail. See #42973. # # When running from the workspace root, this must name the SAME closure # as `hermes update`'s _update_node_dependencies() (ui-tui + web + # --include-workspace-root): the helper prefers `npm ci`, which deletes # node_modules before reifying the requested tree, so a narrower closure # here silently prunes everything the update step just installed (root # devDependencies and the ui-tui workspace) while still exiting 0 — # and since the manifests digest was already recorded, later no-op # updates skip the repair. See #43564/#64354. npm_workspace_args: tuple[str, ...] if npm_cwd == web_dir: npm_workspace_args = () else: npm_workspace_args = ("--workspace", "web", "--include-workspace-root") # Prebuilt/partial checkouts can lack the ui-tui workspace; naming a # missing workspace makes npm fail hard, so only include it when # present (same guard as _update_node_dependencies()). if (npm_cwd / "ui-tui" / "package.json").exists(): npm_workspace_args = ("--workspace", "ui-tui", *npm_workspace_args) if _is_termux_startup_environment(): npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir) def _install_web_deps(*, silent: bool) -> "subprocess.CompletedProcess": return _run_npm_install_deterministic( npm, npm_cwd, extra_args=(*npm_workspace_args, "--silent", "--prefer-offline") if silent else (*npm_workspace_args, "--prefer-offline"), env=build_env, ) r1 = _install_web_deps(silent=True) if r1.returncode != 0: _say( f" {'✗' if fatal else '⚠'} Web UI npm install failed" + ("" if fatal else " (hermes web will not be available)") ) _relay(r1) if fatal: _say(" Run manually: npm install --workspace web && npm run build -w web") return False # First attempt — stream output via idle-timeout helper (issue #33788). # capture_output=True on a long Vite build looks identical to a hang; # users react by rebooting, which leaves the editable install in a # half-state. Streaming + idle-kill makes failures observable AND # recoverable (the stale-dist fallback below handles the kill path). r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env) if r2.returncode != 0: # The install above can exit 0 while leaving the tree without a build # toolchain — a lockfile-hash skip over a half-installed tree, or an # interrupted link step. The generic retry below just reruns the same # command, so `tsc: not found` survives it and the stale dist is # served forever. Reinstall (non-silent, so the user sees it) first. missing_tool = _missing_web_build_tool((r2.stdout or "") + (r2.stderr or "")) if missing_tool: _say(f" ⚠ Build could not resolve {missing_tool} — reinstalling web dependencies...") _install_web_deps(silent=False) r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env) if r2.returncode != 0: # Retry once after a short delay — covers boot-time races on Windows # (antivirus scanning Node.js binaries, npm cache not ready, transient # I/O when launched via Scheduled Task at logon). See issue #23817. _time.sleep(3) r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env) if r2.returncode != 0: # _run_with_idle_timeout merges stderr into stdout; older callers # using subprocess.run kept them split. Pull from whichever has # content so the error surfaces regardless of which path produced # the CompletedProcess. build_output = (r2.stderr or "") + (r2.stdout or "") stderr_preview = build_output.strip() stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else "" project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent dist_dir = project_root / "hermes_cli" / "web_dist" dist_index = dist_dir / "index.html" # If a stale dist exists, serve it as a fallback instead of failing. # A stale UI is far better than no UI for non-interactive callers # (Windows Scheduled Tasks, CI) — issue #23817. if dist_index.exists(): _say(" ⚠ Web UI build failed — serving stale dist as fallback") if stderr_tail: _say(f" Build error:\n {stderr_tail}") return True _say( f" {'✗' if fatal else '⚠'} Web UI build failed" + ("" if fatal else " (hermes web will not be available)") ) _relay(r2) if fatal: _say(" Run manually: npm install --workspace web && npm run build -w web") return False _say(" ✓ Web UI built") project_root = web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent _write_web_ui_build_stamp(project_root, web_dir) return True