From 400e0b253b271dfaa6066bbb77e786d5acf271f9 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sat, 13 Jun 2026 17:26:49 -0700 Subject: [PATCH] Less stack-centric and location-centric model. --- CLAUDE.md | 58 ++++++--- README.md | 18 +-- app/src/pages/ProgramDetail.tsx | 119 +++++++++++------- app/src/pages/ScheduledDetail.tsx | 1 - app/src/types/index.ts | 6 + castle-api/src/castle_api/models.py | 6 + castle-api/src/castle_api/nodes.py | 1 - castle-api/src/castle_api/programs.py | 26 ++-- castle-api/src/castle_api/routes.py | 23 +++- castle-api/tests/conftest.py | 11 ++ castle-api/tests/test_programs_commands.py | 32 +++++ castle-api/tests/test_tools.py | 78 ------------ cli/src/castle_cli/commands/add.py | 134 ++++++++++++++++++++ cli/src/castle_cli/commands/clone.py | 62 ++++++++++ cli/src/castle_cli/commands/create.py | 65 +++++----- cli/src/castle_cli/commands/delete.py | 81 +++++++++++++ cli/src/castle_cli/commands/dev.py | 135 ++++++++------------- cli/src/castle_cli/commands/run_cmd.py | 63 +++++++--- cli/src/castle_cli/commands/tool.py | 75 ------------ cli/src/castle_cli/config.py | 7 +- cli/src/castle_cli/main.py | 92 +++++++++++--- cli/src/castle_cli/manifest.py | 3 +- cli/tests/test_add.py | 72 +++++++++++ cli/tests/test_create.py | 15 ++- cli/tests/test_delete.py | 67 ++++++++++ core/src/castle_core/config.py | 14 +++ core/src/castle_core/manifest.py | 39 +++++- core/src/castle_core/stacks.py | 101 ++++++++++++++- core/tests/test_resolution.py | 57 +++++++++ docs/component-registry.md | 38 +++++- docs/stacks/python-cli.md | 7 ++ docs/stacks/python-fastapi.md | 7 ++ docs/stacks/react-vite.md | 7 ++ 33 files changed, 1112 insertions(+), 408 deletions(-) create mode 100644 castle-api/tests/test_programs_commands.py delete mode 100644 castle-api/tests/test_tools.py create mode 100644 cli/src/castle_cli/commands/add.py create mode 100644 cli/src/castle_cli/commands/clone.py create mode 100644 cli/src/castle_cli/commands/delete.py delete mode 100644 cli/src/castle_cli/commands/tool.py create mode 100644 cli/tests/test_add.py create mode 100644 cli/tests/test_delete.py create mode 100644 core/tests/test_resolution.py diff --git a/CLAUDE.md b/CLAUDE.md index 3974f5b..65baba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,9 +23,23 @@ description fallthrough. configuration (data dir, port, URLs) via env vars. Only castle programs (CLI, gateway) know about castle internals. -## Creating Programs +## Programs: create new, or adopt existing -When creating a new service, tool, or frontend, follow the detailed guides: +A **program** is a source repo castle knows how to work with (dev verbs) and +deploy. There are two ways to get one: + +- **`castle create`** — scaffold *new* code from a **stack** (a creation-time + template). Stacks are guidance for how new code is written; they are NOT + required at runtime. +- **`castle add`** — adopt an *existing* repo (a local path or git URL), + wherever it lives. No stack needed — castle detects sensible dev-verb commands + (pyproject→uv/ruff/pytest, Cargo.toml→cargo, etc.) or you declare them. + +**Stacks vs programs** are decoupled: a stack seeds a new program's default +verb commands and scaffold, but a program stands on its own via its declared +`commands:` (and `source:`/`repo:`). A program may have no stack at all. + +Stack guides (for writing *new* code, AI-facing): - @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle - @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing) @@ -35,21 +49,23 @@ When creating a new service, tool, or frontend, follow the detailed guides: ### Quick start ```bash -# Daemon (python-fastapi) +# New daemon, scaffolded from a stack castle create my-service --stack python-fastapi --description "Does something" -cd ~/.castle/code/my-service && uv sync -uv run my-service # starts on auto-assigned port -castle service enable my-service # register with systemd -castle gateway reload # update reverse proxy routes +cd /data/repos/my-service && uv sync +castle service enable my-service # register with systemd +castle gateway reload # update reverse proxy routes -# Tool (python-cli) +# New tool from a stack castle create my-tool --stack python-cli --description "Does something" -cd ~/.castle/code/my-tool && uv sync + +# Adopt an existing repo (no stack required) +castle add ~/projects/some-rust-tool +castle add https://github.com/me/widget.git --name widget ``` -The `castle create` command scaffolds the project under `~/.castle/code/`, -generates a CLAUDE.md, and registers it in `castle.yaml` with -`source: code/`. +`castle create` scaffolds under `/data/repos/` (override with `CASTLE_REPOS_DIR`) +and registers the program in `castle.yaml` with an absolute `source:`. `castle add` +registers an existing repo in place (or records its `repo:` URL for `castle clone`). ## Castle CLI @@ -60,20 +76,26 @@ castle list # List all programs, services, and jobs castle list --behavior daemon # Filter by behavior castle list --stack python-cli # Filter by stack castle info # Show details (--json for machine-readable) -castle create --stack python-fastapi # Scaffold new project +castle create [--stack ...] # Scaffold new project (--stack optional → bare program) +castle add [--name ...] # Adopt an EXISTING repo as a program (detects verbs) +castle clone [name] # Clone source for programs that declare repo: castle deploy [name] # Deploy to runtime (registry + systemd + Caddyfile) -castle test [project] # Run tests (one or all) -castle lint [project] # Run linter (one or all) -castle run # Run service in foreground +castle build|test|lint|type-check|check [project] # Dev verbs (one or all) +castle install|uninstall [program] # Install/remove a program on PATH +castle run # Run a program (declared run) or service in foreground castle logs [-f] [-n 50] # View service/job logs -castle tool list # List all tools -castle tool info # Show tool details castle gateway start|stop|reload|status # Manage Caddy reverse proxy castle service enable|disable # Manage individual systemd service castle service status # Show all service statuses castle services start|stop # Start/stop everything ``` +**Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`) +overrides the stack default, falling back to the program's stack handler, else +the verb is unavailable. So a wired-in repo with **no `stack`** works as long as +it declares its commands. Tools are reached via `castle list --behavior tool` +(the dedicated `castle tool` command was removed). + ## Infrastructure Castle uses two roots, each overridable by an env var: `CASTLE_HOME` (config, diff --git a/README.md b/README.md index 5e6e2b0..cb57906 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Castle +"Standing to author, run, govern, and maintain your own software" + A personal software platform. Castle manages independent services, tools, and frontends from a single CLI, with a unified gateway, systemd integration, and a web dashboard. Historically, applications have been developed by third parties, distributed through app stores, and installed on user devices. @@ -61,21 +63,23 @@ castle deploy my-app ``` castle list [--behavior B] [--stack S] [--json] List all programs, services, and jobs castle info NAME [--json] Show program details -castle create NAME --stack STACK Scaffold a new project -castle build [NAME] Build projects (one or all) -castle test [NAME] Run tests (one or all) -castle lint [NAME] Run linter (one or all) +castle create NAME [--stack STACK] Scaffold a new project (bare if no stack) +castle add PATH|GIT-URL [--name N] Adopt an existing repo as a program +castle clone [NAME] Clone source for programs that declare repo: +castle build|test|lint|type-check|check [NAME] Dev verbs (one or all) +castle install|uninstall [NAME] Install/remove a program on PATH castle deploy [NAME] Deploy to ~/.castle/ (spec -> runtime) -castle run NAME Run a service in the foreground +castle run NAME Run a program (declared run) or service in foreground castle logs NAME [-f] [-n 50] View service/job logs castle gateway start|stop|reload Manage Caddy reverse proxy castle service enable|disable NAME Manage a systemd service castle service status Show all service statuses castle services start|stop Start/stop everything -castle tool list List all tools -castle tool info NAME Show tool details ``` +Tools are programs with `behavior: tool` — list them with +`castle list --behavior tool`. + ## Registry `castle.yaml` lives at `~/.castle/castle.yaml` and is the single source of truth. It has four top-level sections: diff --git a/app/src/pages/ProgramDetail.tsx b/app/src/pages/ProgramDetail.tsx index e086f09..8faed97 100644 --- a/app/src/pages/ProgramDetail.tsx +++ b/app/src/pages/ProgramDetail.tsx @@ -10,7 +10,6 @@ export function ProgramDetailPage() { useEventStream() const { name } = useParams<{ name: string }>() const { data: component, isLoading, error, refetch } = useProgram(name ?? "") - const isTool = component?.behavior === "tool" const [actionOutput, setActionOutput] = useState(null) if (isLoading) { @@ -51,53 +50,81 @@ export function ProgramDetailPage() {

{component.description}

)} - {isTool && ( -
-

- Tool Info -

-

- How this tool is packaged and what it depends on. -

-
- {component.source && ( - <> - Source - {component.source} - - )} - {component.version && ( - <> - Version - {component.version} - - )} - {component.runner && ( - <> - Runner - {runnerLabel(component.runner)} - - )} - Installed - {component.installed ? "Yes" : "No"} -
- {component.system_dependencies.length > 0 && ( -
- System Dependencies -
- {component.system_dependencies.map((dep) => ( - - {dep} - - ))} -
-
+
+

+ Program Info +

+

+ Where the source lives and how castle works with it. +

+
+ {component.source && ( + <> + Source + {component.source} + + )} + {component.repo && ( + <> + Repo + + {component.repo} + {component.ref ? ` @ ${component.ref}` : ""} + + + )} + {component.version && ( + <> + Version + {component.version} + + )} + {component.runner && ( + <> + Runner + {runnerLabel(component.runner)} + + )} + {component.installed !== null && ( + <> + Installed + {component.installed ? "Yes" : "No"} + )}
- )} + + {component.commands && Object.keys(component.commands).length > 0 && ( +
+ Commands +
+ {Object.entries(component.commands).map(([verb, cmds]) => ( +
+ {verb} + + {cmds.map((argv) => argv.join(" ")).join(" && ")} + +
+ ))} +
+
+ )} + + {component.system_dependencies.length > 0 && ( +
+ System Dependencies +
+ {component.system_dependencies.map((dep) => ( + + {dep} + + ))} +
+
+ )} +
diff --git a/app/src/pages/ScheduledDetail.tsx b/app/src/pages/ScheduledDetail.tsx index 805660d..473271e 100644 --- a/app/src/pages/ScheduledDetail.tsx +++ b/app/src/pages/ScheduledDetail.tsx @@ -37,7 +37,6 @@ export function ScheduledDetailPage() { backTo="/" backLabel="Back to Jobs" name={component.id} - behavior="tool" stack={component.stack} source={component.source} > diff --git a/app/src/types/index.ts b/app/src/types/index.ts index eaa9211..f3bf1c8 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -46,6 +46,9 @@ export interface ProgramSummary { runner: string | null version: string | null source: string | null + repo: string | null + ref: string | null + commands: Record | null system_dependencies: string[] installed: boolean | null actions: string[] @@ -74,6 +77,9 @@ export interface ComponentSummary { systemd: SystemdInfo | null version: string | null source: string | null + repo: string | null + ref: string | null + commands: Record | null system_dependencies: string[] schedule: string | null installed: boolean | null diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 9c39ee3..22abd88 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -27,6 +27,9 @@ class ComponentSummary(BaseModel): systemd: SystemdInfo | None = None version: str | None = None source: str | None = None + repo: str | None = None + ref: str | None = None + commands: dict[str, list[list[str]]] | None = None system_dependencies: list[str] = [] schedule: str | None = None installed: bool | None = None @@ -91,6 +94,9 @@ class ProgramSummary(BaseModel): runner: str | None = None version: str | None = None source: str | None = None + repo: str | None = None + ref: str | None = None + commands: dict[str, list[list[str]]] | None = None system_dependencies: list[str] = [] installed: bool | None = None actions: list[str] = [] diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py index 667add4..7049f9c 100644 --- a/castle-api/src/castle_api/nodes.py +++ b/castle-api/src/castle_api/nodes.py @@ -66,7 +66,6 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSum def get_mesh_status(request: Request) -> MeshStatus: """Get the current state of the mesh coordination layer.""" mqtt_client = getattr(request.app.state, "mqtt_client", None) - mdns = getattr(request.app.state, "mdns", None) peers = list(mesh_state.all_nodes(include_stale=True).keys()) diff --git a/castle-api/src/castle_api/programs.py b/castle-api/src/castle_api/programs.py index e4f2249..9f0802a 100644 --- a/castle-api/src/castle_api/programs.py +++ b/castle-api/src/castle_api/programs.py @@ -4,7 +4,7 @@ from __future__ import annotations from fastapi import APIRouter, HTTPException, status -from castle_core.stacks import available_actions, get_handler +from castle_core.stacks import available_actions, run_action from castle_api.config import get_config @@ -27,7 +27,11 @@ _VALID_ACTIONS = { @programs_router.post("/programs/{name}/{action}") async def program_action(name: str, action: str) -> dict: - """Run a lifecycle action on a program via its stack handler.""" + """Run a lifecycle action on a program. + + Resolution-aware: a declared `commands:` entry overrides the stack default, + so a program with no stack can still be linted/tested/built/installed. + """ if action not in _VALID_ACTIONS: raise HTTPException(status_code=400, detail=f"Unknown action: {action}") @@ -41,24 +45,14 @@ async def program_action(name: str, action: str) -> dict: if not comp.source: raise HTTPException(status_code=400, detail=f"'{name}' has no source directory") - actions = available_actions(comp) - if action not in actions: + if action not in available_actions(comp): raise HTTPException( status_code=400, - detail=f"Action '{action}' not available for '{name}' (stack: {comp.stack})", + detail=f"Action '{action}' not available for '{name}' " + f"(no declared command and no stack handler provides it)", ) - handler = get_handler(comp.stack) - if handler is None: - raise HTTPException( - status_code=400, detail=f"No handler for stack '{comp.stack}'" - ) - - # Map hyphenated action names to method names (type-check → type_check) - method_name = action.replace("-", "_") - method = getattr(handler, method_name) - - result = await method(name, comp, config.root) + result = await run_action(action, name, comp, config.root) if result.status != "ok": raise HTTPException(status_code=500, detail=result.output or f"{action} failed") diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 2d4e0ca..0899eca 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -34,6 +34,19 @@ from castle_api.models import ( router = APIRouter(tags=["dashboard"]) +def _declared_commands_dict(comp: ProgramSpec) -> dict[str, list[list[str]]] | None: + """Serialize a program's declared verbs for the API (build + CommandsSpec).""" + out: dict[str, list[list[str]]] = {} + if comp.build and comp.build.commands: + out["build"] = comp.build.commands + if comp.commands is not None: + for verb in ("lint", "test", "type-check", "check", "run", "install", "uninstall"): + cmds = comp.commands.for_verb(verb) + if cmds: + out[verb] = cmds + return out or None + + def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary: """Build a ComponentSummary from a DeployedComponent.""" managed = deployed.managed @@ -172,7 +185,7 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component runner = "command" installed: bool | None = None - if comp.source and comp.stack: + if comp.source and (comp.stack or comp.commands): installed = shutil.which(name) is not None return ComponentSummary( @@ -184,6 +197,9 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component runner=runner, version=comp.version, source=source, + repo=comp.repo, + ref=comp.ref, + commands=_declared_commands_dict(comp), system_dependencies=comp.system_dependencies, installed=installed, ) @@ -323,7 +339,7 @@ def _program_from_spec( runner = "command" installed: bool | None = None - if comp.source and comp.stack: + if comp.source and (comp.stack or comp.commands): installed = shutil.which(name) is not None return ProgramSummary( @@ -334,6 +350,9 @@ def _program_from_spec( runner=runner, version=comp.version, source=source, + repo=comp.repo, + ref=comp.ref, + commands=_declared_commands_dict(comp), system_dependencies=comp.system_dependencies, installed=installed, actions=available_actions(comp), diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 5822ce8..26a3420 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -36,6 +36,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "behavior": "tool", "version": "2.0.0", }, + "wired-in": { + "description": "Adopted repo, no stack", + "source": "wired-in", + "behavior": "tool", + "repo": "https://github.com/someone/wired-in.git", + "commands": { + "lint": [["make", "lint"]], + "test": [["make", "test"]], + "run": [["./bin/wired-in"]], + }, + }, }, "services": { "test-svc": { diff --git a/castle-api/tests/test_programs_commands.py b/castle-api/tests/test_programs_commands.py new file mode 100644 index 0000000..6ee3b34 --- /dev/null +++ b/castle-api/tests/test_programs_commands.py @@ -0,0 +1,32 @@ +"""Tests for the new per-program commands/repo fields on the programs API.""" + +from fastapi.testclient import TestClient + + +class TestProgramCommands: + def test_wired_in_program_surfaces_commands_and_repo(self, client: TestClient) -> None: + """A stack-less adopted program exposes its declared commands + repo.""" + resp = client.get("/programs") + assert resp.status_code == 200 + progs = {p["id"]: p for p in resp.json()} + assert "wired-in" in progs + w = progs["wired-in"] + assert w["stack"] is None + assert w["repo"] == "https://github.com/someone/wired-in.git" + assert w["commands"]["lint"] == [["make", "lint"]] + assert w["commands"]["run"] == [["./bin/wired-in"]] + + def test_wired_in_actions_resolved_from_commands(self, client: TestClient) -> None: + """available actions come from declared commands when there's no stack.""" + resp = client.get("/programs") + w = next(p for p in resp.json() if p["id"] == "wired-in") + # declared lint/test/run + the composite check (lint/test available) + assert set(w["actions"]) >= {"lint", "test", "run"} + assert "build" not in w["actions"] # not declared, no stack + + def test_tools_via_behavior_filter(self, client: TestClient) -> None: + """Tools are reached via /programs?behavior=tool (no dedicated /tools).""" + resp = client.get("/programs", params={"behavior": "tool"}) + assert resp.status_code == 200 + ids = [p["id"] for p in resp.json()] + assert "wired-in" in ids and "test-tool" in ids diff --git a/castle-api/tests/test_tools.py b/castle-api/tests/test_tools.py deleted file mode 100644 index 249b92c..0000000 --- a/castle-api/tests/test_tools.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Tests for tools endpoints.""" - -from fastapi.testclient import TestClient - - -class TestToolsList: - """GET /tools endpoint tests.""" - - def test_returns_flat_list(self, client: TestClient) -> None: - """Returns tools as a flat sorted list.""" - response = client.get("/tools") - assert response.status_code == 200 - data = response.json() - assert isinstance(data, list) - ids = [t["id"] for t in data] - assert "test-tool" in ids - assert "test-tool-2" in ids - - def test_sorted_alphabetically(self, client: TestClient) -> None: - """Tools are sorted alphabetically by id.""" - response = client.get("/tools") - data = response.json() - ids = [t["id"] for t in data] - assert ids == sorted(ids) - - def test_tool_fields(self, client: TestClient) -> None: - """Tool summary has expected fields.""" - response = client.get("/tools") - data = response.json() - tool = next(t for t in data if t["id"] == "test-tool") - assert tool["description"] == "Test tool" - assert tool["source"].endswith("/test-tool") - assert tool["system_dependencies"] == ["pandoc"] - - def test_installed_flag(self, client: TestClient) -> None: - """Tool installed field reflects whether binary is on PATH.""" - response = client.get("/tools") - data = response.json() - tool = next(t for t in data if t["id"] == "test-tool") - # test-tool binary won't be on PATH in test env - assert isinstance(tool["installed"], bool) - - def test_service_excluded(self, client: TestClient) -> None: - """Services without tool spec are not listed.""" - response = client.get("/tools") - data = response.json() - all_ids = [t["id"] for t in data] - assert "test-svc" not in all_ids - - -class TestToolDetail: - """GET /tools/{name} endpoint tests.""" - - def test_get_tool(self, client: TestClient) -> None: - """Returns detail for a known tool.""" - response = client.get("/tools/test-tool") - assert response.status_code == 200 - data = response.json() - assert data["id"] == "test-tool" - assert data["source"].endswith("/test-tool") - assert data["system_dependencies"] == ["pandoc"] - - def test_no_docs(self, client: TestClient) -> None: - """Tool detail returns null docs (no .md files anymore).""" - response = client.get("/tools/test-tool") - assert response.status_code == 200 - data = response.json() - assert data["docs"] is None - - def test_not_found(self, client: TestClient) -> None: - """Returns 404 for unknown component.""" - response = client.get("/tools/nonexistent") - assert response.status_code == 404 - - def test_not_a_tool(self, client: TestClient) -> None: - """Returns 404 for component that is not a tool.""" - response = client.get("/tools/test-svc") - assert response.status_code == 404 diff --git a/cli/src/castle_cli/commands/add.py b/cli/src/castle_cli/commands/add.py new file mode 100644 index 0000000..75cdda4 --- /dev/null +++ b/cli/src/castle_cli/commands/add.py @@ -0,0 +1,134 @@ +"""castle add — adopt an existing repo as a program (no scaffolding). + +`castle create` makes new code from a stack. `castle add` adopts code that +already exists — a local path, or a git URL to clone. It detects sensible dev +verb commands so a non-castle project becomes usable without writing them by hand. +""" + +from __future__ import annotations + +import argparse +import tomllib +from pathlib import Path + +from castle_cli.config import REPOS_DIR, load_config, save_config +from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec + + +def _is_git_url(s: str) -> bool: + return ( + s.startswith(("http://", "https://", "git@", "ssh://")) + or s.endswith(".git") + ) + + +def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]: + """Detect (stack, commands, behavior) for a source dir. + + Returns a stack name when the project fits a known one (so it inherits those + defaults), otherwise an explicit commands map. behavior defaults to 'tool'. + """ + stack: str | None = None + commands: dict[str, list[list[str]]] = {} + behavior = "tool" + + pyproject = src / "pyproject.toml" + if pyproject.exists(): + try: + data = tomllib.loads(pyproject.read_text()) + except (OSError, tomllib.TOMLDecodeError): + data = {} + deps = " ".join(data.get("project", {}).get("dependencies", [])) + if "fastapi" in deps or "uvicorn" in deps: + stack, behavior = "python-fastapi", "daemon" + else: + stack = "python-cli" + return stack, commands, behavior + + if (src / "Cargo.toml").exists(): + commands = { + "build": [["cargo", "build", "--release"]], + "test": [["cargo", "test"]], + "lint": [["cargo", "clippy"]], + "run": [["cargo", "run"]], + } + return None, commands, "tool" + + if (src / "package.json").exists(): + commands = { + "build": [["pnpm", "build"]], + "test": [["pnpm", "test"]], + "lint": [["pnpm", "lint"]], + } + return None, commands, "frontend" + + if (src / "Makefile").exists() or (src / "makefile").exists(): + commands = {"build": [["make"]], "test": [["make", "test"]]} + return None, commands, "tool" + + return None, commands, "tool" + + +def run_add(args: argparse.Namespace) -> int: + """Adopt an existing repo as a program.""" + config = load_config() + target = args.target + + repo_url: str | None = None + source: str | None = None + + if _is_git_url(target): + repo_url = target + name = args.name or Path(target.rstrip("/")).name.removesuffix(".git") + # Default local clone location; cloned later via `castle clone`. + source = str(REPOS_DIR / name) + src_path = Path(source) + else: + src_path = Path(target).expanduser().resolve() + if not src_path.exists(): + print(f"Error: path does not exist: {src_path}") + return 1 + source = str(src_path) + name = args.name or src_path.name + + if name in config.programs or name in config.services or name in config.jobs: + print(f"Error: '{name}' already exists in castle.yaml") + return 1 + + # Detect verbs from the working copy if we have one on disk. + stack: str | None = None + detected_commands: dict[str, list[list[str]]] = {} + behavior = "tool" + if src_path.exists(): + stack, detected_commands, behavior = _detect(src_path) + + prog = ProgramSpec( + id=name, + description=args.description or f"Adopted from {target}", + source=source, + stack=stack, + repo=repo_url, + behavior=behavior, + ) + # `build` is declared via BuildSpec; every other verb via CommandsSpec. + if detected_commands: + build_cmds = detected_commands.pop("build", None) + if build_cmds: + prog.build = BuildSpec(commands=build_cmds) + if detected_commands: + prog.commands = CommandsSpec.model_validate(detected_commands) + + config.programs[name] = prog + save_config(config) + + print(f"Adopted '{name}' as a program.") + print(f" source: {source}") + if repo_url: + print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)") + if stack: + print(f" stack: {stack} (verbs inherited from stack defaults)") + elif detected_commands: + print(f" commands detected: {', '.join(sorted(detected_commands))}") + else: + print(" no stack/commands detected — declare verbs in castle.yaml as needed") + return 0 diff --git a/cli/src/castle_cli/commands/clone.py b/cli/src/castle_cli/commands/clone.py new file mode 100644 index 0000000..8951392 --- /dev/null +++ b/cli/src/castle_cli/commands/clone.py @@ -0,0 +1,62 @@ +"""castle clone — clone source for programs that declare a `repo:` URL. + +Used to provision a fresh machine: every program with a `repo:` and a missing +local `source:` gets cloned. A program whose source already exists is skipped. +""" + +from __future__ import annotations + +import argparse +import subprocess +from pathlib import Path + +from castle_cli.config import REPOS_DIR, load_config + + +def _clone_one(name: str, repo: str, source: str | None, ref: str | None) -> bool: + dest = Path(source) if source else REPOS_DIR / name + if dest.exists(): + print(f" {name}: already present at {dest}, skipping") + return True + dest.parent.mkdir(parents=True, exist_ok=True) + cmd = ["git", "clone", repo, str(dest)] + print(f" {name}: cloning {repo} → {dest}") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f" {name}: clone failed:\n{result.stderr}") + return False + if ref: + co = subprocess.run( + ["git", "-C", str(dest), "checkout", ref], capture_output=True, text=True + ) + if co.returncode != 0: + print(f" {name}: checkout {ref} failed:\n{co.stderr}") + return False + return True + + +def run_clone(args: argparse.Namespace) -> int: + config = load_config() + + if getattr(args, "name", None): + if args.name not in config.programs: + print(f"Unknown program: {args.name}") + return 1 + prog = config.programs[args.name] + if not prog.repo: + print(f"{args.name} has no repo: URL to clone from") + return 1 + return 0 if _clone_one(args.name, prog.repo, prog.source, prog.ref) else 1 + + # Clone all programs that declare a repo: and lack a present source. + all_ok = True + cloned_any = False + for name, prog in config.programs.items(): + if not prog.repo: + continue + cloned_any = True + if not _clone_one(name, prog.repo, prog.source, prog.ref): + all_ok = False + if not cloned_any: + print("No programs declare a repo: URL.") + return 0 if all_ok else 1 diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 7d6920b..18142b7 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -3,15 +3,16 @@ from __future__ import annotations import argparse +import subprocess -from castle_cli.config import load_config, save_config +from castle_cli.config import REPOS_DIR, load_config, save_config from castle_cli.manifest import ( CaddySpec, - ProgramSpec, ExposeSpec, HttpExposeSpec, HttpInternal, ManageSpec, + ProgramSpec, ProxySpec, RunPython, ServiceSpec, @@ -43,21 +44,20 @@ def next_available_port(config: object) -> int: def run_create(args: argparse.Namespace) -> int: - """Create a new project.""" + """Create a new project (scaffolded from a stack, or a bare program).""" config = load_config() name = args.name stack = args.stack - behavior = STACK_DEFAULTS.get(stack) + behavior = STACK_DEFAULTS.get(stack) if stack else None if name in config.programs or name in config.services or name in config.jobs: print(f"Error: '{name}' already exists in castle.yaml") return 1 - code_dir = config.root / "code" - code_dir.mkdir(exist_ok=True) - project_dir = code_dir / name + REPOS_DIR.mkdir(parents=True, exist_ok=True) + project_dir = REPOS_DIR / name if project_dir.exists(): - print(f"Error: directory 'code/{name}' already exists") + print(f"Error: directory already exists: {project_dir}") return 1 # Determine port for daemons @@ -65,24 +65,29 @@ def run_create(args: argparse.Namespace) -> int: if behavior == "daemon" and port is None: port = next_available_port(config) - # Package name: convert kebab-case to snake_case package_name = name.replace("-", "_") + description = args.description or (f"A castle {stack} program" if stack else f"{name}") - # Scaffold the project files - scaffold_project( - project_dir=project_dir, - name=name, - package_name=package_name, - stack=stack, - description=args.description or f"A castle {stack} program", - port=port, - ) + if stack: + scaffold_project( + project_dir=project_dir, + name=name, + package_name=package_name, + stack=stack, + description=description, + port=port, + ) + else: + # Bare program: empty source tree, no scaffold; user declares commands later. + project_dir.mkdir(parents=True) + + # Initialize a git repo for the new source. + subprocess.run(["git", "init", "-q", str(project_dir)], check=False) - # Build entries config.programs[name] = ProgramSpec( id=name, - description=args.description or f"A castle {stack} program", - source=f"code/{name}", + description=description, + source=str(project_dir), stack=stack, behavior=behavior, ) @@ -103,16 +108,20 @@ def run_create(args: argparse.Namespace) -> int: save_config(config) - print(f"Created {stack} program '{name}' at {project_dir}") + label = f"{stack} program" if stack else "bare program" + print(f"Created {label} '{name}' at {project_dir}") if port: print(f" Port: {port}") print(" Registered in castle.yaml") print("\nNext steps:") - print(f" cd ~/.castle/code/{name}") - print(" uv sync") - if behavior == "daemon": - print(f" uv run {name} # starts on port {port}") - print(f" castle deploy {name} # deploy to ~/.castle/") - print(f" castle test {name}") + print(f" cd {project_dir}") + if stack: + print(" uv sync") + if behavior == "daemon": + print(f" uv run {name} # starts on port {port}") + print(f" castle deploy {name}") + print(f" castle test {name}") + else: + print(" # add code, then declare commands: in castle.yaml") return 0 diff --git a/cli/src/castle_cli/commands/delete.py b/cli/src/castle_cli/commands/delete.py new file mode 100644 index 0000000..600ca1c --- /dev/null +++ b/cli/src/castle_cli/commands/delete.py @@ -0,0 +1,81 @@ +"""castle delete — remove a program/service/job from the registry. + +Config-only by default (removes the castle.yaml entry; leaves source and any +installed binary in place). Use --source to also delete the source directory. +""" + +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path + +from castle_cli.config import load_config, save_config + + +def run_delete(args: argparse.Namespace) -> int: + config = load_config() + name = args.name + + # Find every section the name appears in. + in_programs = name in config.programs + in_services = name in config.services + in_jobs = name in config.jobs + if not (in_programs or in_services or in_jobs): + print(f"Error: '{name}' not found in castle.yaml") + return 1 + + where = [s for s, present in + (("program", in_programs), ("service", in_services), ("job", in_jobs)) if present] + + # Resolve source dir (from the program entry) for the optional --source removal. + source_dir: Path | None = None + if in_programs and config.programs[name].source: + source_dir = Path(config.programs[name].source) + + print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).") + if args.source and source_dir: + print(f"Will ALSO delete source directory: {source_dir}") + + # Confirm unless --yes. + if not args.yes: + prompt = f"Delete '{name}'? [y/N] " + try: + if input(prompt).strip().lower() not in ("y", "yes"): + print("Aborted.") + return 0 + except EOFError: + print("Aborted (no input). Re-run with --yes to confirm non-interactively.") + return 1 + + # Remove registry entries. + if in_programs: + del config.programs[name] + if in_services: + del config.services[name] + if in_jobs: + del config.jobs[name] + save_config(config) + print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).") + + # Optional: delete the source directory. + if args.source and source_dir: + if source_dir.exists(): + shutil.rmtree(source_dir) + print(f"Deleted source directory: {source_dir}") + else: + print(f"Source directory not found (already gone): {source_dir}") + + # Warn about runtime artifacts we did NOT touch. + if in_services or in_jobs: + print( + f"\nNote: the systemd unit for '{name}' (if deployed) was NOT removed.\n" + f" Run: castle service disable {name}" + ) + if shutil.which(name): + print( + f"\nNote: '{name}' is still installed on PATH. To remove it:\n" + f" castle uninstall {name}" + ) + + return 0 diff --git a/cli/src/castle_cli/commands/dev.py b/cli/src/castle_cli/commands/dev.py index 79e2e41..6e92e8d 100644 --- a/cli/src/castle_cli/commands/dev.py +++ b/cli/src/castle_cli/commands/dev.py @@ -1,19 +1,23 @@ -"""castle build / castle test / castle lint - run dev commands across projects.""" +"""castle build/test/lint/type-check/check/run/install/uninstall — dev verbs. + +Verbs resolve per-program: a declared command (manifest `commands:` / `build:`) +overrides the stack default, falling back to the stack handler, else unavailable. +""" from __future__ import annotations import argparse import asyncio -from castle_core.stacks import get_handler +from castle_core.stacks import is_available, run_action from castle_cli.config import CastleConfig, load_config -def _run_action(config: CastleConfig, project_name: str, action: str) -> bool: - """Run a stack action for a single project. Returns True on success.""" +def _run_verb(config: CastleConfig, project_name: str, verb: str) -> bool: + """Run a verb for a single program. Returns True on success.""" if project_name not in config.programs: - print(f"Unknown component: {project_name}") + print(f"Unknown program: {project_name}") return False comp = config.programs[project_name] @@ -21,106 +25,69 @@ def _run_action(config: CastleConfig, project_name: str, action: str) -> bool: print(f" {project_name}: no source directory, skipping") return True - handler = get_handler(comp.stack) - if handler is None: - print(f" {project_name}: unsupported stack '{comp.stack}', skipping") + if not is_available(comp, verb): + print(f" {project_name}: '{verb}' not available (no declared command or stack), skipping") return True - method_name = action.replace("-", "_") - method = getattr(handler, method_name, None) - if method is None: - print(f" {project_name}: action '{action}' not supported") - return False - print(f"\n{'─' * 40}") - print(f" {action}: {project_name}") + print(f" {verb}: {project_name}") print(f"{'─' * 40}") - result = asyncio.run(method(project_name, comp, config.root)) + result = asyncio.run(run_action(verb, project_name, comp, config.root)) if result.output: print(result.output) - return result.status == "ok" -def run_test(args: argparse.Namespace) -> int: - """Run tests for one or all projects.""" - config = load_config() - - if args.project: - success = _run_action(config, args.project, "test") - return 0 if success else 1 - - # Run all +def _run_verb_all(config: CastleConfig, verb: str) -> bool: + """Run a verb across every program that supports it. Returns True if all pass.""" all_passed = True + ran_any = False for name, comp in config.programs.items(): - if not comp.source: + if not comp.source or not is_available(comp, verb): continue - handler = get_handler(comp.stack) - if handler is None: - continue - # Skip projects without a tests directory (python) or test script (node) - source_dir = config.root / comp.source - if comp.stack in ("python-cli", "python-fastapi"): - if not (source_dir / "tests").exists(): - continue - if not _run_action(config, name, "test"): + ran_any = True + if not _run_verb(config, name, verb): all_passed = False + if not ran_any: + print(f"No programs support '{verb}'.") + return all_passed - if all_passed: - print("\nAll tests passed.") - else: - print("\nSome tests failed.") - return 0 if all_passed else 1 + +def run_verb(args: argparse.Namespace, verb: str) -> int: + """Generic entry point for a dev verb (single project or all).""" + config = load_config() + if getattr(args, "project", None): + return 0 if _run_verb(config, args.project, verb) else 1 + ok = _run_verb_all(config, verb) + print(f"\n{'All ' + verb + ' passed.' if ok else 'Some ' + verb + ' failed.'}") + return 0 if ok else 1 + + +# Thin named wrappers wired from main.py. +def run_test(args: argparse.Namespace) -> int: + return run_verb(args, "test") def run_lint(args: argparse.Namespace) -> int: - """Run linter for one or all projects.""" - config = load_config() - - if args.project: - success = _run_action(config, args.project, "lint") - return 0 if success else 1 - - # Run all - all_passed = True - for name, comp in config.programs.items(): - if not comp.source: - continue - handler = get_handler(comp.stack) - if handler is None: - continue - if not _run_action(config, name, "lint"): - all_passed = False - - if all_passed: - print("\nAll lint checks passed.") - else: - print("\nSome lint checks failed.") - return 0 if all_passed else 1 + return run_verb(args, "lint") def run_build(args: argparse.Namespace) -> int: - """Run build for one or all projects.""" - config = load_config() + return run_verb(args, "build") - if args.project: - success = _run_action(config, args.project, "build") - return 0 if success else 1 - # Run all buildable projects - all_passed = True - for name, comp in config.programs.items(): - if not comp.source: - continue - handler = get_handler(comp.stack) - if handler is None: - continue - if not _run_action(config, name, "build"): - all_passed = False +def run_type_check(args: argparse.Namespace) -> int: + return run_verb(args, "type-check") - if all_passed: - print("\nAll builds succeeded.") - else: - print("\nSome builds failed.") - return 0 if all_passed else 1 + +def run_check(args: argparse.Namespace) -> int: + return run_verb(args, "check") + + +def run_install(args: argparse.Namespace) -> int: + return run_verb(args, "install") + + +def run_uninstall(args: argparse.Namespace) -> int: + return run_verb(args, "uninstall") diff --git a/cli/src/castle_cli/commands/run_cmd.py b/cli/src/castle_cli/commands/run_cmd.py index 0ca1279..ce70513 100644 --- a/cli/src/castle_cli/commands/run_cmd.py +++ b/cli/src/castle_cli/commands/run_cmd.py @@ -1,39 +1,72 @@ -"""castle run - run a component in the foreground.""" +"""castle run - run a program or service in the foreground. + +Unified: if the target is a program with a declared `run` command, run that in +the foreground (dev-run). Otherwise fall back to the deployed-service run from +the registry. +""" from __future__ import annotations import argparse import os import subprocess +from pathlib import Path from castle_core.registry import REGISTRY_PATH, load_registry +from castle_cli.config import load_config + + +def _run_program(name: str, extra: list[str]) -> int | None: + """Run a program's declared `run` command in the foreground. + + Returns the exit code, or None if the program has no declared run command + (so the caller can fall back to the deployed-service path). + """ + config = load_config() + prog = config.programs.get(name) + if prog is None or prog.commands is None or prog.commands.run is None: + return None + if not prog.source: + print(f"Error: program '{name}' has no source directory.") + return 1 + cwd = Path(prog.source) + cmds = prog.commands.run + rc = 0 + for i, argv in enumerate(cmds): + # Append extra args to the final command in the sequence. + full = list(argv) + (extra if i == len(cmds) - 1 else []) + print(f"Running {name}: {' '.join(full)}") + rc = subprocess.run(full, cwd=cwd).returncode + if rc != 0: + break + return rc + def run_run(args: argparse.Namespace) -> int: - """Run a component in the foreground using the registry.""" + """Run a program (declared run) or a deployed service in the foreground.""" + name = args.name + extra_args = getattr(args, "extra", []) or [] + + # 1. Program with a declared run command. + prog_rc = _run_program(name, extra_args) + if prog_rc is not None: + return prog_rc + + # 2. Deployed service from the registry. if not REGISTRY_PATH.exists(): print("Error: no registry found. Run 'castle deploy' first.") return 1 registry = load_registry() - name = args.name - if name not in registry.deployed: - print(f"Error: component '{name}' not found in registry.") - print("Run 'castle deploy' to update the registry.") + print(f"Error: '{name}' is not a runnable program or deployed service.") + print("Declare a `run` command in castle.yaml, or run 'castle deploy'.") return 1 deployed = registry.deployed[name] - - # Build command with any extra args - extra_args = getattr(args, "extra", []) or [] cmd = list(deployed.run_cmd) + extra_args - - # Merge environment env = dict(os.environ) env.update(deployed.env) - - # Run in foreground (no cwd — registry-based, no repo dependency) print(f"Running {name}: {' '.join(cmd)}") - result = subprocess.run(cmd, env=env) - return result.returncode + return subprocess.run(cmd, env=env).returncode diff --git a/cli/src/castle_cli/commands/tool.py b/cli/src/castle_cli/commands/tool.py deleted file mode 100644 index 05d785e..0000000 --- a/cli/src/castle_cli/commands/tool.py +++ /dev/null @@ -1,75 +0,0 @@ -"""castle tool - manage tools.""" - -from __future__ import annotations - -import argparse - -from castle_cli.config import load_config - -BOLD = "\033[1m" -RESET = "\033[0m" -DIM = "\033[2m" -CYAN = "\033[96m" - - -def run_tool(args: argparse.Namespace) -> int: - """Manage tools.""" - if not args.tool_command: - print("Usage: castle tool {list|info}") - return 1 - - if args.tool_command == "list": - return _tool_list() - elif args.tool_command == "info": - return _tool_info(args.name) - - return 1 - - -def _tool_list() -> int: - """List all registered tools.""" - config = load_config() - tools = {k: v for k, v in config.programs.items() if v.behavior == "tool"} - - if not tools: - print("No tools registered.") - return 0 - - print(f"\n{BOLD}{CYAN}Tools{RESET}") - print(f"{CYAN}{'─' * 40}{RESET}") - for name, manifest in sorted(tools.items()): - desc = manifest.description or "" - deps = "" - if manifest.system_dependencies: - deps = f" {DIM}[{', '.join(manifest.system_dependencies)}]{RESET}" - print(f" {BOLD}{name:<20}{RESET} {desc}{deps}") - - print() - return 0 - - -def _tool_info(name: str) -> int: - """Show detailed info about a tool, including .md documentation.""" - config = load_config() - if name not in config.programs: - print(f"Error: '{name}' not found") - return 1 - - manifest = config.programs[name] - if manifest.behavior != "tool": - print(f"Error: '{name}' is not a tool") - return 1 - - print(f"\n{BOLD}{name}{RESET}") - print(f"{'─' * 40}") - if manifest.description: - print(f" {manifest.description}") - if manifest.version: - print(f" {BOLD}version{RESET}: {manifest.version}") - if manifest.source: - print(f" {BOLD}source{RESET}: {manifest.source}") - if manifest.system_dependencies: - print(f" {BOLD}requires{RESET}: {', '.join(manifest.system_dependencies)}") - - print() - return 0 diff --git a/cli/src/castle_cli/config.py b/cli/src/castle_cli/config.py index 8247d70..c1da83c 100644 --- a/cli/src/castle_cli/config.py +++ b/cli/src/castle_cli/config.py @@ -2,14 +2,15 @@ from castle_core.config import * # noqa: F401, F403 from castle_core.config import ( # noqa: F401 — explicit re-exports for type checkers + ARTIFACTS_DIR, CASTLE_HOME, CODE_DIR, - ARTIFACTS_DIR, - SPECS_DIR, CONTENT_DIR, DATA_DIR, - SECRETS_DIR, GENERATED_DIR, + REPOS_DIR, + SECRETS_DIR, + SPECS_DIR, STATIC_DIR, CastleConfig, GatewayConfig, diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index d77d766..0fd1c99 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -37,11 +37,30 @@ def build_parser() -> argparse.ArgumentParser: create_parser.add_argument( "--stack", choices=["python-cli", "python-fastapi", "react-vite"], - required=True, - help="Development stack (determines scaffold template and default behavior)", + default=None, + help="Development stack (scaffold template + default behavior). Omit for a bare program.", ) create_parser.add_argument("--description", default="", help="Project description") create_parser.add_argument("--port", type=int, help="Port number (daemons only)") + + # castle add — adopt an existing repo as a program + add_parser = subparsers.add_parser("add", help="Adopt an existing repo (path or git URL)") + add_parser.add_argument("target", help="Local path to an existing repo, or a git URL to clone") + add_parser.add_argument("--name", help="Program name (default: directory/repo name)") + add_parser.add_argument("--description", default="", help="Program description") + + # castle clone — clone repos for programs that declare repo: + clone_parser = subparsers.add_parser("clone", help="Clone source for programs with repo:") + clone_parser.add_argument("name", nargs="?", help="Program to clone (default: all with repo:)") + + # castle delete — remove a program/service/job from the registry + delete_parser = subparsers.add_parser("delete", help="Remove a program/service/job") + delete_parser.add_argument("name", help="Program, service, or job name") + delete_parser.add_argument( + "--source", action="store_true", help="Also delete the source directory" + ) + delete_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") + # castle info info_parser = subparsers.add_parser("info", help="Show program details") info_parser.add_argument("project", help="Program, service, or job name") @@ -59,6 +78,20 @@ def build_parser() -> argparse.ArgumentParser: build_parser = subparsers.add_parser("build", help="Build projects") build_parser.add_argument("project", nargs="?", help="Project to build (default: all)") + # castle type-check + tc_parser = subparsers.add_parser("type-check", help="Run type checker") + tc_parser.add_argument("project", nargs="?", help="Project to type-check (default: all)") + + # castle check (composite: lint + type-check + test) + check_parser = subparsers.add_parser("check", help="Run lint + type-check + test") + check_parser.add_argument("project", nargs="?", help="Project to check (default: all)") + + # castle install / uninstall + install_parser = subparsers.add_parser("install", help="Install a program to PATH") + install_parser.add_argument("project", nargs="?", help="Program to install (default: all)") + uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall a program") + uninstall_parser.add_argument("project", nargs="?", help="Program to uninstall (default: all)") + # castle gateway gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway") gateway_sub = gateway_parser.add_subparsers(dest="gateway_command") @@ -99,24 +132,17 @@ def build_parser() -> argparse.ArgumentParser: "-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)" ) - # castle run - run_parser = subparsers.add_parser("run", help="Run a service in the foreground") - run_parser.add_argument("name", help="Service name") + # castle run — run a program (declared run) or deployed service in the foreground + run_parser = subparsers.add_parser("run", help="Run a program or service in the foreground") + run_parser.add_argument("name", help="Program or service name") run_parser.add_argument( - "extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the service" + "extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the target" ) # castle deploy deploy_parser = subparsers.add_parser("deploy", help="Deploy to ~/.castle/ (spec → runtime)") deploy_parser.add_argument("name", nargs="?", help="Service or job to deploy (default: all)") - # castle tool - tool_parser = subparsers.add_parser("tool", help="Manage tools") - tool_sub = tool_parser.add_subparsers(dest="tool_command") - tool_sub.add_parser("list", help="List all tools") - tool_info_parser = tool_sub.add_parser("info", help="Show tool details") - tool_info_parser.add_argument("name", help="Tool name") - return parser @@ -145,6 +171,21 @@ def main() -> int: return run_create(args) + elif args.command == "add": + from castle_cli.commands.add import run_add + + return run_add(args) + + elif args.command == "clone": + from castle_cli.commands.clone import run_clone + + return run_clone(args) + + elif args.command == "delete": + from castle_cli.commands.delete import run_delete + + return run_delete(args) + elif args.command == "test": from castle_cli.commands.dev import run_test @@ -160,6 +201,26 @@ def main() -> int: return run_build(args) + elif args.command == "type-check": + from castle_cli.commands.dev import run_type_check + + return run_type_check(args) + + elif args.command == "check": + from castle_cli.commands.dev import run_check + + return run_check(args) + + elif args.command == "install": + from castle_cli.commands.dev import run_install + + return run_install(args) + + elif args.command == "uninstall": + from castle_cli.commands.dev import run_uninstall + + return run_uninstall(args) + elif args.command == "gateway": from castle_cli.commands.gateway import run_gateway @@ -190,11 +251,6 @@ def main() -> int: return run_run(args) - elif args.command == "tool": - from castle_cli.commands.tool import run_tool - - return run_tool(args) - else: parser.print_help() return 1 diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index ad304a7..1342550 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ BuildSpec, CaddySpec, Capability, - ProgramSpec, + CommandsSpec, DefaultsSpec, EnvMap, ExposeSpec, @@ -14,6 +14,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ HttpPublic, JobSpec, ManageSpec, + ProgramSpec, ProxySpec, ReadinessHttpGet, RestartPolicy, diff --git a/cli/tests/test_add.py b/cli/tests/test_add.py new file mode 100644 index 0000000..6db84a1 --- /dev/null +++ b/cli/tests/test_add.py @@ -0,0 +1,72 @@ +"""Tests for castle add — adopting existing repos as programs.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +from castle_cli.config import load_config + + +def _run_add(castle_root: Path, **kwargs: object) -> object: + with ( + patch("castle_cli.commands.add.load_config") as mock_load, + patch("castle_cli.commands.add.save_config"), + ): + config = load_config(castle_root) + mock_load.return_value = config + from castle_cli.commands.add import run_add + + args = Namespace(name=None, description="", **kwargs) + rc = run_add(args) + return rc, config + + +class TestAdd: + def test_adopt_python_path_assigns_stack(self, castle_root: Path, tmp_path: Path) -> None: + repo = tmp_path / "mytool" + repo.mkdir() + (repo / "pyproject.toml").write_text('[project]\nname = "mytool"\ndependencies = []\n') + rc, config = _run_add(castle_root, target=str(repo)) + assert rc == 0 + prog = config.programs["mytool"] + assert prog.stack == "python-cli" # detected + assert prog.source == str(repo.resolve()) + + def test_adopt_fastapi_detects_daemon(self, castle_root: Path, tmp_path: Path) -> None: + repo = tmp_path / "svc" + repo.mkdir() + (repo / "pyproject.toml").write_text( + '[project]\nname = "svc"\ndependencies = ["fastapi>=0.1"]\n' + ) + rc, config = _run_add(castle_root, target=str(repo)) + assert rc == 0 + assert config.programs["svc"].stack == "python-fastapi" + assert config.programs["svc"].behavior == "daemon" + + def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None: + repo = tmp_path / "rusty" + repo.mkdir() + (repo / "Cargo.toml").write_text("[package]\nname = \"rusty\"\n") + rc, config = _run_add(castle_root, target=str(repo)) + assert rc == 0 + prog = config.programs["rusty"] + assert prog.stack is None # no castle stack for rust + # build lands in BuildSpec; other verbs in CommandsSpec + assert prog.build is not None + assert prog.build.commands == [["cargo", "build", "--release"]] + assert prog.commands is not None + assert prog.commands.run == [["cargo", "run"]] + + def test_adopt_git_url_records_repo(self, castle_root: Path) -> None: + rc, config = _run_add( + castle_root, target="https://github.com/someone/widget.git" + ) + assert rc == 0 + prog = config.programs["widget"] + assert prog.repo == "https://github.com/someone/widget.git" + + def test_missing_path_fails(self, castle_root: Path, tmp_path: Path) -> None: + rc, _ = _run_add(castle_root, target=str(tmp_path / "nope")) + assert rc == 1 diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index 74e6e29..729b0ad 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -12,11 +12,13 @@ from castle_cli.config import load_config class TestCreateCommand: """Tests for the create command.""" - def test_create_service(self, castle_root: Path) -> None: + def test_create_service(self, castle_root: Path, tmp_path: Path) -> None: """Create a new service project.""" + repos = tmp_path / "repos" with ( patch("castle_cli.commands.create.load_config") as mock_load, patch("castle_cli.commands.create.save_config") as mock_save, + patch("castle_cli.commands.create.REPOS_DIR", repos), ): config = load_config(castle_root) mock_load.return_value = config @@ -32,7 +34,7 @@ class TestCreateCommand: result = run_create(args) assert result == 0 - project_dir = castle_root / "code" / "my-api" + project_dir = repos / "my-api" assert project_dir.exists() assert (project_dir / "pyproject.toml").exists() assert (project_dir / "src" / "my_api" / "main.py").exists() @@ -49,11 +51,13 @@ class TestCreateCommand: assert svc.component == "my-api" mock_save.assert_called_once() - def test_create_tool(self, castle_root: Path) -> None: + def test_create_tool(self, castle_root: Path, tmp_path: Path) -> None: """Create a new tool project.""" + 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 @@ -64,7 +68,7 @@ class TestCreateCommand: result = run_create(args) assert result == 0 - project_dir = castle_root / "code" / "my-tool2" + project_dir = repos / "my-tool2" assert project_dir.exists() assert (project_dir / "src" / "my_tool2" / "main.py").exists() assert (project_dir / "CLAUDE.md").exists() @@ -91,11 +95,12 @@ class TestCreateCommand: assert result == 1 - def test_create_auto_port(self, castle_root: Path) -> None: + def test_create_auto_port(self, castle_root: Path, tmp_path: Path) -> None: """Service creation auto-assigns next available port.""" 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", tmp_path / "repos"), ): config = load_config(castle_root) mock_load.return_value = config diff --git a/cli/tests/test_delete.py b/cli/tests/test_delete.py new file mode 100644 index 0000000..06bbe82 --- /dev/null +++ b/cli/tests/test_delete.py @@ -0,0 +1,67 @@ +"""Tests for castle delete.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +from castle_cli.config import load_config + + +def _run_delete(castle_root: Path, **kwargs: object) -> object: + with ( + patch("castle_cli.commands.delete.load_config") as mock_load, + patch("castle_cli.commands.delete.save_config"), + ): + config = load_config(castle_root) + mock_load.return_value = config + from castle_cli.commands.delete import run_delete + + args = Namespace(source=False, yes=True, **kwargs) + rc = run_delete(args) + return rc, config + + +class TestDelete: + def test_delete_program(self, castle_root: Path) -> None: + rc, config = _run_delete(castle_root, name="test-tool") + assert rc == 0 + assert "test-tool" not in config.programs + + def test_delete_unknown_fails(self, castle_root: Path) -> None: + rc, _ = _run_delete(castle_root, name="does-not-exist") + assert rc == 1 + + def test_delete_source_removes_dir(self, castle_root: Path, tmp_path: Path) -> None: + # Point a program's source at a real temp dir, then delete with --source. + src = tmp_path / "victim" + src.mkdir() + (src / "file.txt").write_text("x") + with ( + patch("castle_cli.commands.delete.load_config") as mock_load, + patch("castle_cli.commands.delete.save_config"), + ): + config = load_config(castle_root) + config.programs["test-tool"].source = str(src) + mock_load.return_value = config + from castle_cli.commands.delete import run_delete + + rc = run_delete(Namespace(name="test-tool", source=True, yes=True)) + assert rc == 0 + assert not src.exists() + + def test_abort_without_yes_and_no_input(self, castle_root: Path) -> None: + # No --yes and no stdin → aborts safely (returns 1, leaves entry). + with ( + patch("castle_cli.commands.delete.load_config") as mock_load, + patch("castle_cli.commands.delete.save_config"), + patch("builtins.input", side_effect=EOFError), + ): + config = load_config(castle_root) + mock_load.return_value = config + from castle_cli.commands.delete import run_delete + + rc = run_delete(Namespace(name="test-tool", source=False, yes=False)) + assert rc == 1 + assert "test-tool" in config.programs diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 43cce5c..ceddf8d 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -53,6 +53,19 @@ def _resolve_data_dir() -> Path: return Path("/data/castle") +def _resolve_repos_dir() -> Path: + """Resolve where program source repos live by default. + + `castle create` scaffolds and `castle add` adopts repos under here. Programs + may also live anywhere (source: is an absolute path); this is just the default + home for new ones. Override with CASTLE_REPOS_DIR. Defaults to /data/repos. + """ + override = os.environ.get("CASTLE_REPOS_DIR") + if override: + return Path(override).expanduser().resolve() + return Path("/data/repos") + + CASTLE_HOME = _resolve_castle_home() CODE_DIR = CASTLE_HOME / "code" ARTIFACTS_DIR = CASTLE_HOME / "artifacts" @@ -60,6 +73,7 @@ SPECS_DIR = ARTIFACTS_DIR / "specs" CONTENT_DIR = ARTIFACTS_DIR / "content" DATA_DIR = _resolve_data_dir() SECRETS_DIR = CASTLE_HOME / "secrets" +REPOS_DIR = _resolve_repos_dir() # Backwards-compat aliases (used by existing imports) GENERATED_DIR = SPECS_DIR diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index f542c81..0ce2002 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -5,7 +5,7 @@ from __future__ import annotations from enum import Enum from typing import Annotated, Literal, Union -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator EnvMap = dict[str, str] @@ -149,6 +149,35 @@ class BuildSpec(BaseModel): outputs: list[str] = Field(default_factory=list) +# --------------------- +# Commands spec — per-program dev verb overrides +# --------------------- + + +class CommandsSpec(BaseModel): + """Per-program dev verb commands. Each verb is a list of argv lists run in + sequence. A declared verb overrides the stack default; an absent verb falls + back to the program's stack handler (if any), else the verb is unavailable. + + This generalizes BuildSpec.commands to the rest of the verb contract, which + is what lets a wired-in repo with no `stack` declare how it is linted/tested/run. + """ + + model_config = ConfigDict(populate_by_name=True) + + lint: list[list[str]] | None = None + test: list[list[str]] | None = None + type_check: list[list[str]] | None = Field(default=None, alias="type-check") + check: list[list[str]] | None = None + run: list[list[str]] | None = None + install: list[list[str]] | None = None + uninstall: list[list[str]] | None = None + + def for_verb(self, verb: str) -> list[list[str]] | None: + """Return the declared commands for a verb name (accepts 'type-check').""" + return getattr(self, verb.replace("-", "_"), None) + + # --------------------- # Capabilities # --------------------- @@ -184,6 +213,14 @@ class ProgramSpec(BaseModel): source: str | None = None stack: str | None = None + # Wiring in existing repos: clone from `repo` (git URL) at optional `ref`; + # `source` (when set) is the local working copy and takes precedence. + repo: str | None = None + ref: str | None = None + + # Per-program dev verb overrides (declared verbs override the stack default). + commands: CommandsSpec | None = None + system_dependencies: list[str] = Field(default_factory=list) install_extras: list[str] = Field(default_factory=list) version: str | None = None diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 2420f32..9198374 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -12,10 +12,15 @@ from pathlib import Path from castle_core.config import CONTENT_DIR from castle_core.manifest import ProgramSpec -DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"] +DEV_ACTIONS = ["build", "test", "lint", "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", "type-check", "check", "install", "uninstall"} +# Verbs whose handler method name differs from the verb spelling. +_VERB_METHOD = {"type-check": "type_check"} + # User-local tool directories that may not be on the systemd service PATH. _EXTRA_PATH_DIRS = [ Path.home() / ".local" / "share" / "pnpm", @@ -253,11 +258,97 @@ def get_handler(stack: str | None) -> StackHandler | None: return HANDLERS.get(stack) +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 can run this verb.""" + return bool(comp.source) and verb in _STACK_VERBS and get_handler(comp.stack) is not None + + +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 actions available for a program.""" + """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(component=name, action=verb, status="error", output="".join(outputs)) + return ActionResult(component=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(...).(...) 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( + component=name, action="check", status="error", + output="No checkable verbs available.", + ) + for sub in subs: + result = await run_action(sub, name, comp, root) + if result.status != "ok": + return ActionResult( + component=name, action="check", status="error", + output=f"{sub} failed:\n{result.output}", + ) + return ActionResult(component=name, action="check", status="ok") + + # 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(component=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 None: - return [] - return list(ALL_ACTIONS) + 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( + component=name, action=verb, status="error", + output=f"Verb '{verb}' is not available for '{name}' " + f"(no declared command and no stack handler provides it).", + ) diff --git a/core/tests/test_resolution.py b/core/tests/test_resolution.py new file mode 100644 index 0000000..1c7f4ae --- /dev/null +++ b/core/tests/test_resolution.py @@ -0,0 +1,57 @@ +"""Tests for per-program verb resolution (declared commands → stack → none).""" + +from __future__ import annotations + +from castle_core.manifest import ProgramSpec +from castle_core.stacks import _declared_commands, available_actions, is_available + + +class TestResolution: + def test_stack_only_program_unchanged(self) -> None: + """A program with a stack and no commands resolves all stack verbs.""" + p = ProgramSpec.model_validate({"source": "/tmp/x", "stack": "python-cli"}) + actions = available_actions(p) + assert "build" in actions and "test" in actions and "lint" in actions + assert "install" in actions and "uninstall" in actions + # `run` is declared-only — a bare stack program does not expose it. + assert "run" not in actions + + def test_wired_in_program_no_stack(self) -> None: + """A program with no stack but declared commands resolves those verbs.""" + p = ProgramSpec.model_validate( + { + "source": "/tmp/y", + "commands": {"lint": [["make", "lint"]], "test": [["make", "test"]], "run": [["./bin/y"]]}, + } + ) + actions = available_actions(p) + assert set(actions) >= {"lint", "test", "run"} + assert "build" not in actions # not declared, no stack + assert _declared_commands(p, "lint") == [["make", "lint"]] + + def test_check_available_when_subverbs_are(self) -> None: + """`check` is a composite; available when any of lint/type-check/test is.""" + p = ProgramSpec.model_validate( + {"source": "/tmp/y", "commands": {"lint": [["ruff", "check", "."]]}} + ) + assert is_available(p, "check") + + def test_hybrid_override_one_verb(self) -> None: + """A stack program can override a single verb; the rest fall back to stack.""" + p = ProgramSpec.model_validate( + {"source": "/tmp/z", "stack": "python-cli", "commands": {"test": [["pytest", "-x"]]}} + ) + assert _declared_commands(p, "test") == [["pytest", "-x"]] + assert _declared_commands(p, "build") is None # build still comes from the stack + + def test_build_declared_via_buildspec(self) -> None: + """`build` is declared through BuildSpec.commands, not CommandsSpec.""" + p = ProgramSpec.model_validate( + {"source": "/tmp/w", "build": {"commands": [["make"]], "outputs": ["dist/"]}} + ) + assert _declared_commands(p, "build") == [["make"]] + assert "build" in available_actions(p) + + def test_no_source_no_actions(self) -> None: + p = ProgramSpec.model_validate({"description": "a library"}) + assert available_actions(p) == [] diff --git a/docs/component-registry.md b/docs/component-registry.md index b25ad1d..1e86c94 100644 --- a/docs/component-registry.md +++ b/docs/component-registry.md @@ -96,13 +96,43 @@ you create live under **`$CASTLE_HOME/code/`** and are recorded as the git repo and use the `repo:` prefix. When `castle deploy` writes `castle.yaml` back out, it rewrites absolute paths into these relative forms. -### `stack` — Development toolchain +### `stack` — Development toolchain (optional) ```yaml -stack: python-fastapi # or: python-cli, react-vite +stack: python-fastapi # or: python-cli, react-vite — OPTIONAL ``` -Stacks define how programs get built, checked, and installed. +A stack provides **default** dev-verb commands (build/test/lint/type-check/…) +and a scaffold template for new code. It is **optional**: a program with no +stack works fine as long as it declares its own `commands:`. Stacks are a +creation-time convenience, not a runtime requirement. + +### `commands` — Per-program dev verbs + +```yaml +commands: + lint: [["ruff", "check", "."]] + test: [["pytest", "tests/"]] + run: [["./bin/my-tool", "--serve"]] +``` + +Each verb is a list of argv lists (run in sequence). A declared verb **overrides** +the stack default; an absent verb falls back to the stack handler (if any), else +the verb is unavailable. `build` is declared via `build:` (it also carries +`outputs:`); every other verb via `commands:`. This is what lets a wired-in repo +with no stack be linted/tested/run. Verb resolution lives in +`core/src/castle_core/stacks.py` (`run_action`, `available_actions`). + +### `repo` / `ref` — Wiring in an existing repo + +```yaml +repo: https://github.com/me/widget.git +ref: v2.1.0 # optional branch/tag/commit +``` + +`repo` records a git URL so `castle clone` can provision the source on a fresh +machine. When `source:` points at an existing working copy, that takes +precedence. Use `castle add ` to register an existing repo as a program. ### `system_dependencies` — Required system packages @@ -111,7 +141,7 @@ system_dependencies: [pandoc, poppler-utils] ``` System packages that must be installed for the program to work. Displayed -in `castle tool list` and the dashboard. +in `castle list --behavior tool` and the dashboard. ### `version` — Program version diff --git a/docs/stacks/python-cli.md b/docs/stacks/python-cli.md index 98ffae5..ee20b0e 100644 --- a/docs/stacks/python-cli.md +++ b/docs/stacks/python-cli.md @@ -1,5 +1,12 @@ # Python Tools in Castle +> **This is a stack — creation-time guidance for writing _new_ CLI tools.** +> A stack is a template + conventions, not a runtime requirement. `castle create +> --stack python-cli` scaffolds from it and seeds the program's default dev-verb +> commands. An existing CLI adopted with `castle add` doesn't need this stack — it +> declares its own `commands:`. See @docs/component-registry.md for `commands:`, +> `stack:` (optional), and `repo:`. + How to build CLI tools following Unix philosophy. ## Principles diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md index 15999d8..d862f0a 100644 --- a/docs/stacks/python-fastapi.md +++ b/docs/stacks/python-fastapi.md @@ -1,5 +1,12 @@ # Web APIs in Castle +> **This is a stack — creation-time guidance for writing _new_ FastAPI services.** +> A stack is a template + conventions, not a runtime requirement. `castle create +> --stack python-fastapi` scaffolds from it and seeds the program's default +> dev-verb commands. An existing service adopted with `castle add` doesn't need +> this stack — it declares its own `commands:`. See @docs/component-registry.md +> for `commands:`, `stack:` (optional), and `repo:`. + How to build Python web APIs as castle service components. Based on the patterns used in [wild-cloud/api](https://github.com/civilsociety-dev/wild-cloud) and existing castle services (central-context, notification-bridge, event-bus). diff --git a/docs/stacks/react-vite.md b/docs/stacks/react-vite.md index bd35be4..b72cafd 100644 --- a/docs/stacks/react-vite.md +++ b/docs/stacks/react-vite.md @@ -1,5 +1,12 @@ # Web Frontends in Castle +> **This is a stack — creation-time guidance for writing _new_ frontends.** +> A stack is a template + conventions, not a runtime requirement. `castle create +> --stack react-vite` scaffolds from it and seeds the program's default dev-verb +> commands. An existing frontend adopted with `castle add` doesn't need this +> stack — it declares its own `commands:`. See @docs/component-registry.md for +> `commands:`, `stack:` (optional), and `repo:`. + How to build, serve, and manage web frontends as castle components. ## Stack