A program has no single kind — it HAS deployments, each with its own kind (a
program can be a tool AND a job, e.g. protonmail). Remove program-level kind:
- core: drop ProgramSpec.kind; CastleConfig.kind_of → deployments_of(name) →
[(deployment-name, kind)]; tools property derives from a tool deployment.
- api: ProgramSummary drops kind/services/jobs → deployments: [{name, kind}];
/programs?kind= filters by deployment-kind membership; a program's legacy
DeploymentSummary carries kind=None.
- cli: list/info show a program's set of deployment kinds; --kind filters by
membership.
Also: Services page now covers statics — /services returns kind in
{service, static} (both are exposed, URL-reachable 'services', caddy vs systemd),
and ServiceSummary gains kind + manager to distinguish them.
Suites: core 124, cli 25, castle-api 58.
170 lines
5.6 KiB
Python
170 lines
5.6 KiB
Python
"""castle program create — scaffold a new program from templates."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
|
|
from castle_cli.config import REPOS_DIR, load_config, save_config
|
|
from castle_cli.manifest import (
|
|
BuildSpec,
|
|
CaddyDeployment,
|
|
DefaultsSpec,
|
|
ExposeSpec,
|
|
HttpExposeSpec,
|
|
HttpInternal,
|
|
ManageSpec,
|
|
PathDeployment,
|
|
ProgramSpec,
|
|
RunPython,
|
|
SystemdDeployment,
|
|
SystemdSpec,
|
|
)
|
|
from castle_cli.templates.scaffold import scaffold_project
|
|
|
|
# Stack determines the default deployment kind + scaffold template.
|
|
STACK_DEFAULTS: dict[str, str] = {
|
|
"python-fastapi": "service",
|
|
"python-cli": "tool",
|
|
"react-vite": "static",
|
|
"supabase": "static",
|
|
}
|
|
|
|
# Static build output per stack, for `static` (caddy) deployments. The gateway
|
|
# serves this dir in place at <name>.<gateway.domain> (no service, no process).
|
|
# A supabase app ships a raw `public/`; react-vite builds to `dist/`.
|
|
STACK_BUILD_OUTPUTS: dict[str, str] = {
|
|
"supabase": "public",
|
|
"react-vite": "dist",
|
|
}
|
|
|
|
|
|
def next_available_port(config: object) -> int:
|
|
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
|
|
used_ports = set()
|
|
for dep in config.deployments.values():
|
|
expose = getattr(dep, "expose", None)
|
|
if expose and expose.http:
|
|
used_ports.add(expose.http.internal.port)
|
|
# Also reserve gateway port
|
|
used_ports.add(config.gateway.port)
|
|
|
|
port = 9001
|
|
while port in used_ports:
|
|
port += 1
|
|
return port
|
|
|
|
|
|
def run_create(args: argparse.Namespace) -> int:
|
|
"""Create a new project (scaffolded from a stack, or a bare program)."""
|
|
config = load_config()
|
|
name = args.name
|
|
stack = args.stack
|
|
kind = STACK_DEFAULTS.get(stack) if stack else None
|
|
|
|
if name in config.programs or name in config.deployments:
|
|
print(f"Error: '{name}' already exists in castle.yaml")
|
|
return 1
|
|
|
|
REPOS_DIR.mkdir(parents=True, exist_ok=True)
|
|
project_dir = REPOS_DIR / name
|
|
if project_dir.exists():
|
|
print(f"Error: directory already exists: {project_dir}")
|
|
return 1
|
|
|
|
# Determine port for service (daemon) deployments
|
|
port = args.port
|
|
if kind == "service" and port is None:
|
|
port = next_available_port(config)
|
|
|
|
package_name = name.replace("-", "_")
|
|
description = args.description or (f"A castle {stack} program" if stack else f"{name}")
|
|
|
|
if stack:
|
|
scaffold_project(
|
|
project_dir=project_dir,
|
|
name=name,
|
|
package_name=package_name,
|
|
stack=stack,
|
|
description=description,
|
|
port=port,
|
|
)
|
|
else:
|
|
# Bare program: empty source tree, no scaffold; user declares commands later.
|
|
project_dir.mkdir(parents=True)
|
|
|
|
# Initialize a git repo for the new source.
|
|
subprocess.run(["git", "init", "-q", str(project_dir)], check=False)
|
|
|
|
# Frontend stacks declare a build output; the program builds it, a `static`
|
|
# service serves it in place at <name>.<gateway.domain>.
|
|
build = None
|
|
static_root = STACK_BUILD_OUTPUTS.get(stack)
|
|
if static_root:
|
|
build = BuildSpec(outputs=[static_root])
|
|
|
|
# `kind` (and thus behavior) is derived from the deployment below — never
|
|
# stored on the program.
|
|
config.programs[name] = ProgramSpec(
|
|
id=name,
|
|
description=description,
|
|
source=str(project_dir),
|
|
stack=stack,
|
|
build=build,
|
|
)
|
|
if kind == "tool":
|
|
# A PATH-managed deployment: installed via `uv tool install`, no unit/route.
|
|
config.deployments[name] = PathDeployment(
|
|
id=name, manager="path", program=name
|
|
)
|
|
elif kind == "static":
|
|
# A caddy-managed static deployment: no systemd unit, served from the build dir.
|
|
config.deployments[name] = CaddyDeployment(
|
|
id=name, manager="caddy", program=name, root=static_root or "dist"
|
|
)
|
|
elif kind == "service":
|
|
prefix = name.replace("-", "_").upper()
|
|
config.deployments[name] = SystemdDeployment(
|
|
id=name,
|
|
manager="systemd",
|
|
program=name,
|
|
run=RunPython(launcher="python", program=name),
|
|
expose=ExposeSpec(
|
|
http=HttpExposeSpec(
|
|
internal=HttpInternal(port=port),
|
|
health_path="/health",
|
|
)
|
|
),
|
|
proxy=True, # expose at <name>.<gateway.domain>
|
|
manage=ManageSpec(systemd=SystemdSpec()),
|
|
# python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's
|
|
# computed port/data dir to those vars (explicit, no hidden injection).
|
|
defaults=DefaultsSpec(
|
|
env={f"{prefix}_PORT": "${port}", f"{prefix}_DATA_DIR": "${data_dir}"}
|
|
),
|
|
)
|
|
|
|
save_config(config)
|
|
|
|
label = f"{stack} program" if stack else "bare program"
|
|
print(f"Created {label} '{name}' at {project_dir}")
|
|
if port:
|
|
print(f" Port: {port}")
|
|
print(" Registered in castle.yaml")
|
|
print("\nNext steps:")
|
|
print(f" cd {project_dir}")
|
|
if stack == "supabase":
|
|
print(" # edit migrations/, functions/, public/ — targets the shared substrate")
|
|
print(f" castle program build {name} # apply migrations to the substrate")
|
|
print(f" castle deploy && castle gateway reload # serve at /{name}/")
|
|
elif stack:
|
|
print(" uv sync")
|
|
if kind == "service":
|
|
print(f" uv run {name} # starts on port {port}")
|
|
print(f" castle deploy {name}")
|
|
print(f" castle test {name}")
|
|
else:
|
|
print(" # add code, then declare commands: in castle.yaml")
|
|
|
|
return 0
|