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:
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,11 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
|
||||
|
||||
p = sub.add_parser("create", help="Scaffold a new program")
|
||||
_add_name(p, "Program name")
|
||||
p.add_argument("--stack", choices=["python-cli", "python-fastapi", "react-vite"], default=None)
|
||||
p.add_argument(
|
||||
"--stack",
|
||||
choices=["python-cli", "python-fastapi", "react-vite", "supabase"],
|
||||
default=None,
|
||||
)
|
||||
p.add_argument("--description", default="", help="Program description")
|
||||
p.add_argument("--port", type=int, help="Port (daemons only)")
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
RestartPolicy,
|
||||
RunBase,
|
||||
RunCommand,
|
||||
RunCompose,
|
||||
RunContainer,
|
||||
RunNode,
|
||||
RunPython,
|
||||
|
||||
@@ -18,6 +18,8 @@ def scaffold_project(
|
||||
_scaffold_service(project_dir, name, package_name, description, port or 9000)
|
||||
elif stack == "python-cli":
|
||||
_scaffold_tool(project_dir, name, package_name, description)
|
||||
elif stack == "supabase":
|
||||
_scaffold_supabase(project_dir, name, description)
|
||||
else:
|
||||
raise ValueError(f"No scaffold template for stack: {stack}")
|
||||
|
||||
@@ -566,6 +568,199 @@ uv run ruff format . # Format
|
||||
)
|
||||
|
||||
|
||||
def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None:
|
||||
"""Scaffold a Patch-shaped app that targets the shared Supabase substrate.
|
||||
|
||||
Produces migrations/ (applied to the substrate by `castle program build`),
|
||||
functions/ (deno edge functions), public/ (static UI served in place at
|
||||
/<name>/ by the gateway), and supabase.app.yaml (auth policy + wiring).
|
||||
|
||||
The app owns its code and stays repo-durable; only its rows/blobs live on the
|
||||
shared substrate. Tables are namespaced by the app id (they share the `public`
|
||||
schema so no substrate reconfiguration is needed) and protected by RLS.
|
||||
"""
|
||||
ident = name.replace("-", "_") # a safe SQL/JS identifier
|
||||
table = f"{ident}_entries"
|
||||
|
||||
def sub(text: str) -> str:
|
||||
return (
|
||||
text.replace("__NAME__", name)
|
||||
.replace("__IDENT__", ident)
|
||||
.replace("__TABLE__", table)
|
||||
.replace("__DESC__", description)
|
||||
)
|
||||
|
||||
# --- supabase.app.yaml — app manifest (substrate wiring + auth policy) ---
|
||||
_write(
|
||||
project_dir / "supabase.app.yaml",
|
||||
sub(
|
||||
"""# Patch-shaped app targeting the shared Supabase substrate.
|
||||
name: __NAME__
|
||||
description: __DESC__
|
||||
substrate: supabase # the shared castle service this app deploys against
|
||||
|
||||
# auth policy: public | private | shared
|
||||
# public — anyone with the URL (anon read/write, still RLS-gated)
|
||||
# private — only the owner; RLS locks rows to auth.uid()
|
||||
# shared — owner + named people (list handles below)
|
||||
auth: public
|
||||
# shared: [alice, bob]
|
||||
|
||||
# Tables are namespaced by this prefix in the shared `public` schema.
|
||||
table_prefix: __IDENT___
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# --- migrations/0001_init.sql — versioned, idempotent, forward-only ---
|
||||
_write(
|
||||
project_dir / "migrations" / "0001_init.sql",
|
||||
sub(
|
||||
"""-- 0001_init: example table + RLS. Applied by `castle program build`
|
||||
-- via the versioned migration runner (tracked in schema_migrations; only
|
||||
-- unapplied migrations run). Forward-only — never edit an applied migration;
|
||||
-- add a new numbered file instead.
|
||||
|
||||
create table if not exists public.__TABLE__ (
|
||||
id bigint generated always as identity primary key,
|
||||
message text not null check (char_length(message) <= 500),
|
||||
author text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- RLS is necessary but NOT sufficient on its own: it protects rows, not the
|
||||
-- static app shell or Storage objects. For a `public` app, anon read/write is
|
||||
-- intended (still row-gated). For `private`/`shared`, replace the policies below
|
||||
-- with owner checks (auth.uid()) AND gate the shell + use signed Storage URLs.
|
||||
alter table public.__TABLE__ enable row level security;
|
||||
|
||||
drop policy if exists "__IDENT___public_read" on public.__TABLE__;
|
||||
create policy "__IDENT___public_read" on public.__TABLE__ for select using (true);
|
||||
|
||||
drop policy if exists "__IDENT___public_write" on public.__TABLE__;
|
||||
create policy "__IDENT___public_write" on public.__TABLE__ for insert with check (true);
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# --- functions/hello/index.ts — deno edge-function stub ---
|
||||
_write(
|
||||
project_dir / "functions" / "hello" / "index.ts",
|
||||
sub(
|
||||
"""// Edge function for __NAME__. Runs on the substrate's edge-runtime.
|
||||
// Deployed alongside migrations; call it from the app (never expose the
|
||||
// service_role key to the browser — privileged work happens here, server-side).
|
||||
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
|
||||
|
||||
serve((_req: Request) => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, app: "__NAME__", ts: new Date().toISOString() }),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
});
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# --- public/config.js — substrate wiring (anon key is public-safe by design) ---
|
||||
_write(
|
||||
project_dir / "public" / "config.js",
|
||||
sub(
|
||||
"""// Substrate wiring for __NAME__. The anon key is designed to be public
|
||||
// (RLS enforces access) — paste yours here, or inject at deploy time:
|
||||
// cat ~/.castle/secrets/SUPABASE_ANON_KEY
|
||||
window.APP = {
|
||||
SUPABASE_URL: "https://supabase.lan",
|
||||
SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE",
|
||||
TABLE: "__TABLE__",
|
||||
};
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# --- public/index.html — reads/writes the substrate via supabase-js ---
|
||||
_write(
|
||||
project_dir / "public" / "index.html",
|
||||
sub(
|
||||
"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>__NAME__</title>
|
||||
<script src="./config.js"></script>
|
||||
<script type="module">
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
const { SUPABASE_URL, SUPABASE_ANON_KEY, TABLE } = window.APP;
|
||||
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||
|
||||
const list = document.getElementById("list");
|
||||
async function refresh() {
|
||||
const { data, error } = await db.from(TABLE)
|
||||
.select("message, author, created_at")
|
||||
.order("created_at", { ascending: false }).limit(50);
|
||||
list.textContent = error ? "Error: " + error.message
|
||||
: (data.map(r => `${r.author ?? "anon"}: ${r.message}`).join("\\n") || "(empty)");
|
||||
}
|
||||
document.getElementById("form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const message = e.target.message.value.trim();
|
||||
if (!message) return;
|
||||
const { error } = await db.from(TABLE).insert({ message });
|
||||
if (error) { alert(error.message); return; }
|
||||
e.target.reset();
|
||||
refresh();
|
||||
});
|
||||
refresh();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>__NAME__</h1>
|
||||
<p>__DESC__</p>
|
||||
<form id="form"><input name="message" placeholder="Say something…" autocomplete="off" />
|
||||
<button>Post</button></form>
|
||||
<pre id="list">Loading…</pre>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# --- CLAUDE.md — how this app works ---
|
||||
_write(
|
||||
project_dir / "CLAUDE.md",
|
||||
sub(
|
||||
"""# __NAME__
|
||||
|
||||
__DESC__
|
||||
|
||||
A **supabase-stack** app: it owns its code (this repo) and rents its backend from
|
||||
the shared Supabase substrate (the `supabase` castle service). Only its rows/blobs
|
||||
live on the substrate; rebuild the rest from git anytime.
|
||||
|
||||
## Layout
|
||||
- `migrations/` — versioned, idempotent Postgres migrations (forward-only). Applied
|
||||
to the substrate by `castle program build __NAME__`.
|
||||
- `functions/` — deno edge functions deployed to the substrate's edge-runtime.
|
||||
- `public/` — static UI served in place at `/__NAME__/` by the gateway. Talks to
|
||||
the substrate with `@supabase/supabase-js` + the public anon key (RLS-gated).
|
||||
- `supabase.app.yaml` — substrate wiring + auth policy (public/private/shared).
|
||||
|
||||
## Develop
|
||||
- Edit `migrations/`, then `castle program build __NAME__` to apply new migrations
|
||||
(re-running is a no-op — only unapplied migrations run).
|
||||
- Set the anon key in `public/config.js` (`cat ~/.castle/secrets/SUPABASE_ANON_KEY`).
|
||||
- `castle deploy && castle gateway reload` → served at `/__NAME__/`.
|
||||
|
||||
## Privacy note
|
||||
RLS protects rows, not the static shell or Storage. For a `private`/`shared` app,
|
||||
also gate the shell and use signed Storage URLs — RLS alone is not leak-proof.
|
||||
Auth/WebCrypto apps should get their own HTTPS host route (secure context).
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> None:
|
||||
"""Write content to a file, creating parent directories."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -76,6 +76,39 @@ class TestCreateCommand:
|
||||
comp = config.programs["my-tool2"]
|
||||
assert comp.behavior == "tool"
|
||||
|
||||
def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None:
|
||||
"""A supabase app scaffolds a Patch-shaped project registered as a static
|
||||
frontend (build.outputs=[public]) with no service."""
|
||||
repos = tmp_path / "repos"
|
||||
with (
|
||||
patch("castle_cli.commands.create.load_config") as mock_load,
|
||||
patch("castle_cli.commands.create.save_config"),
|
||||
patch("castle_cli.commands.create.REPOS_DIR", repos),
|
||||
):
|
||||
config = load_config(castle_root)
|
||||
mock_load.return_value = config
|
||||
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
args = Namespace(
|
||||
name="guestbook", stack="supabase", description="Guestbook", port=None
|
||||
)
|
||||
result = run_create(args)
|
||||
|
||||
assert result == 0
|
||||
project_dir = repos / "guestbook"
|
||||
assert (project_dir / "migrations" / "0001_init.sql").exists()
|
||||
assert (project_dir / "functions" / "hello" / "index.ts").exists()
|
||||
assert (project_dir / "public" / "index.html").exists()
|
||||
assert (project_dir / "supabase.app.yaml").exists()
|
||||
|
||||
# Registered as a static frontend, no service, build output = public/
|
||||
comp = config.programs["guestbook"]
|
||||
assert comp.behavior == "frontend"
|
||||
assert comp.stack == "supabase"
|
||||
assert comp.build is not None and comp.build.outputs == ["public"]
|
||||
assert "guestbook" not in config.services
|
||||
|
||||
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
|
||||
"""Creating a project with existing name fails."""
|
||||
with patch("castle_cli.commands.create.load_config") as mock_load:
|
||||
|
||||
Reference in New Issue
Block a user