Add supabase stack + general compose runner

Adds "a stack whose default is a substrate": a shared self-hosted Supabase
backend plus a `--stack supabase` that scaffolds per-app projects (migrations +
edge functions + static UI) which deploy against it. Apps own their code and
stay repo-durable; only their rows/blobs live on the shared substrate.

- compose runner: new RunCompose supervises a multi-container stack as one
  systemd unit (ExecStart=`compose up`, generated ExecStop=`compose down`);
  secrets via EnvironmentFile. Reusable beyond Supabase. Deployment.stop_cmd
  carries the teardown command through the registry.
- supabase stack: CLI --stack choice, STACK_DEFAULTS→frontend with
  build.outputs=[public], _scaffold_supabase() (migrations/RLS/functions/
  static UI/app manifest), and SupabaseHandler with a forward-only idempotent
  migration runner (plan_migrations + psql build) and deno dev-verbs.
- docs: docs/stacks/supabase.md, registered in CLAUDE.md; compose runner
  documented in registry.md.
- tests: compose run/stop cmd, systemd ExecStop, compose host-route TLS,
  migration planner, supabase verb resolution, create --stack supabase.
This commit is contained in:
2026-06-30 18:18:47 -07:00
parent fc5802d823
commit b726e79c23
19 changed files with 822 additions and 10 deletions

View File

@@ -7,6 +7,7 @@ import subprocess
from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import (
BuildSpec,
CaddySpec,
DefaultsSpec,
ExposeSpec,
@@ -26,6 +27,15 @@ STACK_DEFAULTS: dict[str, str] = {
"python-fastapi": "daemon",
"python-cli": "tool",
"react-vite": "frontend",
"supabase": "frontend",
}
# Static build output per stack, for `behavior: frontend` programs. The gateway
# serves this dir in place at /<name>/ (no service, no process). A supabase app
# ships a raw `public/`; react-vite builds to `dist/`.
STACK_BUILD_OUTPUTS: dict[str, str] = {
"supabase": "public",
"react-vite": "dist",
}
@@ -85,12 +95,19 @@ def run_create(args: argparse.Namespace) -> int:
# Initialize a git repo for the new source.
subprocess.run(["git", "init", "-q", str(project_dir)], check=False)
# Frontend stacks with a static output get a build spec so the gateway emits
# an in-place static route at /<name>/ (no service, no process).
build = None
if stack in STACK_BUILD_OUTPUTS:
build = BuildSpec(outputs=[STACK_BUILD_OUTPUTS[stack]])
config.programs[name] = ProgramSpec(
id=name,
description=description,
source=str(project_dir),
stack=stack,
behavior=behavior,
build=build,
)
if behavior == "daemon":
prefix = name.replace("-", "_").upper()
@@ -122,7 +139,11 @@ def run_create(args: argparse.Namespace) -> int:
print(" Registered in castle.yaml")
print("\nNext steps:")
print(f" cd {project_dir}")
if stack:
if stack == "supabase":
print(" # edit migrations/, functions/, public/ — targets the shared substrate")
print(f" castle program build {name} # apply migrations to the substrate")
print(f" castle deploy && castle gateway reload # serve at /{name}/")
elif stack:
print(" uv sync")
if behavior == "daemon":
print(f" uv run {name} # starts on port {port}")

View File

@@ -30,6 +30,7 @@ STACK_DISPLAY: dict[str, str] = {
"python-fastapi": "python-fastapi",
"python-cli": "python-cli",
"react-vite": "react-vite",
"supabase": "supabase",
"rust": "rust",
"go": "go",
"bash": "bash",

View File

@@ -19,6 +19,8 @@ def run_logs(args: argparse.Namespace) -> int:
svc = config.services[name]
if svc.run.runner == "container":
return _container_logs(name, args)
if svc.run.runner == "compose":
return _compose_logs(name, svc, args)
return _systemd_logs(name, args)
# Check jobs
@@ -45,6 +47,25 @@ def _systemd_logs(name: str, args: argparse.Namespace) -> int:
return result.returncode
def _compose_logs(name: str, svc: object, args: argparse.Namespace) -> int:
"""Show aggregated logs for a compose-runner stack (by project label)."""
import shutil
runtime = shutil.which("docker") or shutil.which("podman") or "docker"
project = getattr(svc.run, "project_name", None) or f"castle-{name}" # type: ignore[attr-defined]
cmd = [runtime, "compose", "-p", project, "logs"]
lines = getattr(args, "lines", 50)
if lines:
cmd.extend(["--tail", str(lines)])
if getattr(args, "follow", False):
cmd.append("-f")
result = subprocess.run(cmd)
return result.returncode
def _container_logs(name: str, args: argparse.Namespace) -> int:
"""Show container logs."""
import shutil