Less stack-centric and location-centric model.
This commit is contained in:
134
cli/src/castle_cli/commands/add.py
Normal file
134
cli/src/castle_cli/commands/add.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""castle add — adopt an existing repo as a program (no scaffolding).
|
||||
|
||||
`castle create` makes new code from a stack. `castle add` adopts code that
|
||||
already exists — a local path, or a git URL to clone. It detects sensible dev
|
||||
verb commands so a non-castle project becomes usable without writing them by hand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from castle_cli.config import REPOS_DIR, load_config, save_config
|
||||
from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec
|
||||
|
||||
|
||||
def _is_git_url(s: str) -> bool:
|
||||
return (
|
||||
s.startswith(("http://", "https://", "git@", "ssh://"))
|
||||
or s.endswith(".git")
|
||||
)
|
||||
|
||||
|
||||
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]], str]:
|
||||
"""Detect (stack, commands, behavior) for a source dir.
|
||||
|
||||
Returns a stack name when the project fits a known one (so it inherits those
|
||||
defaults), otherwise an explicit commands map. behavior defaults to 'tool'.
|
||||
"""
|
||||
stack: str | None = None
|
||||
commands: dict[str, list[list[str]]] = {}
|
||||
behavior = "tool"
|
||||
|
||||
pyproject = src / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
try:
|
||||
data = tomllib.loads(pyproject.read_text())
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
data = {}
|
||||
deps = " ".join(data.get("project", {}).get("dependencies", []))
|
||||
if "fastapi" in deps or "uvicorn" in deps:
|
||||
stack, behavior = "python-fastapi", "daemon"
|
||||
else:
|
||||
stack = "python-cli"
|
||||
return stack, commands, behavior
|
||||
|
||||
if (src / "Cargo.toml").exists():
|
||||
commands = {
|
||||
"build": [["cargo", "build", "--release"]],
|
||||
"test": [["cargo", "test"]],
|
||||
"lint": [["cargo", "clippy"]],
|
||||
"run": [["cargo", "run"]],
|
||||
}
|
||||
return None, commands, "tool"
|
||||
|
||||
if (src / "package.json").exists():
|
||||
commands = {
|
||||
"build": [["pnpm", "build"]],
|
||||
"test": [["pnpm", "test"]],
|
||||
"lint": [["pnpm", "lint"]],
|
||||
}
|
||||
return None, commands, "frontend"
|
||||
|
||||
if (src / "Makefile").exists() or (src / "makefile").exists():
|
||||
commands = {"build": [["make"]], "test": [["make", "test"]]}
|
||||
return None, commands, "tool"
|
||||
|
||||
return None, commands, "tool"
|
||||
|
||||
|
||||
def run_add(args: argparse.Namespace) -> int:
|
||||
"""Adopt an existing repo as a program."""
|
||||
config = load_config()
|
||||
target = args.target
|
||||
|
||||
repo_url: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
if _is_git_url(target):
|
||||
repo_url = target
|
||||
name = args.name or Path(target.rstrip("/")).name.removesuffix(".git")
|
||||
# Default local clone location; cloned later via `castle clone`.
|
||||
source = str(REPOS_DIR / name)
|
||||
src_path = Path(source)
|
||||
else:
|
||||
src_path = Path(target).expanduser().resolve()
|
||||
if not src_path.exists():
|
||||
print(f"Error: path does not exist: {src_path}")
|
||||
return 1
|
||||
source = str(src_path)
|
||||
name = args.name or src_path.name
|
||||
|
||||
if name in config.programs or name in config.services or name in config.jobs:
|
||||
print(f"Error: '{name}' already exists in castle.yaml")
|
||||
return 1
|
||||
|
||||
# Detect verbs from the working copy if we have one on disk.
|
||||
stack: str | None = None
|
||||
detected_commands: dict[str, list[list[str]]] = {}
|
||||
behavior = "tool"
|
||||
if src_path.exists():
|
||||
stack, detected_commands, behavior = _detect(src_path)
|
||||
|
||||
prog = ProgramSpec(
|
||||
id=name,
|
||||
description=args.description or f"Adopted from {target}",
|
||||
source=source,
|
||||
stack=stack,
|
||||
repo=repo_url,
|
||||
behavior=behavior,
|
||||
)
|
||||
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
|
||||
if detected_commands:
|
||||
build_cmds = detected_commands.pop("build", None)
|
||||
if build_cmds:
|
||||
prog.build = BuildSpec(commands=build_cmds)
|
||||
if detected_commands:
|
||||
prog.commands = CommandsSpec.model_validate(detected_commands)
|
||||
|
||||
config.programs[name] = prog
|
||||
save_config(config)
|
||||
|
||||
print(f"Adopted '{name}' as a program.")
|
||||
print(f" source: {source}")
|
||||
if repo_url:
|
||||
print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)")
|
||||
if stack:
|
||||
print(f" stack: {stack} (verbs inherited from stack defaults)")
|
||||
elif detected_commands:
|
||||
print(f" commands detected: {', '.join(sorted(detected_commands))}")
|
||||
else:
|
||||
print(" no stack/commands detected — declare verbs in castle.yaml as needed")
|
||||
return 0
|
||||
62
cli/src/castle_cli/commands/clone.py
Normal file
62
cli/src/castle_cli/commands/clone.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""castle clone — clone source for programs that declare a `repo:` URL.
|
||||
|
||||
Used to provision a fresh machine: every program with a `repo:` and a missing
|
||||
local `source:` gets cloned. A program whose source already exists is skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from castle_cli.config import REPOS_DIR, load_config
|
||||
|
||||
|
||||
def _clone_one(name: str, repo: str, source: str | None, ref: str | None) -> bool:
|
||||
dest = Path(source) if source else REPOS_DIR / name
|
||||
if dest.exists():
|
||||
print(f" {name}: already present at {dest}, skipping")
|
||||
return True
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = ["git", "clone", repo, str(dest)]
|
||||
print(f" {name}: cloning {repo} → {dest}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f" {name}: clone failed:\n{result.stderr}")
|
||||
return False
|
||||
if ref:
|
||||
co = subprocess.run(
|
||||
["git", "-C", str(dest), "checkout", ref], capture_output=True, text=True
|
||||
)
|
||||
if co.returncode != 0:
|
||||
print(f" {name}: checkout {ref} failed:\n{co.stderr}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_clone(args: argparse.Namespace) -> int:
|
||||
config = load_config()
|
||||
|
||||
if getattr(args, "name", None):
|
||||
if args.name not in config.programs:
|
||||
print(f"Unknown program: {args.name}")
|
||||
return 1
|
||||
prog = config.programs[args.name]
|
||||
if not prog.repo:
|
||||
print(f"{args.name} has no repo: URL to clone from")
|
||||
return 1
|
||||
return 0 if _clone_one(args.name, prog.repo, prog.source, prog.ref) else 1
|
||||
|
||||
# Clone all programs that declare a repo: and lack a present source.
|
||||
all_ok = True
|
||||
cloned_any = False
|
||||
for name, prog in config.programs.items():
|
||||
if not prog.repo:
|
||||
continue
|
||||
cloned_any = True
|
||||
if not _clone_one(name, prog.repo, prog.source, prog.ref):
|
||||
all_ok = False
|
||||
if not cloned_any:
|
||||
print("No programs declare a repo: URL.")
|
||||
return 0 if all_ok else 1
|
||||
@@ -3,15 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
|
||||
from castle_cli.config import load_config, save_config
|
||||
from castle_cli.config import REPOS_DIR, load_config, save_config
|
||||
from castle_cli.manifest import (
|
||||
CaddySpec,
|
||||
ProgramSpec,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
ManageSpec,
|
||||
ProgramSpec,
|
||||
ProxySpec,
|
||||
RunPython,
|
||||
ServiceSpec,
|
||||
@@ -43,21 +44,20 @@ def next_available_port(config: object) -> int:
|
||||
|
||||
|
||||
def run_create(args: argparse.Namespace) -> int:
|
||||
"""Create a new project."""
|
||||
"""Create a new project (scaffolded from a stack, or a bare program)."""
|
||||
config = load_config()
|
||||
name = args.name
|
||||
stack = args.stack
|
||||
behavior = STACK_DEFAULTS.get(stack)
|
||||
behavior = STACK_DEFAULTS.get(stack) if stack else None
|
||||
|
||||
if name in config.programs or name in config.services or name in config.jobs:
|
||||
print(f"Error: '{name}' already exists in castle.yaml")
|
||||
return 1
|
||||
|
||||
code_dir = config.root / "code"
|
||||
code_dir.mkdir(exist_ok=True)
|
||||
project_dir = code_dir / name
|
||||
REPOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
project_dir = REPOS_DIR / name
|
||||
if project_dir.exists():
|
||||
print(f"Error: directory 'code/{name}' already exists")
|
||||
print(f"Error: directory already exists: {project_dir}")
|
||||
return 1
|
||||
|
||||
# Determine port for daemons
|
||||
@@ -65,24 +65,29 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
if behavior == "daemon" and port is None:
|
||||
port = next_available_port(config)
|
||||
|
||||
# Package name: convert kebab-case to snake_case
|
||||
package_name = name.replace("-", "_")
|
||||
description = args.description or (f"A castle {stack} program" if stack else f"{name}")
|
||||
|
||||
# Scaffold the project files
|
||||
scaffold_project(
|
||||
project_dir=project_dir,
|
||||
name=name,
|
||||
package_name=package_name,
|
||||
stack=stack,
|
||||
description=args.description or f"A castle {stack} program",
|
||||
port=port,
|
||||
)
|
||||
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)
|
||||
|
||||
# Build entries
|
||||
config.programs[name] = ProgramSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {stack} program",
|
||||
source=f"code/{name}",
|
||||
description=description,
|
||||
source=str(project_dir),
|
||||
stack=stack,
|
||||
behavior=behavior,
|
||||
)
|
||||
@@ -103,16 +108,20 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
|
||||
save_config(config)
|
||||
|
||||
print(f"Created {stack} program '{name}' at {project_dir}")
|
||||
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 ~/.castle/code/{name}")
|
||||
print(" uv sync")
|
||||
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}")
|
||||
print(f" cd {project_dir}")
|
||||
if stack:
|
||||
print(" uv sync")
|
||||
if behavior == "daemon":
|
||||
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
|
||||
|
||||
81
cli/src/castle_cli/commands/delete.py
Normal file
81
cli/src/castle_cli/commands/delete.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""castle delete — remove a program/service/job from the registry.
|
||||
|
||||
Config-only by default (removes the castle.yaml entry; leaves source and any
|
||||
installed binary in place). Use --source to also delete the source directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from castle_cli.config import load_config, save_config
|
||||
|
||||
|
||||
def run_delete(args: argparse.Namespace) -> int:
|
||||
config = load_config()
|
||||
name = args.name
|
||||
|
||||
# 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
|
||||
if not (in_programs or in_services or in_jobs):
|
||||
print(f"Error: '{name}' not found in castle.yaml")
|
||||
return 1
|
||||
|
||||
where = [s for s, present in
|
||||
(("program", in_programs), ("service", in_services), ("job", in_jobs)) if present]
|
||||
|
||||
# Resolve source dir (from the program entry) for the optional --source removal.
|
||||
source_dir: Path | None = None
|
||||
if in_programs and config.programs[name].source:
|
||||
source_dir = Path(config.programs[name].source)
|
||||
|
||||
print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).")
|
||||
if args.source and source_dir:
|
||||
print(f"Will ALSO delete source directory: {source_dir}")
|
||||
|
||||
# Confirm unless --yes.
|
||||
if not args.yes:
|
||||
prompt = f"Delete '{name}'? [y/N] "
|
||||
try:
|
||||
if input(prompt).strip().lower() not in ("y", "yes"):
|
||||
print("Aborted.")
|
||||
return 0
|
||||
except EOFError:
|
||||
print("Aborted (no input). Re-run with --yes to confirm non-interactively.")
|
||||
return 1
|
||||
|
||||
# Remove registry entries.
|
||||
if in_programs:
|
||||
del config.programs[name]
|
||||
if in_services:
|
||||
del config.services[name]
|
||||
if in_jobs:
|
||||
del config.jobs[name]
|
||||
save_config(config)
|
||||
print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).")
|
||||
|
||||
# Optional: delete the source directory.
|
||||
if args.source and source_dir:
|
||||
if source_dir.exists():
|
||||
shutil.rmtree(source_dir)
|
||||
print(f"Deleted source directory: {source_dir}")
|
||||
else:
|
||||
print(f"Source directory not found (already gone): {source_dir}")
|
||||
|
||||
# Warn about runtime artifacts we did NOT touch.
|
||||
if in_services or in_jobs:
|
||||
print(
|
||||
f"\nNote: the systemd unit for '{name}' (if deployed) was NOT removed.\n"
|
||||
f" Run: castle service disable {name}"
|
||||
)
|
||||
if shutil.which(name):
|
||||
print(
|
||||
f"\nNote: '{name}' is still installed on PATH. To remove it:\n"
|
||||
f" castle uninstall {name}"
|
||||
)
|
||||
|
||||
return 0
|
||||
@@ -1,19 +1,23 @@
|
||||
"""castle build / castle test / castle lint - run dev commands across projects."""
|
||||
"""castle build/test/lint/type-check/check/run/install/uninstall — dev verbs.
|
||||
|
||||
Verbs resolve per-program: a declared command (manifest `commands:` / `build:`)
|
||||
overrides the stack default, falling back to the stack handler, else unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from castle_core.stacks import get_handler
|
||||
from castle_core.stacks import is_available, run_action
|
||||
|
||||
from castle_cli.config import CastleConfig, load_config
|
||||
|
||||
|
||||
def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
|
||||
"""Run a stack action for a single project. Returns True on success."""
|
||||
def _run_verb(config: CastleConfig, project_name: str, verb: str) -> bool:
|
||||
"""Run a verb for a single program. Returns True on success."""
|
||||
if project_name not in config.programs:
|
||||
print(f"Unknown component: {project_name}")
|
||||
print(f"Unknown program: {project_name}")
|
||||
return False
|
||||
|
||||
comp = config.programs[project_name]
|
||||
@@ -21,106 +25,69 @@ def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
|
||||
print(f" {project_name}: no source directory, skipping")
|
||||
return True
|
||||
|
||||
handler = get_handler(comp.stack)
|
||||
if handler is None:
|
||||
print(f" {project_name}: unsupported stack '{comp.stack}', skipping")
|
||||
if not is_available(comp, verb):
|
||||
print(f" {project_name}: '{verb}' not available (no declared command or stack), skipping")
|
||||
return True
|
||||
|
||||
method_name = action.replace("-", "_")
|
||||
method = getattr(handler, method_name, None)
|
||||
if method is None:
|
||||
print(f" {project_name}: action '{action}' not supported")
|
||||
return False
|
||||
|
||||
print(f"\n{'─' * 40}")
|
||||
print(f" {action}: {project_name}")
|
||||
print(f" {verb}: {project_name}")
|
||||
print(f"{'─' * 40}")
|
||||
|
||||
result = asyncio.run(method(project_name, comp, config.root))
|
||||
result = asyncio.run(run_action(verb, project_name, comp, config.root))
|
||||
if result.output:
|
||||
print(result.output)
|
||||
|
||||
return result.status == "ok"
|
||||
|
||||
|
||||
def run_test(args: argparse.Namespace) -> int:
|
||||
"""Run tests for one or all projects."""
|
||||
config = load_config()
|
||||
|
||||
if args.project:
|
||||
success = _run_action(config, args.project, "test")
|
||||
return 0 if success else 1
|
||||
|
||||
# Run all
|
||||
def _run_verb_all(config: CastleConfig, verb: str) -> bool:
|
||||
"""Run a verb across every program that supports it. Returns True if all pass."""
|
||||
all_passed = True
|
||||
ran_any = False
|
||||
for name, comp in config.programs.items():
|
||||
if not comp.source:
|
||||
if not comp.source or not is_available(comp, verb):
|
||||
continue
|
||||
handler = get_handler(comp.stack)
|
||||
if handler is None:
|
||||
continue
|
||||
# Skip projects without a tests directory (python) or test script (node)
|
||||
source_dir = config.root / comp.source
|
||||
if comp.stack in ("python-cli", "python-fastapi"):
|
||||
if not (source_dir / "tests").exists():
|
||||
continue
|
||||
if not _run_action(config, name, "test"):
|
||||
ran_any = True
|
||||
if not _run_verb(config, name, verb):
|
||||
all_passed = False
|
||||
if not ran_any:
|
||||
print(f"No programs support '{verb}'.")
|
||||
return all_passed
|
||||
|
||||
if all_passed:
|
||||
print("\nAll tests passed.")
|
||||
else:
|
||||
print("\nSome tests failed.")
|
||||
return 0 if all_passed else 1
|
||||
|
||||
def run_verb(args: argparse.Namespace, verb: str) -> int:
|
||||
"""Generic entry point for a dev verb (single project or all)."""
|
||||
config = load_config()
|
||||
if getattr(args, "project", None):
|
||||
return 0 if _run_verb(config, args.project, verb) else 1
|
||||
ok = _run_verb_all(config, verb)
|
||||
print(f"\n{'All ' + verb + ' passed.' if ok else 'Some ' + verb + ' failed.'}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
# Thin named wrappers wired from main.py.
|
||||
def run_test(args: argparse.Namespace) -> int:
|
||||
return run_verb(args, "test")
|
||||
|
||||
|
||||
def run_lint(args: argparse.Namespace) -> int:
|
||||
"""Run linter for one or all projects."""
|
||||
config = load_config()
|
||||
|
||||
if args.project:
|
||||
success = _run_action(config, args.project, "lint")
|
||||
return 0 if success else 1
|
||||
|
||||
# Run all
|
||||
all_passed = True
|
||||
for name, comp in config.programs.items():
|
||||
if not comp.source:
|
||||
continue
|
||||
handler = get_handler(comp.stack)
|
||||
if handler is None:
|
||||
continue
|
||||
if not _run_action(config, name, "lint"):
|
||||
all_passed = False
|
||||
|
||||
if all_passed:
|
||||
print("\nAll lint checks passed.")
|
||||
else:
|
||||
print("\nSome lint checks failed.")
|
||||
return 0 if all_passed else 1
|
||||
return run_verb(args, "lint")
|
||||
|
||||
|
||||
def run_build(args: argparse.Namespace) -> int:
|
||||
"""Run build for one or all projects."""
|
||||
config = load_config()
|
||||
return run_verb(args, "build")
|
||||
|
||||
if args.project:
|
||||
success = _run_action(config, args.project, "build")
|
||||
return 0 if success else 1
|
||||
|
||||
# Run all buildable projects
|
||||
all_passed = True
|
||||
for name, comp in config.programs.items():
|
||||
if not comp.source:
|
||||
continue
|
||||
handler = get_handler(comp.stack)
|
||||
if handler is None:
|
||||
continue
|
||||
if not _run_action(config, name, "build"):
|
||||
all_passed = False
|
||||
def run_type_check(args: argparse.Namespace) -> int:
|
||||
return run_verb(args, "type-check")
|
||||
|
||||
if all_passed:
|
||||
print("\nAll builds succeeded.")
|
||||
else:
|
||||
print("\nSome builds failed.")
|
||||
return 0 if all_passed else 1
|
||||
|
||||
def run_check(args: argparse.Namespace) -> int:
|
||||
return run_verb(args, "check")
|
||||
|
||||
|
||||
def run_install(args: argparse.Namespace) -> int:
|
||||
return run_verb(args, "install")
|
||||
|
||||
|
||||
def run_uninstall(args: argparse.Namespace) -> int:
|
||||
return run_verb(args, "uninstall")
|
||||
|
||||
@@ -1,39 +1,72 @@
|
||||
"""castle run - run a component in the foreground."""
|
||||
"""castle run - run a program or service in the foreground.
|
||||
|
||||
Unified: if the target is a program with a declared `run` command, run that in
|
||||
the foreground (dev-run). Otherwise fall back to the deployed-service run from
|
||||
the registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.registry import REGISTRY_PATH, load_registry
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
|
||||
def _run_program(name: str, extra: list[str]) -> int | None:
|
||||
"""Run a program's declared `run` command in the foreground.
|
||||
|
||||
Returns the exit code, or None if the program has no declared run command
|
||||
(so the caller can fall back to the deployed-service path).
|
||||
"""
|
||||
config = load_config()
|
||||
prog = config.programs.get(name)
|
||||
if prog is None or prog.commands is None or prog.commands.run is None:
|
||||
return None
|
||||
if not prog.source:
|
||||
print(f"Error: program '{name}' has no source directory.")
|
||||
return 1
|
||||
cwd = Path(prog.source)
|
||||
cmds = prog.commands.run
|
||||
rc = 0
|
||||
for i, argv in enumerate(cmds):
|
||||
# Append extra args to the final command in the sequence.
|
||||
full = list(argv) + (extra if i == len(cmds) - 1 else [])
|
||||
print(f"Running {name}: {' '.join(full)}")
|
||||
rc = subprocess.run(full, cwd=cwd).returncode
|
||||
if rc != 0:
|
||||
break
|
||||
return rc
|
||||
|
||||
|
||||
def run_run(args: argparse.Namespace) -> int:
|
||||
"""Run a component in the foreground using the registry."""
|
||||
"""Run a program (declared run) or a deployed service in the foreground."""
|
||||
name = args.name
|
||||
extra_args = getattr(args, "extra", []) or []
|
||||
|
||||
# 1. Program with a declared run command.
|
||||
prog_rc = _run_program(name, extra_args)
|
||||
if prog_rc is not None:
|
||||
return prog_rc
|
||||
|
||||
# 2. Deployed service from the registry.
|
||||
if not REGISTRY_PATH.exists():
|
||||
print("Error: no registry found. Run 'castle deploy' first.")
|
||||
return 1
|
||||
|
||||
registry = load_registry()
|
||||
name = args.name
|
||||
|
||||
if name not in registry.deployed:
|
||||
print(f"Error: component '{name}' not found in registry.")
|
||||
print("Run 'castle deploy' to update the registry.")
|
||||
print(f"Error: '{name}' is not a runnable program or deployed service.")
|
||||
print("Declare a `run` command in castle.yaml, or run 'castle deploy'.")
|
||||
return 1
|
||||
|
||||
deployed = registry.deployed[name]
|
||||
|
||||
# Build command with any extra args
|
||||
extra_args = getattr(args, "extra", []) or []
|
||||
cmd = list(deployed.run_cmd) + extra_args
|
||||
|
||||
# Merge environment
|
||||
env = dict(os.environ)
|
||||
env.update(deployed.env)
|
||||
|
||||
# Run in foreground (no cwd — registry-based, no repo dependency)
|
||||
print(f"Running {name}: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, env=env)
|
||||
return result.returncode
|
||||
return subprocess.run(cmd, env=env).returncode
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""castle tool - manage tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
DIM = "\033[2m"
|
||||
CYAN = "\033[96m"
|
||||
|
||||
|
||||
def run_tool(args: argparse.Namespace) -> int:
|
||||
"""Manage tools."""
|
||||
if not args.tool_command:
|
||||
print("Usage: castle tool {list|info}")
|
||||
return 1
|
||||
|
||||
if args.tool_command == "list":
|
||||
return _tool_list()
|
||||
elif args.tool_command == "info":
|
||||
return _tool_info(args.name)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _tool_list() -> int:
|
||||
"""List all registered tools."""
|
||||
config = load_config()
|
||||
tools = {k: v for k, v in config.programs.items() if v.behavior == "tool"}
|
||||
|
||||
if not tools:
|
||||
print("No tools registered.")
|
||||
return 0
|
||||
|
||||
print(f"\n{BOLD}{CYAN}Tools{RESET}")
|
||||
print(f"{CYAN}{'─' * 40}{RESET}")
|
||||
for name, manifest in sorted(tools.items()):
|
||||
desc = manifest.description or ""
|
||||
deps = ""
|
||||
if manifest.system_dependencies:
|
||||
deps = f" {DIM}[{', '.join(manifest.system_dependencies)}]{RESET}"
|
||||
print(f" {BOLD}{name:<20}{RESET} {desc}{deps}")
|
||||
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def _tool_info(name: str) -> int:
|
||||
"""Show detailed info about a tool, including .md documentation."""
|
||||
config = load_config()
|
||||
if name not in config.programs:
|
||||
print(f"Error: '{name}' not found")
|
||||
return 1
|
||||
|
||||
manifest = config.programs[name]
|
||||
if manifest.behavior != "tool":
|
||||
print(f"Error: '{name}' is not a tool")
|
||||
return 1
|
||||
|
||||
print(f"\n{BOLD}{name}{RESET}")
|
||||
print(f"{'─' * 40}")
|
||||
if manifest.description:
|
||||
print(f" {manifest.description}")
|
||||
if manifest.version:
|
||||
print(f" {BOLD}version{RESET}: {manifest.version}")
|
||||
if manifest.source:
|
||||
print(f" {BOLD}source{RESET}: {manifest.source}")
|
||||
if manifest.system_dependencies:
|
||||
print(f" {BOLD}requires{RESET}: {', '.join(manifest.system_dependencies)}")
|
||||
|
||||
print()
|
||||
return 0
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
from castle_core.config import * # noqa: F401, F403
|
||||
from castle_core.config import ( # noqa: F401 — explicit re-exports for type checkers
|
||||
ARTIFACTS_DIR,
|
||||
CASTLE_HOME,
|
||||
CODE_DIR,
|
||||
ARTIFACTS_DIR,
|
||||
SPECS_DIR,
|
||||
CONTENT_DIR,
|
||||
DATA_DIR,
|
||||
SECRETS_DIR,
|
||||
GENERATED_DIR,
|
||||
REPOS_DIR,
|
||||
SECRETS_DIR,
|
||||
SPECS_DIR,
|
||||
STATIC_DIR,
|
||||
CastleConfig,
|
||||
GatewayConfig,
|
||||
|
||||
@@ -37,11 +37,30 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
create_parser.add_argument(
|
||||
"--stack",
|
||||
choices=["python-cli", "python-fastapi", "react-vite"],
|
||||
required=True,
|
||||
help="Development stack (determines scaffold template and default behavior)",
|
||||
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)")
|
||||
|
||||
# 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")
|
||||
|
||||
# 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 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("project", help="Program, service, or job name")
|
||||
@@ -59,6 +78,20 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
build_parser = subparsers.add_parser("build", help="Build projects")
|
||||
build_parser.add_argument("project", nargs="?", help="Project to build (default: all)")
|
||||
|
||||
# castle type-check
|
||||
tc_parser = subparsers.add_parser("type-check", help="Run type checker")
|
||||
tc_parser.add_argument("project", nargs="?", help="Project 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("project", nargs="?", help="Project to check (default: all)")
|
||||
|
||||
# castle install / uninstall
|
||||
install_parser = subparsers.add_parser("install", help="Install a program to PATH")
|
||||
install_parser.add_argument("project", nargs="?", help="Program to install (default: all)")
|
||||
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall a program")
|
||||
uninstall_parser.add_argument("project", nargs="?", help="Program to uninstall (default: all)")
|
||||
|
||||
# castle gateway
|
||||
gateway_parser = subparsers.add_parser("gateway", help="Manage the Caddy gateway")
|
||||
gateway_sub = gateway_parser.add_subparsers(dest="gateway_command")
|
||||
@@ -99,24 +132,17 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
|
||||
)
|
||||
|
||||
# castle run
|
||||
run_parser = subparsers.add_parser("run", help="Run a service in the foreground")
|
||||
run_parser.add_argument("name", help="Service name")
|
||||
# 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 service"
|
||||
"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)")
|
||||
|
||||
# castle tool
|
||||
tool_parser = subparsers.add_parser("tool", help="Manage tools")
|
||||
tool_sub = tool_parser.add_subparsers(dest="tool_command")
|
||||
tool_sub.add_parser("list", help="List all tools")
|
||||
tool_info_parser = tool_sub.add_parser("info", help="Show tool details")
|
||||
tool_info_parser.add_argument("name", help="Tool name")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -145,6 +171,21 @@ def main() -> int:
|
||||
|
||||
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 == "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
|
||||
|
||||
@@ -160,6 +201,26 @@ def main() -> int:
|
||||
|
||||
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
|
||||
|
||||
@@ -190,11 +251,6 @@ def main() -> int:
|
||||
|
||||
return run_run(args)
|
||||
|
||||
elif args.command == "tool":
|
||||
from castle_cli.commands.tool import run_tool
|
||||
|
||||
return run_tool(args)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
BuildSpec,
|
||||
CaddySpec,
|
||||
Capability,
|
||||
ProgramSpec,
|
||||
CommandsSpec,
|
||||
DefaultsSpec,
|
||||
EnvMap,
|
||||
ExposeSpec,
|
||||
@@ -14,6 +14,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
HttpPublic,
|
||||
JobSpec,
|
||||
ManageSpec,
|
||||
ProgramSpec,
|
||||
ProxySpec,
|
||||
ReadinessHttpGet,
|
||||
RestartPolicy,
|
||||
|
||||
Reference in New Issue
Block a user