"""Source launch/update composition over the shared JavaScript builders.""" import os from pathlib import Path import shutil import subprocess import sys def source_product_current(project_root: Path, product: str, out: Path) -> bool: """Read the compiler's receipt without acquiring tools or dependencies.""" from pm import env_for, installed_package installed = installed_package("node") if installed is None or installed.binary is None: return False # Only PM's Node may run the receipt reader; never the user's PATH copy. node, env = str(installed.binary), env_for("node") try: result = subprocess.run( [node, str(project_root / "scripts/build/freshness.mjs"), "--source", str(project_root), "--product", product, "--out", str(out)], cwd=project_root, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=True, ) return result.stdout.strip() == "true" except (OSError, subprocess.SubprocessError): return False def source_build_env(base_env: dict | None = None, *, explicit: bool = False, verify: bool = True) -> dict[str, str]: from pm import ensure from pm.environments import project_python, running_from_selected_environment from pm.paths import repo_root from hermes_constants import get_hermes_home # The historical update runs on store Python with the selected environment # activated in-process. Icon generation starts an isolated child, which needs # the selected venv executable rather than the store interpreter. root = repo_root() python = str(project_python(root)) if running_from_selected_environment(root) else sys.executable env = {**os.environ, **(base_env or {}), "CI": "1", "HERMES_PYTHON": python, "PYTHON": python} env.pop("ESBUILD_BINARY_PATH", None) npmrc = get_hermes_home() / "npmrc" if npmrc.is_file(): env.setdefault("NPM_CONFIG_USERCONFIG", str(npmrc)) return ensure("npm", base_env=env, explicit=explicit, verify=verify).env def run_in_custody(project_root: Path, command: list, label: str, **kwargs): """``pm.progress.run_contained`` for a build command that writes the checkout (node, npm): it and everything it starts stay in the update's custody (POSIX: the checkout lock fd, the caller's process group, and every descendant killed when it exits; Windows: the owner's kill-on-close job), so the checkout is never handed to a contender while one of them still writes. Outside an update it is ``run_contained`` as is.""" from pm.progress import run_contained from hermes_cli.update_custody import contained_command with contained_command(command, root=project_root) as (argv, custody): return run_contained(argv, label, **kwargs, **custody) def run_source_script(project_root: Path, script: str, *args: str, env: dict, label: str) -> None: command = [shutil.which("node", path=env["PATH"]), str(project_root / script), *args] # npm's deprecation warnings are the loudest lines and never actionable # here; they still land in the failure tail. run_in_custody(project_root, command, label, hide=lambda line: line.lower().startswith("npm warn"), indent=" ", cwd=project_root, env=env) def prepare_source_dependencies(project_root: Path, workspaces: tuple[str, ...], *, env: dict, explicit: bool = False) -> None: from pm import lazy_installs_allowed run_source_script( project_root, "scripts/build/node-deps.mjs", "--source", str(project_root), "--reuse", *(() if explicit or lazy_installs_allowed() else ("--no-install",)), *(arg for workspace in workspaces for arg in ("--workspace", workspace)), env=env, label="Preparing Node dependencies", ) def prepare_launch_dependencies(project_root: Path, *, env: dict) -> None: """A launch rebuild must not prune another installed source frontend.""" from hermes_cli.main_desktop import _desktop_dist_exists, _desktop_packaged_executable desktop_dir = project_root / "apps/desktop" desktop = _desktop_dist_exists(desktop_dir) or _desktop_packaged_executable(desktop_dir) is not None workspaces = ("ui-tui", "web") + (("apps/desktop",) if desktop else ()) prepare_source_dependencies(project_root, workspaces, env=env) def build_source_tui(project_root: Path, *, env: dict) -> None: run_source_script(project_root, "scripts/build/tui.mjs", env=env, label="Building the TUI") def build_source_web(project_root: Path, *, env: dict, icons: Path | None = None) -> None: # Bounded build (#63338): the Vite/Rolldown dashboard build saturates small # hosts; cap the V8 heap and the native bundler's rayon thread pool. from hermes_cli.web_build_limits import apply_web_build_limits apply_web_build_limits(env) # Default-brand icons are committed; installs never render them. icons = icons or project_root run_source_script(project_root, "scripts/build/web.mjs", "--source", str(project_root), "--icons", str(icons), "--out", str(project_root / "hermes_cli/web_dist"), env=env, label="Building the web UI") def source_frontends(project_root: Path) -> tuple[str, ...]: """The frontend workspaces this checkout carries. A source slice without them (python-only installs, the installer's acceptance fixture) has no products to build; it still publishes commands and runs the maintenance tail.""" return tuple(name for name in ("ui-tui", "web") if (project_root / name / "package.json").is_file()) class ProductBuildError(RuntimeError): """One or more products failed; every independent product was still attempted.""" def __init__(self, failures: list[tuple[str, BaseException]]): self.failures = failures super().__init__("; ".join(f"{name}: {_failure_text(exc)}" for name, exc in failures)) def _failure_text(exc: BaseException) -> str: if isinstance(exc, subprocess.CalledProcessError): return f"{' '.join(map(str, exc.cmd)) if isinstance(exc.cmd, (list, tuple)) else exc.cmd} exited {exc.returncode}" return f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ def _headless_linux() -> bool: return sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) def build_update_products(project_root: Path, *, desktop: bool) -> None: """Prepare the selected union once, attempting every independent product. A failed product never skips the next one (a broken web build must not leave the TUI or the desktop app stale); the failures are raised together at the end as ``ProductBuildError``. """ # Both current updates and historical takeover reach this in a fresh target # interpreter, never in the updater's pre-sync import graph. from hermes_cli.main_install_repair import _install_configured_features_missing_deps from hermes_cli.update_stage import publish_stage failures: list[tuple[str, BaseException]] = [] def attempt(name: str, step) -> bool: try: step() return True except Exception as exc: # health: allow BLE001 -- collected and raised after the independent steps print(f" ⚠ {name} failed: {_failure_text(exc)}") failures.append((name, exc)) return False attempt("feature dependencies", lambda: _install_configured_features_missing_deps(project_root)) frontends = source_frontends(project_root) if frontends: desktop_built = False env: dict = {} workspaces = frontends + (("apps/desktop",) if desktop else ()) publish_stage("Updating Node dependencies") def node_dependencies(selected: tuple[str, ...]) -> None: # Acquiring npm is part of this step: its failure must be reported like the install's. # A recorded install is trusted as at startup (re-hashing node+npm costs ~2 s per # tail); a missing one is still installed explicitly. env.update(source_build_env(explicit=True, verify=False)) prepare_source_dependencies(project_root, selected, env=env, explicit=True) # Every product compiles from these node_modules: without them there is nothing to build. prepared = attempt("Node dependencies", lambda: node_dependencies(workspaces)) desktop_skipped = False if not prepared and desktop and env: # Only apps/desktop brings native addons (node-pty, electron) into the union: a host # whose compiler cannot build them must still get the TUI and web UI it runs. print(" → Retrying Node dependencies without the desktop app") failed = failures.pop() prepared = desktop_skipped = attempt("Node dependencies", lambda: node_dependencies(frontends)) if desktop_skipped: failures.append(("desktop dependencies", failed[1])) desktop_reason = failures[-1][0] if failures else "" if desktop and (desktop_skipped or _headless_linux()): print(" ⓘ If this host never runs the desktop app, `hermes uninstall --gui` removes it " "and future updates stop building it.") if prepared: # An update that changed no TUI/web input reuses the receipted output, as the # launch path already does; recompiling it produces the same bytes. Desktop # additionally needs the packaged app to name HEAD (its baked stamp carries the # commit), so it is reused only when HEAD did not move: "Already up to date", # a retried tail, a takeover re-entry. if "ui-tui" in frontends: if source_product_current(project_root, "tui", project_root / "ui-tui/dist"): print(" ✓ TUI is up to date") else: publish_stage("Building the TUI") attempt("TUI build", lambda: build_source_tui(project_root, env=env)) if "web" in frontends: if source_product_current(project_root, "web", project_root / "hermes_cli/web_dist"): print(" ✓ Web UI is up to date") else: publish_stage("Building the web UI") attempt("web UI build", lambda: build_source_web(project_root, env=env)) if desktop and not desktop_skipped: desktop_built = attempt("desktop app build", lambda: _build_desktop_product(project_root, env, publish_stage)) desktop_reason = failures[-1][0] if not desktop_built else "" if desktop and not desktop_built: # One whole line the Desktop hand-off scripts match: the follow-up text is truncated # and names whichever products failed first, so it cannot say if THIS app was rebuilt. print(f" Desktop app build owed: {desktop_reason} failed") # A configured memory provider that no longer ships in core is installed from the # catalog for every profile home sharing this venv (config, data and tool names # unchanged). The update must finish even if the migration blows up. try: from hermes_cli.memory_provider_migration import migrate_all_homes migrate_all_homes() except Exception as exc: # health: allow BLE001 -- post-commit boundary: printed as ⚠, never fails the committed update; every later update/tail re-runs it print(f" ⚠ Memory provider migration skipped: {exc}") # Same for a gateway platform / toolset that left core (Home Assistant): every home that used # it gets its catalog plugin (hermes_cli/left_core_migration.py). try: from hermes_cli.left_core_migration import migrate_all_homes as migrate_left_core migrate_left_core() except Exception as exc: # health: allow BLE001 -- post-commit boundary: printed as ⚠, never fails the committed update; every later update/tail re-runs it print(f" ⚠ Plugin migration skipped: {exc}") if failures: raise ProductBuildError(failures) def _build_desktop_product(project_root: Path, env: dict, publish_stage) -> None: from hermes_cli.main_desktop import ( _packaged_desktop_current_for_head, _refresh_installed_desktop_apps, build_prepared_desktop) desktop_dir = project_root / "apps/desktop" if _packaged_desktop_current_for_head(desktop_dir, project_root): print(" ✓ Desktop app is up to date") else: publish_stage("Building the desktop app") # The desktop build mutates checkout-scoped node_modules and # apps/desktop/release; serialize it against a concurrent manual # `hermes desktop` (#93940). The update path waits rather than exits: # the in-flight build it queues behind produces the same fresh tree # this update needs. from hermes_cli.desktop_build_lock import DesktopBuildLock build_lock = DesktopBuildLock(project_root) build_lock.acquire(wait=True) try: build_prepared_desktop( desktop_dir, source_mode=False, npm=shutil.which("npm", path=env["PATH"]), env=env, icons=project_root, ) finally: build_lock.release() # A current release/ can still sit beside a stale installed copy (an earlier # update rebuilt but never installed); healing must not wait for the next build. _refresh_installed_desktop_apps(desktop_dir) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Build source-install frontends") parser.add_argument("--source", type=Path, required=True) parser.add_argument("--desktop", action="store_true") args = parser.parse_args() build_update_products(args.source.resolve(), desktop=args.desktop)