refactor: Decouple roles.

This commit is contained in:
2026-02-23 01:49:24 -08:00
parent 72d35f2641
commit eeaa5045d0
55 changed files with 2144 additions and 1276 deletions

View File

@@ -7,7 +7,7 @@ import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ComponentManifest,
ComponentSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -16,6 +16,7 @@ from castle_cli.manifest import (
PathInstallSpec,
ProxySpec,
RunPython,
ServiceSpec,
SystemdSpec,
ToolSpec,
)
@@ -25,9 +26,9 @@ from castle_cli.templates.scaffold import scaffold_project
def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
used_ports = set()
for manifest in config.components.values():
if manifest.expose and manifest.expose.http:
used_ports.add(manifest.expose.http.internal.port)
for svc in config.services.values():
if svc.expose and svc.expose.http:
used_ports.add(svc.expose.http.internal.port)
# Also reserve gateway port
used_ports.add(config.gateway.port)
@@ -43,8 +44,8 @@ def run_create(args: argparse.Namespace) -> int:
name = args.name
proj_type = args.type
if name in config.components:
print(f"Error: component '{name}' already exists in castle.yaml")
if name in config.components or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml")
return 1
components_dir = config.root / "components"
@@ -72,16 +73,19 @@ def run_create(args: argparse.Namespace) -> int:
port=port,
)
# Build manifest entry
# Build entries
if proj_type == "service":
manifest = ComponentManifest(
# Component for software identity
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
run=RunPython(
runner="python",
tool=name,
),
)
# Service for deployment
config.services[name] = ServiceSpec(
id=name,
component=name,
run=RunPython(runner="python", tool=name),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=port),
@@ -92,22 +96,21 @@ def run_create(args: argparse.Namespace) -> int:
manage=ManageSpec(systemd=SystemdSpec()),
)
elif proj_type == "tool":
manifest = ComponentManifest(
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
tool=ToolSpec(source=f"components/{name}/"),
tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias=name)),
)
else:
# library or other
manifest = ComponentManifest(
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
)
config.components[name] = manifest
save_config(config)
print(f"Created {proj_type} '{name}' at {project_dir}")

View File

@@ -19,11 +19,10 @@ from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
from castle_core.manifest import ComponentManifest
from castle_core.manifest import JobSpec, ServiceSpec
from castle_core.registry import (
REGISTRY_PATH,
DeployedComponent,
@@ -40,15 +39,7 @@ SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy components from castle.yaml to ~/.castle/."""
config = load_config()
component_name = getattr(args, "component", None)
if component_name:
if component_name not in config.components:
print(f"Error: component '{component_name}' not found in castle.yaml")
return 1
names = [component_name]
else:
names = list(config.components.keys())
target_name = getattr(args, "component", None)
ensure_dirs()
@@ -57,7 +48,7 @@ def run_deploy(args: argparse.Namespace) -> int:
# Load existing registry to preserve components not being redeployed,
# or start fresh if deploying all
if component_name and REGISTRY_PATH.exists():
if target_name and REGISTRY_PATH.exists():
try:
existing = load_registry()
registry = NodeRegistry(node=node, deployed=dict(existing.deployed))
@@ -67,14 +58,21 @@ def run_deploy(args: argparse.Namespace) -> int:
registry = NodeRegistry(node=node)
deployed_count = 0
for name in names:
manifest = config.components[name]
# Only deploy components with a run spec
if not manifest.run:
# Deploy services
for name, svc in config.services.items():
if target_name and name != target_name:
continue
deployed = _build_deployed_service(config, name, svc)
registry.deployed[name] = deployed
deployed_count += 1
_print_deployed(name, deployed)
deployed = _build_deployed(config, name, manifest)
# Deploy jobs
for name, job in config.jobs.items():
if target_name and name != target_name:
continue
deployed = _build_deployed_job(config, name, job)
registry.deployed[name] = deployed
deployed_count += 1
_print_deployed(name, deployed)
@@ -108,69 +106,100 @@ def _env_prefix(name: str) -> str:
return name.replace("-", "_").upper()
def _build_deployed(
config: CastleConfig, name: str, manifest: ComponentManifest
) -> DeployedComponent:
"""Build a DeployedComponent from a manifest spec."""
run = manifest.run
assert run is not None
def _resolve_description(
config: CastleConfig, spec: ServiceSpec | JobSpec
) -> str | None:
"""Get description, falling through to component if referenced."""
if spec.description:
return spec.description
if spec.component and spec.component in config.components:
return config.components[spec.component].description
return None
# 1. Convention-based env vars
def _build_deployed_service(
config: CastleConfig, name: str, svc: ServiceSpec
) -> DeployedComponent:
"""Build a DeployedComponent from a ServiceSpec."""
run = svc.run
prefix = _env_prefix(name)
env: dict[str, str] = {}
# Data dir convention (for all managed components)
if manifest.manage and manifest.manage.systemd:
# Data dir convention (for managed services)
managed = True # Services are always managed by default
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False
if managed:
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
# Port convention (if exposed)
if manifest.expose and manifest.expose.http:
env[f"{prefix}_PORT"] = str(manifest.expose.http.internal.port)
# 2. Merge defaults.env (overrides conventions)
if manifest.defaults and manifest.defaults.env:
env.update(manifest.defaults.env)
# 3. Resolve secrets
env = resolve_env_vars(env, manifest)
# 4. Build run_cmd
run_cmd = _build_run_cmd(run, env)
# 5. Extract metadata
port = None
health_path = None
if manifest.expose and manifest.expose.http:
port = manifest.expose.http.internal.port
health_path = manifest.expose.http.health_path
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
env[f"{prefix}_PORT"] = str(port)
health_path = svc.expose.http.health_path
# Merge defaults.env (overrides conventions)
if svc.defaults and svc.defaults.env:
env.update(svc.defaults.env)
# Resolve secrets
env = resolve_env_vars(env)
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
# Proxy path
proxy_path = None
if manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable:
proxy_path = manifest.proxy.caddy.path_prefix or f"/{name}"
schedule = None
sched_trigger = get_schedule_trigger(manifest)
if sched_trigger:
schedule = sched_trigger.cron
managed = bool(manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable)
roles = [r.value for r in manifest.roles]
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=manifest.description,
roles=roles,
description=_resolve_description(config, svc),
category="service",
port=port,
health_path=health_path,
proxy_path=proxy_path,
schedule=schedule,
managed=managed,
)
def _build_deployed_job(
config: CastleConfig, name: str, job: JobSpec
) -> DeployedComponent:
"""Build a DeployedComponent from a JobSpec."""
run = job.run
prefix = _env_prefix(name)
env: dict[str, str] = {}
# Data dir convention
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
# Merge defaults.env (overrides conventions)
if job.defaults and job.defaults.env:
env.update(job.defaults.env)
# Resolve secrets
env = resolve_env_vars(env)
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=_resolve_description(config, job),
category="job",
schedule=job.schedule,
managed=True,
)
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec."""
match run.runner:
@@ -199,7 +228,6 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
cmd.extend(["-p", f"{host_port}:{container_port}"])
for vol in run.volumes:
cmd.extend(["-v", vol])
# Container env comes from both run.env (container-specific) and deployed env
for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"])
for key, val in env.items():
@@ -238,13 +266,12 @@ def _copy_app_static(config: CastleConfig) -> None:
if "castle-app" not in config.components:
return
manifest = config.components["castle-app"]
if not (manifest.build and manifest.build.outputs):
comp = config.components["castle-app"]
if not (comp.build and comp.build.outputs):
return
# Find the source dist directory
source_dir = manifest.source_dir or "app"
for output in manifest.build.outputs:
source_dir = comp.source_dir or "app"
for output in comp.build.outputs:
src = config.root / source_dir / output
if src.exists():
dest = STATIC_DIR / "castle-app"
@@ -262,23 +289,30 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non
if not deployed.managed:
continue
# Get systemd spec from manifest (for restart policy, exec_reload, etc.)
# Get systemd spec from config (services or jobs)
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
# Generate and write service unit
svc_name = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
# Generate timer if scheduled
if name in config.components:
timer_content = generate_timer(name, config.components[name])
if timer_content:
tmr_name = timer_name(name)
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
# Generate timer for jobs
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
tmr_name = timer_name(name)
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
print(f"Systemd units written: {SYSTEMD_USER_DIR}")

View File

@@ -69,8 +69,8 @@ def _gateway_start(config: CastleConfig) -> int:
"""Generate config and enable the gateway service."""
from castle_cli.commands.service import _service_enable
if GATEWAY_COMPONENT not in config.managed:
print(f"Error: '{GATEWAY_COMPONENT}' not found in castle.yaml or not managed")
if GATEWAY_COMPONENT not in config.services:
print(f"Error: '{GATEWAY_COMPONENT}' not found in services section")
return 1
print("Generating gateway configuration...")
@@ -101,7 +101,6 @@ def _gateway_reload() -> int:
if result.returncode == 0:
print("Gateway reloaded.")
else:
# Fall back to restart if reload not supported
print("Reload signal sent. Verifying...")
result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT],

View File

@@ -29,105 +29,102 @@ def _load_deployed_component(name: str) -> object | None:
def run_info(args: argparse.Namespace) -> int:
"""Show detailed info for a component."""
"""Show detailed info for a component, service, or job."""
config = load_config()
name = args.project
if name not in config.components:
print(f"Error: component '{name}' not found in castle.yaml")
# Look up in all sections
component = config.components.get(name)
service = config.services.get(name)
job = config.jobs.get(name)
if not component and not service and not job:
print(f"Error: '{name}' not found in castle.yaml")
return 1
manifest = config.components[name]
deployed = _load_deployed_component(name)
if getattr(args, "json", False):
data = manifest.model_dump(exclude_none=True)
# Include CLAUDE.md content if it exists
claude_md = _find_claude_md(config.root, manifest.source_dir or name)
if claude_md:
data["claude_md"] = claude_md
if deployed:
data["deployed"] = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"port": deployed.port,
"managed": deployed.managed,
}
print(json.dumps(data, indent=2))
return 0
return _info_json(config, name, component, service, job, deployed)
# Human-readable output
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
print(f" {BOLD}roles{RESET}: {', '.join(r.value for r in manifest.roles)}")
if manifest.description:
print(f" {BOLD}description{RESET}: {manifest.description}")
# Source
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
# Determine category
if service:
print(f" {BOLD}category{RESET}: service")
elif job:
print(f" {BOLD}category{RESET}: job")
elif component:
if component.tool or (component.install and component.install.path):
print(f" {BOLD}category{RESET}: tool")
elif component.build:
print(f" {BOLD}category{RESET}: frontend")
else:
print(f" {BOLD}category{RESET}: component")
# Run spec
if manifest.run:
print(f" {BOLD}runner{RESET}: {manifest.run.runner}")
if hasattr(manifest.run, "tool"):
print(f" {BOLD}tool{RESET}: {manifest.run.tool}")
elif hasattr(manifest.run, "argv"):
print(f" {BOLD}argv{RESET}: {manifest.run.argv}")
elif hasattr(manifest.run, "image"):
print(f" {BOLD}image{RESET}: {manifest.run.image}")
# Component info
if component:
if component.description:
print(f" {BOLD}description{RESET}: {component.description}")
if component.source:
print(f" {BOLD}source{RESET}: {component.source}")
if component.install and component.install.path:
pi = component.install.path
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
if component.tool:
t = component.tool
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
if component.tags:
print(f" {BOLD}tags{RESET}: {', '.join(component.tags)}")
# Defaults env
if manifest.defaults and manifest.defaults.env:
print(f" {BOLD}defaults.env{RESET}:")
for key, val in manifest.defaults.env.items():
print(f" {key}: {val}")
# Service info
spec = service or job
if spec:
desc = spec.description
if not desc and spec.component and spec.component in config.components:
desc = config.components[spec.component].description
if desc and not (component and component.description == desc):
print(f" {BOLD}description{RESET}: {desc}")
if spec.component:
print(f" {BOLD}component{RESET}: {spec.component}")
# Expose
if manifest.expose and manifest.expose.http:
http = manifest.expose.http
print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}")
# Run spec
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
if hasattr(spec.run, "tool"):
print(f" {BOLD}tool{RESET}: {spec.run.tool}")
elif hasattr(spec.run, "argv"):
print(f" {BOLD}argv{RESET}: {spec.run.argv}")
elif hasattr(spec.run, "image"):
print(f" {BOLD}image{RESET}: {spec.run.image}")
# Proxy
if manifest.proxy and manifest.proxy.caddy:
caddy = manifest.proxy.caddy
if caddy.path_prefix:
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
# Defaults env
if spec.defaults and spec.defaults.env:
print(f" {BOLD}defaults.env{RESET}:")
for key, val in spec.defaults.env.items():
print(f" {key}: {val}")
# Manage
if manifest.manage and manifest.manage.systemd:
sd = manifest.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
# Service-specific: expose, proxy, manage
if service:
if service.expose and service.expose.http:
http = service.expose.http
print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}")
if service.proxy and service.proxy.caddy:
caddy = service.proxy.caddy
if caddy.path_prefix:
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
if service.manage and service.manage.systemd:
sd = service.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
# Install
if manifest.install and manifest.install.path:
pi = manifest.install.path
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
# Tool
if manifest.tool:
t = manifest.tool
if t.source:
print(f" {BOLD}tool.source{RESET}: {t.source}")
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
# Tags
if manifest.tags:
print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}")
# Capabilities
if manifest.provides:
print(f" {BOLD}provides{RESET}:")
for cap in manifest.provides:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
if manifest.consumes:
print(f" {BOLD}consumes{RESET}:")
for cap in manifest.consumes:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
# Job-specific
if job:
print(f" {BOLD}schedule{RESET}: {job.schedule}")
print(f" {BOLD}timezone{RESET}: {job.timezone}")
# Deployed state from registry
if deployed:
@@ -142,20 +139,66 @@ def run_info(args: argparse.Namespace) -> int:
for key, val in deployed.env.items():
print(f" {key}={val}")
else:
print(f"\n {DIM}not deployed (run 'castle deploy {name}'){RESET}")
print(f"\n {DIM}not deployed (run 'castle deploy'){RESET}")
# Show CLAUDE.md if it exists
source_dir = manifest.source_dir or name
claude_md = _find_claude_md(config.root, source_dir)
if claude_md:
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
print(f"{DIM}{claude_md}{RESET}")
source_dir = None
if component and component.source_dir:
source_dir = component.source_dir
elif spec and spec.component and spec.component in config.components:
source_dir = config.components[spec.component].source_dir
if source_dir:
claude_md = _find_claude_md(config.root, source_dir)
if claude_md:
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
print(f"{DIM}{claude_md}{RESET}")
print()
return 0
def _info_json(
config: object,
name: str,
component: object | None,
service: object | None,
job: object | None,
deployed: object | None,
) -> int:
"""Output JSON info."""
data: dict = {"name": name}
if component:
data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
if service:
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
data["category"] = "service"
if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
data["category"] = "job"
if not service and not job and component:
if component.tool or (component.install and component.install.path):
data["category"] = "tool"
elif component.build:
data["category"] = "frontend"
else:
data["category"] = "component"
if deployed:
data["deployed"] = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"port": deployed.port,
"managed": deployed.managed,
}
print(json.dumps(data, indent=2))
return 0
def _find_claude_md(root: Path, source_dir: str) -> str | None:
"""Read CLAUDE.md from project directory if it exists."""
claude_path = root / source_dir / "CLAUDE.md"

View File

@@ -7,7 +7,6 @@ import json
import logging
from castle_cli.config import load_config
from castle_cli.manifest import Role
log = logging.getLogger(__name__)
@@ -17,15 +16,15 @@ RESET = "\033[0m"
DIM = "\033[2m"
GREEN = "\033[92m"
RED = "\033[91m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
YELLOW = "\033[93m"
ROLE_COLORS: dict[str, str] = {
Role.SERVICE: "\033[92m", # green
Role.TOOL: "\033[96m", # cyan
Role.WORKER: "\033[94m", # blue
Role.JOB: "\033[95m", # magenta
Role.FRONTEND: "\033[93m", # yellow
Role.REMOTE: "\033[90m", # dim
Role.CONTAINERIZED: "\033[33m", # orange
CATEGORY_COLORS: dict[str, str] = {
"service": GREEN,
"job": MAGENTA,
"tool": CYAN,
"frontend": YELLOW,
}
@@ -41,71 +40,132 @@ def _load_deployed() -> dict[str, object] | None:
def run_list(args: argparse.Namespace) -> int:
"""List all components."""
"""List all components, services, and jobs."""
config = load_config()
deployed = _load_deployed()
components = config.components
filter_role = getattr(args, "role", None)
if filter_role:
components = {k: v for k, v in components.items() if filter_role in v.roles}
filter_type = getattr(args, "type", None)
if getattr(args, "json", False):
output = []
for name, manifest in components.items():
entry: dict = {
"name": name,
"roles": [r.value for r in manifest.roles],
"deployed": deployed is not None and name in deployed,
}
if manifest.description:
entry["description"] = manifest.description
if manifest.expose and manifest.expose.http:
entry["port"] = manifest.expose.http.internal.port
if deployed and name in deployed:
dep = deployed[name]
if dep.port is not None:
entry["port"] = dep.port
output.append(entry)
print(json.dumps(output, indent=2))
return 0
return _list_json(config, deployed, filter_type)
if not components:
any_output = False
# Services
if not filter_type or filter_type == "service":
if config.services:
any_output = True
color = CATEGORY_COLORS["service"]
print(f"\n{BOLD}{color}Services{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, svc in config.services.items():
port_str = ""
if svc.expose and svc.expose.http:
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}"
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}")
# Jobs
if not filter_type or filter_type == "job":
if config.jobs:
any_output = True
color = CATEGORY_COLORS["job"]
print(f"\n{BOLD}{color}Jobs{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, job in config.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}"
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
# Tools
if not filter_type or filter_type == "tool":
tools = config.tools
if tools:
any_output = True
color = CATEGORY_COLORS["tool"]
print(f"\n{BOLD}{color}Tools{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, comp in tools.items():
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
print(f" {BOLD}{name}{RESET}{desc}")
# Frontends
if not filter_type or filter_type == "frontend":
frontends = config.frontends
if frontends:
any_output = True
color = CATEGORY_COLORS["frontend"]
print(f"\n{BOLD}{color}Frontends{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, comp in frontends.items():
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
print(f" {BOLD}{name}{RESET}{desc}")
if not any_output:
print("No components found.")
return 0
# Group by primary role (first in sorted list)
by_role: dict[str, list[tuple[str, object]]] = {}
for name, manifest in components.items():
primary_role = manifest.roles[0].value if manifest.roles else "other"
by_role.setdefault(primary_role, []).append((name, manifest))
# Display order
role_order = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
for role_name in role_order:
items = by_role.get(role_name, [])
if not items:
continue
color = ROLE_COLORS.get(role_name, "")
print(f"\n{BOLD}{color}{role_name}s{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, manifest in items:
port_str = ""
if manifest.expose and manifest.expose.http:
port_str = f" :{manifest.expose.http.internal.port}"
# Show deployed status indicator
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}{manifest.description}{RESET}" if manifest.description else ""
print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}")
if deployed is None:
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
print()
return 0
def _list_json(
config: object, deployed: dict | None, filter_type: str | None
) -> int:
"""Output JSON list of all entries."""
output = []
if not filter_type or filter_type == "service":
for name, svc in config.services.items():
entry: dict = {
"name": name,
"category": "service",
"deployed": deployed is not None and name in deployed,
}
if svc.description:
entry["description"] = svc.description
if svc.expose and svc.expose.http:
entry["port"] = svc.expose.http.internal.port
output.append(entry)
if not filter_type or filter_type == "job":
for name, job in config.jobs.items():
entry = {
"name": name,
"category": "job",
"deployed": deployed is not None and name in deployed,
"schedule": job.schedule,
}
if job.description:
entry["description"] = job.description
output.append(entry)
if not filter_type or filter_type == "tool":
for name, comp in config.tools.items():
entry = {"name": name, "category": "tool"}
if comp.description:
entry["description"] = comp.description
output.append(entry)
if not filter_type or filter_type == "frontend":
for name, comp in config.frontends.items():
entry = {"name": name, "category": "frontend"}
if comp.description:
entry["description"] = comp.description
output.append(entry)
print(json.dumps(output, indent=2))
return 0

View File

@@ -10,25 +10,22 @@ from castle_cli.config import load_config
def run_logs(args: argparse.Namespace) -> int:
"""View logs for a component."""
"""View logs for a service or job."""
config = load_config()
name = args.name
if name not in config.components:
print(f"Error: component '{name}' not found in castle.yaml")
return 1
manifest = config.components[name]
# Container logs
if manifest.run and manifest.run.runner == "container":
return _container_logs(name, args)
# Systemd logs (default for managed services)
if manifest.manage and manifest.manage.systemd:
# Check services
if name in config.services:
svc = config.services[name]
if svc.run.runner == "container":
return _container_logs(name, args)
return _systemd_logs(name, args)
print(f"Error: '{name}' has no log source (not systemd-managed or containerized)")
# Check jobs
if name in config.jobs:
return _systemd_logs(name, args)
print(f"Error: '{name}' not found in services or jobs")
return 1

View File

@@ -9,7 +9,6 @@ from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
@@ -21,6 +20,9 @@ from castle_cli.config import (
load_config,
)
# Re-export for use by other commands
UNIT_PREFIX = "castle-"
def _install_unit(uname: str, content: str) -> None:
"""Write a systemd unit file."""
@@ -76,7 +78,6 @@ def run_services(args: argparse.Namespace) -> int:
def _service_enable(config: CastleConfig, name: str) -> int:
"""Enable and start a single service (or timer for scheduled jobs)."""
# Require registry
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
@@ -93,22 +94,29 @@ def _service_enable(config: CastleConfig, name: str) -> int:
ensure_dirs()
# Get systemd spec from manifest for restart policy etc.
# Get systemd spec from config (services or jobs)
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
# Generate and install the service unit from registry
svc_unit = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
_install_unit(svc_unit, svc_content)
# Check for timer (still uses manifest for schedule config)
manifest = config.components.get(name)
timer_content = generate_timer(name, manifest) if manifest else None
if timer_content:
# Check for timer (jobs have schedule)
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
tmr_unit = timer_name(name)
_install_unit(tmr_unit, timer_content)
@@ -156,7 +164,6 @@ def _service_disable(name: str) -> int:
print(f"Disabling {name}...")
# Stop and disable timer if exists
timer_path = SYSTEMD_USER_DIR / tmr_unit
if timer_path.exists():
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
@@ -172,14 +179,35 @@ def _service_disable(name: str) -> int:
def _service_status(config: CastleConfig) -> int:
"""Show status of all managed services."""
"""Show status of all managed services and jobs."""
print("\nCastle Services")
print("=" * 50)
for name, manifest in config.managed.items():
is_scheduled = get_schedule_trigger(manifest) is not None
for name, svc in config.services.items():
svc_unit = unit_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", svc_unit],
capture_output=True,
text=True,
)
status = result.stdout.strip()
if status == "active":
color = "\033[92m"
elif status == "inactive":
color = "\033[90m"
else:
color = "\033[91m"
reset = "\033[0m"
if is_scheduled:
port_str = ""
if svc.expose and svc.expose.http:
port_str = f":{svc.expose.http.internal.port}"
print(f" {color}{status:10s}{reset} {name}{port_str}")
if config.jobs:
print(f"\n{'' * 50}")
print("Jobs")
for name in config.jobs:
tmr_unit = timer_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", tmr_unit],
@@ -195,26 +223,6 @@ def _service_status(config: CastleConfig) -> int:
color = "\033[91m"
reset = "\033[0m"
print(f" {color}{status:10s}{reset} {name} (timer)")
else:
svc_unit = unit_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", svc_unit],
capture_output=True,
text=True,
)
status = result.stdout.strip()
if status == "active":
color = "\033[92m"
elif status == "inactive":
color = "\033[90m"
else:
color = "\033[91m"
reset = "\033[0m"
port_str = ""
if manifest.expose and manifest.expose.http:
port_str = f":{manifest.expose.http.internal.port}"
print(f" {color}{status:10s}{reset} {name}{port_str}")
print()
return 0
@@ -222,28 +230,33 @@ def _service_status(config: CastleConfig) -> int:
def _service_dry_run(config: CastleConfig, name: str) -> int:
"""Print the generated systemd unit(s) without installing."""
# Try registry first, fall back to showing what deploy would generate
if REGISTRY_PATH.exists():
registry = load_registry()
if name in registry.deployed:
deployed = registry.deployed[name]
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
svc_unit = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
print(f"# {svc_unit}")
print(svc_content)
manifest = config.components.get(name)
if manifest:
timer_content = generate_timer(name, manifest)
if timer_content:
print(f"# {timer_name(name)}")
print(timer_content)
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
print(f"# {timer_name(name)}")
print(timer_content)
return 0
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
@@ -252,14 +265,12 @@ def _service_dry_run(config: CastleConfig, name: str) -> int:
def _services_start(config: CastleConfig) -> int:
"""Start all managed services and gateway."""
# Require registry
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
ensure_dirs()
# Generate Caddyfile from registry
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
@@ -268,7 +279,13 @@ def _services_start(config: CastleConfig) -> int:
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f"Generated {caddyfile_path}")
for name in config.managed:
for name in config.services:
if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue
_service_enable(config, name)
for name in config.jobs:
if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue
@@ -279,12 +296,15 @@ def _services_start(config: CastleConfig) -> int:
def _services_stop(config: CastleConfig) -> int:
"""Stop all managed services and gateway."""
for name, manifest in config.managed.items():
is_scheduled = get_schedule_trigger(manifest) is not None
if is_scheduled:
tmr_unit = timer_name(name)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
"""Stop all managed services and jobs."""
for name in config.jobs:
tmr_unit = timer_name(name)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
for name in config.services:
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")

View File

@@ -8,26 +8,6 @@ import subprocess
from pathlib import Path
from castle_cli.config import ensure_dirs, load_config
from castle_cli.manifest import ComponentManifest
def _sync_cmd(manifest: ComponentManifest) -> list[str] | None:
"""Derive the sync command from the manifest's runner."""
run = manifest.run
if run is None:
# No runner — check for build commands (frontends)
if manifest.build and manifest.build.commands:
# Frontends declare build commands; infer from source dir at call site
return None
return None
match run.runner:
case "python":
return ["uv", "sync"]
case "node":
return [run.package_manager, "install"]
case _:
return None
def run_sync(args: argparse.Namespace) -> int:
@@ -44,11 +24,12 @@ def run_sync(args: argparse.Namespace) -> int:
if result.returncode != 0:
print("Warning: git submodule update failed (may not be a git repo)")
# Sync dependencies in each project
# Sync dependencies in each component's source directory
all_ok = True
synced_dirs: set[Path] = set()
for name, manifest in config.components.items():
source_dir = manifest.source_dir
for name, comp in config.components.items():
source_dir = comp.source_dir
if not source_dir:
continue
project_dir = config.root / source_dir
@@ -56,14 +37,16 @@ def run_sync(args: argparse.Namespace) -> int:
if project_dir in synced_dirs or not project_dir.is_dir():
continue
cmd = _sync_cmd(manifest)
# Determine sync command based on project type
cmd = None
if (project_dir / "pyproject.toml").exists():
cmd = ["uv", "sync"]
elif (project_dir / "package.json").exists():
pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm"
cmd = [pm, "install"]
if cmd is None:
# No runner — check if it's a frontend with a package.json
if manifest.build and (project_dir / "package.json").exists():
pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm"
cmd = [pm, "install"]
else:
continue
continue
label = cmd[0]
print(f"\nSyncing {name} ({label})...")
@@ -75,28 +58,33 @@ def run_sync(args: argparse.Namespace) -> int:
print(" OK")
synced_dirs.add(project_dir)
# Install components as uv tools or symlinks
# Install components and python-runner services as uv tools
uv_path = shutil.which("uv") or "uv"
installed_dirs: set[Path] = set()
for name, manifest in config.components.items():
# Install if: has install.path, or is a python runner service
if not (
(manifest.install and manifest.install.path)
or (manifest.run and manifest.run.runner == "python")
):
# Install components with install.path
for name, comp in config.components.items():
if not (comp.install and comp.install.path):
continue
source = manifest.source_dir
source = comp.source_dir
if not source:
continue
_try_install(config.root / source, name, comp, uv_path, installed_dirs)
# Install python-runner services
for name, svc in config.services.items():
if svc.run.runner != "python":
continue
# Find source from component reference
source = None
if svc.component and svc.component in config.components:
source = config.components[svc.component].source_dir
if not source:
continue
source_dir = config.root / source
if source_dir in installed_dirs:
continue
if (source_dir / "pyproject.toml").exists():
# Python package — uv tool install
if source_dir in installed_dirs:
continue
print(f"\nInstalling {name}...")
result = subprocess.run(
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
@@ -113,21 +101,53 @@ def run_sync(args: argparse.Namespace) -> int:
print(f" {name}: OK")
installed_dirs.add(source_dir)
elif source_dir.is_file():
# Script file — symlink to ~/.local/bin/
alias = name
if manifest.install and manifest.install.path and manifest.install.path.alias:
alias = manifest.install.path.alias
if not shutil.which(alias):
link = Path.home() / ".local" / "bin" / alias
link.parent.mkdir(parents=True, exist_ok=True)
if not link.exists():
link.symlink_to(source_dir)
print(f"\n Linked {alias}{source_dir}")
if all_ok:
print("\nAll projects synced.")
else:
print("\nSync completed with warnings.")
return 0
def _try_install(
source_dir: Path,
name: str,
comp: object,
uv_path: str,
installed_dirs: set[Path],
) -> bool:
"""Try to install a component. Returns True if installed."""
if source_dir in installed_dirs:
return False
if (source_dir / "pyproject.toml").exists():
print(f"\nInstalling {name}...")
result = subprocess.run(
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
capture_output=True,
text=True,
)
if result.returncode != 0:
if "already installed" in result.stderr.lower():
print(f" {name}: already installed")
else:
print(f" Warning: {result.stderr.strip()}")
return False
else:
print(f" {name}: OK")
installed_dirs.add(source_dir)
return True
elif source_dir.is_file():
alias = name
if comp.install and comp.install.path and comp.install.path.alias:
alias = comp.install.path.alias
if not shutil.which(alias):
link = Path.home() / ".local" / "bin" / alias
link.parent.mkdir(parents=True, exist_ok=True)
if not link.exists():
link.symlink_to(source_dir)
print(f"\n Linked {alias}{source_dir}")
return True
return False

View File

@@ -67,8 +67,8 @@ def _tool_info(name: str) -> int:
if manifest.description:
print(f" {manifest.description}")
print(f" {BOLD}version{RESET}: {t.version}")
if t.source:
print(f" {BOLD}source{RESET}: {t.source}")
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")