Every source update installs Node dependencies as one npm ci over ui-tui,
web and, when a built desktop app is present, apps/desktop. Only
apps/desktop brings native addons (node-pty, electron), so a host whose
compiler cannot build them (Ubuntu 20.04's g++ 9 rejects -std=gnu++20)
failed the whole install and lost the TUI and web UI builds as well, with
"a source update could not be finished automatically" on every launch.
When the union fails and the desktop app is part of it, retry once without
apps/desktop: the TUI and web UI build, the desktop build is reported owed
("desktop dependencies failed", the line the hand-off scripts match), and
the update names `hermes uninstall --gui` for hosts that never run the
app. The same hint prints whenever a headless Linux update builds Desktop,
since such a host keeps rebuilding an app nobody launches.
270 lines
14 KiB
Python
270 lines
14 KiB
Python
"""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)
|