feat: castle UX fixes from the lakehouse adoption test

Driven by adopting lakehouse (an existing daemon that bundles its own SPA) —
see docs/findings-lakehouse.md.

#3 deploy reloads the gateway. castle deploy regenerated the Caddyfile but
   left the running Caddy on the old config, so new proxy routes were silently
   dead. Deploy now reloads the gateway when it's running.

#1 castle expose <program>. Turns an adopted program into a service
   (run/port/health/proxy/systemd) in one command — the missing
   daemon-to-service step. Flags: --port --health --path --run --port-env
   --host --no-proxy.

#2 port_env mapping. A service can declare expose.http.internal.port_env so
   castle sets the env var the program actually reads (e.g. lakehoused reads
   LAKEHOUSED_DAEMON_PORT, not castle's convention LAKEHOUSE_PORT). Castle now
   genuinely drives an adopted daemon's bind port.

#4a auto-base for react-vite. The build passes VITE_BASE = the gateway serve
   prefix (/<name>/, or / for castle-app); vite.config reads it. A castle-built
   frontend now works at its subpath with no hand-tuned base. castle-app's
   vite.config updated as the reference.

#4b host-based routing. proxy.caddy.host routes a whole hostname to the backend
   root via a host matcher inside the :9000 site, so a root-based SPA (base="/")
   serves unchanged — the fix for proxying an app castle can't rebuild. Caddyfile
   now emits 'auto_https off' (HTTP-only gateway on a non-standard port).

Nit: activate skips the editable reinstall when the tool is already on PATH.

Tests: core 94, cli 24, api 52; ruff + app build clean. Verified live: lakehouse
runs under systemd, API at /lakehouse, full UI (SPA boots) via host routing.
This commit is contained in:
2026-06-14 13:16:34 -07:00
parent 203f5212e6
commit 4840cbe72a
12 changed files with 350 additions and 8 deletions

View File

@@ -45,12 +45,15 @@ def _build_env() -> dict[str, str]:
return env
async def _run(cmd: list[str], cwd: Path) -> tuple[int, str]:
async def _run(cmd: list[str], cwd: Path, env: dict[str, str] | None = None) -> tuple[int, str]:
"""Run a subprocess and return (returncode, combined output)."""
run_env = _build_env()
if env:
run_env.update(env)
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=cwd,
env=_build_env(),
env=run_env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@@ -58,6 +61,16 @@ async def _run(cmd: list[str], cwd: Path) -> tuple[int, str]:
return proc.returncode or 0, (stdout or b"").decode()
def _vite_base(name: str) -> str:
"""The base path a castle-served static frontend builds against.
Matches the Caddyfile serve prefix: castle-app is the root app ('/'); every
other static frontend mounts at '/<name>/'. Exposed to the build as VITE_BASE
so the bundle's absolute asset URLs resolve at the gateway subpath (the
vite.config reads `base: process.env.VITE_BASE ?? '/'`)."""
return "/" if name == "castle-app" else f"/{name}/"
def _source_dir(comp: ProgramSpec, root: Path) -> Path:
"""Resolve source directory, raising ValueError if absent."""
if not comp.source:
@@ -181,7 +194,9 @@ class ReactViteHandler(StackHandler):
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "build"], src)
# Build against the gateway serve prefix so absolute asset URLs resolve at
# /<name>/ (vite.config reads VITE_BASE). Removes the hand-tuned-base footgun.
rc, output = await _run(["pnpm", "build"], src, env={"VITE_BASE": _vite_base(name)})
return ActionResult(
program=name, action="build", status="ok" if rc == 0 else "error", output=output
)