"""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 castle_core.config import USER_TOOL_PATH_DIRS from castle_core.manifest import ProgramSpec from castle_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 = "" 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:`castle_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 castle-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 castle-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 `castle delete` # surfaces a data remnant / honors `--purge-data`. Overridden to True by the # stacks whose `teardown` actually destroys something. owns_data: bool = False 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; `castle 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.""" 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