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:
89
CLAUDE.md
89
CLAUDE.md
@@ -28,10 +28,10 @@ know about castle internals.
|
||||
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),
|
||||
- **`castle program 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 program 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.
|
||||
|
||||
@@ -50,53 +50,76 @@ Stack guides (for writing *new* code, AI-facing):
|
||||
|
||||
```bash
|
||||
# New daemon, scaffolded from a stack
|
||||
castle create my-service --stack python-fastapi --description "Does something"
|
||||
castle program create my-service --stack python-fastapi --description "Does something"
|
||||
cd /data/repos/my-service && uv sync
|
||||
castle service enable my-service # register with systemd
|
||||
castle gateway reload # update reverse proxy routes
|
||||
castle service create my-service --program my-service --port 9001 # declare the service
|
||||
castle service deploy my-service && castle service enable my-service # unit + start
|
||||
castle gateway reload # update reverse proxy routes
|
||||
|
||||
# New tool from a stack
|
||||
castle create my-tool --stack python-cli --description "Does something"
|
||||
castle program create my-tool --stack python-cli --description "Does something"
|
||||
|
||||
# Adopt an existing repo (no stack required)
|
||||
castle add ~/projects/some-rust-tool
|
||||
castle add https://github.com/me/widget.git --name widget
|
||||
castle program add ~/projects/some-rust-tool
|
||||
castle program add https://github.com/me/widget.git --name widget
|
||||
```
|
||||
|
||||
`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 program create` scaffolds under `/data/repos/` (override with
|
||||
`CASTLE_REPOS_DIR`) and registers the program in `castle.yaml` with an absolute
|
||||
`source:`. `castle program add` registers an existing repo in place (or records
|
||||
its `repo:` URL for `castle program clone`).
|
||||
|
||||
## Castle CLI
|
||||
|
||||
The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
|
||||
|
||||
The CLI is **resource-first**: operations live under the resource they act on
|
||||
(`program`, `service`, `job`, `gateway`). Names can collide across resource
|
||||
types (a program and a service may share a name), so the resource is explicit.
|
||||
Platform-wide lifecycle and the cross-resource overview are top-level.
|
||||
|
||||
```bash
|
||||
castle list # List all programs, services, and jobs
|
||||
castle list --behavior daemon # Filter the program catalog by behavior
|
||||
castle list --stack python-cli # Filter by stack
|
||||
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 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 up # Deploy + start everything (one-shot bring-up)
|
||||
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 restart <name> # Restart a service or job
|
||||
castle logs <name> [-f] [-n 50] # View service/job logs
|
||||
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
|
||||
castle service enable|disable <name> # Manage an individual systemd service
|
||||
castle services start|stop|status # Start/stop/inspect all services together
|
||||
# Programs — the software catalog
|
||||
castle program list [--behavior daemon] [--stack python-cli] [--json]
|
||||
castle program info <name> [--json]
|
||||
castle program create <name> [--stack ...] [--description ...] # scaffold new
|
||||
castle program add <path|git-url> [--name ...] # adopt existing repo
|
||||
castle program clone [name] # clone repo: source
|
||||
castle program delete <name> [--source] [-y]
|
||||
castle program run <name> [args...] # declared run command
|
||||
castle program install|uninstall [name] # activate tools/frontends
|
||||
castle program build|test|lint|format|type-check|check [name] # dev verbs
|
||||
|
||||
# Services — long-running daemons
|
||||
castle service list [--json]
|
||||
castle service info <name> [--json]
|
||||
castle service create <name> [--program P] [--port N] [--health ...] \
|
||||
[--path ...] [--host ...] [--port-env ...] [--runner ...]
|
||||
castle service delete <name> [-y]
|
||||
castle service deploy <name> # generate unit + route
|
||||
castle service enable|disable <name> # systemd enable/disable
|
||||
castle service start|stop|restart <name> # systemd lifecycle (one)
|
||||
castle service logs <name> [-f] [-n 50]
|
||||
|
||||
# Jobs — scheduled tasks (same verbs; create takes --schedule)
|
||||
castle job create <name> [--program P] --schedule "0 2 * * *" [--runner ...]
|
||||
castle job <list|info|delete|deploy|enable|disable|start|stop|restart|logs> ...
|
||||
|
||||
# Platform-wide (top-level)
|
||||
castle list [--behavior ...] [--stack ...] [--json] # programs + services + jobs
|
||||
castle status # unified status
|
||||
castle deploy [name] # apply config → units + Caddyfile
|
||||
castle start | stop | restart # all services (+ gateway)
|
||||
castle gateway start|stop|reload|status # the Caddy gateway
|
||||
```
|
||||
|
||||
Bringing everything online is the two honest steps `castle deploy && castle
|
||||
start` (apply config, then start) — there is no bundled `up`.
|
||||
|
||||
**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).
|
||||
it declares its commands. Tools are reached via `castle program list --behavior tool`.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
119
cli/src/castle_cli/commands/deploy_create.py
Normal file
119
cli/src/castle_cli/commands/deploy_create.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""Castle CLI entry point."""
|
||||
"""Castle CLI entry point — resource-first command surface.
|
||||
|
||||
Operations live under the resource they act on (`program`, `service`, `job`,
|
||||
`gateway`); platform-wide lifecycle (`start`/`stop`/`restart`/`status`/`deploy`)
|
||||
and the cross-resource `list` are top-level. Names can collide across resource
|
||||
types (a program and a service may share a name), so the resource is always
|
||||
explicit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,182 +14,244 @@ import sys
|
||||
|
||||
from castle_cli import __version__
|
||||
|
||||
DEV_VERBS = ["build", "test", "lint", "format", "type-check", "check"]
|
||||
|
||||
|
||||
def _add_name(p: argparse.ArgumentParser, help: str = "Name", optional: bool = False) -> None:
|
||||
p.add_argument("name", nargs="?" if optional else None, help=help)
|
||||
|
||||
|
||||
def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
|
||||
prog = subparsers.add_parser("program", help="Manage programs (the software catalog)")
|
||||
prog.set_defaults(resource="program")
|
||||
sub = prog.add_subparsers(dest="program_command")
|
||||
|
||||
p = sub.add_parser("list", help="List programs")
|
||||
p.add_argument("--behavior", choices=["daemon", "tool", "frontend"], help="Filter by behavior")
|
||||
p.add_argument("--stack", help="Filter by stack")
|
||||
p.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
p = sub.add_parser("info", help="Show program details")
|
||||
_add_name(p, "Program name")
|
||||
p.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
p = sub.add_parser("create", help="Scaffold a new program")
|
||||
_add_name(p, "Program name")
|
||||
p.add_argument("--stack", choices=["python-cli", "python-fastapi", "react-vite"], default=None)
|
||||
p.add_argument("--description", default="", help="Program description")
|
||||
p.add_argument("--port", type=int, help="Port (daemons only)")
|
||||
|
||||
p = sub.add_parser("add", help="Adopt an existing repo (path or git URL)")
|
||||
p.add_argument("target", help="Local path or git URL")
|
||||
p.add_argument("--name", help="Program name (default: dir/repo name)")
|
||||
p.add_argument("--description", default="", help="Program description")
|
||||
|
||||
p = sub.add_parser("clone", help="Clone source for programs with repo:")
|
||||
_add_name(p, "Program to clone (default: all with repo:)", optional=True)
|
||||
|
||||
p = sub.add_parser("delete", help="Remove a program from castle.yaml")
|
||||
_add_name(p, "Program name")
|
||||
p.add_argument("--source", action="store_true", help="Also delete the source directory")
|
||||
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
||||
|
||||
p = sub.add_parser("run", help="Run a program's declared run command")
|
||||
_add_name(p, "Program name")
|
||||
p.add_argument("extra", nargs=argparse.REMAINDER, help="Extra args passed to the program")
|
||||
|
||||
sub.add_parser("install", help="Activate a program (tool→PATH, frontend→served)").add_argument(
|
||||
"name", nargs="?", help="Program (default: all)"
|
||||
)
|
||||
sub.add_parser("uninstall", help="Deactivate a program").add_argument(
|
||||
"name", nargs="?", help="Program"
|
||||
)
|
||||
|
||||
for verb in DEV_VERBS:
|
||||
p = sub.add_parser(verb, help=f"Run {verb}")
|
||||
_add_name(p, "Program (default: all)", optional=True)
|
||||
|
||||
|
||||
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
|
||||
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
|
||||
_add_name(p, f"{kind.capitalize()} name")
|
||||
p.add_argument("--program", help="Program this deployment runs (convenience ref)")
|
||||
p.add_argument("--description", default="", help="Description")
|
||||
p.add_argument("--run", help="Console script / command to run (default: --program or name)")
|
||||
p.add_argument("--runner", choices=["python", "command"], default="python")
|
||||
if kind == "service":
|
||||
p.add_argument("--port", type=int, help="HTTP port")
|
||||
p.add_argument("--health", default="/health", help="Health path (default: /health)")
|
||||
p.add_argument("--path", help="Gateway proxy prefix (default: /<name>)")
|
||||
p.add_argument("--host", help="Route by hostname instead of a path prefix")
|
||||
p.add_argument("--port-env", help="Env var the program reads for its port")
|
||||
p.add_argument("--no-proxy", action="store_true", help="Don't add a gateway route")
|
||||
else:
|
||||
p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)")
|
||||
|
||||
|
||||
def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -> None:
|
||||
"""Build the `service` or `job` group (shared verb set)."""
|
||||
grp = subparsers.add_parser(kind, help=f"Manage {kind}s")
|
||||
grp.set_defaults(resource=kind)
|
||||
sub = grp.add_subparsers(dest=f"{kind}_command")
|
||||
|
||||
p = sub.add_parser("list", help=f"List {kind}s")
|
||||
p.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
p = sub.add_parser("info", help=f"Show {kind} details")
|
||||
_add_name(p, f"{kind.capitalize()} name")
|
||||
p.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
_add_service_create(sub, kind)
|
||||
|
||||
p = sub.add_parser("delete", help=f"Remove a {kind} from castle.yaml")
|
||||
_add_name(p, f"{kind.capitalize()} name")
|
||||
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
||||
p.add_argument("--source", action="store_true", help=argparse.SUPPRESS)
|
||||
|
||||
cap = f"{kind.capitalize()} name"
|
||||
_add_name(sub.add_parser("deploy", help=f"Deploy this {kind} (unit + gateway)"), cap)
|
||||
|
||||
p = sub.add_parser("enable", help=f"Enable and start the {kind}")
|
||||
_add_name(p, cap)
|
||||
if kind == "service":
|
||||
p.add_argument("--dry-run", action="store_true", help="Print the unit without installing")
|
||||
_add_name(sub.add_parser("disable", help=f"Stop and disable the {kind}"), cap)
|
||||
_add_name(sub.add_parser("start", help=f"Start the {kind}"), cap)
|
||||
_add_name(sub.add_parser("stop", help=f"Stop the {kind}"), cap)
|
||||
_add_name(sub.add_parser("restart", help=f"Restart the {kind}"), cap)
|
||||
|
||||
p = sub.add_parser("logs", help=f"View {kind} logs")
|
||||
_add_name(p, f"{kind.capitalize()} name")
|
||||
p.add_argument("-f", "--follow", action="store_true", help="Follow log output")
|
||||
p.add_argument("-n", "--lines", type=int, default=50, help="Lines to show (default: 50)")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="castle",
|
||||
description="Castle platform CLI - manage projects, services, and infrastructure",
|
||||
description="Castle platform CLI — programs, services, jobs, and infrastructure",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"castle {__version__}")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# castle list
|
||||
list_parser = subparsers.add_parser("list", help="List all programs, services, and jobs")
|
||||
list_parser.add_argument(
|
||||
"--behavior",
|
||||
choices=["daemon", "tool", "frontend"],
|
||||
help="Filter by behavior",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--stack",
|
||||
help="Filter by stack (e.g. python-cli, python-fastapi, react-vite)",
|
||||
)
|
||||
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
_build_program_group(subparsers)
|
||||
_build_deployment_group(subparsers, "service")
|
||||
_build_deployment_group(subparsers, "job")
|
||||
|
||||
# castle create
|
||||
create_parser = subparsers.add_parser("create", help="Create a new project")
|
||||
create_parser.add_argument("name", help="Project name")
|
||||
create_parser.add_argument(
|
||||
"--stack",
|
||||
choices=["python-cli", "python-fastapi", "react-vite"],
|
||||
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)")
|
||||
# Gateway (infrastructure)
|
||||
gw = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
|
||||
gw_sub = gw.add_subparsers(dest="gateway_command")
|
||||
p = gw_sub.add_parser("start", help="Start the gateway")
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
gw_sub.add_parser("stop", help="Stop the gateway")
|
||||
p = gw_sub.add_parser("reload", help="Reload gateway configuration")
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
gw_sub.add_parser("status", help="Show gateway status")
|
||||
|
||||
# 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")
|
||||
# Platform-wide lifecycle (top-level)
|
||||
subparsers.add_parser("start", help="Start all services and the gateway")
|
||||
subparsers.add_parser("stop", help="Stop all services and the gateway")
|
||||
subparsers.add_parser("restart", help="Restart all services and jobs")
|
||||
subparsers.add_parser("status", help="Show status across the platform")
|
||||
p = subparsers.add_parser("deploy", help="Apply config to runtime (units + Caddyfile)")
|
||||
p.add_argument("name", nargs="?", help="Service/job to deploy (default: all)")
|
||||
|
||||
# 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 expose — turn an existing program into a service
|
||||
expose_parser = subparsers.add_parser("expose", help="Run an existing program as a service")
|
||||
expose_parser.add_argument("name", help="Program to expose")
|
||||
expose_parser.add_argument("--port", type=int, help="HTTP port the program binds")
|
||||
expose_parser.add_argument("--health", default="/health", help="Health path (default: /health)")
|
||||
expose_parser.add_argument("--path", help="Gateway proxy prefix (default: /<name>)")
|
||||
expose_parser.add_argument(
|
||||
"--host", help="Route by hostname instead of a path prefix (e.g. lakehouse.civil.lan)"
|
||||
)
|
||||
expose_parser.add_argument("--run", help="Console script / command to run (default: <name>)")
|
||||
expose_parser.add_argument(
|
||||
"--port-env", help="Env var the program reads for its port (e.g. LAKEHOUSED_DAEMON_PORT)"
|
||||
)
|
||||
expose_parser.add_argument(
|
||||
"--no-proxy", action="store_true", help="Don't add a gateway route"
|
||||
)
|
||||
|
||||
# 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("name", help="Program, service, or job name")
|
||||
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
# castle test
|
||||
test_parser = subparsers.add_parser("test", help="Run tests")
|
||||
test_parser.add_argument("name", nargs="?", help="Program to test (default: all)")
|
||||
|
||||
# castle lint
|
||||
lint_parser = subparsers.add_parser("lint", help="Run linter")
|
||||
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
|
||||
build_parser = subparsers.add_parser("build", help="Build programs")
|
||||
build_parser.add_argument("name", nargs="?", help="Program to build (default: all)")
|
||||
|
||||
# castle type-check
|
||||
tc_parser = subparsers.add_parser("type-check", help="Run type checker")
|
||||
tc_parser.add_argument("name", nargs="?", help="Program 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("name", nargs="?", help="Program to check (default: all)")
|
||||
|
||||
# castle install / uninstall — activate / deactivate a program in its mode
|
||||
install_parser = subparsers.add_parser(
|
||||
"install", help="Activate a program (tool→PATH, service/job→systemd, frontend→served)"
|
||||
)
|
||||
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
|
||||
gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
|
||||
gateway_sub = gateway_parser.add_subparsers(dest="gateway_command")
|
||||
gw_start = gateway_sub.add_parser("start", help="Start the gateway")
|
||||
gw_start.add_argument(
|
||||
"--dry-run", action="store_true", help="Print generated config without applying"
|
||||
)
|
||||
gateway_sub.add_parser("stop", help="Stop the gateway")
|
||||
gw_reload = gateway_sub.add_parser("reload", help="Reload gateway configuration")
|
||||
gw_reload.add_argument(
|
||||
"--dry-run", action="store_true", help="Print generated config without applying"
|
||||
)
|
||||
gateway_sub.add_parser("status", help="Show gateway status")
|
||||
|
||||
# castle service (singular - manage individual services)
|
||||
service_parser = subparsers.add_parser("service", help="Manage a service")
|
||||
service_sub = service_parser.add_subparsers(dest="service_command")
|
||||
enable_parser = service_sub.add_parser("enable", help="Enable and start a service")
|
||||
enable_parser.add_argument("name", help="Service name")
|
||||
enable_parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Print generated unit without installing"
|
||||
)
|
||||
disable_parser = service_sub.add_parser("disable", help="Stop and disable a service")
|
||||
disable_parser.add_argument("name", help="Service name")
|
||||
|
||||
# castle services (plural - manage all services)
|
||||
services_parser = subparsers.add_parser("services", help="Manage all services together")
|
||||
services_sub = services_parser.add_subparsers(dest="services_command")
|
||||
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("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
|
||||
logs_parser = subparsers.add_parser("logs", help="View service/job logs")
|
||||
logs_parser.add_argument("name", help="Service or job name")
|
||||
logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow log output")
|
||||
logs_parser.add_argument(
|
||||
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
|
||||
)
|
||||
|
||||
# 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 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)")
|
||||
# Cross-resource overview
|
||||
p = subparsers.add_parser("list", help="List programs, services, and jobs")
|
||||
p.add_argument("--behavior", choices=["daemon", "tool", "frontend"], help="Filter by behavior")
|
||||
p.add_argument("--stack", help="Filter by stack")
|
||||
p.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _dispatch_program(args: argparse.Namespace) -> int:
|
||||
sub = args.program_command
|
||||
if not sub:
|
||||
verbs = "list|info|create|add|clone|delete|run|install|uninstall|" + "|".join(DEV_VERBS)
|
||||
print(f"Usage: castle program {{{verbs}}}")
|
||||
return 1
|
||||
if sub == "list":
|
||||
from castle_cli.commands.list_cmd import run_list
|
||||
|
||||
return run_list(args)
|
||||
if sub == "info":
|
||||
from castle_cli.commands.info import run_info
|
||||
|
||||
return run_info(args)
|
||||
if sub == "create":
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
return run_create(args)
|
||||
if sub == "add":
|
||||
from castle_cli.commands.add import run_add
|
||||
|
||||
return run_add(args)
|
||||
if sub == "clone":
|
||||
from castle_cli.commands.clone import run_clone
|
||||
|
||||
return run_clone(args)
|
||||
if sub == "delete":
|
||||
from castle_cli.commands.delete import run_delete
|
||||
|
||||
return run_delete(args)
|
||||
if sub == "run":
|
||||
from castle_cli.commands.run_cmd import run_run
|
||||
|
||||
return run_run(args)
|
||||
if sub == "install":
|
||||
from castle_cli.commands.dev import run_install
|
||||
|
||||
return run_install(args)
|
||||
if sub == "uninstall":
|
||||
from castle_cli.commands.dev import run_uninstall
|
||||
|
||||
return run_uninstall(args)
|
||||
if sub in DEV_VERBS:
|
||||
from castle_cli.commands.dev import run_verb
|
||||
|
||||
return run_verb(args, sub)
|
||||
return 1
|
||||
|
||||
|
||||
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
|
||||
sub = getattr(args, f"{kind}_command")
|
||||
if not sub:
|
||||
verbs = "list|info|create|delete|deploy|enable|disable|start|stop|restart|logs"
|
||||
print(f"Usage: castle {kind} {{{verbs}}}")
|
||||
return 1
|
||||
if sub == "list":
|
||||
from castle_cli.commands.list_cmd import run_list
|
||||
|
||||
return run_list(args)
|
||||
if sub == "info":
|
||||
from castle_cli.commands.info import run_info
|
||||
|
||||
return run_info(args)
|
||||
if sub == "create":
|
||||
from castle_cli.commands.deploy_create import run_job_create, run_service_create
|
||||
|
||||
return run_service_create(args) if kind == "service" else run_job_create(args)
|
||||
if sub == "delete":
|
||||
from castle_cli.commands.delete import run_delete
|
||||
|
||||
return run_delete(args)
|
||||
if sub == "deploy":
|
||||
from castle_cli.commands.deploy import run_deploy
|
||||
|
||||
return run_deploy(args)
|
||||
if sub in ("enable", "disable", "start", "stop", "restart"):
|
||||
from castle_cli.commands.service import run_job_cmd, run_service_cmd
|
||||
|
||||
return run_service_cmd(args) if kind == "service" else run_job_cmd(args)
|
||||
if sub == "logs":
|
||||
from castle_cli.commands.logs import run_logs
|
||||
|
||||
return run_logs(args)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -190,134 +259,37 @@ def main() -> int:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
# Import command handlers lazily to keep startup fast
|
||||
if args.command == "list":
|
||||
cmd = args.command
|
||||
if cmd == "program":
|
||||
return _dispatch_program(args)
|
||||
if cmd in ("service", "job"):
|
||||
return _dispatch_deployment(args, cmd)
|
||||
if cmd == "gateway":
|
||||
from castle_cli.commands.gateway import run_gateway
|
||||
|
||||
return run_gateway(args)
|
||||
if cmd in ("start", "stop", "restart"):
|
||||
from castle_cli.commands.service import run_platform
|
||||
|
||||
return run_platform(args)
|
||||
if cmd == "status":
|
||||
from castle_cli.commands.service import run_status
|
||||
|
||||
return run_status(args)
|
||||
if cmd == "deploy":
|
||||
from castle_cli.commands.deploy import run_deploy
|
||||
|
||||
return run_deploy(args)
|
||||
if cmd == "list":
|
||||
from castle_cli.commands.list_cmd import run_list
|
||||
|
||||
return run_list(args)
|
||||
|
||||
elif args.command == "info":
|
||||
from castle_cli.commands.info import run_info
|
||||
|
||||
return run_info(args)
|
||||
|
||||
elif args.command == "create":
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
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 == "expose":
|
||||
from castle_cli.commands.expose import run_expose
|
||||
|
||||
return run_expose(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
|
||||
|
||||
return run_test(args)
|
||||
|
||||
elif args.command == "lint":
|
||||
from castle_cli.commands.dev import run_lint
|
||||
|
||||
return run_lint(args)
|
||||
|
||||
elif args.command == "format":
|
||||
from castle_cli.commands.dev import run_format
|
||||
|
||||
return run_format(args)
|
||||
|
||||
elif args.command == "build":
|
||||
from castle_cli.commands.dev import run_build
|
||||
|
||||
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
|
||||
|
||||
return run_gateway(args)
|
||||
|
||||
elif args.command == "service":
|
||||
from castle_cli.commands.service import run_service
|
||||
|
||||
return run_service(args)
|
||||
|
||||
elif args.command == "services":
|
||||
from castle_cli.commands.service import run_services
|
||||
|
||||
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":
|
||||
from castle_cli.commands.logs import run_logs
|
||||
|
||||
return run_logs(args)
|
||||
|
||||
elif args.command == "deploy":
|
||||
from castle_cli.commands.deploy import run_deploy
|
||||
|
||||
return run_deploy(args)
|
||||
|
||||
elif args.command == "run":
|
||||
from castle_cli.commands.run_cmd import run_run
|
||||
|
||||
return run_run(args)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
return 1
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
"""Entry point for the CLI."""
|
||||
sys.exit(main())
|
||||
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class TestInfoCommand:
|
||||
|
||||
assert result == 1
|
||||
output = capsys.readouterr().out
|
||||
assert "not found" in output
|
||||
assert "nope" in output
|
||||
|
||||
def test_info_json_service(self, castle_root: Path, capsys) -> None:
|
||||
"""--json produces valid JSON with service fields."""
|
||||
|
||||
@@ -163,7 +163,7 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
|
||||
"activate",
|
||||
"error",
|
||||
f"'{name}' is a daemon with no service. Run "
|
||||
f"'castle expose {name} --port <PORT>' to deploy it as a service.",
|
||||
f"'castle service create {name} --program {name} --port <PORT>' to deploy it.",
|
||||
)
|
||||
|
||||
# Tool: install to PATH.
|
||||
|
||||
@@ -157,9 +157,9 @@ 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
|
||||
`repo` records a git URL so `castle program 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.
|
||||
precedence. Use `castle program add <path|url>` to register an existing repo as a program.
|
||||
|
||||
### `system_dependencies` — Required system packages
|
||||
|
||||
@@ -240,7 +240,7 @@ manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
Enables `castle service enable/disable` and `castle logs`. An empty `{}`
|
||||
Enables `castle service enable/disable` and `castle service logs`. An empty `{}`
|
||||
uses defaults (enable=true, restart=on-failure, restart_sec=2).
|
||||
|
||||
Full options:
|
||||
@@ -292,7 +292,7 @@ semantics as services.
|
||||
Every program's source lives in one place — `~/.castle/code/<name>/`. It can
|
||||
arrive there a few ways:
|
||||
|
||||
1. **Scaffold a new one** with `castle create` — writes the project into
|
||||
1. **Scaffold a new one** with `castle program create` — writes the project into
|
||||
`~/.castle/code/<name>/` and registers it in `castle.yaml` with
|
||||
`source: code/<name>`.
|
||||
2. **Clone an existing project** — `git clone <url> ~/.castle/code/<name>`,
|
||||
@@ -310,14 +310,14 @@ inside the castle git repo and are referenced with `source: repo:<name>`.
|
||||
|
||||
## Registering a new program
|
||||
|
||||
### Via `castle create` (recommended)
|
||||
### Via `castle program create` (recommended)
|
||||
|
||||
```bash
|
||||
# Service — scaffolds into ~/.castle/code/, assigns port, registers in castle.yaml
|
||||
castle create my-service --stack python-fastapi --description "Does something"
|
||||
castle program create my-service --stack python-fastapi --description "Does something"
|
||||
|
||||
# Tool — scaffolds into ~/.castle/code/
|
||||
castle create my-tool --stack python-cli --description "Does something"
|
||||
castle program create my-tool --stack python-cli --description "Does something"
|
||||
```
|
||||
|
||||
### Manually
|
||||
@@ -363,10 +363,10 @@ services:
|
||||
### Service lifecycle
|
||||
|
||||
```bash
|
||||
castle create my-service --stack python-fastapi # 1. Scaffold + register
|
||||
castle program create my-service --stack python-fastapi # 1. Scaffold + register
|
||||
cd ~/.castle/code/my-service && uv sync # 2. Install deps
|
||||
# ... implement ...
|
||||
castle test my-service # 3. Run tests
|
||||
castle program test my-service # 3. Run tests
|
||||
castle service enable my-service # 4. Generate systemd unit, start
|
||||
castle gateway reload # 5. Update Caddy routes
|
||||
```
|
||||
@@ -376,17 +376,17 @@ on failure. Manage with:
|
||||
|
||||
```bash
|
||||
castle logs my-service -f # Tail logs
|
||||
castle run my-service # Run in foreground (for debugging)
|
||||
castle service run my-service # Run in foreground (for debugging)
|
||||
castle service disable my-service # Stop and remove systemd unit
|
||||
```
|
||||
|
||||
### Tool lifecycle
|
||||
|
||||
```bash
|
||||
castle create my-tool --stack python-cli # 1. Scaffold + register
|
||||
castle program create my-tool --stack python-cli # 1. Scaffold + register
|
||||
cd ~/.castle/code/my-tool && uv sync # 2. Install deps
|
||||
# ... implement ...
|
||||
castle test my-tool # 3. Run tests
|
||||
castle program test my-tool # 3. Run tests
|
||||
uv tool install --editable ~/.castle/code/my-tool/ # 4. Install to PATH
|
||||
```
|
||||
|
||||
@@ -406,7 +406,7 @@ jobs:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
`castle service enable my-job` generates both a `.service` (Type=oneshot)
|
||||
`castle job enable my-job` generates both a `.service` (Type=oneshot)
|
||||
and a `.timer` file.
|
||||
|
||||
## Infrastructure paths
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 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
|
||||
> A stack is a template + conventions, not a runtime requirement. `castle program 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
|
||||
> commands. An existing CLI adopted with `castle program add` doesn't need this stack — it
|
||||
> declares its own `commands:`. See @docs/registry.md for `commands:`,
|
||||
> `stack:` (optional), and `repo:`.
|
||||
|
||||
@@ -49,7 +49,7 @@ Examples: `code/pdf2md/`, `code/gpt/`, `code/protonmail/`
|
||||
## Creating a new tool
|
||||
|
||||
```bash
|
||||
castle create my-tool --stack python-cli --description "Does something"
|
||||
castle program create my-tool --stack python-cli --description "Does something"
|
||||
cd ~/.castle/code/my-tool && uv sync
|
||||
```
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 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
|
||||
> A stack is a template + conventions, not a runtime requirement. `castle program 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
|
||||
> dev-verb commands. An existing service adopted with `castle program add` doesn't need
|
||||
> this stack — it declares its own `commands:`. See @docs/registry.md
|
||||
> for `commands:`, `stack:` (optional), and `repo:`.
|
||||
|
||||
@@ -442,10 +442,10 @@ uv run ruff format . # Format
|
||||
|
||||
## Scaffolding
|
||||
|
||||
`castle create` generates all of this automatically:
|
||||
`castle program create` generates all of this automatically:
|
||||
|
||||
```bash
|
||||
castle create my-service --stack python-fastapi --description "Does something useful"
|
||||
castle program create my-service --stack python-fastapi --description "Does something useful"
|
||||
```
|
||||
|
||||
See @docs/registry.md for manifest fields, castle.yaml structure,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 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
|
||||
> A stack is a template + conventions, not a runtime requirement. `castle program 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
|
||||
> commands. An existing frontend adopted with `castle program add` doesn't need this
|
||||
> stack — it declares its own `commands:`. See @docs/registry.md for
|
||||
> `commands:`, `stack:` (optional), and `repo:`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user