Files
wild-pc/cli/src/castle_cli/commands/dev.py
Paul Payne 2953aea548 Runtime half: convergent deploy + unified active lifecycle
Convergent deploy (prefix-based prune): castle deploy now removes orphaned
castle-* systemd units/timers no longer in the registry. Full deploy only;
partial (--name) deploys preserve siblings. Fixes the orphaned-timer / stale-
path drift class.

Container runner: derive --name from the service name (castle-<name>) instead
of the image name; skip the <PREFIX>_DATA_DIR env injection for containers
(avoids colliding with image env namespaces like NEO4J_*).

Unified active lifecycle (core/lifecycle.py): install/uninstall keep their
names but become activate/deactivate, dispatching by behavior — tool→PATH,
daemon/self-serving-frontend/job→systemd, static frontend→served. is_active
reports a uniform state (PATH-independent for tools). The CLI install/uninstall
and service enable/disable route through the extracted core.

API + dashboard: surface a uniform `active` state on programs alongside
`installed`; ProgramDetail shows active/inactive.

Tests: test_deploy_prune (6), test_lifecycle (5). 168 tests pass; lint clean.
2026-06-13 18:08:54 -07:00

125 lines
4.1 KiB
Python

"""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 is_available, run_action
from castle_cli.config import CastleConfig, load_config
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 program: {project_name}")
return False
comp = config.programs[project_name]
if not comp.source:
print(f" {project_name}: no source directory, skipping")
return True
if not is_available(comp, verb):
print(f" {project_name}: '{verb}' not available (no declared command or stack), skipping")
return True
print(f"\n{'' * 40}")
print(f" {verb}: {project_name}")
print(f"{'' * 40}")
result = asyncio.run(run_action(verb, project_name, comp, config.root))
if result.output:
print(result.output)
return result.status == "ok"
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 or not is_available(comp, verb):
continue
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
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:
return run_verb(args, "lint")
def run_build(args: argparse.Namespace) -> int:
return run_verb(args, "build")
def run_type_check(args: argparse.Namespace) -> int:
return run_verb(args, "type-check")
def run_check(args: argparse.Namespace) -> int:
return run_verb(args, "check")
def _lifecycle(config: CastleConfig, name: str, deactivate: bool) -> bool:
"""Activate or deactivate a single program. Returns True on success."""
from castle_core.lifecycle import activate
from castle_core.lifecycle import deactivate as do_deactivate
if name not in config.programs and name not in config.services and name not in config.jobs:
print(f"Unknown program: {name}")
return False
verb = "deactivate" if deactivate else "activate"
print(f"\n{'' * 40}\n {verb}: {name}\n{'' * 40}")
fn = do_deactivate if deactivate else activate
result = asyncio.run(fn(name, config, config.root))
if result.output:
print(result.output)
return result.status == "ok"
def run_install(args: argparse.Namespace) -> int:
"""Activate (install/deploy/serve) a program — verb word kept, meaning unified."""
config = load_config()
if getattr(args, "project", None):
return 0 if _lifecycle(config, args.project, deactivate=False) else 1
ok = all(
_lifecycle(config, name, deactivate=False)
for name, comp in config.programs.items()
if comp.source
)
return 0 if ok else 1
def run_uninstall(args: argparse.Namespace) -> int:
"""Deactivate (uninstall/stop/unpublish) a program."""
config = load_config()
if getattr(args, "project", None):
return 0 if _lifecycle(config, args.project, deactivate=True) else 1
print("Refusing to deactivate all programs; name a target.")
return 1