refactor(cli): resource-first command surface

Names collide across resource types (a program and a service can share a name),
so the CLI no longer resolves bare names. Operations live under the resource
they act on; platform lifecycle and the cross-resource overview stay top-level.

  castle program  list|info|create|add|clone|delete|run|install|uninstall|
                  build|test|lint|format|type-check|check
  castle service  list|info|create|delete|deploy|enable|disable|start|stop|restart|logs
  castle job      <same verbs>   (create takes --schedule)
  castle gateway  start|stop|reload|status
  castle list | status | deploy [name]        # cross-cutting
  castle start | stop | restart               # whole platform (systemd verbs)

Key changes:
- info/delete/list/run are resource-scoped (no more cross-section name match).
- delete only removes the named resource's entry; program delete still blocks
  when a deployment references it.
- per-resource start/stop/restart (systemctl on the unit/timer); top-level
  start/stop/restart act on the whole platform.
- expose → 'service create' (more general: --program ref, --runner); gained
  'job create' (the CLI never had one).
- dropped up/down — bringing things online is the honest 'deploy && start'.

Docs (CLAUDE.md, registry.md, stack guides) and user-facing messages updated.
core 94 / cli 24 / api 52 green; ruff clean. Verified live: program/service/job
list+info scope correctly, service create+scoped-delete round-trips.
This commit is contained in:
2026-06-14 16:25:34 -07:00
parent 82f12c9d61
commit 5c6d8ec6f1
16 changed files with 556 additions and 511 deletions

View File

@@ -16,13 +16,15 @@ from castle_cli.config import load_config, save_config
def run_delete(args: argparse.Namespace) -> int:
config = load_config()
name = args.name
resource = getattr(args, "resource", None) # "program" | "service" | "job"
# 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
# Resolve which sections this delete touches (scoped to one resource).
in_programs = name in config.programs and resource in (None, "program")
in_services = name in config.services and resource in (None, "service")
in_jobs = name in config.jobs and resource in (None, "job")
if not (in_programs or in_services or in_jobs):
print(f"Error: '{name}' not found in castle.yaml")
where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in castle.yaml")
return 1
where = [s for s, present in
@@ -32,8 +34,14 @@ def run_delete(args: argparse.Namespace) -> int:
# named the same are removed in this call; any other referencing deployment
# would be left dangling, so refuse.
if in_programs:
dangling = [s for s, spec in config.services.items() if spec.program == name and s != name]
dangling += [j for j, spec in config.jobs.items() if spec.program == name and j != name]
dangling = [
s for s, spec in config.services.items()
if spec.program == name and not (s == name and in_services)
]
dangling += [
j for j, spec in config.jobs.items()
if spec.program == name and not (j == name and in_jobs)
]
if dangling:
print(
"Error: programs with active jobs or services cannot be removed.\n"

View File

@@ -17,5 +17,5 @@ def run_deploy(args: argparse.Namespace) -> int:
print(f"\nDeployed {result.deployed_count} item(s).")
if result.deployed_count > 0:
print("Run 'castle services start' to start all services.")
print("Run 'castle start' to start all services.")
return 0

View File

@@ -0,0 +1,119 @@
"""castle service create / castle job create — declare a deployment.
A service or job can run anything (a castle program or not). `--program`
records a convenience reference for description fallthrough; the run target is
the console script (python) or argv (command) to execute.
"""
from __future__ import annotations
import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
JobSpec,
ManageSpec,
ProxySpec,
RunCommand,
RunPython,
ServiceSpec,
SystemdSpec,
)
def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand:
if runner == "command":
return RunCommand(runner="command", argv=target.split() or [name])
return RunPython(runner="python", program=target or name)
def _check_new(config: object, name: str, section: str) -> str | None:
"""Return an error message if the name can't be created, else None."""
existing = getattr(config, section)
if name in existing:
return f"Error: {section[:-1]} '{name}' already exists."
return None
def run_service_create(args: argparse.Namespace) -> int:
"""Create a service entry in castle.yaml."""
config = load_config()
name = args.name
if err := _check_new(config, name, "services"):
print(err)
return 1
run = _run_spec(args.runner, args.run or args.program or name, name)
expose = None
proxy = None
if args.port is not None:
expose = ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=args.port, port_env=args.port_env),
health_path=args.health,
)
)
if not args.no_proxy:
path = args.path
if args.path:
path = args.path if args.path.startswith("/") else f"/{args.path}"
elif args.host:
path = None
else:
path = f"/{name}"
proxy = ProxySpec(caddy=CaddySpec(path_prefix=path, host=args.host))
config.services[name] = ServiceSpec(
id=name,
program=args.program,
description=args.description or None,
run=run,
expose=expose,
proxy=proxy,
manage=ManageSpec(systemd=SystemdSpec()),
)
save_config(config)
print(f"Created service '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})")
if expose:
print(f" port: {args.port}" + (f" (env: {args.port_env})" if args.port_env else ""))
if proxy and proxy.caddy:
if proxy.caddy.path_prefix:
print(f" proxy: {proxy.caddy.path_prefix}")
if proxy.caddy.host:
print(f" host: {proxy.caddy.host}")
print(f"\nNext: castle service deploy {name} && castle service start {name}")
return 0
def run_job_create(args: argparse.Namespace) -> int:
"""Create a job entry in castle.yaml."""
config = load_config()
name = args.name
if err := _check_new(config, name, "jobs"):
print(err)
return 1
run = _run_spec(args.runner, args.run or args.program or name, name)
config.jobs[name] = JobSpec(
id=name,
program=args.program,
description=args.description or None,
run=run,
schedule=args.schedule,
manage=ManageSpec(systemd=SystemdSpec()),
)
save_config(config)
print(f"Created job '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})")
print(f" schedule: {args.schedule}")
print(f"\nNext: castle job deploy {name} && castle job enable {name}")
return 0

View File

@@ -1,99 +0,0 @@
"""castle expose — turn an existing program into a service.
`castle add` adopts source as a program; `castle expose` declares how to *run*
that program as a long-running systemd service (port, health, proxy). It fills
the gap where adopting a daemon left you with a program but no way to run it.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ManageSpec,
ProxySpec,
RunCommand,
RunPython,
ServiceSpec,
SystemdSpec,
)
def _is_python(program: object) -> bool:
"""Whether the program runs as a python console script (uv-installed)."""
stack = getattr(program, "stack", None)
if stack and stack.startswith("python"):
return True
source = getattr(program, "source", None)
return bool(source and (Path(source) / "pyproject.toml").exists())
def run_expose(args: argparse.Namespace) -> int:
"""Create a service entry that runs an existing program."""
config = load_config()
name = args.name
program = config.programs.get(name)
if program is None:
print(f"Error: no program '{name}'. Adopt it first with 'castle add', then expose it.")
return 1
if name in config.services or name in config.jobs:
print(f"Error: '{name}' is already a service or job.")
return 1
run_script = args.run or name
run = (
RunPython(runner="python", program=run_script)
if _is_python(program)
else RunCommand(runner="command", argv=[run_script])
)
expose = None
proxy = None
if args.port is not None:
expose = ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=args.port, port_env=args.port_env),
health_path=args.health,
)
)
host = getattr(args, "host", None)
if not args.no_proxy:
# A path prefix, a hostname, or both. With a host but no explicit
# path, route by host only (root-based apps serve unchanged).
if args.path:
prefix = args.path if args.path.startswith("/") else "/" + args.path
elif host:
prefix = None
else:
prefix = f"/{name}"
proxy = ProxySpec(caddy=CaddySpec(path_prefix=prefix, host=host))
config.services[name] = ServiceSpec(
id=name,
program=name,
run=run,
expose=expose,
proxy=proxy,
manage=ManageSpec(systemd=SystemdSpec()),
)
save_config(config)
print(f"Exposed '{name}' as a service.")
print(f" run: {run.runner} ({run_script})")
if expose:
print(f" port: {args.port}" + (f" (env: {args.port_env})" if args.port_env else ""))
print(f" health: {args.health}")
if proxy and proxy.caddy:
if proxy.caddy.path_prefix:
print(f" proxy: {proxy.caddy.path_prefix}")
if proxy.caddy.host:
print(f" host: {proxy.caddy.host}")
print("\nNext: castle deploy " + name + " && castle service enable " + name)
return 0

View File

@@ -32,14 +32,16 @@ def run_info(args: argparse.Namespace) -> int:
"""Show detailed info for a program, service, or job."""
config = load_config()
name = args.name
resource = getattr(args, "resource", None)
# Look up in all sections
program = config.programs.get(name)
service = config.services.get(name)
job = config.jobs.get(name)
# Look up in the requested section (or all, when unscoped).
program = config.programs.get(name) if resource in (None, "program") else None
service = config.services.get(name) if resource in (None, "service") else None
job = config.jobs.get(name) if resource in (None, "job") else None
if not program and not service and not job:
print(f"Error: '{name}' not found in castle.yaml")
where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in castle.yaml")
return 1
deployed = _load_deployed_program(name)

View File

@@ -71,6 +71,7 @@ def run_list(args: argparse.Namespace) -> int:
filter_behavior = getattr(args, "behavior", None)
filter_stack = getattr(args, "stack", None)
resource = getattr(args, "resource", None) # scope to one section, or all
if getattr(args, "json", False):
return _list_json(config, filter_behavior, filter_stack)
@@ -81,12 +82,16 @@ def run_list(args: argparse.Namespace) -> int:
any_output = False
# Programs (the catalog) — filtered by real behavior + stack
progs = {
name: comp
for name, comp in config.programs.items()
if (not filter_behavior or comp.behavior == filter_behavior)
and (not filter_stack or comp.stack == filter_stack)
}
progs = (
{
name: comp
for name, comp in config.programs.items()
if (not filter_behavior or comp.behavior == filter_behavior)
and (not filter_stack or comp.stack == filter_stack)
}
if resource in (None, "program")
else {}
)
if progs:
any_output = True
print(f"\n{BOLD}{CYAN}Programs{RESET}")
@@ -100,8 +105,8 @@ def run_list(args: argparse.Namespace) -> int:
print(f" {dot(name)} {BOLD}{name}{RESET}{behavior_str}{stack_str}{desc}")
# Services + Jobs (deployment views) — independent of behavior, so only shown
# when no behavior filter is applied.
if not filter_behavior:
# when no behavior filter is applied. Each gated by its own resource scope.
if not filter_behavior and resource in (None, "service"):
services = _filter_by_stack(config.services, config, filter_stack)
if services:
any_output = True
@@ -117,6 +122,7 @@ def run_list(args: argparse.Namespace) -> int:
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
if not filter_behavior and resource in (None, "job"):
jobs = _filter_by_stack(config.jobs, config, filter_stack)
if jobs:
any_output = True
@@ -128,7 +134,7 @@ def run_list(args: argparse.Namespace) -> int:
print(f" {dot(name)} {BOLD}{name}{RESET}{sched}{desc}")
if not any_output:
print("No programs found.")
print(f"No {resource or 'program'}s found.")
print()
return 0

View File

@@ -44,24 +44,32 @@ def _run_program(name: str, extra: list[str]) -> int | None:
def run_run(args: argparse.Namespace) -> int:
"""Run a program (declared run) or a deployed service in the foreground."""
"""Run a program's declared run, or a deployed service, in the foreground.
Scoped by `resource`: `program run` runs the program's declared `run`;
`service run` runs the deployed service's command from the registry.
"""
name = args.name
extra_args = getattr(args, "extra", []) or []
resource = getattr(args, "resource", None)
# 1. Program with a declared run command.
prog_rc = _run_program(name, extra_args)
if prog_rc is not None:
return prog_rc
# Program: declared run command.
if resource in (None, "program"):
prog_rc = _run_program(name, extra_args)
if prog_rc is not None:
return prog_rc
if resource == "program":
print(f"Error: program '{name}' has no declared `run` command.")
return 1
# 2. Deployed service from the registry.
# Service: deployed command from the registry.
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
registry = load_registry()
if name not in registry.deployed:
print(f"Error: '{name}' is not a runnable program or deployed service.")
print("Declare a `run` command in castle.yaml, or run 'castle deploy'.")
print(f"Error: '{name}' is not a deployed service. Run 'castle deploy' first.")
return 1
deployed = registry.deployed[name]

View File

@@ -1,4 +1,4 @@
"""castle service / castle services - manage systemd service units."""
"""castle service / castle job — manage systemd service & timer units."""
from __future__ import annotations
@@ -40,56 +40,73 @@ def _remove_unit(uname: str) -> None:
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
def run_service(args: argparse.Namespace) -> int:
"""Manage individual services."""
if not args.service_command:
print("Usage: castle service {enable|disable}")
return 1
_PAST = {"start": "started", "stop": "stopped", "restart": "restarted"}
def run_service_cmd(args: argparse.Namespace) -> int:
"""`castle service <enable|disable|start|stop|restart> <name>`."""
sub = args.service_command
config = load_config()
if args.service_command == "enable":
if sub == "enable":
if getattr(args, "dry_run", False):
return _service_dry_run(config, args.name)
return _service_enable(config, args.name)
elif args.service_command == "disable":
if sub == "disable":
return _service_disable(args.name)
if sub in ("start", "stop", "restart"):
return _unit_action(config, args.name, sub, is_job=False)
return 1
def run_services(args: argparse.Namespace) -> int:
"""Manage all services together."""
if not args.services_command:
print("Usage: castle services {start|stop|status}")
return 1
def run_job_cmd(args: argparse.Namespace) -> int:
"""`castle job <enable|disable|start|stop|restart> <name>` (acts on the timer)."""
sub = args.job_command
config = load_config()
if sub == "enable":
return _service_enable(config, args.name) # enable_service handles timers
if sub == "disable":
return _service_disable(args.name)
if sub in ("start", "stop", "restart"):
return _unit_action(config, args.name, sub, is_job=True)
return 1
if args.services_command == "start":
def run_platform(args: argparse.Namespace) -> int:
"""Top-level `castle start|stop|restart` — the whole platform."""
config = load_config()
action = args.command
if action == "start":
return _services_start(config)
elif args.services_command == "stop":
if action == "stop":
return _services_stop(config)
elif args.services_command == "status":
return _service_status(config)
if action == "restart":
return _services_restart(config)
return 1
def run_restart(args: argparse.Namespace) -> int:
"""Restart a single deployed service or job."""
config = load_config()
name = args.name
if name not in config.services and name not in config.jobs:
print(f"Error: '{name}' is not a known service or job.")
def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) -> int:
"""systemctl start/stop/restart one service (unit) or job (timer)."""
section = config.jobs if is_job else config.services
if name not in section:
print(f"Error: no {'job' if is_job else 'service'} '{name}'.")
return 1
# Jobs are driven by their timer; services by the service unit.
unit = timer_name(name) if name in config.jobs else unit_name(name)
result = subprocess.run(["systemctl", "--user", "restart", unit], check=False)
unit = timer_name(name) if is_job else unit_name(name)
result = subprocess.run(["systemctl", "--user", action, unit], check=False)
if result.returncode != 0:
print(f"Error: failed to restart {unit}")
print(f"Error: failed to {action} {unit}")
return 1
print(f" {name}: restarted")
print(f" {name}: {_PAST[action]}")
return 0
def _services_restart(config: CastleConfig) -> int:
"""Restart every managed service and job unit."""
for name in config.jobs:
subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False)
print(f" {name}: restarted (timer)")
for name in config.services:
subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False)
print(f" {name}: restarted")
return 0
@@ -120,17 +137,6 @@ def run_status(args: argparse.Namespace) -> int:
return 0
def run_up(args: argparse.Namespace) -> int:
"""Bring everything online: deploy from castle.yaml, then start all services."""
from castle_cli.commands.deploy import run_deploy
config = load_config()
print("Deploying from castle.yaml...")
run_deploy(argparse.Namespace(name=None))
print("\nStarting services and gateway...")
return _services_start(config)
def _service_enable(config: CastleConfig, name: str) -> int:
"""Enable and start a single service (or timer for scheduled jobs)."""
from castle_core.lifecycle import enable_service