Less stack-centric and location-centric model.
This commit is contained in:
58
CLAUDE.md
58
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/<name>`.
|
||||
`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 <name> # Show details (--json for machine-readable)
|
||||
castle create <name> --stack python-fastapi # Scaffold new project
|
||||
castle create <name> [--stack ...] # Scaffold new project (--stack optional → bare program)
|
||||
castle add <path|git-url> [--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 <name> # 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 <name> # Run a program (declared run) or service in foreground
|
||||
castle logs <name> [-f] [-n 50] # View service/job logs
|
||||
castle tool list # List all tools
|
||||
castle tool info <name> # Show tool details
|
||||
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
|
||||
castle service enable|disable <name> # 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,
|
||||
|
||||
18
README.md
18
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:
|
||||
|
||||
@@ -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<ActionOutput | null>(null)
|
||||
|
||||
if (isLoading) {
|
||||
@@ -51,53 +50,81 @@ export function ProgramDetailPage() {
|
||||
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p>
|
||||
)}
|
||||
|
||||
{isTool && (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
|
||||
Tool Info
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--muted)] mb-4">
|
||||
How this tool is packaged and what it depends on.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
|
||||
{component.source && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Source</span>
|
||||
<span className="font-mono">{component.source}</span>
|
||||
</>
|
||||
)}
|
||||
{component.version && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Version</span>
|
||||
<span>{component.version}</span>
|
||||
</>
|
||||
)}
|
||||
{component.runner && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Runner</span>
|
||||
<span>{runnerLabel(component.runner)}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-[var(--muted)]">Installed</span>
|
||||
<span>{component.installed ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
{component.system_dependencies.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{component.system_dependencies.map((dep) => (
|
||||
<span
|
||||
key={dep}
|
||||
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
|
||||
>
|
||||
{dep}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
|
||||
Program Info
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--muted)] mb-4">
|
||||
Where the source lives and how castle works with it.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
|
||||
{component.source && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Source</span>
|
||||
<span className="font-mono break-all">{component.source}</span>
|
||||
</>
|
||||
)}
|
||||
{component.repo && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Repo</span>
|
||||
<span className="font-mono break-all">
|
||||
{component.repo}
|
||||
{component.ref ? ` @ ${component.ref}` : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{component.version && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Version</span>
|
||||
<span>{component.version}</span>
|
||||
</>
|
||||
)}
|
||||
{component.runner && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Runner</span>
|
||||
<span>{runnerLabel(component.runner)}</span>
|
||||
</>
|
||||
)}
|
||||
{component.installed !== null && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Installed</span>
|
||||
<span>{component.installed ? "Yes" : "No"}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{component.commands && Object.keys(component.commands).length > 0 && (
|
||||
<div className="mb-4">
|
||||
<span className="text-sm text-[var(--muted)] block mb-1">Commands</span>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(component.commands).map(([verb, cmds]) => (
|
||||
<div key={verb} className="flex gap-2 text-xs">
|
||||
<span className="text-[var(--muted)] w-20 shrink-0">{verb}</span>
|
||||
<span className="font-mono break-all">
|
||||
{cmds.map((argv) => argv.join(" ")).join(" && ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{component.system_dependencies.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{component.system_dependencies.map((dep) => (
|
||||
<span
|
||||
key={dep}
|
||||
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
|
||||
>
|
||||
{dep}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfigPanel component={component} configSection="programs" onRefetch={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,6 @@ export function ScheduledDetailPage() {
|
||||
backTo="/"
|
||||
backLabel="Back to Jobs"
|
||||
name={component.id}
|
||||
behavior="tool"
|
||||
stack={component.stack}
|
||||
source={component.source}
|
||||
>
|
||||
|
||||
@@ -46,6 +46,9 @@ export interface ProgramSummary {
|
||||
runner: string | null
|
||||
version: string | null
|
||||
source: string | null
|
||||
repo: string | null
|
||||
ref: string | null
|
||||
commands: Record<string, string[][]> | 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<string, string[][]> | null
|
||||
system_dependencies: string[]
|
||||
schedule: string | null
|
||||
installed: boolean | null
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
32
castle-api/tests/test_programs_commands.py
Normal file
32
castle-api/tests/test_programs_commands.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
134
cli/src/castle_cli/commands/add.py
Normal file
134
cli/src/castle_cli/commands/add.py
Normal file
@@ -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
|
||||
62
cli/src/castle_cli/commands/clone.py
Normal file
62
cli/src/castle_cli/commands/clone.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
81
cli/src/castle_cli/commands/delete.py
Normal file
81
cli/src/castle_cli/commands/delete.py
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
72
cli/tests/test_add.py
Normal file
72
cli/tests/test_add.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
67
cli/tests/test_delete.py
Normal file
67
cli/tests/test_delete.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(...).<method>(...) directly so the override logic stays in one place.
|
||||
"""
|
||||
# `check` is a composite that must respect per-verb overrides — unless the
|
||||
# program declares its own `check`, run each available sub-verb via run_action.
|
||||
if verb == "check" and _declared_commands(comp, "check") is None:
|
||||
subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)]
|
||||
if not subs:
|
||||
return ActionResult(
|
||||
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).",
|
||||
)
|
||||
|
||||
57
core/tests/test_resolution.py
Normal file
57
core/tests/test_resolution.py
Normal file
@@ -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) == []
|
||||
@@ -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 <path|url>` 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user