Less stack-centric and location-centric model.

This commit is contained in:
2026-06-13 17:26:49 -07:00
parent 7bf98c17b7
commit 400e0b253b
33 changed files with 1112 additions and 408 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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).",
)