refactor: Update component structure to use behavior and stack attributes, enhancing clarity and functionality across services, tools, and frontends

This commit is contained in:
2026-02-23 16:32:55 -08:00
parent 3343e955fd
commit 56b9c8ddf1
45 changed files with 698 additions and 667 deletions

View File

@@ -22,6 +22,13 @@ from castle_cli.manifest import (
)
from castle_cli.templates.scaffold import scaffold_project
# Stack determines default behavior and scaffold template
STACK_DEFAULTS: dict[str, str] = {
"python-fastapi": "daemon",
"python-cli": "tool",
"react-vite": "frontend",
}
def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
@@ -42,7 +49,8 @@ def run_create(args: argparse.Namespace) -> int:
"""Create a new project."""
config = load_config()
name = args.name
proj_type = args.type
stack = args.stack
behavior = STACK_DEFAULTS.get(stack)
if name in config.components or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml")
@@ -55,9 +63,9 @@ def run_create(args: argparse.Namespace) -> int:
print(f"Error: directory 'components/{name}' already exists")
return 1
# Determine port for services
# Determine port for daemons
port = args.port
if proj_type == "service" and port is None:
if behavior == "daemon" and port is None:
port = next_available_port(config)
# Package name: convert kebab-case to snake_case
@@ -68,18 +76,19 @@ def run_create(args: argparse.Namespace) -> int:
project_dir=project_dir,
name=name,
package_name=package_name,
proj_type=proj_type,
description=args.description or f"A castle {proj_type}",
stack=stack,
description=args.description or f"A castle {stack} component",
port=port,
)
# Build entries
if proj_type == "service":
if behavior == "daemon":
# Component for software identity
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
stack=stack,
)
# Service for deployment
config.services[name] = ServiceSpec(
@@ -95,32 +104,34 @@ def run_create(args: argparse.Namespace) -> int:
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
manage=ManageSpec(systemd=SystemdSpec()),
)
elif proj_type == "tool":
elif behavior == "tool":
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
stack=stack,
tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias=name)),
)
else:
# library or other
# frontend or other
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
description=args.description or f"A castle {stack} component",
source=f"components/{name}",
stack=stack,
)
save_config(config)
print(f"Created {proj_type} '{name}' at {project_dir}")
print(f"Created {stack} component '{name}' at {project_dir}")
if port:
print(f" Port: {port}")
print(" Registered in castle.yaml")
print("\nNext steps:")
print(f" cd components/{name}")
print(" uv sync")
if proj_type == "service":
if behavior == "daemon":
print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name} # deploy to ~/.castle/")
print(f" castle test {name}")

View File

@@ -155,12 +155,18 @@ def _build_deployed_service(
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
# Resolve stack from referenced component
stack = None
if svc.component and svc.component in config.components:
stack = config.components[svc.component].stack
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=_resolve_description(config, svc),
category="service",
behavior="daemon",
stack=stack,
port=port,
health_path=health_path,
proxy_path=proxy_path,
@@ -189,12 +195,18 @@ def _build_deployed_job(
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
# Resolve stack from referenced component
stack = None
if job.component and job.component in config.components:
stack = config.components[job.component].stack
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=_resolve_description(config, job),
category="job",
behavior="tool",
stack=stack,
schedule=job.schedule,
managed=True,
)

View File

@@ -51,18 +51,29 @@ def run_info(args: argparse.Namespace) -> int:
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
# Determine category
# Determine behavior
if service:
print(f" {BOLD}category{RESET}: service")
print(f" {BOLD}behavior{RESET}: daemon")
elif job:
print(f" {BOLD}category{RESET}: job")
print(f" {BOLD}behavior{RESET}: tool")
elif component:
if component.tool or (component.install and component.install.path):
print(f" {BOLD}category{RESET}: tool")
print(f" {BOLD}behavior{RESET}: tool")
elif component.build:
print(f" {BOLD}category{RESET}: frontend")
print(f" {BOLD}behavior{RESET}: frontend")
else:
print(f" {BOLD}category{RESET}: component")
print(f" {BOLD}behavior{RESET}: ")
# Show stack
stack = None
if component and component.stack:
stack = component.stack
elif service and service.component and service.component in config.components:
stack = config.components[service.component].stack
elif job and job.component and job.component in config.components:
stack = config.components[job.component].stack
if stack:
print(f" {BOLD}stack{RESET}: {stack}")
# Component info
if component:
@@ -174,17 +185,26 @@ def _info_json(
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"
data["behavior"] = "daemon"
if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
data["category"] = "job"
data["behavior"] = "tool"
if not service and not job and component:
if component.tool or (component.install and component.install.path):
data["category"] = "tool"
data["behavior"] = "tool"
elif component.build:
data["category"] = "frontend"
else:
data["category"] = "component"
data["behavior"] = "frontend"
# Resolve stack
stack = None
if component and component.stack:
stack = component.stack
elif service and service.component and service.component in config.components:
stack = config.components[service.component].stack
elif job and job.component and job.component in config.components:
stack = config.components[job.component].stack
if stack:
data["stack"] = stack
if deployed:
data["deployed"] = {

View File

@@ -20,13 +20,23 @@ CYAN = "\033[96m"
MAGENTA = "\033[95m"
YELLOW = "\033[93m"
CATEGORY_COLORS: dict[str, str] = {
"service": GREEN,
"job": MAGENTA,
BEHAVIOR_COLORS: dict[str, str] = {
"daemon": GREEN,
"tool": CYAN,
"frontend": YELLOW,
}
STACK_DISPLAY: dict[str, str] = {
"python-fastapi": "python-fastapi",
"python-cli": "python-cli",
"react-vite": "react-vite",
"rust": "rust",
"go": "go",
"bash": "bash",
"container": "container",
"command": "command",
}
def _load_deployed() -> dict[str, object] | None:
"""Try to load deployed state from registry, return None if unavailable."""
@@ -39,26 +49,48 @@ def _load_deployed() -> dict[str, object] | None:
return None
def _resolve_stack(config: object, name: str) -> str | None:
"""Resolve stack from component reference or direct component."""
# Check services for component ref
if name in config.services:
svc = config.services[name]
comp_name = svc.component
if comp_name and comp_name in config.components:
return config.components[comp_name].stack
# Check jobs for component ref
if name in config.jobs:
job = config.jobs[name]
comp_name = job.component
if comp_name and comp_name in config.components:
return config.components[comp_name].stack
# Direct component
if name in config.components:
return config.components[name].stack
return None
def run_list(args: argparse.Namespace) -> int:
"""List all components, services, and jobs."""
config = load_config()
deployed = _load_deployed()
filter_type = getattr(args, "type", None)
filter_behavior = getattr(args, "behavior", None)
filter_stack = getattr(args, "stack", None)
if getattr(args, "json", False):
return _list_json(config, deployed, filter_type)
return _list_json(config, deployed, filter_behavior, filter_stack)
any_output = False
# Services
if not filter_type or filter_type == "service":
if config.services:
# Daemons (services)
if not filter_behavior or filter_behavior == "daemon":
services = _filter_by_stack(config.services, config, filter_stack)
if services:
any_output = True
color = CATEGORY_COLORS["service"]
print(f"\n{BOLD}{color}Services{RESET}")
color = BEHAVIOR_COLORS["daemon"]
print(f"\n{BOLD}{color}Daemons{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, svc in config.services.items():
for name, svc in services.items():
port_str = ""
if svc.expose and svc.expose.http:
port_str = f" :{svc.expose.http.internal.port}"
@@ -68,17 +100,20 @@ def run_list(args: argparse.Namespace) -> int:
else:
status = f"{DIM}?{RESET}"
stack = _resolve_stack(config, name)
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}")
print(f" {status} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
# Jobs
if not filter_type or filter_type == "job":
if config.jobs:
# Scheduled (jobs)
if not filter_behavior or filter_behavior == "tool":
jobs = _filter_by_stack(config.jobs, config, filter_stack)
if jobs:
any_output = True
color = CATEGORY_COLORS["job"]
print(f"\n{BOLD}{color}Jobs{RESET}")
color = MAGENTA
print(f"\n{BOLD}{color}Scheduled{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, job in config.jobs.items():
for name, job in jobs.items():
if deployed is not None:
status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}"
else:
@@ -88,29 +123,37 @@ def run_list(args: argparse.Namespace) -> int:
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}")
# Components (tools, frontends, etc.)
show_tools = not filter_behavior or filter_behavior == "tool"
show_frontends = not filter_behavior or filter_behavior == "frontend"
# Frontends
if not filter_type or filter_type == "frontend":
frontends = config.frontends
if frontends:
if show_tools or show_frontends:
# Collect non-daemon components
comps: dict[str, tuple[str, str | None, str | None]] = {}
if show_tools:
for name, comp in config.tools.items():
if filter_stack and comp.stack != filter_stack:
continue
comps[name] = ("tool", comp.stack, comp.description)
if show_frontends:
for name, comp in config.frontends.items():
if filter_stack and comp.stack != filter_stack:
continue
if name not in comps:
comps[name] = ("frontend", comp.stack, comp.description)
if comps:
any_output = True
color = CATEGORY_COLORS["frontend"]
print(f"\n{BOLD}{color}Frontends{RESET}")
color = CYAN
print(f"\n{BOLD}{color}Components{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}")
for name, (behavior, stack, description) in comps.items():
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
behavior_str = f" {behavior}"
desc = f" {DIM}{description}{RESET}" if description else ""
print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}")
if not any_output:
print("No components found.")
@@ -122,47 +165,83 @@ def run_list(args: argparse.Namespace) -> int:
return 0
def _filter_by_stack(
items: dict[str, object],
config: object,
filter_stack: str | None,
) -> dict[str, object]:
"""Filter items by stack if a filter is provided."""
if not filter_stack:
return items
return {
name: item
for name, item in items.items()
if _resolve_stack(config, name) == filter_stack
}
def _list_json(
config: object, deployed: dict | None, filter_type: str | None
config: object,
deployed: dict | None,
filter_behavior: str | None,
filter_stack: str | None,
) -> int:
"""Output JSON list of all entries."""
output = []
if not filter_type or filter_type == "service":
if not filter_behavior or filter_behavior == "daemon":
for name, svc in config.services.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry: dict = {
"name": name,
"category": "service",
"behavior": "daemon",
"deployed": deployed is not None and name in deployed,
}
if stack:
entry["stack"] = stack
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":
if not filter_behavior or filter_behavior == "tool":
for name, job in config.jobs.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {
"name": name,
"category": "job",
"behavior": "tool",
"deployed": deployed is not None and name in deployed,
"schedule": job.schedule,
}
if stack:
entry["stack"] = stack
if job.description:
entry["description"] = job.description
output.append(entry)
if not filter_type or filter_type == "tool":
if not filter_behavior or filter_behavior == "tool":
for name, comp in config.tools.items():
entry = {"name": name, "category": "tool"}
if filter_stack and comp.stack != filter_stack:
continue
entry = {"name": name, "behavior": "tool"}
if comp.stack:
entry["stack"] = comp.stack
if comp.description:
entry["description"] = comp.description
output.append(entry)
if not filter_type or filter_type == "frontend":
if not filter_behavior or filter_behavior == "frontend":
for name, comp in config.frontends.items():
entry = {"name": name, "category": "frontend"}
if filter_stack and comp.stack != filter_stack:
continue
entry = {"name": name, "behavior": "frontend"}
if comp.stack:
entry["stack"] = comp.stack
if comp.description:
entry["description"] = comp.description
output.append(entry)

View File

@@ -21,9 +21,13 @@ def build_parser() -> argparse.ArgumentParser:
# castle list
list_parser = subparsers.add_parser("list", help="List all components")
list_parser.add_argument(
"--type",
choices=["service", "job", "tool", "frontend"],
help="Filter by type",
"--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")
@@ -31,13 +35,13 @@ def build_parser() -> argparse.ArgumentParser:
create_parser = subparsers.add_parser("create", help="Create a new project")
create_parser.add_argument("name", help="Project name")
create_parser.add_argument(
"--type",
choices=["service", "tool", "library"],
"--stack",
choices=["python-cli", "python-fastapi", "react-vite"],
required=True,
help="Project type",
help="Development stack (determines scaffold template and default behavior)",
)
create_parser.add_argument("--description", default="", help="Project description")
create_parser.add_argument("--port", type=int, help="Port number (services only)")
create_parser.add_argument("--port", type=int, help="Port number (daemons only)")
# castle info
info_parser = subparsers.add_parser("info", help="Show component details")
info_parser.add_argument("project", help="Component name")

View File

@@ -9,19 +9,17 @@ def scaffold_project(
project_dir: Path,
name: str,
package_name: str,
proj_type: str,
stack: str,
description: str,
port: int | None = None,
) -> None:
"""Scaffold a new project from templates."""
if proj_type == "service":
"""Scaffold a new project from templates based on stack."""
if stack == "python-fastapi":
_scaffold_service(project_dir, name, package_name, description, port or 9000)
elif proj_type == "tool":
elif stack == "python-cli":
_scaffold_tool(project_dir, name, package_name, description)
elif proj_type == "library":
_scaffold_library(project_dir, name, package_name, description)
else:
raise ValueError(f"Unknown project type: {proj_type}")
raise ValueError(f"No scaffold template for stack: {stack}")
def _scaffold_service(