Files
ai-agent/sidecar/scaffold.py
Gabriel Vidal 80726f7b91 feat(sidecar): /scaffold, /scaffold/check and /relocate — the host half of New projects
/scaffold runs the owner's project generator (SIDECAR_SCAFFOLD_CMD, default
the lab's new-project) with a vars JSON and reads its SCAFFOLD_RESULT= line
back (a generated site password stays out of the log); /scaffold/check is its
--check. /relocate lets a session go on in another directory: it stops a live
run and copies the session file (same id) into that cwd's transcript folder,
where the next --resume finds it with its whole history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:42:07 +02:00

145 lines
5.6 KiB
Python

"""
``POST /scaffold`` — create a project on this host from one of the owner's
templates: the host half of the hub's *New project* flow (backend
``newproject/``).
The hub can't scaffold by itself. The generator needs the host's node/npm, the
private npm registry bound to the host's loopback, the Gitea credentials, and
SSH to the zipgo host (a password lock is set before the first deploy). So the
sidecar runs the configured generator (``SIDECAR_SCAFFOLD_CMD``, default
``new-project`` — the homelab's new-frontend-project skill) with a vars JSON and
reports what it printed. The generator's contract:
* ``<cmd> --template <name> --vars <file> [flags]`` scaffolds
``~/projects/<NAME>`` and, last, prints one ``SCAFFOLD_RESULT={json}`` line
(``dir``, ``url``, ``host`` and, with ``--password``, ``password``);
* with ``--check`` it creates nothing and prints ``SCAFFOLD_CHECK={json}``:
``{"ok": bool, "problems": [{"field", "message"}]}`` — the name already used
by a project, the address already served, …
Only the flags below go onto the command line; every other choice travels in
the vars JSON (``NAME``, ``LABEL``, ``SUBDOMAIN``, ``GITEA_OWNER``, …), which
the generator validates itself.
"""
from __future__ import annotations
import json
import os
import re
import shlex
import subprocess
import tempfile
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
from config import RUN_ENV_STRIP
from validate import _auth
SCAFFOLD_CMD = shlex.split(os.environ.get("SIDECAR_SCAFFOLD_CMD", "new-project"))
# npm install + the first Gitea push; a cold registry cache takes a few minutes.
SCAFFOLD_TIMEOUT_S = float(os.environ.get("SIDECAR_SCAFFOLD_TIMEOUT_S", "900"))
CHECK_TIMEOUT_S = 60.0
_TEMPLATE_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,31}$")
_LOG_TAIL = 6000
router = APIRouter()
class ScaffoldBody(BaseModel):
template: str
vars: dict
noGit: bool = False
password: bool = False # lock the site (zipgo password, generated)
def _args(body: ScaffoldBody, vars_path: str, check: bool) -> list[str]:
template = (body.template or "").strip()
if not _TEMPLATE_RE.match(template):
raise HTTPException(400, f"invalid template: {template!r}")
args = [*SCAFFOLD_CMD, "--template", template, "--vars", vars_path]
if check:
return args + ["--check"]
if body.noGit:
args.append("--no-git")
if body.password:
args.append("--password")
return args
def _vars(raw: dict) -> dict[str, str]:
out: dict[str, str] = {}
for k, v in (raw or {}).items():
if not isinstance(k, str) or not _KEY_RE.match(k):
raise HTTPException(400, f"invalid vars key: {k!r}")
if not isinstance(v, (str, int, float)):
raise HTTPException(400, f"vars[{k}] must be a string")
out[k] = str(v)[:2000]
if not out.get("NAME"):
raise HTTPException(400, "vars.NAME is required")
return out
def _marker(out: str, name: str) -> dict | None:
"""The JSON after the last ``<name>=`` line the generator printed."""
for line in reversed(out.splitlines()):
if line.startswith(f"{name}="):
try:
return json.loads(line[len(name) + 1:])
except ValueError:
return None
return None
def _run(body: ScaffoldBody, check: bool) -> dict:
env = {k: v for k, v in os.environ.items() if k not in RUN_ENV_STRIP}
with tempfile.NamedTemporaryFile("w", suffix=".json", prefix="scaffold-",
delete=False) as f:
json.dump(_vars(body.vars), f)
vars_path = f.name
try:
args = _args(body, vars_path, check)
try:
p = subprocess.run(args, cwd=os.path.expanduser("~"), env=env,
stdin=subprocess.DEVNULL, capture_output=True,
text=True,
timeout=CHECK_TIMEOUT_S if check else SCAFFOLD_TIMEOUT_S)
except FileNotFoundError:
raise HTTPException(501, f"scaffold command not found: {SCAFFOLD_CMD[0]}")
except subprocess.TimeoutExpired as e:
out = (e.stdout or b"").decode(errors="ignore") if isinstance(e.stdout, bytes) else (e.stdout or "")
return {"ok": False, "exitCode": None, "error": "timed out",
"log": out[-_LOG_TAIL:]}
finally:
os.unlink(vars_path)
out = (p.stdout or "") + (("\n" + p.stderr) if p.stderr else "")
marker = _marker(p.stdout or "", "SCAFFOLD_CHECK" if check else "SCAFFOLD_RESULT")
ok = p.returncode == 0 and marker is not None
if check and marker is not None:
ok = bool(marker.get("ok"))
# A finished scaffold's password is the guest's to read, once — the log
# only goes to the hub's server log, so keep it out of there.
log = out[-_LOG_TAIL:]
if not check and marker and marker.get("password"):
log = log.replace(marker["password"], "••••••")
return {"ok": ok, "exitCode": p.returncode, "result": marker, "log": log}
@router.post("/scaffold")
def scaffold(body: ScaffoldBody, authorization: str | None = Header(default=None)) -> dict:
"""Scaffold ``~/projects/<vars.NAME>`` from ``template`` (blocking: npm
install and the first Gitea push run before it answers)."""
_auth(authorization)
return _run(body, check=False)
@router.post("/scaffold/check")
def scaffold_check(body: ScaffoldBody, authorization: str | None = Header(default=None)) -> dict:
"""Would that scaffold work? The name, the address, the template — nothing
is created."""
_auth(authorization)
return _run(body, check=True)