host.controller_web_editor.build

Auto-build helper for the SPA.

A fresh checkout already includes the compiled bundle under static/, so the server runs without Node installed. But when a contributor edits the Svelte sources, we'd like the next python -m host.controller_web_editor invocation to pick up their changes without making them remember npm run build.

This module compares web/src/ mtimes against static/ mtimes and runs the build if the sources are newer. When Node isn't on PATH or the CONTROLLER_WEB_EDITOR_SKIP_BUILD env var is set, the build is silently skipped (the checked-in artifacts still work).

  1"""Auto-build helper for the SPA.
  2
  3A fresh checkout already includes the compiled bundle under ``static/``,
  4so the server runs without Node installed.  But when a contributor edits
  5the Svelte sources, we'd like the next ``python -m host.controller_web_editor``
  6invocation to pick up their changes without making them remember
  7``npm run build``.
  8
  9This module compares ``web/src/`` mtimes against ``static/`` mtimes and
 10runs the build if the sources are newer.  When Node isn't on PATH or the
 11``CONTROLLER_WEB_EDITOR_SKIP_BUILD`` env var is set, the build is
 12silently skipped (the checked-in artifacts still work).
 13"""
 14
 15from __future__ import annotations
 16
 17import logging
 18import os
 19import shutil
 20import subprocess
 21from pathlib import Path
 22
 23
 24log = logging.getLogger(__name__)
 25
 26HERE = Path(__file__).resolve().parent
 27WEB_DIR = HERE / "web"
 28SRC_DIR = WEB_DIR / "src"
 29STATIC_DIR = HERE / "static"
 30NODE_MODULES = WEB_DIR / "node_modules"
 31
 32
 33def _latest_mtime(root: Path) -> float:
 34    """Highest mtime under ``root`` (recursive), or 0.0 if empty/missing."""
 35    if not root.exists():
 36        return 0.0
 37    best = 0.0
 38    for path in root.rglob("*"):
 39        if path.is_file():
 40            try:
 41                m = path.stat().st_mtime
 42            except OSError:
 43                continue
 44            if m > best:
 45                best = m
 46    return best
 47
 48
 49def _resolve_npm() -> str | None:
 50    """Return the absolute path to ``npm`` or ``None`` if not on PATH.
 51
 52    Windows installs npm as ``npm.cmd``; ``shutil.which`` finds either
 53    once PATHEXT is honored, which it is by default.
 54    """
 55    return shutil.which("npm")
 56
 57
 58def _run(cmd: list[str], cwd: Path) -> bool:
 59    log.info("running %s in %s", " ".join(cmd), cwd)
 60    try:
 61        subprocess.run(cmd, cwd=cwd, check=True, shell=False)
 62        return True
 63    except (subprocess.CalledProcessError, FileNotFoundError) as exc:
 64        log.warning("build step failed: %s", exc)
 65        return False
 66
 67
 68def maybe_build_spa() -> str:
 69    """Build the SPA if sources are newer than the checked-in bundle.
 70
 71    Returns one of: ``"skipped"``, ``"already_fresh"``, ``"built"``, or
 72    ``"failed"``.  Never raises -- this is a best-effort convenience.
 73    """
 74    if os.environ.get("CONTROLLER_WEB_EDITOR_SKIP_BUILD"):
 75        log.debug("skipping SPA build (CONTROLLER_WEB_EDITOR_SKIP_BUILD set)")
 76        return "skipped"
 77
 78    if not WEB_DIR.is_dir():
 79        log.debug("no web/ directory; nothing to build")
 80        return "skipped"
 81
 82    npm = _resolve_npm()
 83    if not npm:
 84        if not (STATIC_DIR / "index.html").is_file():
 85            log.warning(
 86                "npm not found and static/index.html missing -- the SPA "
 87                "won't load.  Easiest fix: re-run scripts/controller_editor/"
 88                "launch.{ps1,sh}, which auto-installs Node.  Or install "
 89                "Node.js manually from https://nodejs.org and run "
 90                "'npm install && npm run build' in %s", WEB_DIR)
 91        else:
 92            log.debug("npm not on PATH; using existing static/ bundle")
 93        return "skipped"
 94
 95    src_mtime = max(
 96        _latest_mtime(SRC_DIR),
 97        (WEB_DIR / "index.html").stat().st_mtime if (WEB_DIR / "index.html").is_file() else 0.0,
 98        (WEB_DIR / "package.json").stat().st_mtime if (WEB_DIR / "package.json").is_file() else 0.0,
 99        (WEB_DIR / "vite.config.ts").stat().st_mtime if (WEB_DIR / "vite.config.ts").is_file() else 0.0,
100    )
101    static_mtime = _latest_mtime(STATIC_DIR)
102
103    if static_mtime >= src_mtime and (STATIC_DIR / "index.html").is_file():
104        log.debug("static/ is fresh (mtime %.0f >= %.0f)",
105                  static_mtime, src_mtime)
106        return "already_fresh"
107
108    log.info("SPA sources newer than static/; rebuilding")
109    if not NODE_MODULES.is_dir():
110        # First-run npm install can take minutes -- drop --silent so the
111        # user sees progress instead of staring at a frozen terminal.
112        log.info("installing npm dependencies (first run, this may take a minute)")
113        if not _run([npm, "install"], cwd=WEB_DIR):
114            return "failed"
115
116    if not _run([npm, "run", "build"], cwd=WEB_DIR):
117        return "failed"
118    return "built"
119
120
121__all__ = ["maybe_build_spa"]
def maybe_build_spa() -> str:
 69def maybe_build_spa() -> str:
 70    """Build the SPA if sources are newer than the checked-in bundle.
 71
 72    Returns one of: ``"skipped"``, ``"already_fresh"``, ``"built"``, or
 73    ``"failed"``.  Never raises -- this is a best-effort convenience.
 74    """
 75    if os.environ.get("CONTROLLER_WEB_EDITOR_SKIP_BUILD"):
 76        log.debug("skipping SPA build (CONTROLLER_WEB_EDITOR_SKIP_BUILD set)")
 77        return "skipped"
 78
 79    if not WEB_DIR.is_dir():
 80        log.debug("no web/ directory; nothing to build")
 81        return "skipped"
 82
 83    npm = _resolve_npm()
 84    if not npm:
 85        if not (STATIC_DIR / "index.html").is_file():
 86            log.warning(
 87                "npm not found and static/index.html missing -- the SPA "
 88                "won't load.  Easiest fix: re-run scripts/controller_editor/"
 89                "launch.{ps1,sh}, which auto-installs Node.  Or install "
 90                "Node.js manually from https://nodejs.org and run "
 91                "'npm install && npm run build' in %s", WEB_DIR)
 92        else:
 93            log.debug("npm not on PATH; using existing static/ bundle")
 94        return "skipped"
 95
 96    src_mtime = max(
 97        _latest_mtime(SRC_DIR),
 98        (WEB_DIR / "index.html").stat().st_mtime if (WEB_DIR / "index.html").is_file() else 0.0,
 99        (WEB_DIR / "package.json").stat().st_mtime if (WEB_DIR / "package.json").is_file() else 0.0,
100        (WEB_DIR / "vite.config.ts").stat().st_mtime if (WEB_DIR / "vite.config.ts").is_file() else 0.0,
101    )
102    static_mtime = _latest_mtime(STATIC_DIR)
103
104    if static_mtime >= src_mtime and (STATIC_DIR / "index.html").is_file():
105        log.debug("static/ is fresh (mtime %.0f >= %.0f)",
106                  static_mtime, src_mtime)
107        return "already_fresh"
108
109    log.info("SPA sources newer than static/; rebuilding")
110    if not NODE_MODULES.is_dir():
111        # First-run npm install can take minutes -- drop --silent so the
112        # user sees progress instead of staring at a frozen terminal.
113        log.info("installing npm dependencies (first run, this may take a minute)")
114        if not _run([npm, "install"], cwd=WEB_DIR):
115            return "failed"
116
117    if not _run([npm, "run", "build"], cwd=WEB_DIR):
118        return "failed"
119    return "built"

Build the SPA if sources are newer than the checked-in bundle.

Returns one of: "skipped", "already_fresh", "built", or "failed". Never raises -- this is a best-effort convenience.