Rename Castle -> Wild PC across the repo

Repo-side rename only (Phases 1-3 of the migration plan); the live box
(~/.castle, systemd units, /data/castle, domains) is a separate cutover.

- Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api),
  dist names, entry point `wildpc = wildpc_cli.main:main`.
- Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*.
- Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml ->
  wildpc.yaml, /data/castle -> /data/wildpc.
- Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc.
- Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend.
- Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated;
  redundant nested uv.lock files dropped (workspace root lock is authoritative).

Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds.
Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
This commit is contained in:
2026-07-18 22:55:08 -07:00
parent 25d7a522cc
commit 05b28cb584
190 changed files with 2428 additions and 3337 deletions

View File

@@ -0,0 +1,223 @@
"""wildpc service / wildpc job — manage systemd service & timer units."""
from __future__ import annotations
import argparse
import subprocess
from wildpc_core.generators.systemd import (
SYSTEMD_USER_DIR,
timer_name,
unit_name,
)
from wildpc_cli.config import (
WildpcConfig,
load_config,
)
# Re-export for use by other commands
UNIT_PREFIX = "wildpc-"
def _install_unit(uname: str, content: str) -> None:
"""Write a systemd unit file."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
unit_path = SYSTEMD_USER_DIR / uname
unit_path.write_text(content)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
def _remove_unit(uname: str) -> None:
"""Remove a systemd unit file."""
unit_path = SYSTEMD_USER_DIR / uname
if unit_path.exists():
unit_path.unlink()
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
_PAST = {"start": "started", "stop": "stopped", "restart": "restarted"}
def run_service_cmd(args: argparse.Namespace) -> int:
"""`wildpc service restart <name>` — the imperative bounce (only verb left).
Lifecycle (deploy/enable/disable/start/stop) is now convergence: `wildpc apply`.
"""
config = load_config()
return _unit_action(config, args.name, "restart", "service")
def run_job_cmd(args: argparse.Namespace) -> int:
"""`wildpc job restart <name>` — bounce the job's timer."""
config = load_config()
return _unit_action(config, args.name, "restart", "job")
def run_restart(args: argparse.Namespace) -> int:
"""Top-level `wildpc restart [name]` — bounce one deployment, or all of them.
An imperative op: it re-actualizes current desired state, it does not change it
(that's `wildpc apply`). A bare name bounces every kind sharing it.
"""
config = load_config()
name = getattr(args, "name", None)
if not name:
return _services_restart(config)
named = config.deployments_named(name)
if not named:
print(f"Error: no deployment '{name}'.")
return 1
rc = 0
for kind, _spec in named:
rc |= _unit_action(config, name, "restart", kind)
return rc
_GATEWAY_NAME = "wildpc-gateway"
def _unit_action(config: WildpcConfig, name: str, action: str, kind: str) -> int:
"""start/stop/restart one deployment (name, kind), dispatched by its manager.
systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway;
path (a tool) → install/uninstall; none (remote) → nothing to do.
"""
dep = config.deployment(kind, name)
if dep is None:
print(f"Error: no {kind} '{name}'.")
return 1
manager = dep.manager
if manager != "systemd":
return _managed_lifecycle(config, name, action, manager, kind)
# A scheduled systemd deployment (a job) is driven by its .timer.
unit = timer_name(name) if kind == "job" else unit_name(name, kind)
result = subprocess.run(["systemctl", "--user", action, unit], check=False)
if result.returncode != 0:
print(f"Error: failed to {action} {unit}")
return 1
print(f" {name}: {_PAST[action]}")
return 0
def _managed_lifecycle(
config: WildpcConfig, name: str, action: str, manager: str, kind: str
) -> int:
"""Lifecycle for non-systemd managers (no unit to systemctl)."""
if manager == "caddy":
if action == "stop":
print(f" {name}: gateway-served — disable or remove it to drop the route.")
return 0
# start/restart → reload the gateway so current routes take effect.
subprocess.run(["systemctl", "--user", "reload", unit_name(_GATEWAY_NAME)], check=False)
print(f" {name}: gateway reloaded ({_PAST[action]}).")
return 0
if manager == "path":
return _path_lifecycle(config, name, action, kind)
# none (remote): external, nothing local to act on.
print(f" {name}: external ({manager}) — nothing to {action}.")
return 0
def _path_lifecycle(config: WildpcConfig, name: str, action: str, kind: str) -> int:
"""A `path` (tool) deployment's lifecycle is install/uninstall on PATH."""
import asyncio
from wildpc_core.lifecycle import activate, deactivate
# stop → uninstall; start/restart → ensure installed (activate skips if on PATH).
coro = deactivate if action == "stop" else activate
res = asyncio.run(coro(name, kind, config, config.root))
print(f" {res.output}")
return 0 if res.status == "ok" else 1
def _services_restart(config: WildpcConfig) -> int:
"""Restart every systemd-managed deployment (service or job) unit.
caddy/path/none deployments have no unit — they ride along with the gateway
restart (static) or are stateless (remote), so we don't systemctl them here.
"""
for kind, name, dep in config.all_deployments():
if dep.manager != "systemd":
continue
if kind == "job":
subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False)
print(f" {name}: restarted (timer)")
else:
subprocess.run(["systemctl", "--user", "restart", unit_name(name, kind)], check=False)
print(f" {name}: restarted")
return 0
def run_status(args: argparse.Namespace) -> int:
"""Unified status across the platform: services + jobs + programs."""
from wildpc_core.lifecycle import is_active
config = load_config()
# Services + jobs (deployment state); the gateway appears here as a service.
_service_status(config)
# Programs (catalog activation: tools on PATH, statics served by the gateway)
catalog = {
n: c
for n, c in config.programs.items()
if n not in config.services and n not in config.jobs
}
if catalog:
print(f"{'' * 50}")
print("Programs")
for name, _comp in catalog.items():
_pk = sorted({k for _, k in config.deployments_of(name)})
on = is_active(name, _pk[0] if _pk else "tool", config)
color = "\033[92m" if on else "\033[90m"
label = "active" if on else "inactive"
kinds = sorted({k for _, k in config.deployments_of(name)})
tag = ", ".join(kinds) if kinds else "program"
print(f" {color}{label:10s}\033[0m {name} ({tag})")
print()
return 0
def _service_status(config: WildpcConfig) -> int:
"""Show status of all services and jobs, dispatched by manager."""
from wildpc_core.lifecycle import is_active
print("\nWild PC Services")
print("=" * 50)
for name, svc in config.services.items():
active = is_active(name, "service", config) # manager-aware
manager = svc.manager
color = "\033[92m" if active else "\033[90m"
reset = "\033[0m"
label = "active" if active else "inactive"
port_str = ""
if svc.expose and svc.expose.http:
port_str = f":{svc.expose.http.internal.port}"
print(f" {color}{label:10s}{reset} {name}{port_str} \033[90m[{manager}]{reset}")
if config.jobs:
print(f"\n{'' * 50}")
print("Jobs")
for name in config.jobs:
tmr_unit = timer_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True,
text=True,
)
status = result.stdout.strip()
if status in ("active", "waiting"):
color = "\033[92m"
elif status == "inactive":
color = "\033[90m"
else:
color = "\033[91m"
reset = "\033[0m"
print(f" {color}{status:10s}{reset} {name} (timer)")
print()
return 0