Less stack-centric and location-centric model.
This commit is contained in:
@@ -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).",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user