Rename Castle -> Wild PC across the repo
Repo-side rename only (Phases 1-3 of the migration plan); the live box (~/.castle, systemd units, /data/castle, domains) is a separate cutover. - Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api), dist names, entry point `wildpc = wildpc_cli.main:main`. - Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*. - Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml -> wildpc.yaml, /data/castle -> /data/wildpc. - Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc. - Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend. - Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated; redundant nested uv.lock files dropped (workspace root lock is authoritative). Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds. Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
This commit is contained in:
938
core/src/wildpc_core/stacks.py
Normal file
938
core/src/wildpc_core/stacks.py
Normal file
@@ -0,0 +1,938 @@
|
||||
"""Stack protocol — lifecycle actions for each development stack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from wildpc_core.config import USER_TOOL_PATH_DIRS
|
||||
from wildpc_core.manifest import ProgramSpec
|
||||
from wildpc_core.toolchains import ToolchainError, resolve_node_bin
|
||||
|
||||
DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"]
|
||||
INSTALL_ACTIONS = ["install", "uninstall"]
|
||||
ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS
|
||||
|
||||
# Verbs a stack handler can provide (everything except `run`, which is declared-only).
|
||||
_STACK_VERBS = {
|
||||
"build",
|
||||
"test",
|
||||
"lint",
|
||||
"format",
|
||||
"type-check",
|
||||
"check",
|
||||
"install",
|
||||
"uninstall",
|
||||
}
|
||||
# Verbs whose handler method name differs from the verb spelling.
|
||||
_VERB_METHOD = {"type-check": "type_check"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionResult:
|
||||
"""Result of a program lifecycle action."""
|
||||
|
||||
program: str
|
||||
action: str
|
||||
status: str # "ok" | "error"
|
||||
output: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolRequirement:
|
||||
"""A host toolchain a stack needs — the declarative counterpart to the argv each
|
||||
handler hard-codes (``uv sync``, ``pnpm build``, …). Made explicit so wildpc can
|
||||
*check* whether the tool is present (on the box and, for run-phase tools, on the
|
||||
service's own PATH) and, when it isn't, show a copyable fix instead of failing
|
||||
mid-subprocess with a raw ``command not found``.
|
||||
|
||||
- ``phase`` — when the tool is needed. ``build`` tools run only at
|
||||
``wildpc apply``/build time (checked against the build env); ``run`` tools must
|
||||
also be on the *running service's* PATH (the curated systemd env, which can
|
||||
drift from your shell); ``both`` is checked in both places.
|
||||
- ``install_hint`` — the exact command a user can copy to install it.
|
||||
"""
|
||||
|
||||
command: (
|
||||
str # the executable, e.g. "uv" | "pnpm" | "node" | "hugo" | "deno" | "psql"
|
||||
)
|
||||
purpose: str # human phrase, e.g. "build & run Python programs"
|
||||
phase: str # "run" | "build" | "both"
|
||||
install_hint: str # copyable install command
|
||||
version_min: str | None = None
|
||||
|
||||
|
||||
def _build_env(node_source: Path | None = None) -> dict[str, str]:
|
||||
"""Build a subprocess env with user tool dirs on PATH.
|
||||
|
||||
``node_source`` is the program's source dir: if it pins a node version (see
|
||||
:mod:`wildpc_core.toolchains`), that node's bin dir goes on the front of PATH so
|
||||
the verb uses the program's node instead of whatever ambient node the caller
|
||||
happens to have (the CLI inherits your shell's; the wildpc-api build executor's
|
||||
default PATH has none). Raises :class:`ToolchainError` if the pin isn't installed.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
dirs = [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
|
||||
node_bin = resolve_node_bin(node_source)
|
||||
if node_bin is not None:
|
||||
dirs.insert(0, str(node_bin))
|
||||
if dirs:
|
||||
env["PATH"] = ":".join(dirs) + ":" + env.get("PATH", "")
|
||||
return env
|
||||
|
||||
|
||||
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).
|
||||
|
||||
The verb runs in ``cwd`` (the program source), so that dir doubles as the node
|
||||
pin source — a pinned-but-missing node fails loud here rather than as a cryptic
|
||||
``node: not found`` mid-build."""
|
||||
try:
|
||||
run_env = _build_env(cwd)
|
||||
except ToolchainError as e:
|
||||
return 1, str(e)
|
||||
if env:
|
||||
run_env.update(env)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=cwd,
|
||||
env=run_env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
return proc.returncode or 0, (stdout or b"").decode()
|
||||
|
||||
|
||||
def _vite_base(name: str) -> str:
|
||||
"""The base path a wildpc-served static frontend builds against.
|
||||
|
||||
Every frontend now serves at the **root of its own subdomain**
|
||||
(`<name>.<gateway.domain>`), so the base is always '/'. Exposed to the build
|
||||
as VITE_BASE (the vite.config reads `base: process.env.VITE_BASE ?? '/'`)."""
|
||||
return "/"
|
||||
|
||||
|
||||
def _source_dir(comp: ProgramSpec, root: Path) -> Path:
|
||||
"""Resolve source directory, raising ValueError if absent."""
|
||||
if not comp.source:
|
||||
raise ValueError("No source directory")
|
||||
return root / comp.source
|
||||
|
||||
|
||||
class StackHandler:
|
||||
"""Base class — subclasses implement each lifecycle action."""
|
||||
|
||||
# Whether this stack owns *persistent external state* (a database schema, a
|
||||
# bucket, …) that outlives a code delete. Drives whether `wildpc delete`
|
||||
# surfaces a data remnant / honors `--purge-data`. Overridden to True by the
|
||||
# stacks whose `teardown` actually destroys something.
|
||||
owns_data: bool = False
|
||||
|
||||
# The dev verbs this stack advertises — what `available_actions` offers and
|
||||
# `run_action` will dispatch. Defaults to every stack verb (the python/react/
|
||||
# supabase handlers implement them all); a narrow stack like hugo (build-only,
|
||||
# no native lint/test/type-check) overrides this so callers aren't offered verbs
|
||||
# that would only error. `check` composes the sub-verbs, so a handler that drops
|
||||
# lint/type-check/test also drops `check` implicitly.
|
||||
provides: set[str] = _STACK_VERBS
|
||||
|
||||
# The host toolchains this stack needs, declared so wildpc can check them and
|
||||
# hint a fix. Empty by default (a stackless / declared-command program depends on
|
||||
# nothing wildpc can name); each real handler overrides it. See `tools_for`.
|
||||
tools: tuple[ToolRequirement, ...] = ()
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def type_check(
|
||||
self, name: str, comp: ProgramSpec, root: Path
|
||||
) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Composite: lint + type-check + test. Runs all, reports first failure."""
|
||||
for action_fn, action_name in [
|
||||
(self.lint, "lint"),
|
||||
(self.type_check, "type-check"),
|
||||
(self.test, "test"),
|
||||
]:
|
||||
result = await action_fn(name, comp, root)
|
||||
if result.status != "ok":
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="check",
|
||||
status="error",
|
||||
output=f"{action_name} failed:\n{result.output}",
|
||||
)
|
||||
return ActionResult(program=name, action="check", status="ok")
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def teardown(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Destroy the persistent external state this program's stack created
|
||||
(database schema, blobs, …) — the irreversible counterpart to a code
|
||||
delete. Distinct from `uninstall` (which only takes a program offline).
|
||||
|
||||
Default: nothing to tear down. Only stacks that set ``owns_data`` and own
|
||||
durable state override this; `wildpc delete --purge-data` invokes it.
|
||||
"""
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="teardown",
|
||||
status="ok",
|
||||
output=f"{name}: no persistent state to tear down.",
|
||||
)
|
||||
|
||||
|
||||
class PythonHandler(StackHandler):
|
||||
"""Handler for python-cli and python-fastapi stacks."""
|
||||
|
||||
tools = (
|
||||
ToolRequirement(
|
||||
command="uv",
|
||||
purpose="build & run Python programs",
|
||||
phase="both",
|
||||
install_hint="curl -LsSf https://astral.sh/uv/install.sh | sh",
|
||||
),
|
||||
)
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["uv", "sync"], src)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="build",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
if not (src / "tests").exists():
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="test",
|
||||
status="ok",
|
||||
output="No tests directory found, skipping.",
|
||||
)
|
||||
rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="test",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["uv", "run", "ruff", "check", "."], src)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="lint",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["uv", "run", "ruff", "format", "."], src)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="format",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def type_check(
|
||||
self, name: str, comp: ProgramSpec, root: Path
|
||||
) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["uv", "run", "pyright"], src)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="type-check",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
pkg_spec = str(src)
|
||||
if comp.install_extras:
|
||||
pkg_spec += "[" + ",".join(comp.install_extras) + "]"
|
||||
rc, output = await _run(
|
||||
["uv", "tool", "install", "--editable", pkg_spec, "--force"], src
|
||||
)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="install",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
pkg_name = src.name
|
||||
pyproject = src / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
with open(pyproject, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
pkg_name = data.get("project", {}).get("name", pkg_name)
|
||||
rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="uninstall",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
|
||||
# pnpm (10+) runs a deps-status check before `pnpm <script>` that wants to purge +
|
||||
# reinstall node_modules and aborts when there's no TTY
|
||||
# (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY). It's a false positive for a build —
|
||||
# the verb builds, it doesn't install — so skip the check and use the existing
|
||||
# modules. (The env-var form isn't honored; the CLI flag is.) CI=true guards any
|
||||
# other interactive prompt.
|
||||
_PNPM_ENV = {"CI": "true"}
|
||||
|
||||
|
||||
def _pnpm(*args: str) -> list[str]:
|
||||
"""A pnpm argv with the pre-run deps check disabled."""
|
||||
return ["pnpm", "--config.verify-deps-before-run=false", *args]
|
||||
|
||||
|
||||
class ReactViteHandler(StackHandler):
|
||||
"""Handler for react-vite stack."""
|
||||
|
||||
# Build-phase only: the output is a static `dist/` the gateway serves in place,
|
||||
# so no node/pnpm process runs at serve time. node is resolved per-program from
|
||||
# its pin (see toolchains); the hint names nvm, the ecosystem-standard installer.
|
||||
tools = (
|
||||
ToolRequirement(
|
||||
command="node",
|
||||
purpose="build the frontend (Vite/React toolchain)",
|
||||
phase="build",
|
||||
install_hint="nvm install --lts # or match the program's .node-version",
|
||||
),
|
||||
ToolRequirement(
|
||||
command="pnpm",
|
||||
purpose="install deps & run the Vite build",
|
||||
phase="build",
|
||||
install_hint="npm install -g pnpm # or: corepack enable pnpm",
|
||||
),
|
||||
)
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
# Build against the gateway serve root so absolute asset URLs resolve at /
|
||||
# (vite.config reads VITE_BASE=/). Removes the hand-tuned-base footgun.
|
||||
rc, output = await _run(
|
||||
_pnpm("build"), src, env={**_PNPM_ENV, "VITE_BASE": _vite_base(name)}
|
||||
)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="build",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(_pnpm("test"), src, env=_PNPM_ENV)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="test",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(_pnpm("lint"), src, env=_PNPM_ENV)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="lint",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(_pnpm("format"), src, env=_PNPM_ENV)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="format",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def type_check(
|
||||
self, name: str, comp: ProgramSpec, root: Path
|
||||
) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(_pnpm("type-check"), src, env=_PNPM_ENV)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="type-check",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Build the static assets in place. The gateway serves them directly from
|
||||
<source>/<build.outputs[0]> — no copy into a central content dir."""
|
||||
result = await self.build(name, comp, root)
|
||||
if result.status != "ok":
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="install",
|
||||
status="error",
|
||||
output=f"Build failed:\n{result.output}",
|
||||
)
|
||||
outputs = comp.build.outputs if comp.build else []
|
||||
if not outputs:
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="install",
|
||||
status="error",
|
||||
output="No build outputs configured.",
|
||||
)
|
||||
dist = _source_dir(comp, root) / outputs[0]
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="install",
|
||||
status="ok",
|
||||
output=f"Built; served in place from {dist}",
|
||||
)
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Static frontends have no install footprint to remove (served in place).
|
||||
|
||||
Deactivating one means dropping its gateway route — handled by removing the
|
||||
program from the registry, not by deleting build output."""
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="uninstall",
|
||||
status="ok",
|
||||
output=f"{name}: served in place; nothing to uninstall.",
|
||||
)
|
||||
|
||||
|
||||
class HugoHandler(StackHandler):
|
||||
"""Handler for the hugo stack — a static site built by the Hugo generator.
|
||||
|
||||
Hugo has one real dev verb: **build** (`hugo --gc --minify` → `public/`), which
|
||||
a `caddy` deployment then serves in place at `<name>.<gateway.domain>`. There is
|
||||
no native lint/test/type-check, so `provides` is narrowed to the verbs that do
|
||||
something. A theme with an asset pipeline (e.g. Blowfish + Tailwind) declares its
|
||||
own two-step `build.commands` (`pnpm build` then `hugo …`), which override this
|
||||
default per the usual declared-command-wins resolution."""
|
||||
|
||||
provides = {"build", "install", "uninstall"}
|
||||
tools = (
|
||||
ToolRequirement(
|
||||
command="hugo",
|
||||
purpose="build the static site",
|
||||
phase="build",
|
||||
install_hint="sudo apt install hugo # or: snap install hugo",
|
||||
),
|
||||
)
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["hugo", "--gc", "--minify"], src)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="build",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Build the site in place. The gateway serves it directly from
|
||||
<source>/<build.outputs[0]> (default `public/`) — no copy step."""
|
||||
result = await self.build(name, comp, root)
|
||||
if result.status != "ok":
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="install",
|
||||
status="error",
|
||||
output=f"Build failed:\n{result.output}",
|
||||
)
|
||||
outputs = comp.build.outputs if comp.build else []
|
||||
dist = _source_dir(comp, root) / (outputs[0] if outputs else "public")
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="install",
|
||||
status="ok",
|
||||
output=f"Built; served in place from {dist}",
|
||||
)
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Static sites have no install footprint — served in place. Deactivating one
|
||||
means dropping its gateway route, not deleting build output."""
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="uninstall",
|
||||
status="ok",
|
||||
output=f"{name}: served in place; nothing to uninstall.",
|
||||
)
|
||||
|
||||
|
||||
def _migration_version(path: Path) -> str:
|
||||
"""The version key of a migration file — the leading token before '_'.
|
||||
|
||||
e.g. ``0001_init.sql`` → ``0001``. Recorded in ``schema_migrations`` so a
|
||||
redeploy applies only the unapplied files.
|
||||
"""
|
||||
return path.name.split("_", 1)[0]
|
||||
|
||||
|
||||
def plan_migrations(files: list[Path], applied: set[str]) -> list[Path]:
|
||||
"""Order migrations by filename and drop any whose version is already applied.
|
||||
|
||||
Forward-only and idempotent (mirrors Patch's runner): re-running applies only
|
||||
new files, never re-applies an existing one. Pure — no DB — so it's unit-tested
|
||||
without a substrate.
|
||||
"""
|
||||
return [
|
||||
p
|
||||
for p in sorted(files, key=lambda x: x.name)
|
||||
if _migration_version(p) not in applied
|
||||
]
|
||||
|
||||
|
||||
def _substrate_db_url() -> str | None:
|
||||
"""Best-effort Postgres URL for the shared substrate.
|
||||
|
||||
Prefers an explicit ``SUPABASE_DB_URL``; otherwise builds one from the
|
||||
generated ``SUPABASE_POSTGRES_PASSWORD`` secret against the substrate's direct
|
||||
Postgres port. The self-hosted substrate publishes Postgres on host **5433**
|
||||
(5432 is taken by another Postgres on this node — see the substrate compose),
|
||||
overridable via ``SUPABASE_DB_HOST_PORT``. Returns None if neither an explicit
|
||||
URL nor the secret is available (build then fails loud with guidance).
|
||||
"""
|
||||
explicit = os.environ.get("SUPABASE_DB_URL")
|
||||
if explicit:
|
||||
return explicit
|
||||
from wildpc_core.config import read_secret
|
||||
|
||||
pw = read_secret("SUPABASE_POSTGRES_PASSWORD")
|
||||
if pw:
|
||||
port = os.environ.get("SUPABASE_DB_HOST_PORT", "5433")
|
||||
return f"postgresql://postgres:{pw}@localhost:{port}/postgres"
|
||||
return None
|
||||
|
||||
|
||||
def app_schema(name: str) -> str:
|
||||
"""The dedicated Postgres schema a supabase app owns.
|
||||
|
||||
Each app is isolated in its own schema (named after the program, ``-``→``_``
|
||||
for a valid unquoted identifier) rather than sharing ``public``. That gives a
|
||||
clean teardown (``drop schema … cascade``) and a per-app ``schema_migrations``
|
||||
so migration version tokens never collide across apps.
|
||||
"""
|
||||
return name.replace("-", "_")
|
||||
|
||||
|
||||
# The privilege grant that makes an app schema reachable through PostgREST — the
|
||||
# canonical Supabase "expose a custom schema" snippet. Idempotent; run before
|
||||
# migrations so `alter default privileges` also covers the tables they create.
|
||||
# Exposure ALSO requires the schema in the substrate's PGRST_DB_SCHEMAS — wildpc
|
||||
# derives that from the registered supabase apps (`${supabase_app_schemas}`), so a
|
||||
# newly-added app needs a `wildpc deploy` + substrate restart to become routable.
|
||||
def _schema_setup_sql(schema: str) -> str:
|
||||
roles = "anon, authenticated, service_role"
|
||||
return (
|
||||
f'create schema if not exists "{schema}";\n'
|
||||
f'grant usage on schema "{schema}" to {roles};\n'
|
||||
f'grant all on all tables in schema "{schema}" to {roles};\n'
|
||||
f'grant all on all routines in schema "{schema}" to {roles};\n'
|
||||
f'grant all on all sequences in schema "{schema}" to {roles};\n'
|
||||
f'alter default privileges in schema "{schema}" '
|
||||
f"grant all on tables to {roles};\n"
|
||||
f'alter default privileges in schema "{schema}" '
|
||||
f"grant all on routines to {roles};\n"
|
||||
f'alter default privileges in schema "{schema}" '
|
||||
f"grant all on sequences to {roles};\n"
|
||||
)
|
||||
|
||||
|
||||
class SupabaseHandler(StackHandler):
|
||||
"""Stack handler for supabase apps (migrations + edge functions + static UI).
|
||||
|
||||
Each app is isolated in its **own Postgres schema** (``app_schema(name)``): the
|
||||
migration runner creates + grants that schema, tracks applied versions in
|
||||
``<schema>.schema_migrations``, and runs each migration with ``search_path``
|
||||
set to it — so migration SQL is schema-agnostic and version tokens never
|
||||
collide across apps. The static UI is served in place by the gateway (no
|
||||
process), so install/uninstall are no-ops like a frontend. `teardown` drops the
|
||||
schema (and thus every object the app created) in one shot.
|
||||
"""
|
||||
|
||||
owns_data = True
|
||||
tools = (
|
||||
ToolRequirement(
|
||||
command="psql",
|
||||
purpose="run schema migrations against the substrate DB",
|
||||
phase="both",
|
||||
install_hint="sudo apt install postgresql-client",
|
||||
),
|
||||
ToolRequirement(
|
||||
command="deno",
|
||||
purpose="serve/deploy edge functions",
|
||||
phase="both",
|
||||
install_hint="curl -fsSL https://deno.land/install.sh | sh",
|
||||
),
|
||||
)
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Apply unapplied migrations into the app's own schema (idempotent)."""
|
||||
src = _source_dir(comp, root)
|
||||
schema = app_schema(name)
|
||||
mig_dir = src / "migrations"
|
||||
files = sorted(mig_dir.glob("*.sql")) if mig_dir.is_dir() else []
|
||||
|
||||
psql = shutil.which("psql")
|
||||
url = _substrate_db_url()
|
||||
if not psql:
|
||||
return ActionResult(
|
||||
name,
|
||||
"build",
|
||||
"error",
|
||||
"psql not found — install postgresql-client to run migrations.",
|
||||
)
|
||||
if not url:
|
||||
return ActionResult(
|
||||
name,
|
||||
"build",
|
||||
"error",
|
||||
"No substrate DB URL. Set SUPABASE_DB_URL, or generate secrets "
|
||||
"(scripts/gen-keys.py) so SUPABASE_POSTGRES_PASSWORD exists.",
|
||||
)
|
||||
|
||||
# Ensure the app's schema exists, is PostgREST-exposable (grants), and has
|
||||
# its own tracking table — then read applied versions from THAT schema.
|
||||
rc, out = await _run(
|
||||
[
|
||||
psql,
|
||||
url,
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-c",
|
||||
_schema_setup_sql(schema),
|
||||
"-c",
|
||||
f'create table if not exists "{schema}".schema_migrations '
|
||||
"(version text primary key, applied_at timestamptz default now())",
|
||||
],
|
||||
src,
|
||||
)
|
||||
if rc != 0:
|
||||
return ActionResult(name, "build", "error", f"connect/init failed:\n{out}")
|
||||
|
||||
if not files:
|
||||
return ActionResult(
|
||||
name, "build", "ok", f"Schema '{schema}' ready. No migrations to apply."
|
||||
)
|
||||
|
||||
rc, out = await _run(
|
||||
[
|
||||
psql,
|
||||
url,
|
||||
"-tA",
|
||||
"-c",
|
||||
f'select version from "{schema}".schema_migrations',
|
||||
],
|
||||
src,
|
||||
)
|
||||
if rc != 0:
|
||||
return ActionResult(
|
||||
name, "build", "error", f"read migrations failed:\n{out}"
|
||||
)
|
||||
applied = {line.strip() for line in out.splitlines() if line.strip()}
|
||||
|
||||
pending = plan_migrations(files, applied)
|
||||
if not pending:
|
||||
return ActionResult(
|
||||
name,
|
||||
"build",
|
||||
"ok",
|
||||
f"All migrations already applied (schema {schema}).",
|
||||
)
|
||||
|
||||
log = []
|
||||
for path in pending:
|
||||
version = _migration_version(path)
|
||||
# search_path → the app schema, so migration SQL can write unqualified
|
||||
# names and they land in the app's schema (not public). File +
|
||||
# version-insert in ONE transaction: a failed migration records
|
||||
# nothing, so the next build safely retries it.
|
||||
rc, out = await _run(
|
||||
[
|
||||
psql,
|
||||
url,
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"--single-transaction",
|
||||
"-c",
|
||||
f'set search_path to "{schema}", public',
|
||||
"-f",
|
||||
str(path),
|
||||
"-c",
|
||||
f'insert into "{schema}".schema_migrations(version) '
|
||||
f"values('{version}')",
|
||||
],
|
||||
src,
|
||||
)
|
||||
if rc != 0:
|
||||
log.append(f"✗ {path.name}\n{out}")
|
||||
return ActionResult(name, "build", "error", "\n".join(log))
|
||||
log.append(f"✓ {path.name}")
|
||||
return ActionResult(name, "build", "ok", "\n".join(log))
|
||||
|
||||
async def teardown(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Drop the app's schema and everything in it (tables, its own
|
||||
schema_migrations, functions) in one statement — total and knowable
|
||||
because the app owns exactly one schema. The substrate's PGRST_DB_SCHEMAS
|
||||
drops the (now-absent) schema on the next `wildpc deploy` + restart.
|
||||
"""
|
||||
schema = app_schema(name)
|
||||
psql = shutil.which("psql")
|
||||
url = _substrate_db_url()
|
||||
if not psql or not url:
|
||||
return ActionResult(
|
||||
name,
|
||||
"teardown",
|
||||
"error",
|
||||
f"Cannot drop schema '{schema}': psql or substrate DB URL "
|
||||
'unavailable. Drop it manually: drop schema "%s" cascade;' % schema,
|
||||
)
|
||||
cwd = src if (src := (root / comp.source if comp.source else None)) else root
|
||||
rc, out = await _run(
|
||||
[
|
||||
psql,
|
||||
url,
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-c",
|
||||
f'drop schema if exists "{schema}" cascade',
|
||||
],
|
||||
cwd,
|
||||
)
|
||||
if rc != 0:
|
||||
return ActionResult(name, "teardown", "error", f"drop failed:\n{out}")
|
||||
return ActionResult(
|
||||
name,
|
||||
"teardown",
|
||||
"ok",
|
||||
f"Dropped schema '{schema}' (all tables + rows). Run `wildpc deploy` "
|
||||
"and restart the substrate to prune it from PGRST_DB_SCHEMAS.",
|
||||
)
|
||||
|
||||
async def _deno(
|
||||
self, name: str, action: str, comp: ProgramSpec, root: Path, args: list[str]
|
||||
) -> ActionResult:
|
||||
"""Run a `deno` subcommand over functions/, or skip cleanly if deno absent."""
|
||||
src = _source_dir(comp, root)
|
||||
fns = src / "functions"
|
||||
deno = shutil.which("deno")
|
||||
if not deno or not fns.is_dir():
|
||||
return ActionResult(
|
||||
name, action, "ok", f"{action}: skipped (no deno/functions)"
|
||||
)
|
||||
rc, out = await _run([deno, *args, "functions/"], src)
|
||||
return ActionResult(name, action, "ok" if rc == 0 else "error", out)
|
||||
|
||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
return await self._deno(name, "test", comp, root, ["test", "--allow-all"])
|
||||
|
||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
return await self._deno(name, "lint", comp, root, ["lint"])
|
||||
|
||||
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
return await self._deno(name, "format", comp, root, ["fmt"])
|
||||
|
||||
async def type_check(
|
||||
self, name: str, comp: ProgramSpec, root: Path
|
||||
) -> ActionResult:
|
||||
return await self._deno(name, "type-check", comp, root, ["check"])
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Static UI is served in place by the gateway; nothing to install."""
|
||||
return ActionResult(
|
||||
name, "install", "ok", f"{name}: served in place at /{name}/."
|
||||
)
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
return ActionResult(
|
||||
name, "uninstall", "ok", f"{name}: served in place; nothing to remove."
|
||||
)
|
||||
|
||||
|
||||
HANDLERS: dict[str, StackHandler] = {
|
||||
"python-cli": PythonHandler(),
|
||||
"python-fastapi": PythonHandler(),
|
||||
"react-vite": ReactViteHandler(),
|
||||
"supabase": SupabaseHandler(),
|
||||
"hugo": HugoHandler(),
|
||||
}
|
||||
|
||||
|
||||
def get_handler(stack: str | None) -> StackHandler | None:
|
||||
"""Get the handler for a given stack, or None if unsupported."""
|
||||
if stack is None:
|
||||
return None
|
||||
return HANDLERS.get(stack)
|
||||
|
||||
|
||||
def available_stacks() -> list[str]:
|
||||
"""The stack names wildpc has handlers for — the single source of truth for the
|
||||
CLI ``--stack`` choices, the ``GET /stacks`` endpoint, and the dashboard select.
|
||||
"""
|
||||
return sorted(HANDLERS)
|
||||
|
||||
|
||||
def tools_for(stack: str | None) -> tuple[ToolRequirement, ...]:
|
||||
"""The host toolchains a stack declares it needs (empty for an unknown/absent
|
||||
stack). The single source of truth for the dependency checks in `relations`,
|
||||
`wildpc stack`, `wildpc doctor`, and the dashboard Stacks page."""
|
||||
handler = get_handler(stack)
|
||||
return handler.tools if handler is not None else ()
|
||||
|
||||
|
||||
def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
|
||||
"""Declared argv-lists for a verb, or None.
|
||||
|
||||
`build` is declared via BuildSpec.commands; every other verb via CommandsSpec.
|
||||
"""
|
||||
if verb == "build":
|
||||
if comp.build and comp.build.commands:
|
||||
return comp.build.commands
|
||||
return None
|
||||
if comp.commands is not None:
|
||||
return comp.commands.for_verb(verb)
|
||||
return None
|
||||
|
||||
|
||||
def _stack_provides(comp: ProgramSpec, verb: str) -> bool:
|
||||
"""Whether the program's stack handler advertises this verb.
|
||||
|
||||
Consults the handler's ``provides`` set (default: all of ``_STACK_VERBS``) so a
|
||||
build-only stack like hugo doesn't offer lint/test/type-check it can't run."""
|
||||
handler = get_handler(comp.stack)
|
||||
return bool(comp.source) and handler is not None and verb in handler.provides
|
||||
|
||||
|
||||
def is_available(comp: ProgramSpec, verb: str) -> bool:
|
||||
"""Whether a verb can be run for a program (declared command or stack default)."""
|
||||
if _declared_commands(comp, verb) is not None:
|
||||
return True
|
||||
if verb == "check":
|
||||
return any(is_available(comp, sub) for sub in ("lint", "type-check", "test"))
|
||||
return _stack_provides(comp, verb)
|
||||
|
||||
|
||||
def available_actions(comp: ProgramSpec) -> list[str]:
|
||||
"""Return the list of verbs available for a program (resolution-aware)."""
|
||||
if not comp.source:
|
||||
return []
|
||||
return [verb for verb in ALL_ACTIONS if is_available(comp, verb)]
|
||||
|
||||
|
||||
async def _run_declared(
|
||||
name: str, verb: str, cmds: list[list[str]], src: Path
|
||||
) -> ActionResult:
|
||||
"""Run declared argv-lists in sequence; stop at the first failure."""
|
||||
outputs: list[str] = []
|
||||
for argv in cmds:
|
||||
rc, output = await _run(argv, src)
|
||||
outputs.append(output)
|
||||
if rc != 0:
|
||||
return ActionResult(
|
||||
program=name, action=verb, status="error", output="".join(outputs)
|
||||
)
|
||||
return ActionResult(program=name, action=verb, status="ok", output="".join(outputs))
|
||||
|
||||
|
||||
async def run_action(
|
||||
verb: str, name: str, comp: ProgramSpec, root: Path
|
||||
) -> ActionResult:
|
||||
"""Resolve and run a verb: declared command → stack default → unavailable.
|
||||
|
||||
This is the single entry point callers should use; it replaces reaching for
|
||||
get_handler(...).<method>(...) directly so the override logic stays in one place.
|
||||
"""
|
||||
# `check` is a composite that must respect per-verb overrides — unless the
|
||||
# program declares its own `check`, run each available sub-verb via run_action.
|
||||
if verb == "check" and _declared_commands(comp, "check") is None:
|
||||
subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)]
|
||||
if not subs:
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="check",
|
||||
status="error",
|
||||
output="No checkable verbs available.",
|
||||
)
|
||||
sections: list[str] = []
|
||||
for sub in subs:
|
||||
result = await run_action(sub, name, comp, root)
|
||||
mark = "✓" if result.status == "ok" else "✗"
|
||||
body = result.output.strip()
|
||||
sections.append(f"{mark} {sub}" + (f"\n{body}" if body else ""))
|
||||
if result.status != "ok":
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="check",
|
||||
status="error",
|
||||
output="\n\n".join(sections),
|
||||
)
|
||||
return ActionResult(
|
||||
program=name, action="check", status="ok", output="\n\n".join(sections)
|
||||
)
|
||||
|
||||
# 1. Declared command overrides the stack default.
|
||||
declared = _declared_commands(comp, verb)
|
||||
if declared is not None:
|
||||
try:
|
||||
src = _source_dir(comp, root)
|
||||
except ValueError:
|
||||
return ActionResult(
|
||||
program=name, action=verb, status="error", output="No source directory"
|
||||
)
|
||||
return await _run_declared(name, verb, declared, src)
|
||||
|
||||
# 2. Stack default.
|
||||
handler = get_handler(comp.stack)
|
||||
if handler is not None and verb in _STACK_VERBS:
|
||||
method = getattr(handler, _VERB_METHOD.get(verb, verb), None)
|
||||
if method is not None:
|
||||
return await method(name, comp, root)
|
||||
|
||||
# 3. Unavailable.
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action=verb,
|
||||
status="error",
|
||||
output=f"Verb '{verb}' is not available for '{name}' "
|
||||
f"(no declared command and no stack handler provides it).",
|
||||
)
|
||||
Reference in New Issue
Block a user