feat: CLI consistency pass — unified args, verbs, and status
#1 Positional dest unified to `name` across info + dev verbs (was `project`). #2 install/uninstall help now states the activate semantics (tool→PATH, service/job→systemd, frontend→served) rather than 'to PATH'. #4 `service status` (a plural op) moved to `services status`; the singular `service` group is enable/disable only. #5 `list --behavior` now filters the program *catalog* by its real behavior field. behavior (what a program is) and service/job (how it's deployed) are separate axes: `list` shows a Programs catalog plus Services/Jobs deployment views; a behavior filter scopes the catalog only. #6 `list` uses lifecycle.is_active uniformly for the status dot (tool on PATH / service running / job timer / static frontend served) instead of registry presence. #7 New `castle restart <name>` — restart a service (unit) or job (timer). #8 New `castle status` — unified services + jobs + programs activation view. #9 New `castle format [name]` dev verb (ruff format / pnpm format); wired into stacks DEV_ACTIONS + handlers. #10 New `castle up` — deploy + start everything in one shot. Docs: CLAUDE.md command reference refreshed. Tests updated to the two-axis list model (+ a daemon-behavior program fixture). core 92 · cli 24 · api 52 green; ruff clean.
This commit is contained in:
14
CLAUDE.md
14
CLAUDE.md
@@ -73,21 +73,23 @@ The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
castle list # List all programs, services, and jobs
|
castle list # List all programs, services, and jobs
|
||||||
castle list --behavior daemon # Filter by behavior
|
castle list --behavior daemon # Filter the program catalog by behavior
|
||||||
castle list --stack python-cli # Filter by stack
|
castle list --stack python-cli # Filter by stack
|
||||||
castle info <name> # Show details (--json for machine-readable)
|
castle info <name> # Show details (--json for machine-readable)
|
||||||
|
castle status # Unified status (services + jobs + programs)
|
||||||
castle create <name> [--stack ...] # Scaffold new project (--stack optional → bare program)
|
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 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 clone [name] # Clone source for programs that declare repo:
|
||||||
castle deploy [name] # Deploy to runtime (registry + systemd + Caddyfile)
|
castle deploy [name] # Deploy to runtime (registry + systemd + Caddyfile)
|
||||||
castle build|test|lint|type-check|check [project] # Dev verbs (one or all)
|
castle up # Deploy + start everything (one-shot bring-up)
|
||||||
castle install|uninstall [program] # Install/remove a program on PATH
|
castle build|test|lint|format|type-check|check [name] # Dev verbs (one program or all)
|
||||||
|
castle install|uninstall [name] # Activate/deactivate (tool→PATH, service→systemd, frontend→served)
|
||||||
castle run <name> # Run a program (declared run) or service in foreground
|
castle run <name> # Run a program (declared run) or service in foreground
|
||||||
|
castle restart <name> # Restart a service or job
|
||||||
castle logs <name> [-f] [-n 50] # View service/job logs
|
castle logs <name> [-f] [-n 50] # View service/job logs
|
||||||
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
|
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
|
||||||
castle service enable|disable <name> # Manage individual systemd service
|
castle service enable|disable <name> # Manage an individual systemd service
|
||||||
castle service status # Show all service statuses
|
castle services start|stop|status # Start/stop/inspect all services together
|
||||||
castle services start|stop # Start/stop everything
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`)
|
**Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`)
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ def _run_verb_all(config: CastleConfig, verb: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def run_verb(args: argparse.Namespace, verb: str) -> int:
|
def run_verb(args: argparse.Namespace, verb: str) -> int:
|
||||||
"""Generic entry point for a dev verb (single project or all)."""
|
"""Generic entry point for a dev verb (single program or all)."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if getattr(args, "project", None):
|
if getattr(args, "name", None):
|
||||||
return 0 if _run_verb(config, args.project, verb) else 1
|
return 0 if _run_verb(config, args.name, verb) else 1
|
||||||
ok = _run_verb_all(config, verb)
|
ok = _run_verb_all(config, verb)
|
||||||
print(f"\n{'All ' + verb + ' passed.' if ok else 'Some ' + verb + ' failed.'}")
|
print(f"\n{'All ' + verb + ' passed.' if ok else 'Some ' + verb + ' failed.'}")
|
||||||
return 0 if ok else 1
|
return 0 if ok else 1
|
||||||
@@ -73,6 +73,10 @@ def run_lint(args: argparse.Namespace) -> int:
|
|||||||
return run_verb(args, "lint")
|
return run_verb(args, "lint")
|
||||||
|
|
||||||
|
|
||||||
|
def run_format(args: argparse.Namespace) -> int:
|
||||||
|
return run_verb(args, "format")
|
||||||
|
|
||||||
|
|
||||||
def run_build(args: argparse.Namespace) -> int:
|
def run_build(args: argparse.Namespace) -> int:
|
||||||
return run_verb(args, "build")
|
return run_verb(args, "build")
|
||||||
|
|
||||||
@@ -105,8 +109,8 @@ def _lifecycle(config: CastleConfig, name: str, deactivate: bool) -> bool:
|
|||||||
def run_install(args: argparse.Namespace) -> int:
|
def run_install(args: argparse.Namespace) -> int:
|
||||||
"""Activate (install/deploy/serve) a program — verb word kept, meaning unified."""
|
"""Activate (install/deploy/serve) a program — verb word kept, meaning unified."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if getattr(args, "project", None):
|
if getattr(args, "name", None):
|
||||||
return 0 if _lifecycle(config, args.project, deactivate=False) else 1
|
return 0 if _lifecycle(config, args.name, deactivate=False) else 1
|
||||||
ok = all(
|
ok = all(
|
||||||
_lifecycle(config, name, deactivate=False)
|
_lifecycle(config, name, deactivate=False)
|
||||||
for name, comp in config.programs.items()
|
for name, comp in config.programs.items()
|
||||||
@@ -118,7 +122,7 @@ def run_install(args: argparse.Namespace) -> int:
|
|||||||
def run_uninstall(args: argparse.Namespace) -> int:
|
def run_uninstall(args: argparse.Namespace) -> int:
|
||||||
"""Deactivate (uninstall/stop/unpublish) a program."""
|
"""Deactivate (uninstall/stop/unpublish) a program."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if getattr(args, "project", None):
|
if getattr(args, "name", None):
|
||||||
return 0 if _lifecycle(config, args.project, deactivate=True) else 1
|
return 0 if _lifecycle(config, args.name, deactivate=True) else 1
|
||||||
print("Refusing to deactivate all programs; name a target.")
|
print("Refusing to deactivate all programs; name a target.")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def _load_deployed_program(name: str) -> object | None:
|
|||||||
def run_info(args: argparse.Namespace) -> int:
|
def run_info(args: argparse.Namespace) -> int:
|
||||||
"""Show detailed info for a program, service, or job."""
|
"""Show detailed info for a program, service, or job."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
name = args.project
|
name = args.name
|
||||||
|
|
||||||
# Look up in all sections
|
# Look up in all sections
|
||||||
program = config.programs.get(name)
|
program = config.programs.get(name)
|
||||||
|
|||||||
@@ -38,17 +38,6 @@ STACK_DISPLAY: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_deployed() -> dict[str, object] | None:
|
|
||||||
"""Try to load deployed state from registry, return None if unavailable."""
|
|
||||||
try:
|
|
||||||
from castle_core.registry import load_registry
|
|
||||||
|
|
||||||
registry = load_registry()
|
|
||||||
return registry.deployed
|
|
||||||
except (FileNotFoundError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_stack(config: object, name: str) -> str | None:
|
def _resolve_stack(config: object, name: str) -> str | None:
|
||||||
"""Resolve stack from program reference or direct program."""
|
"""Resolve stack from program reference or direct program."""
|
||||||
# Check services for program ref
|
# Check services for program ref
|
||||||
@@ -70,97 +59,77 @@ def _resolve_stack(config: object, name: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def run_list(args: argparse.Namespace) -> int:
|
def run_list(args: argparse.Namespace) -> int:
|
||||||
"""List all programs, services, and jobs."""
|
"""List all programs, services, and jobs.
|
||||||
|
|
||||||
|
Two orthogonal axes: the **Programs** catalog (filtered by real `behavior`)
|
||||||
|
and the **Services**/**Jobs** deployment views. `--behavior` filters the
|
||||||
|
catalog only — it's a property of a program, not of a deployment.
|
||||||
|
"""
|
||||||
|
from castle_core.lifecycle import is_active
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
deployed = _load_deployed()
|
|
||||||
|
|
||||||
filter_behavior = getattr(args, "behavior", None)
|
filter_behavior = getattr(args, "behavior", None)
|
||||||
filter_stack = getattr(args, "stack", None)
|
filter_stack = getattr(args, "stack", None)
|
||||||
|
|
||||||
if getattr(args, "json", False):
|
if getattr(args, "json", False):
|
||||||
return _list_json(config, deployed, filter_behavior, filter_stack)
|
return _list_json(config, filter_behavior, filter_stack)
|
||||||
|
|
||||||
|
def dot(name: str) -> str:
|
||||||
|
return f"{GREEN}●{RESET}" if is_active(name, config) else f"{RED}○{RESET}"
|
||||||
|
|
||||||
any_output = False
|
any_output = False
|
||||||
|
|
||||||
# Daemons (services)
|
# Programs (the catalog) — filtered by real behavior + stack
|
||||||
if not filter_behavior or filter_behavior == "daemon":
|
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 progs:
|
||||||
|
any_output = True
|
||||||
|
print(f"\n{BOLD}{CYAN}Programs{RESET}")
|
||||||
|
print(f"{CYAN}{'─' * 40}{RESET}")
|
||||||
|
for name, comp in progs.items():
|
||||||
|
behavior = comp.behavior or "program"
|
||||||
|
bcolor = BEHAVIOR_COLORS.get(behavior, "")
|
||||||
|
behavior_str = f" {bcolor}{behavior}{RESET}"
|
||||||
|
stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else ""
|
||||||
|
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
|
||||||
|
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:
|
||||||
services = _filter_by_stack(config.services, config, filter_stack)
|
services = _filter_by_stack(config.services, config, filter_stack)
|
||||||
if services:
|
if services:
|
||||||
any_output = True
|
any_output = True
|
||||||
color = BEHAVIOR_COLORS["daemon"]
|
color = BEHAVIOR_COLORS["daemon"]
|
||||||
print(f"\n{BOLD}{color}Daemons{RESET}")
|
print(f"\n{BOLD}{color}Services{RESET}")
|
||||||
print(f"{color}{'─' * 40}{RESET}")
|
print(f"{color}{'─' * 40}{RESET}")
|
||||||
for name, svc in services.items():
|
for name, svc in services.items():
|
||||||
port_str = ""
|
port_str = ""
|
||||||
if svc.expose and svc.expose.http:
|
if svc.expose and svc.expose.http:
|
||||||
port_str = f" :{svc.expose.http.internal.port}"
|
port_str = f" :{svc.expose.http.internal.port}"
|
||||||
|
|
||||||
if deployed is not None:
|
|
||||||
status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}"
|
|
||||||
else:
|
|
||||||
status = f"{DIM}?{RESET}"
|
|
||||||
|
|
||||||
stack = _resolve_stack(config, name)
|
stack = _resolve_stack(config, name)
|
||||||
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
|
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
|
||||||
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
|
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
|
||||||
print(f" {status} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
|
print(f" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
|
||||||
|
|
||||||
# Scheduled (jobs)
|
|
||||||
if not filter_behavior or filter_behavior == "tool":
|
|
||||||
jobs = _filter_by_stack(config.jobs, config, filter_stack)
|
jobs = _filter_by_stack(config.jobs, config, filter_stack)
|
||||||
if jobs:
|
if jobs:
|
||||||
any_output = True
|
any_output = True
|
||||||
color = MAGENTA
|
print(f"\n{BOLD}{MAGENTA}Jobs{RESET}")
|
||||||
print(f"\n{BOLD}{color}Scheduled{RESET}")
|
print(f"{MAGENTA}{'─' * 40}{RESET}")
|
||||||
print(f"{color}{'─' * 40}{RESET}")
|
|
||||||
for name, job in jobs.items():
|
for name, job in jobs.items():
|
||||||
if deployed is not None:
|
|
||||||
status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}"
|
|
||||||
else:
|
|
||||||
status = f"{DIM}?{RESET}"
|
|
||||||
|
|
||||||
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
|
|
||||||
sched = f" {DIM}[{job.schedule}]{RESET}"
|
sched = f" {DIM}[{job.schedule}]{RESET}"
|
||||||
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
|
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
|
||||||
|
print(f" {dot(name)} {BOLD}{name}{RESET}{sched}{desc}")
|
||||||
# Programs (tools, frontends, etc.)
|
|
||||||
show_tools = not filter_behavior or filter_behavior == "tool"
|
|
||||||
show_frontends = not filter_behavior or filter_behavior == "frontend"
|
|
||||||
|
|
||||||
if show_tools or show_frontends:
|
|
||||||
# Collect non-daemon programs
|
|
||||||
comps: dict[str, tuple[str, str | None, str | None]] = {}
|
|
||||||
|
|
||||||
if show_tools:
|
|
||||||
for name, comp in config.tools.items():
|
|
||||||
if filter_stack and comp.stack != filter_stack:
|
|
||||||
continue
|
|
||||||
comps[name] = ("tool", comp.stack, comp.description)
|
|
||||||
|
|
||||||
if show_frontends:
|
|
||||||
for name, comp in config.frontends.items():
|
|
||||||
if filter_stack and comp.stack != filter_stack:
|
|
||||||
continue
|
|
||||||
if name not in comps:
|
|
||||||
comps[name] = ("frontend", comp.stack, comp.description)
|
|
||||||
|
|
||||||
if comps:
|
|
||||||
any_output = True
|
|
||||||
color = CYAN
|
|
||||||
print(f"\n{BOLD}{color}Programs{RESET}")
|
|
||||||
print(f"{color}{'─' * 40}{RESET}")
|
|
||||||
for name, (behavior, stack, description) in comps.items():
|
|
||||||
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
|
|
||||||
behavior_str = f" {behavior}"
|
|
||||||
desc = f" {DIM}{description}{RESET}" if description else ""
|
|
||||||
print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}")
|
|
||||||
|
|
||||||
if not any_output:
|
if not any_output:
|
||||||
print("No programs found.")
|
print("No programs found.")
|
||||||
|
|
||||||
if deployed is None:
|
|
||||||
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -182,23 +151,39 @@ def _filter_by_stack(
|
|||||||
|
|
||||||
def _list_json(
|
def _list_json(
|
||||||
config: object,
|
config: object,
|
||||||
deployed: dict | None,
|
|
||||||
filter_behavior: str | None,
|
filter_behavior: str | None,
|
||||||
filter_stack: str | None,
|
filter_stack: str | None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Output JSON list of all entries."""
|
"""Output JSON: the program catalog (behavior-filterable) plus deployments."""
|
||||||
|
from castle_core.lifecycle import is_active
|
||||||
|
|
||||||
output = []
|
output = []
|
||||||
|
|
||||||
if not filter_behavior or filter_behavior == "daemon":
|
# Programs (catalog) — filtered by real behavior + stack
|
||||||
|
for name, comp in config.programs.items():
|
||||||
|
if filter_behavior and comp.behavior != filter_behavior:
|
||||||
|
continue
|
||||||
|
if filter_stack and comp.stack != filter_stack:
|
||||||
|
continue
|
||||||
|
entry: dict = {
|
||||||
|
"name": name,
|
||||||
|
"kind": "program",
|
||||||
|
"behavior": comp.behavior,
|
||||||
|
"active": is_active(name, config),
|
||||||
|
}
|
||||||
|
if comp.stack:
|
||||||
|
entry["stack"] = comp.stack
|
||||||
|
if comp.description:
|
||||||
|
entry["description"] = comp.description
|
||||||
|
output.append(entry)
|
||||||
|
|
||||||
|
# Services + Jobs (deployments) — only when not filtering by behavior
|
||||||
|
if not filter_behavior:
|
||||||
for name, svc in config.services.items():
|
for name, svc in config.services.items():
|
||||||
stack = _resolve_stack(config, name)
|
stack = _resolve_stack(config, name)
|
||||||
if filter_stack and stack != filter_stack:
|
if filter_stack and stack != filter_stack:
|
||||||
continue
|
continue
|
||||||
entry: dict = {
|
entry = {"name": name, "kind": "service", "active": is_active(name, config)}
|
||||||
"name": name,
|
|
||||||
"behavior": "daemon",
|
|
||||||
"deployed": deployed is not None and name in deployed,
|
|
||||||
}
|
|
||||||
if stack:
|
if stack:
|
||||||
entry["stack"] = stack
|
entry["stack"] = stack
|
||||||
if svc.description:
|
if svc.description:
|
||||||
@@ -207,15 +192,14 @@ def _list_json(
|
|||||||
entry["port"] = svc.expose.http.internal.port
|
entry["port"] = svc.expose.http.internal.port
|
||||||
output.append(entry)
|
output.append(entry)
|
||||||
|
|
||||||
if not filter_behavior or filter_behavior == "tool":
|
|
||||||
for name, job in config.jobs.items():
|
for name, job in config.jobs.items():
|
||||||
stack = _resolve_stack(config, name)
|
stack = _resolve_stack(config, name)
|
||||||
if filter_stack and stack != filter_stack:
|
if filter_stack and stack != filter_stack:
|
||||||
continue
|
continue
|
||||||
entry = {
|
entry = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"behavior": "tool",
|
"kind": "job",
|
||||||
"deployed": deployed is not None and name in deployed,
|
"active": is_active(name, config),
|
||||||
"schedule": job.schedule,
|
"schedule": job.schedule,
|
||||||
}
|
}
|
||||||
if stack:
|
if stack:
|
||||||
@@ -224,27 +208,5 @@ def _list_json(
|
|||||||
entry["description"] = job.description
|
entry["description"] = job.description
|
||||||
output.append(entry)
|
output.append(entry)
|
||||||
|
|
||||||
if not filter_behavior or filter_behavior == "tool":
|
|
||||||
for name, comp in config.tools.items():
|
|
||||||
if filter_stack and comp.stack != filter_stack:
|
|
||||||
continue
|
|
||||||
entry = {"name": name, "behavior": "tool"}
|
|
||||||
if comp.stack:
|
|
||||||
entry["stack"] = comp.stack
|
|
||||||
if comp.description:
|
|
||||||
entry["description"] = comp.description
|
|
||||||
output.append(entry)
|
|
||||||
|
|
||||||
if not filter_behavior or filter_behavior == "frontend":
|
|
||||||
for name, comp in config.frontends.items():
|
|
||||||
if filter_stack and comp.stack != filter_stack:
|
|
||||||
continue
|
|
||||||
entry = {"name": name, "behavior": "frontend"}
|
|
||||||
if comp.stack:
|
|
||||||
entry["stack"] = comp.stack
|
|
||||||
if comp.description:
|
|
||||||
entry["description"] = comp.description
|
|
||||||
output.append(entry)
|
|
||||||
|
|
||||||
print(json.dumps(output, indent=2))
|
print(json.dumps(output, indent=2))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def _remove_unit(uname: str) -> None:
|
|||||||
def run_service(args: argparse.Namespace) -> int:
|
def run_service(args: argparse.Namespace) -> int:
|
||||||
"""Manage individual services."""
|
"""Manage individual services."""
|
||||||
if not args.service_command:
|
if not args.service_command:
|
||||||
print("Usage: castle service {enable|disable|status}")
|
print("Usage: castle service {enable|disable}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -54,8 +54,6 @@ def run_service(args: argparse.Namespace) -> int:
|
|||||||
return _service_enable(config, args.name)
|
return _service_enable(config, args.name)
|
||||||
elif args.service_command == "disable":
|
elif args.service_command == "disable":
|
||||||
return _service_disable(args.name)
|
return _service_disable(args.name)
|
||||||
elif args.service_command == "status":
|
|
||||||
return _service_status(config)
|
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -63,7 +61,7 @@ def run_service(args: argparse.Namespace) -> int:
|
|||||||
def run_services(args: argparse.Namespace) -> int:
|
def run_services(args: argparse.Namespace) -> int:
|
||||||
"""Manage all services together."""
|
"""Manage all services together."""
|
||||||
if not args.services_command:
|
if not args.services_command:
|
||||||
print("Usage: castle services {start|stop}")
|
print("Usage: castle services {start|stop|status}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -72,10 +70,67 @@ def run_services(args: argparse.Namespace) -> int:
|
|||||||
return _services_start(config)
|
return _services_start(config)
|
||||||
elif args.services_command == "stop":
|
elif args.services_command == "stop":
|
||||||
return _services_stop(config)
|
return _services_stop(config)
|
||||||
|
elif args.services_command == "status":
|
||||||
|
return _service_status(config)
|
||||||
|
|
||||||
return 1
|
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.")
|
||||||
|
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)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Error: failed to restart {unit}")
|
||||||
|
return 1
|
||||||
|
print(f" {name}: restarted")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_status(args: argparse.Namespace) -> int:
|
||||||
|
"""Unified status across the platform: services + jobs + programs."""
|
||||||
|
from castle_core.lifecycle import is_active
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
# Services + jobs (deployment state); the gateway appears here as a service.
|
||||||
|
_service_status(config)
|
||||||
|
|
||||||
|
# Programs (catalog activation: tools on PATH, static frontends served)
|
||||||
|
catalog = {
|
||||||
|
n: c
|
||||||
|
for n, c in config.programs.items()
|
||||||
|
if n not in config.services and n not in config.jobs
|
||||||
|
}
|
||||||
|
if catalog:
|
||||||
|
print(f"{'─' * 50}")
|
||||||
|
print("Programs")
|
||||||
|
for name, comp in catalog.items():
|
||||||
|
on = is_active(name, config)
|
||||||
|
color = "\033[92m" if on else "\033[90m"
|
||||||
|
label = "active" if on else "inactive"
|
||||||
|
print(f" {color}{label:10s}\033[0m {name} ({comp.behavior or 'program'})")
|
||||||
|
print()
|
||||||
|
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:
|
def _service_enable(config: CastleConfig, name: str) -> int:
|
||||||
"""Enable and start a single service (or timer for scheduled jobs)."""
|
"""Enable and start a single service (or timer for scheduled jobs)."""
|
||||||
from castle_core.lifecycle import enable_service
|
from castle_core.lifecycle import enable_service
|
||||||
|
|||||||
@@ -63,34 +63,42 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
# castle info
|
# castle info
|
||||||
info_parser = subparsers.add_parser("info", help="Show program details")
|
info_parser = subparsers.add_parser("info", help="Show program details")
|
||||||
info_parser.add_argument("project", help="Program, service, or job name")
|
info_parser.add_argument("name", help="Program, service, or job name")
|
||||||
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
|
|
||||||
# castle test
|
# castle test
|
||||||
test_parser = subparsers.add_parser("test", help="Run tests")
|
test_parser = subparsers.add_parser("test", help="Run tests")
|
||||||
test_parser.add_argument("project", nargs="?", help="Project to test (default: all)")
|
test_parser.add_argument("name", nargs="?", help="Program to test (default: all)")
|
||||||
|
|
||||||
# castle lint
|
# castle lint
|
||||||
lint_parser = subparsers.add_parser("lint", help="Run linter")
|
lint_parser = subparsers.add_parser("lint", help="Run linter")
|
||||||
lint_parser.add_argument("project", nargs="?", help="Project to lint (default: all)")
|
lint_parser.add_argument("name", nargs="?", help="Program to lint (default: all)")
|
||||||
|
|
||||||
|
# castle format
|
||||||
|
format_parser = subparsers.add_parser("format", help="Format source code")
|
||||||
|
format_parser.add_argument("name", nargs="?", help="Program to format (default: all)")
|
||||||
|
|
||||||
# castle build
|
# castle build
|
||||||
build_parser = subparsers.add_parser("build", help="Build projects")
|
build_parser = subparsers.add_parser("build", help="Build programs")
|
||||||
build_parser.add_argument("project", nargs="?", help="Project to build (default: all)")
|
build_parser.add_argument("name", nargs="?", help="Program to build (default: all)")
|
||||||
|
|
||||||
# castle type-check
|
# castle type-check
|
||||||
tc_parser = subparsers.add_parser("type-check", help="Run type checker")
|
tc_parser = subparsers.add_parser("type-check", help="Run type checker")
|
||||||
tc_parser.add_argument("project", nargs="?", help="Project to type-check (default: all)")
|
tc_parser.add_argument("name", nargs="?", help="Program to type-check (default: all)")
|
||||||
|
|
||||||
# castle check (composite: lint + type-check + test)
|
# castle check (composite: lint + type-check + test)
|
||||||
check_parser = subparsers.add_parser("check", help="Run 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)")
|
check_parser.add_argument("name", nargs="?", help="Program to check (default: all)")
|
||||||
|
|
||||||
# castle install / uninstall
|
# castle install / uninstall — activate / deactivate a program in its mode
|
||||||
install_parser = subparsers.add_parser("install", help="Install a program to PATH")
|
install_parser = subparsers.add_parser(
|
||||||
install_parser.add_argument("project", nargs="?", help="Program to install (default: all)")
|
"install", help="Activate a program (tool→PATH, service/job→systemd, frontend→served)"
|
||||||
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall a program")
|
)
|
||||||
uninstall_parser.add_argument("project", nargs="?", help="Program to uninstall (default: all)")
|
install_parser.add_argument("name", nargs="?", help="Program to activate (default: all)")
|
||||||
|
uninstall_parser = subparsers.add_parser(
|
||||||
|
"uninstall", help="Deactivate a program (reverse of install)"
|
||||||
|
)
|
||||||
|
uninstall_parser.add_argument("name", nargs="?", help="Program to deactivate")
|
||||||
|
|
||||||
# castle gateway
|
# castle gateway
|
||||||
gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
|
gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
|
||||||
@@ -116,13 +124,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
disable_parser = service_sub.add_parser("disable", help="Stop and disable a service")
|
disable_parser = service_sub.add_parser("disable", help="Stop and disable a service")
|
||||||
disable_parser.add_argument("name", help="Service name")
|
disable_parser.add_argument("name", help="Service name")
|
||||||
service_sub.add_parser("status", help="Show status of all services")
|
|
||||||
|
|
||||||
# castle services (plural - manage all services)
|
# castle services (plural - manage all services)
|
||||||
services_parser = subparsers.add_parser("services", help="Manage all services together")
|
services_parser = subparsers.add_parser("services", help="Manage all services together")
|
||||||
services_sub = services_parser.add_subparsers(dest="services_command")
|
services_sub = services_parser.add_subparsers(dest="services_command")
|
||||||
services_sub.add_parser("start", help="Start all services and gateway")
|
services_sub.add_parser("start", help="Start all services and gateway")
|
||||||
services_sub.add_parser("stop", help="Stop all services and gateway")
|
services_sub.add_parser("stop", help="Stop all services and gateway")
|
||||||
|
services_sub.add_parser("status", help="Show status of all services and jobs")
|
||||||
|
|
||||||
|
# castle restart — restart a single deployed service or job
|
||||||
|
restart_parser = subparsers.add_parser("restart", help="Restart a service or job")
|
||||||
|
restart_parser.add_argument("name", help="Service or job name")
|
||||||
|
|
||||||
|
# castle status — unified status (gateway + services + jobs + programs)
|
||||||
|
subparsers.add_parser("status", help="Show overall status across the platform")
|
||||||
|
|
||||||
|
# castle up — bring everything online (deploy + start all services)
|
||||||
|
subparsers.add_parser("up", help="Deploy and start all services and the gateway")
|
||||||
|
|
||||||
# castle logs
|
# castle logs
|
||||||
logs_parser = subparsers.add_parser("logs", help="View service/job logs")
|
logs_parser = subparsers.add_parser("logs", help="View service/job logs")
|
||||||
@@ -196,6 +214,11 @@ def main() -> int:
|
|||||||
|
|
||||||
return run_lint(args)
|
return run_lint(args)
|
||||||
|
|
||||||
|
elif args.command == "format":
|
||||||
|
from castle_cli.commands.dev import run_format
|
||||||
|
|
||||||
|
return run_format(args)
|
||||||
|
|
||||||
elif args.command == "build":
|
elif args.command == "build":
|
||||||
from castle_cli.commands.dev import run_build
|
from castle_cli.commands.dev import run_build
|
||||||
|
|
||||||
@@ -236,6 +259,21 @@ def main() -> int:
|
|||||||
|
|
||||||
return run_services(args)
|
return run_services(args)
|
||||||
|
|
||||||
|
elif args.command == "restart":
|
||||||
|
from castle_cli.commands.service import run_restart
|
||||||
|
|
||||||
|
return run_restart(args)
|
||||||
|
|
||||||
|
elif args.command == "status":
|
||||||
|
from castle_cli.commands.service import run_status
|
||||||
|
|
||||||
|
return run_status(args)
|
||||||
|
|
||||||
|
elif args.command == "up":
|
||||||
|
from castle_cli.commands.service import run_up
|
||||||
|
|
||||||
|
return run_up(args)
|
||||||
|
|
||||||
elif args.command == "logs":
|
elif args.command == "logs":
|
||||||
from castle_cli.commands.logs import run_logs
|
from castle_cli.commands.logs import run_logs
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
"description": "Test tool",
|
"description": "Test tool",
|
||||||
"behavior": "tool",
|
"behavior": "tool",
|
||||||
},
|
},
|
||||||
|
"test-daemon": {
|
||||||
|
"description": "Test daemon program",
|
||||||
|
"behavior": "daemon",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"test-svc": {
|
"test-svc": {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class TestInfoCommand:
|
|||||||
|
|
||||||
from castle_cli.commands.info import run_info
|
from castle_cli.commands.info import run_info
|
||||||
|
|
||||||
result = run_info(Namespace(project="test-svc", json=False))
|
result = run_info(Namespace(name="test-svc", json=False))
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
@@ -37,7 +37,7 @@ class TestInfoCommand:
|
|||||||
|
|
||||||
from castle_cli.commands.info import run_info
|
from castle_cli.commands.info import run_info
|
||||||
|
|
||||||
result = run_info(Namespace(project="test-tool", json=False))
|
result = run_info(Namespace(name="test-tool", json=False))
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
@@ -53,7 +53,7 @@ class TestInfoCommand:
|
|||||||
|
|
||||||
from castle_cli.commands.info import run_info
|
from castle_cli.commands.info import run_info
|
||||||
|
|
||||||
result = run_info(Namespace(project="nope", json=False))
|
result = run_info(Namespace(name="nope", json=False))
|
||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
@@ -68,7 +68,7 @@ class TestInfoCommand:
|
|||||||
|
|
||||||
from castle_cli.commands.info import run_info
|
from castle_cli.commands.info import run_info
|
||||||
|
|
||||||
result = run_info(Namespace(project="test-svc", json=True))
|
result = run_info(Namespace(name="test-svc", json=True))
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
@@ -85,7 +85,7 @@ class TestInfoCommand:
|
|||||||
|
|
||||||
from castle_cli.commands.info import run_info
|
from castle_cli.commands.info import run_info
|
||||||
|
|
||||||
result = run_info(Namespace(project="test-svc", json=False))
|
result = run_info(Namespace(name="test-svc", json=False))
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
@@ -100,7 +100,7 @@ class TestInfoCommand:
|
|||||||
|
|
||||||
from castle_cli.commands.info import run_info
|
from castle_cli.commands.info import run_info
|
||||||
|
|
||||||
result = run_info(Namespace(project="test-svc", json=False))
|
result = run_info(Namespace(name="test-svc", json=False))
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class TestListCommand:
|
|||||||
assert "test-tool" in captured.out
|
assert "test-tool" in captured.out
|
||||||
|
|
||||||
def test_list_filter_daemon(self, castle_root: Path, capsys: object) -> None:
|
def test_list_filter_daemon(self, castle_root: Path, capsys: object) -> None:
|
||||||
"""List filtered to daemons."""
|
"""--behavior filters the program catalog by its real behavior field."""
|
||||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||||
from castle_cli.config import load_config
|
from castle_cli.config import load_config
|
||||||
|
|
||||||
@@ -38,8 +38,10 @@ class TestListCommand:
|
|||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
captured = capsys.readouterr() # type: ignore[attr-defined]
|
captured = capsys.readouterr() # type: ignore[attr-defined]
|
||||||
assert "test-svc" in captured.out
|
assert "test-daemon" in captured.out
|
||||||
assert "test-tool" not in captured.out
|
assert "test-tool" not in captured.out
|
||||||
|
# Services/jobs are deployment views, not behaviors — hidden under a filter.
|
||||||
|
assert "test-svc" not in captured.out
|
||||||
|
|
||||||
def test_list_filter_tool(self, castle_root: Path, capsys: object) -> None:
|
def test_list_filter_tool(self, castle_root: Path, capsys: object) -> None:
|
||||||
"""List filtered to tools."""
|
"""List filtered to tools."""
|
||||||
@@ -55,22 +57,24 @@ class TestListCommand:
|
|||||||
assert "test-tool" in captured.out
|
assert "test-tool" in captured.out
|
||||||
assert "test-svc" not in captured.out
|
assert "test-svc" not in captured.out
|
||||||
|
|
||||||
def test_list_filter_job(self, castle_root: Path, capsys: object) -> None:
|
def test_list_jobs_are_deployments(self, castle_root: Path, capsys: object) -> None:
|
||||||
"""List filtered to jobs (shown under tool behavior)."""
|
"""Jobs are a deployment view: shown unfiltered, hidden under a behavior filter."""
|
||||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||||
from castle_cli.config import load_config
|
from castle_cli.config import load_config
|
||||||
|
|
||||||
mock_load.return_value = load_config(castle_root)
|
mock_load.return_value = load_config(castle_root)
|
||||||
args = Namespace(behavior="tool", stack=None, json=False)
|
# Unfiltered: the job appears.
|
||||||
result = run_list(args)
|
run_list(Namespace(behavior=None, stack=None, json=False))
|
||||||
|
assert "test-job" in capsys.readouterr().out # type: ignore[attr-defined]
|
||||||
assert result == 0
|
# Behavior filter targets the catalog, so jobs drop out.
|
||||||
captured = capsys.readouterr() # type: ignore[attr-defined]
|
run_list(Namespace(behavior="tool", stack=None, json=False))
|
||||||
assert "test-job" in captured.out
|
out = capsys.readouterr().out # type: ignore[attr-defined]
|
||||||
assert "test-svc" not in captured.out
|
assert "test-job" not in out
|
||||||
|
assert "test-svc" not in out
|
||||||
|
assert "test-tool" in out
|
||||||
|
|
||||||
def test_list_json(self, castle_root: Path, capsys: object) -> None:
|
def test_list_json(self, castle_root: Path, capsys: object) -> None:
|
||||||
"""List output as JSON."""
|
"""JSON output tags each entry with its kind; behavior lives on programs."""
|
||||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||||
from castle_cli.config import load_config
|
from castle_cli.config import load_config
|
||||||
|
|
||||||
@@ -85,4 +89,7 @@ class TestListCommand:
|
|||||||
assert "test-svc" in names
|
assert "test-svc" in names
|
||||||
assert "test-tool" in names
|
assert "test-tool" in names
|
||||||
svc = next(p for p in data if p["name"] == "test-svc")
|
svc = next(p for p in data if p["name"] == "test-svc")
|
||||||
assert svc["behavior"] == "daemon"
|
assert svc["kind"] == "service"
|
||||||
|
tool = next(p for p in data if p["name"] == "test-tool")
|
||||||
|
assert tool["kind"] == "program"
|
||||||
|
assert tool["behavior"] == "tool"
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
from castle_core.manifest import ProgramSpec
|
from castle_core.manifest import ProgramSpec
|
||||||
|
|
||||||
DEV_ACTIONS = ["build", "test", "lint", "type-check", "check", "run"]
|
DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"]
|
||||||
INSTALL_ACTIONS = ["install", "uninstall"]
|
INSTALL_ACTIONS = ["install", "uninstall"]
|
||||||
ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS
|
ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS
|
||||||
|
|
||||||
# Verbs a stack handler can provide (everything except `run`, which is declared-only).
|
# Verbs a stack handler can provide (everything except `run`, which is declared-only).
|
||||||
_STACK_VERBS = {"build", "test", "lint", "type-check", "check", "install", "uninstall"}
|
_STACK_VERBS = {"build", "test", "lint", "format", "type-check", "check", "install", "uninstall"}
|
||||||
# Verbs whose handler method name differs from the verb spelling.
|
# Verbs whose handler method name differs from the verb spelling.
|
||||||
_VERB_METHOD = {"type-check": "type_check"}
|
_VERB_METHOD = {"type-check": "type_check"}
|
||||||
|
|
||||||
@@ -77,6 +77,9 @@ class StackHandler:
|
|||||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -133,6 +136,13 @@ class PythonHandler(StackHandler):
|
|||||||
program=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
program=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["uv", "run", "ruff", "format", "."], src)
|
||||||
|
return ActionResult(
|
||||||
|
program=name, action="format", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["uv", "run", "pyright"], src)
|
rc, output = await _run(["uv", "run", "pyright"], src)
|
||||||
@@ -190,6 +200,13 @@ class ReactViteHandler(StackHandler):
|
|||||||
program=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
program=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["pnpm", "format"], src)
|
||||||
|
return ActionResult(
|
||||||
|
program=name, action="format", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["pnpm", "type-check"], src)
|
rc, output = await _run(["pnpm", "type-check"], src)
|
||||||
|
|||||||
Reference in New Issue
Block a user