"""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** (`.`), 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