199 lines
6.9 KiB
Python
199 lines
6.9 KiB
Python
"""castle program create — scaffold a new program from templates."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
|
|
from castle_cli.config import load_config, save_config
|
|
from castle_cli.manifest import (
|
|
BuildSpec,
|
|
CaddyDeployment,
|
|
DefaultsSpec,
|
|
ExposeSpec,
|
|
HttpExposeSpec,
|
|
HttpInternal,
|
|
ManageSpec,
|
|
PathDeployment,
|
|
ProgramSpec,
|
|
Requirement,
|
|
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",
|
|
"hugo": "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/`; hugo to `public/`.
|
|
STACK_BUILD_OUTPUTS: dict[str, str] = {
|
|
"supabase": "public",
|
|
"react-vite": "dist",
|
|
"hugo": "public",
|
|
}
|
|
|
|
# Substrate a stack's apps depend on — seeded as a deployment `requires` at creation
|
|
# so the relationship graph shows it. This keeps `stack` uncoupled from the runtime
|
|
# model: the stack declares the edge once here; the graph only ever reads the encoded
|
|
# `requires`, never the stack. See docs/relationships.md.
|
|
STACK_REQUIRES: dict[str, list[Requirement]] = {
|
|
"supabase": [Requirement(ref="supabase")],
|
|
}
|
|
|
|
|
|
def next_available_port(config: object) -> int:
|
|
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
|
|
used_ports = set()
|
|
for _k, _n, dep in config.all_deployments():
|
|
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 config.deployments_named(name):
|
|
print(f"Error: '{name}' already exists in castle.yaml")
|
|
return 1
|
|
|
|
config.repos_dir.mkdir(parents=True, exist_ok=True)
|
|
project_dir = config.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,
|
|
)
|
|
# The stack's substrate dependency (e.g. supabase) is a deployment-to-deployment
|
|
# `requires` — seeded on the deployment so the relationship graph shows the edge.
|
|
seeded_requires = list(STACK_REQUIRES.get(stack or "", []))
|
|
if kind == "tool":
|
|
# A PATH-managed deployment: installed via `uv tool install`, no unit/route.
|
|
config.tools[name] = PathDeployment(
|
|
id=name,
|
|
manager="path",
|
|
program=name,
|
|
description=description,
|
|
requires=seeded_requires,
|
|
)
|
|
elif kind == "static":
|
|
# A caddy-managed static deployment: no systemd unit, served from the build dir.
|
|
config.statics[name] = CaddyDeployment(
|
|
id=name,
|
|
manager="caddy",
|
|
program=name,
|
|
root=static_root or "dist",
|
|
description=description,
|
|
requires=seeded_requires,
|
|
)
|
|
elif kind == "service":
|
|
prefix = name.replace("-", "_").upper()
|
|
config.services[name] = SystemdDeployment(
|
|
id=name,
|
|
manager="systemd",
|
|
program=name,
|
|
description=description,
|
|
run=RunPython(launcher="python", program=name),
|
|
expose=ExposeSpec(
|
|
http=HttpExposeSpec(
|
|
internal=HttpInternal(port=port),
|
|
health_path="/health",
|
|
)
|
|
),
|
|
requires=seeded_requires,
|
|
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 apply # serve at {name}.<gateway.domain>")
|
|
elif stack == "hugo":
|
|
print(" # edit content/, layouts/ (or add a theme under themes/)")
|
|
print(f" castle program build {name} # hugo --gc --minify -> public/")
|
|
print(f" castle apply {name} # serve at {name}.<gateway.domain>")
|
|
elif stack:
|
|
print(" uv sync")
|
|
if kind == "service":
|
|
print(f" uv run {name} # starts on port {port}")
|
|
print(f" castle apply {name}")
|
|
print(f" castle test {name}")
|
|
else:
|
|
print(" # add code, then declare commands: in castle.yaml")
|
|
|
|
return 0
|