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

View File

@@ -0,0 +1,45 @@
"""wildpc program add — adopt an existing repo as a program (no scaffolding).
`wildpc program create` makes new code from a stack. `wildpc program add` adopts code that
already exists — a local path, or a git URL to clone. It detects sensible dev
verb commands so a non-wildpc project becomes usable without writing them by hand.
The adopt logic itself lives in ``wildpc_core.adopt`` so the CLI and the API
(`POST /programs/adopt`, the dashboard's "Add program") behave identically.
"""
from __future__ import annotations
import argparse
from wildpc_core.adopt import AdoptError, build_adopted_program
from wildpc_cli.config import load_config, save_config
def run_add(args: argparse.Namespace) -> int:
"""Adopt an existing repo as a program."""
config = load_config()
try:
adopted = build_adopted_program(
config, args.target, name=args.name, description=args.description
)
except AdoptError as e:
print(f"Error: {e}")
return 1
config.programs[adopted.name] = adopted.spec
save_config(config)
print(f"Adopted '{adopted.name}' as a program.")
print(f" source: {adopted.source}")
if adopted.repo:
print(f" repo: {adopted.repo} (run 'wildpc clone {adopted.name}' to fetch it)")
if adopted.stack:
print(f" stack: {adopted.stack} (verbs inherited from stack defaults)")
elif adopted.commands:
print(f" commands detected: {', '.join(adopted.commands)}")
else:
print(" no stack/commands detected — declare verbs in wildpc.yaml as needed")
return 0

View File

@@ -0,0 +1,72 @@
"""wildpc apply — converge the running system to match config.
The one workhorse verb: renders systemd units + the Caddyfile + tunnel config,
then reconciles the runtime (activate what's enabled and down, restart what
changed, deactivate what's disabled). Replaces the old `deploy && start` plus the
per-kind enable/disable/install/uninstall verbs.
`--plan` computes and prints the diff without writing or touching the runtime.
"""
from __future__ import annotations
import argparse
_C = {
"activate": "\033[32m", # green
"restart": "\033[33m", # yellow
"deactivate": "\033[31m", # red
"reset": "\033[0m",
"dim": "\033[90m",
}
def _line(verb_color: str, verb: str, names: list[str]) -> None:
if not names:
return
print(f" {verb_color}{verb}{_C['reset']} {', '.join(sorted(names))}")
def run_apply(args: argparse.Namespace) -> int:
from wildpc_core.deploy import apply
target = getattr(args, "name", None)
plan = getattr(args, "plan", False)
result = apply(target_name=target, plan=plan)
# Unresolved secrets abort the run before any change — print the error block
# (which names each deployment, the missing ${secret:…}, and the fix) and exit
# non-zero so a broken credential never slips through as a bogus placeholder.
if result.blocked:
for msg in result.messages:
print(f" {_C['deactivate']}{msg}{_C['reset']}")
return 1
# Surface any warnings the render produced (acme prerequisites, tunnel notes).
for msg in result.messages:
if msg.startswith("Warning"):
print(f" {_C['dim']}{msg}{_C['reset']}")
if plan:
print("\n\033[1mPlan\033[0m " + _C["dim"] + "(no changes made)" + _C["reset"])
if not result.changed:
print(f" {_C['dim']}nothing to do — already converged{_C['reset']}")
return 0
_line(_C["activate"], "would activate ", result.activated)
_line(_C["restart"], "would restart ", result.restarted)
_line(_C["deactivate"], "would deactivate", result.deactivated)
if result.gateway_changed:
print(f" {_C['restart']}would reload {_C['reset']} gateway routes")
return 0
print("\n\033[1mApplied\033[0m")
if not result.changed:
print(f" {_C['dim']}nothing to do — already converged{_C['reset']}")
return 0
_line(_C["activate"], "activated ", result.activated)
_line(_C["restart"], "restarted ", result.restarted)
_line(_C["deactivate"], "deactivated", result.deactivated)
if result.gateway_changed:
print(f" {_C['restart']}reloaded {_C['reset']} gateway routes")
return 0

View File

@@ -0,0 +1,62 @@
"""wildpc 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 wildpc_cli.config import load_config
def _clone_one(name: str, repo: str, source: str | None, ref: str | None, repos_dir: Path) -> 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, config.repos_dir) 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, config.repos_dir):
all_ok = False
if not cloned_any:
print("No programs declare a repo: URL.")
return 0 if all_ok else 1

View File

@@ -0,0 +1,204 @@
"""wildpc program create — scaffold a new program from templates."""
from __future__ import annotations
import argparse
import subprocess
from wildpc_cli.config import load_config, save_config
from wildpc_cli.manifest import (
BuildSpec,
CaddyDeployment,
DefaultsSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ManageSpec,
PathDeployment,
ProgramSpec,
Requirement,
RunPython,
SystemdDeployment,
SystemdSpec,
)
from wildpc_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",
}
# Stacks whose static output is a multi-page content site (not a single-page app):
# the gateway resolves directory indexes and 404s missing paths instead of falling
# back to the root index.html. See CaddyDeployment.spa.
CONTENT_SITE_STACKS: set[str] = {"hugo"}
# 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 wildpc.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 wildpc {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",
spa=stack not in CONTENT_SITE_STACKS,
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 wildpc'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 wildpc.yaml")
print("\nNext steps:")
print(f" cd {project_dir}")
if stack == "supabase":
print(" # edit migrations/, functions/, public/ — targets the shared substrate")
print(f" wildpc program build {name} # apply migrations to the substrate")
print(f" wildpc apply # serve at {name}.<gateway.domain>")
elif stack == "hugo":
print(" # edit content/, layouts/ (or add a theme under themes/)")
print(f" wildpc program build {name} # hugo --gc --minify -> public/")
print(f" wildpc 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" wildpc apply {name}")
print(f" wildpc test {name}")
else:
print(" # add code, then declare commands: in wildpc.yaml")
return 0

View File

@@ -0,0 +1,182 @@
"""wildpc delete — remove a program/deployment from the registry AND tear it down.
Deleting cascades: a program's referencing deployments are taken offline (stop +
disable a service/job, uninstall a tool from PATH, drop a static route) before the
config entries are removed, then the runtime is reconciled (`deploy()` prunes
orphan units, regenerates the Caddyfile, reloads the gateway + tunnel) so nothing
is left running or served. Use --source to also delete the source directory.
Persistent *data* a program's stack created (e.g. a Supabase app's Postgres
schema) is destroyed only with --purge-data — it survives an ordinary delete and
is surfaced as a remnant otherwise. One remnant is still only surfaced, not
auto-removed (pending a DNS-token decision): a public service's Cloudflare CNAME.
"""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
from wildpc_cli.config import load_config, save_config
def run_delete(args: argparse.Namespace) -> int:
config = load_config()
name = args.name
resource = getattr(args, "resource", None) # "program" | "service" | "job" | ...
# Resolve which sections this delete touches (scoped to one resource). Any
# deployment resource name (service/job/tool/static/deployment) targets the
# single deployments/ collection — the kind is derived, not a separate section.
_DEPLOY_RESOURCES = (None, "service", "job", "tool", "static", "deployment")
in_programs = name in config.programs and resource in (None, "program")
named = config.deployments_named(name) # [(kind, spec), ...] sharing this name
in_deployment = bool(named) and resource in _DEPLOY_RESOURCES
if not (in_programs or in_deployment):
where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in wildpc.yaml")
return 1
where = [
s for s, present in (("program", in_programs), ("deployment", in_deployment)) if present
]
# Cascade: every deployment referencing this program is torn down and removed
# (a program and its 1:1 service/tool/static are one thing to the user). A
# deployment-only delete targets just the co-named deployment.
# Each entry is (kind, name) — the deployment's identity.
deployments_to_remove: list[tuple[str, str]] = []
if in_programs:
deployments_to_remove = [
(kind, n) for kind, n, spec in config.all_deployments() if spec.program == name
]
if in_deployment:
for kind, _spec in named:
if (kind, name) not in deployments_to_remove:
deployments_to_remove.append((kind, name))
# Capture remnant facts BEFORE mutating config: public CNAMEs, and the program
# entry itself — its stack may own persistent data (a DB schema) that survives
# a code delete unless --purge-data drops it via the stack's `teardown`.
public_hosts = _public_hosts(config, deployments_to_remove)
from wildpc_core.stacks import get_handler
program_spec = config.programs.get(name) if in_programs else None
stack = program_spec.stack if program_spec else None
handler = get_handler(stack)
owns_data = bool(getattr(handler, "owns_data", False)) and program_spec is not None
# 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)
purge_data = getattr(args, "purge_data", False)
print(f"Will remove '{name}' from wildpc.yaml ({', '.join(where)}).")
if deployments_to_remove:
print(
"Will tear down deployment(s): "
+ ", ".join(f"{n} ({k})" for k, n in deployments_to_remove)
)
if args.source and source_dir:
print(f"Will ALSO delete source directory: {source_dir}")
if owns_data and purge_data:
print(f"Will ALSO destroy persistent data (stack: {stack}).")
# 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
# Take each deployment offline in its mode, then drop the config entry. Teardown
# is best-effort — the config is removed even if the runtime is already gone.
if deployments_to_remove:
import asyncio
from wildpc_core.lifecycle import deactivate
for kind, d in deployments_to_remove:
try:
res = asyncio.run(deactivate(d, kind, config, config.root))
if getattr(res, "message", None):
print(f" {res.message}")
except Exception as e:
print(f" warning: teardown of '{d}' failed: {e}")
config.store_for(kind).pop(d, None)
if in_programs:
del config.programs[name]
save_config(config)
print(f"Removed '{name}' from wildpc.yaml ({', '.join(where)}).")
# Converge the runtime: prune orphan units, regenerate the Caddyfile (dropping
# the static route), reload the gateway + tunnel.
if deployments_to_remove:
from wildpc_core.deploy import deploy
try:
deploy()
print("Reconciled runtime (wildpc apply).")
except Exception as e:
print(f"warning: reconcile failed — run 'wildpc apply': {e}")
# 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}")
# Surface the remnants we don't yet auto-remove.
if public_hosts:
print("\nNote: public DNS record(s) still exist in Cloudflare (not auto-removed):")
for h in public_hosts:
print(f" - {h}")
print(" Remove them in the Cloudflare dashboard for the public zone.")
# Persistent data the stack owns: destroy it on --purge-data, else surface it.
if owns_data:
if purge_data:
import asyncio
try:
res = asyncio.run(handler.teardown(name, program_spec, config.root))
print(f"\n{res.output}")
if res.status != "ok":
print(" warning: data teardown reported an error (see above).")
except Exception as e:
print(f"\nwarning: data teardown failed: {e}")
else:
print(
f"\nNote: this program's persistent data (stack: {stack}) was left "
"intact.\n Re-run with --purge-data to destroy it."
)
return 0
def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]:
"""The Cloudflare CNAMEs (a ``public_host`` override, else
<subdomain>.<public_domain>) of any public deployments being removed —
surfaced so the operator can clean up DNS."""
gw = getattr(config, "gateway", None)
public_domain = getattr(gw, "public_domain", None) if gw else None
hosts: list[str] = []
for kind, d in deployments:
spec = config.deployment(kind, d)
if spec is None or not getattr(spec, "public", False):
continue
override = getattr(spec, "public_host", None)
if override:
hosts.append(override)
elif public_domain:
sub = getattr(spec, "subdomain", None) or d
hosts.append(f"{sub}.{public_domain}")
return hosts

View File

@@ -0,0 +1,123 @@
"""wildpc service create / wildpc job create — declare a deployment.
A service or job can run anything (a wildpc program or not). `--program`
records a convenience reference for description fallthrough; the run target is
the console script (python) or argv (command) to execute.
"""
from __future__ import annotations
import argparse
from wildpc_cli.config import load_config, save_config
from wildpc_cli.manifest import (
DefaultsSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ManageSpec,
Reach,
RunCommand,
RunPython,
SystemdDeployment,
SystemdSpec,
)
def _defaults(env_args: list[str] | None) -> DefaultsSpec | None:
"""Parse repeated --env KEY=VALUE into a DefaultsSpec, or None."""
if not env_args:
return None
env: dict[str, str] = {}
for item in env_args:
key, _, value = item.partition("=")
env[key.strip()] = value
return DefaultsSpec(env=env)
def _run_spec(launcher: str, target: str, name: str) -> RunPython | RunCommand:
if launcher == "command":
return RunCommand(launcher="command", argv=target.split() or [name])
return RunPython(launcher="python", program=target or name)
def _check_new(config: object, name: str, label: str) -> str | None:
"""Return an error message if the deployment name is taken, else None."""
if config.deployments_named(name):
return f"Error: {label} '{name}' already exists."
return None
def run_service_create(args: argparse.Namespace) -> int:
"""Create a service entry in wildpc.yaml."""
config = load_config()
name = args.name
if err := _check_new(config, name, "service"):
print(err)
return 1
run = _run_spec(args.launcher, args.run or args.program or name, name)
expose = None
reach = Reach.OFF
if args.port is not None:
expose = ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=args.port),
health_path=args.health,
)
)
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
reach = Reach.OFF if args.no_proxy else Reach.INTERNAL
config.services[name] = SystemdDeployment(
id=name,
manager="systemd",
program=args.program,
description=args.description or None,
run=run,
expose=expose,
reach=reach,
manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
)
save_config(config)
print(f"Created service '{name}'.")
print(f" runs: {args.launcher} ({args.run or args.program or name})")
if expose:
print(f" port: {args.port}")
if reach != Reach.OFF:
print(f" subdomain: {name}.<gateway.domain>")
print(f"\nNext: wildpc apply {name}")
return 0
def run_job_create(args: argparse.Namespace) -> int:
"""Create a job entry in wildpc.yaml."""
config = load_config()
name = args.name
if err := _check_new(config, name, "job"):
print(err)
return 1
run = _run_spec(args.launcher, args.run or args.program or name, name)
# A job is a systemd deployment with a schedule (→ a .timer).
config.jobs[name] = SystemdDeployment(
id=name,
manager="systemd",
program=args.program,
description=args.description or None,
run=run,
schedule=args.schedule,
manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
)
save_config(config)
print(f"Created job '{name}'.")
print(f" runs: {args.launcher} ({args.run or args.program or name})")
print(f" schedule: {args.schedule}")
print(f"\nNext: wildpc apply {name}")
return 0

View File

@@ -0,0 +1,128 @@
"""wildpc 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 wildpc_core.stacks import is_available, run_action
from wildpc_cli.config import WildpcConfig, load_config
def _run_verb(config: WildpcConfig, 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: WildpcConfig, 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 program or all)."""
config = load_config()
if getattr(args, "name", None):
return 0 if _run_verb(config, args.name, 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_format(args: argparse.Namespace) -> int:
return run_verb(args, "format")
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: WildpcConfig, name: str, deactivate: bool) -> bool:
"""Activate or deactivate a single program. Returns True on success."""
from wildpc_core.lifecycle import activate
from wildpc_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, "name", None):
return 0 if _lifecycle(config, args.name, 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, "name", None):
return 0 if _lifecycle(config, args.name, deactivate=True) else 1
print("Refusing to deactivate all programs; name a target.")
return 1

View File

@@ -0,0 +1,550 @@
"""wildpc doctor — diagnose whether this node is set up and running.
Read-only. It answers the question the runtime status view can't: *is this node
correctly configured, and if not, what's the exact next command?* Runs a series
of checks grouped into Environment, Configuration, Runtime, and TLS & exposure;
each check reports ok / warn / fail with a one-line hint when action is needed.
Exit code: 0 when nothing FAILed (warnings are allowed), 1 otherwise — so it
doubles as a scriptable smoke test after `./install.sh` or `wildpc apply`.
"""
from __future__ import annotations
import argparse
import os
import shutil
import socket
from dataclasses import dataclass
from pathlib import Path
OK, WARN, FAIL = "ok", "warn", "fail"
_ICON = {
OK: "\033[32m✓\033[0m",
WARN: "\033[33m!\033[0m",
FAIL: "\033[31m✗\033[0m",
}
_GATEWAY = "wildpc-gateway"
_API = "wildpc-api"
_DASHBOARD = "wildpc"
@dataclass
class Check:
status: str
label: str
detail: str = ""
hint: str = ""
def _print(check: Check) -> None:
line = f" {_ICON[check.status]} {check.label}"
if check.detail:
line += f" \033[90m{check.detail}\033[0m"
print(line)
if check.hint and check.status != OK:
print(f" \033[90m→ {check.hint}\033[0m")
def _port_open(port: int, host: str = "127.0.0.1") -> bool:
try:
with socket.create_connection((host, port), timeout=0.5):
return True
except OSError:
return False
# --- Environment ------------------------------------------------------------
def _check_environment() -> list[Check]:
checks: list[Check] = []
if shutil.which("wildpc"):
checks.append(Check(OK, "wildpc CLI on PATH"))
else:
checks.append(
Check(
WARN,
"wildpc CLI not on PATH",
hint="ensure ~/.local/bin is on PATH (uv tool install target)",
)
)
if shutil.which("uv"):
checks.append(Check(OK, "uv installed"))
else:
checks.append(
Check(FAIL, "uv not found", hint="curl -LsSf https://astral.sh/uv/install.sh | sh")
)
checks.append(_check_lingering())
return checks
def _check_lingering() -> Check:
import getpass
import subprocess
user = getpass.getuser()
try:
out = subprocess.run(
["loginctl", "show-user", user],
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError:
return Check(WARN, "systemd lingering unknown", detail="loginctl not found")
if "Linger=yes" in out.stdout:
return Check(OK, "systemd user lingering enabled")
return Check(
WARN,
"systemd user lingering off",
detail="services stop when you log out",
hint=f"sudo loginctl enable-linger {user}",
)
# --- Configuration ----------------------------------------------------------
def _check_configuration(config) -> list[Check]:
checks: list[Check] = []
gw = config.gateway
tls = (gw.tls or "off").lower()
checks.append(
Check(
OK,
"wildpc.yaml loaded",
detail=f"gateway :{gw.port}, tls={tls}"
+ (f", domain={gw.domain}" if gw.domain else ""),
)
)
if config.repo:
checks.append(Check(OK, "repo: set", detail=str(config.repo)))
else:
checks.append(
Check(
FAIL,
"repo: not set in wildpc.yaml",
detail="source: repo:<name> cannot resolve wildpc's own programs",
hint="add 'repo: <path-to-wildpc-checkout>' to ~/.wildpc/wildpc.yaml",
)
)
# data dir must exist and be writable — the exact condition that crashes apply
# (ensure_dirs) when data_dir points at a non-existent volume like /data.
ddir = config.data_dir
if ddir.is_dir() and os.access(ddir, os.W_OK):
checks.append(Check(OK, "data dir writable", detail=str(ddir)))
else:
checks.append(
Check(
FAIL,
"data dir missing or not writable",
detail=str(ddir),
hint=f"set data_dir: in ~/.wildpc/wildpc.yaml, or: "
f"sudo mkdir -p {ddir} && sudo chown $(id -un) {ddir}",
)
)
# Drift guard: wildpc.yaml is the single source of truth for the roots. An env var
# override is per-process, so it's the one way the CLI and the api service can still
# diverge (env set in your shell, absent in the service unit — the original bug).
for var in ("WILDPC_DATA_DIR", "WILDPC_REPOS_DIR"):
if var in os.environ:
checks.append(
Check(
WARN,
f"{var} overrides wildpc.yaml",
detail=f"{var}={os.environ[var]}",
hint=f"set data_dir:/repos_dir: in wildpc.yaml and unset {var}, so "
"every process (CLI and api) resolves the same roots",
)
)
missing = [n for n in (_GATEWAY, _API, _DASHBOARD) if not config.deployments_named(n)]
if not missing:
checks.append(Check(OK, "control plane registered", detail="gateway, api, dashboard"))
else:
checks.append(
Check(
FAIL,
"control plane missing",
detail=", ".join(missing),
hint="re-run ./install.sh (it seeds the control plane from bootstrap/)",
)
)
checks.append(_check_dashboard_built(config))
return checks
def _check_dashboard_built(config) -> Check:
from wildpc_core.lifecycle import _static_built
if not config.deployments_named(_DASHBOARD):
return Check(WARN, "dashboard not registered", detail="skipping build check")
if _static_built(_DASHBOARD, config):
return Check(OK, "dashboard built", detail="app/dist/")
return Check(
WARN,
"dashboard not built",
detail="gateway has no UI to serve at /",
hint=f"wildpc program build {_DASHBOARD}",
)
# --- Runtime ----------------------------------------------------------------
def _deployment_port(config, name: str) -> int | None:
dep = next((d for _k, d in config.deployments_named(name)), None)
expose = getattr(dep, "expose", None)
http = getattr(expose, "http", None)
internal = getattr(http, "internal", None)
return getattr(internal, "port", None)
def _check_runtime(config) -> list[Check]:
from wildpc_core.config import SPECS_DIR
from wildpc_core.lifecycle import is_active
checks: list[Check] = []
# Gateway: active + actually listening on its port.
gw_port = config.gateway.port
if is_active(_GATEWAY, "service", config):
if _port_open(gw_port):
checks.append(Check(OK, "gateway running", detail=f"listening on :{gw_port}"))
else:
checks.append(
Check(
WARN,
"gateway active but not listening",
detail=f":{gw_port} refused",
hint="wildpc gateway reload; check 'wildpc service logs wildpc-gateway'",
)
)
else:
checks.append(
Check(
FAIL,
"gateway not running",
hint="wildpc apply",
)
)
# API: active + listening on its port.
api_port = _deployment_port(config, _API)
if is_active(_API, "service", config):
if api_port and not _port_open(api_port):
checks.append(
Check(
WARN,
"wildpc-api active but not listening",
detail=f":{api_port} refused",
hint="wildpc service logs wildpc-api",
)
)
else:
detail = f"listening on :{api_port}" if api_port else ""
checks.append(Check(OK, "wildpc-api running", detail=detail))
else:
checks.append(Check(FAIL, "wildpc-api not running", hint="wildpc apply"))
# Generated artifacts.
registry = SPECS_DIR / "registry.yaml"
caddyfile = SPECS_DIR / "Caddyfile"
if registry.exists() and caddyfile.exists():
checks.append(Check(OK, "registry + Caddyfile generated"))
else:
missing = [p.name for p in (registry, caddyfile) if not p.exists()]
checks.append(
Check(
FAIL,
"generated specs missing",
detail=", ".join(missing),
hint="wildpc apply",
)
)
return checks
# --- TLS & exposure ---------------------------------------------------------
def _check_tls_exposure(config) -> list[Check]:
checks: list[Check] = []
gw = config.gateway
tls = (gw.tls or "off").lower()
if tls == "acme":
provider = gw.acme_dns_provider or "cloudflare"
if not gw.domain:
checks.append(
Check(
FAIL,
"tls=acme but no domain set",
hint="add 'domain: <your-zone>' under gateway: in wildpc.yaml",
)
)
# DNS-plugin Caddy (stock apt Caddy has no DNS-01 modules).
plugin = Path("/usr/local/bin/caddy")
module = f"dns.providers.{provider}"
has_module = False
if plugin.exists():
import subprocess
out = subprocess.run(
[str(plugin), "list-modules"],
capture_output=True,
text=True,
check=False,
)
has_module = module in out.stdout
if has_module:
checks.append(Check(OK, "DNS-plugin Caddy present", detail=module))
else:
checks.append(
Check(
FAIL,
"DNS-plugin Caddy missing",
detail=f"need {module} at /usr/local/bin/caddy",
hint=f"./install.sh --with-dns-plugin={provider}",
)
)
# Provider token secret.
token_name = {"cloudflare": "CLOUDFLARE_API_TOKEN"}.get(
provider, f"{provider.upper()}_API_TOKEN"
)
from wildpc_core.config import read_secret
if read_secret(token_name):
checks.append(Check(OK, "provider token present", detail=token_name))
else:
checks.append(
Check(
FAIL,
"provider token missing",
detail=token_name,
hint="set it via the dashboard Secrets page (or the file/vault backend)",
)
)
# Can the gateway bind :443/:80?
checks.append(_check_privileged_ports())
# Public exposure (only relevant if a deployment opts in).
public_specs = [(n, d) for _k, n, d in config.all_deployments() if getattr(d, "public", False)]
public = [n for n, _d in public_specs]
if public:
from wildpc_core.lifecycle import is_active
# A public deployment gets its public name from its own `public_host`
# override or the node-wide `public_domain`; the default domain is only
# required if some public deployment relies on it.
need_default = any(not getattr(d, "public_host", None) for _n, d in public_specs)
if gw.tunnel_id and (gw.public_domain or not need_default):
checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)"))
else:
missing = []
if need_default and not gw.public_domain:
missing.append("public_domain")
if not gw.tunnel_id:
missing.append("tunnel_id")
checks.append(
Check(
FAIL,
"public services but tunnel unconfigured",
detail="missing " + ", ".join(missing),
hint="see docs/tunnel-setup.md",
)
)
if not is_active("wildpc-tunnel", "service", config):
checks.append(
Check(
WARN,
"wildpc-tunnel not running",
detail="public routes are down",
hint="enable wildpc-tunnel in its deployment, then: wildpc apply",
)
)
checks.append(_check_public_dns(config))
if not checks:
checks.append(Check(OK, "off mode — no TLS/exposure to check", detail="localhost only"))
return checks
def _check_public_dns(config) -> Check:
"""Whether Wild PC can manage the public CNAMEs itself.
Read-only: confirms the token exists and can reach the public zone + its DNS
records. The required permission is a single DNS:Edit (Cloudflare's 'Edit zone
DNS' template), which also grants the zone lookup — so a correctly-scoped token
passes this probe. Write itself isn't exercised (that would mutate); a
DNS:Read-only token would false-pass here but `wildpc apply` then surfaces a
403 with the fix. Absent token → WARN (CNAMEs stay manual), not a failure.
"""
import urllib.error
from wildpc_core.generators.dns import PUBLIC_DNS_TOKEN, _api, public_dns_token
gw = config.gateway
token = public_dns_token()
if not token:
return Check(
WARN,
"public DNS not automated",
detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual",
hint=(
f"add a Cloudflare token with DNS:Edit on "
f"{gw.public_domain or 'the public zone(s)'} "
f"('Edit zone DNS' template) → ~/.wildpc/secrets/{PUBLIC_DNS_TOKEN} "
"(else route each host by hand)"
),
)
try:
# With a node-wide public_domain, probe that specific zone; otherwise
# (custom public_host hosts only) confirm the token can list *some* zone —
# reconcile resolves each host's zone by longest-suffix match at apply.
query = f"/zones?name={gw.public_domain}" if gw.public_domain else "/zones?per_page=1"
zres = (_api(token, "GET", query).get("result")) or []
if not zres:
where = gw.public_domain or "any zone"
return Check(
FAIL,
"public DNS token can't see the zone",
detail=f"{where} not visible",
hint=(
f"token needs DNS:Edit scoped to {where}, in that "
"zone's account ('Edit zone DNS' template)"
),
)
zid = zres[0]["id"]
_api(token, "GET", f"/zones/{zid}/dns_records?type=CNAME&per_page=1")
except urllib.error.HTTPError as e:
if e.code == 403:
return Check(
FAIL,
"public DNS token lacks DNS access",
detail="zone readable but DNS records forbidden (403)",
hint="add DNS:Edit to the token (Cloudflare 'Edit zone DNS' template)",
)
return Check(WARN, "public DNS token check inconclusive", detail=f"HTTP {e.code}")
except Exception as e: # noqa: BLE001 — never let a network hiccup fail doctor
return Check(WARN, "public DNS token check inconclusive", detail=str(e)[:60])
return Check(
OK,
"public DNS token valid",
detail=f"can reach {gw.public_domain or 'its zones'} + records",
)
def _check_privileged_ports() -> Check:
try:
val = int(Path("/proc/sys/net/ipv4/ip_unprivileged_port_start").read_text().strip())
except (OSError, ValueError):
return Check(WARN, "cannot read unprivileged port floor")
if val <= 80:
return Check(OK, "can bind :80/:443", detail=f"unprivileged floor {val}")
return Check(
WARN,
"cannot bind :80/:443",
detail=f"unprivileged floor is {val}",
hint="echo 'net.ipv4.ip_unprivileged_port_start=80' | "
"sudo tee /etc/sysctl.d/50-wildpc-gateway.conf && sudo sysctl --system",
)
# --- Driver -----------------------------------------------------------------
def _check_stacks(config: object) -> list[Check]:
"""Stack toolchains: is each *in-use* stack's host tooling present where its
programs need it (run-phase tools against the service's runtime PATH)? A missing
tool for an enabled deployment is a FAIL (its service can't build/run); missing
for a not-yet-enabled program is a WARN. Unused stacks are skipped — no nagging
about pnpm when there are no frontends."""
from wildpc_core.stack_status import all_stack_status
checks: list[Check] = []
for st in all_stack_status(config, with_version=False):
if not st.in_use or not st.tools:
continue
n = len(st.programs)
label = f"{st.name} ({n} program{'s' if n != 1 else ''})"
missing = [t for t in st.tools if not t.present]
if not missing:
present = ", ".join(t.command for t in st.tools)
checks.append(Check(OK, label, detail=f"{present} present"))
continue
status = FAIL if st.has_enabled_deployment else WARN
checks.append(
Check(
status,
label,
detail="missing: " + ", ".join(t.command for t in missing),
hint=missing[0].install_hint,
)
)
if not checks:
checks.append(Check(OK, "no stack toolchains in use"))
return checks
def run_doctor(args: argparse.Namespace) -> int:
from wildpc_core.config import load_config
print("\n\033[1mWild PC Doctor\033[0m")
try:
config = load_config()
except Exception as exc: # noqa: BLE001 — surface any load failure as the first FAIL
print("\n\033[1mConfiguration\033[0m")
_print(
Check(
FAIL,
"wildpc.yaml failed to load",
detail=str(exc),
hint="check ~/.wildpc/wildpc.yaml — re-run ./install.sh to reseed",
)
)
print()
return 1
sections: list[tuple[str, list[Check]]] = [
("Environment", _check_environment()),
("Configuration", _check_configuration(config)),
("Stacks & dependencies", _check_stacks(config)),
("Runtime", _check_runtime(config)),
("TLS & exposure", _check_tls_exposure(config)),
]
fails = warns = 0
for title, checks in sections:
print(f"\n\033[1m{title}\033[0m")
for check in checks:
_print(check)
fails += check.status == FAIL
warns += check.status == WARN
print()
if fails:
print(f"\033[31m{fails} problem(s)\033[0m" + (f", {warns} warning(s)" if warns else ""))
return 1
if warns:
print(f"\033[33m{warns} warning(s)\033[0m — Wild PC is up; address when convenient")
return 0
print("\033[32mAll checks passed.\033[0m")
return 0

View File

@@ -0,0 +1,52 @@
"""wildpc gateway — inspect the Caddy reverse proxy gateway.
The gateway is itself a deployment (`wildpc-gateway`): start/stop/reload it the
same way as anything else — `wildpc apply` (render routes + reload), `wildpc
restart wildpc-gateway` (bounce), or `enabled: false` + apply (stop). This command
is the read-only inspection lens: is it up, and what's the route table.
"""
from __future__ import annotations
import argparse
import subprocess
from wildpc_core.registry import REGISTRY_PATH, load_registry
GATEWAY_UNIT = "wildpc-wildpc-gateway.service"
def run_gateway(args: argparse.Namespace) -> int:
"""Show the gateway's status + route table (the only gateway verb)."""
return _gateway_status()
def _gateway_status() -> int:
"""Show gateway status + the full route table (static, proxy, remote)."""
result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True,
text=True,
)
status = result.stdout.strip()
print(f"Gateway: {'running' if status == 'active' else status}")
if not REGISTRY_PATH.exists():
print(" (no registry — run 'wildpc apply')")
return 0
from wildpc_core.generators.caddyfile import compute_routes
routes = compute_routes(load_registry())
if not routes:
print(" No routes configured.")
return 0
# Each route: address → target, tagged by kind. static = files served in
# place; proxy/remote = reverse-proxied to a process. (Caddyfile order is
# precedence-sensitive; this table is alphabetical.)
print(f"\n {'ADDRESS':24} {'KIND':7} TARGET")
for r in sorted(routes, key=lambda r: r.address):
target = r.target.replace("localhost:", ":") if r.kind != "static" else r.target
print(f" {r.address:24} {r.kind:7} {target}")
return 0

View File

@@ -0,0 +1,68 @@
"""wildpc graph — the relationship model: repos, `requires` edges, derived status.
A read-only diagnostic (see docs/relationships.md). Nothing here is stored — repos
come from git, predicates (functional/fresh/deployed) are computed on the fly.
"""
from __future__ import annotations
import argparse
import dataclasses
import json
from wildpc_cli.config import load_config
BOLD, DIM, RESET = "\033[1m", "\033[2m", "\033[0m"
GREEN, RED, YELLOW, CYAN = "\033[32m", "\033[31m", "\033[33m", "\033[36m"
def run_graph(args: argparse.Namespace) -> int:
from wildpc_core.relations import build_model
config = load_config()
model = build_model(config, check=True, freshness=True)
if getattr(args, "json", False):
print(
json.dumps(
{
"repos": [dataclasses.asdict(r) for r in model.repos],
"nodes": [dataclasses.asdict(n) for n in model.nodes],
"edges": [dataclasses.asdict(e) for e in model.edges],
},
indent=2,
)
)
return 0
monos = [r for r in model.repos if r.multi]
print(f"{BOLD}Repos{RESET} ({len(model.repos)}, {len(monos)} monorepo)")
for r in monos:
fresh = (
""
if r.fresh is None
else (f" {GREEN}fresh{RESET}" if r.fresh else f" {YELLOW}stale{RESET}")
)
print(f" {CYAN}{r.key}{RESET}{fresh} {DIM}{', '.join(r.programs)}{RESET}")
edges = [e for e in model.edges if e.kind == "deployment"]
print(f"\n{BOLD}requires{RESET} (deployment → deployment): {len(edges)}")
for e in edges:
bind = f" {DIM}→ ${e.bind}{RESET}" if e.bind else ""
print(f" {e.src} {DIM}requires{RESET} {e.dst}{bind}")
if not edges:
print(f" {DIM}(none declared — front-end/back-end deps have no encoded edge yet){RESET}")
unhealthy = [n for n in model.nodes if not n.functional]
print(f"\n{BOLD}functional?{RESET}{len(unhealthy)} with unmet requirements")
for n in unhealthy:
print(f" {RED}{RESET} {n.name} {DIM}unmet: {', '.join(n.unmet)}{RESET}")
if not unhealthy:
print(f" {GREEN}✓ all functional{RESET}")
depended = sorted((n for n in model.nodes if n.depended_on_by), key=lambda n: -n.depended_on_by)
if depended:
print(f"\n{BOLD}widely depended-on{RESET}")
for n in depended:
print(f" {n.name} {DIM}{n.depended_on_by} dependent(s){RESET}")
return 0

View File

@@ -0,0 +1,231 @@
"""wildpc info - show detailed program information."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from wildpc_cli.config import load_config
# Terminal colors
BOLD = "\033[1m"
RESET = "\033[0m"
CYAN = "\033[96m"
DIM = "\033[2m"
GREEN = "\033[92m"
RED = "\033[91m"
def _load_deployed_program(name: str) -> object | None:
"""Try to load a specific deployed program from registry."""
try:
from wildpc_core.registry import load_registry
registry = load_registry()
return next(iter(registry.named(name)), None)
except (FileNotFoundError, ValueError):
return None
def run_info(args: argparse.Namespace) -> int:
"""Show detailed info for a program, service, or job."""
config = load_config()
name = args.name
resource = getattr(args, "resource", None)
# Look up in the requested section (or all, when unscoped).
program = config.programs.get(name) if resource in (None, "program") else None
service = config.services.get(name) if resource in (None, "service") else None
job = config.jobs.get(name) if resource in (None, "job") else None
if not program and not service and not job:
where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in wildpc.yaml")
return 1
deployed = _load_deployed_program(name)
if getattr(args, "json", False):
return _info_json(config, name, program, service, job, deployed)
# Human-readable output
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
# Determine kind(s) — for a program, the kinds of its deployments; for a
# single deployment, its own kind.
kinds: list[str] = []
if program:
kinds = sorted({k for _, k in config.deployments_of(name)})
elif service:
kinds = ["service"]
elif job:
kinds = ["job"]
if kinds:
label = "kind" if len(kinds) == 1 else "kinds"
print(f" {BOLD}{label}{RESET}: {', '.join(kinds)}")
# Show stack
stack = None
if program and program.stack:
stack = program.stack
elif service and service.program and service.program in config.programs:
stack = config.programs[service.program].stack
elif job and job.program and job.program in config.programs:
stack = config.programs[job.program].stack
if stack:
print(f" {BOLD}stack{RESET}: {stack}")
# Program info
if program:
if program.description:
print(f" {BOLD}description{RESET}: {program.description}")
if program.source:
print(f" {BOLD}source{RESET}: {program.source}")
if program.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(program.system_dependencies)}")
if program.tags:
print(f" {BOLD}tags{RESET}: {', '.join(program.tags)}")
# Service info
spec = service or job
if spec:
desc = spec.description
if not desc and spec.program and spec.program in config.programs:
desc = config.programs[spec.program].description
if desc and not (program and program.description == desc):
print(f" {BOLD}description{RESET}: {desc}")
if spec.program:
print(f" {BOLD}program{RESET}: {spec.program}")
# Launch spec
print(f" {BOLD}launcher{RESET}: {spec.run.launcher}")
if hasattr(spec.run, "program"):
print(f" {BOLD}program{RESET}: {spec.run.program}")
elif hasattr(spec.run, "argv"):
print(f" {BOLD}argv{RESET}: {spec.run.argv}")
elif hasattr(spec.run, "image"):
print(f" {BOLD}image{RESET}: {spec.run.image}")
# Defaults env
if spec.defaults and spec.defaults.env:
print(f" {BOLD}defaults.env{RESET}:")
for key, val in spec.defaults.env.items():
print(f" {key}: {val}")
# Service-specific: expose, proxy, manage
if service:
if service.expose and service.expose.http:
http = service.expose.http
print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}")
if service.http_exposed:
print(f" {BOLD}subdomain{RESET}: {name}.<gateway.domain>")
if service.tcp_port is not None:
# Raw-TCP reach is internal-only (public TCP is rejected at load, see
# SystemdDeployment._validate_reach), so there's no "public" state here.
print(
f" {BOLD}tcp{RESET}: {name}.<gateway.domain>:{service.tcp_port} (internal)"
)
if service.manage and service.manage.systemd:
sd = service.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
# Job-specific
if job:
print(f" {BOLD}schedule{RESET}: {job.schedule}")
print(f" {BOLD}timezone{RESET}: {job.timezone}")
# Deployed state from registry
if deployed:
print(f"\n {GREEN}{BOLD}deployed{RESET}")
print(f" {'' * 36}")
print(f" {BOLD}run_cmd{RESET}: {' '.join(deployed.run_cmd)}")
if deployed.port is not None:
print(f" {BOLD}port{RESET}: {deployed.port}")
print(f" {BOLD}managed{RESET}: {deployed.managed}")
if deployed.env:
print(f" {BOLD}env{RESET}:")
for key, val in deployed.env.items():
print(f" {key}={val}")
if deployed.secret_env_keys:
print(f" {BOLD}secrets{RESET}: {', '.join(deployed.secret_env_keys)}")
else:
print(f"\n {DIM}not applied (run 'wildpc apply'){RESET}")
# Show CLAUDE.md if it exists
source_dir = None
if program and program.source_dir:
source_dir = program.source_dir
elif spec and spec.program and spec.program in config.programs:
source_dir = config.programs[spec.program].source_dir
if source_dir:
claude_md = _find_claude_md(config.root, source_dir)
if claude_md:
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
print(f"{DIM}{claude_md}{RESET}")
print()
return 0
def _info_json(
config: object,
name: str,
program: object | None,
service: object | None,
job: object | None,
deployed: object | None,
) -> int:
"""Output JSON info."""
data: dict = {"name": name}
if program:
data["program"] = program.model_dump(exclude_none=True, exclude={"id"})
if service:
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
if program:
data["kinds"] = sorted({k for _, k in config.deployments_of(name)})
elif service:
data["kind"] = "service"
elif job:
data["kind"] = "job"
# Resolve stack
stack = None
if program and program.stack:
stack = program.stack
elif service and service.program and service.program in config.programs:
stack = config.programs[service.program].stack
elif job and job.program and job.program in config.programs:
stack = config.programs[job.program].stack
if stack:
data["stack"] = stack
if deployed:
data["deployed"] = {
"manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
"port": deployed.port,
"managed": deployed.managed,
}
print(json.dumps(data, indent=2))
return 0
def _find_claude_md(root: Path, source_dir: str) -> str | None:
"""Read CLAUDE.md from project directory if it exists."""
claude_path = root / source_dir / "CLAUDE.md"
if claude_path.exists():
return claude_path.read_text()
return None

View File

@@ -0,0 +1,268 @@
"""wildpc list - the program catalog plus every deployment view (services, jobs,
tools, static)."""
from __future__ import annotations
import argparse
import json
import logging
from wildpc_cli.config import load_config
log = logging.getLogger(__name__)
def _deployments_of_kind(config: object, kind: str) -> dict:
"""The deployments whose derived kind matches (a lens over config.deployments)."""
return config.store_for(kind)
# Terminal colors
BOLD = "\033[1m"
RESET = "\033[0m"
DIM = "\033[2m"
GREEN = "\033[92m"
RED = "\033[91m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
YELLOW = "\033[93m"
KIND_COLORS: dict[str, str] = {
"service": GREEN,
"job": MAGENTA,
"tool": CYAN,
"static": YELLOW,
"reference": DIM,
}
STACK_DISPLAY: dict[str, str] = {
"python-fastapi": "python-fastapi",
"python-cli": "python-cli",
"react-vite": "react-vite",
"supabase": "supabase",
"rust": "rust",
"go": "go",
"bash": "bash",
"container": "container",
"command": "command",
}
def _resolve_stack(config: object, name: str) -> str | None:
"""Resolve stack from program reference or direct program."""
# Check services for program ref
if name in config.services:
svc = config.services[name]
comp_name = svc.program
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Check jobs for program ref
if name in config.jobs:
job = config.jobs[name]
comp_name = job.program
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Direct program
if name in config.programs:
return config.programs[name].stack
return None
def run_list(args: argparse.Namespace) -> int:
"""List all programs, services, and jobs.
Two orthogonal axes: the **Programs** catalog (filtered by derived `kind`)
and the **Services**/**Jobs** deployment views. `--kind` filters the catalog
by a program's derived kind (service/job/tool/static/reference).
"""
from wildpc_core.lifecycle import is_active
config = load_config()
filter_kind = getattr(args, "kind", None)
filter_stack = getattr(args, "stack", None)
resource = getattr(args, "resource", None) # scope to one section, or all
if getattr(args, "json", False):
return _list_json(config, filter_kind, filter_stack)
def dot(name: str, kind: str = "service") -> str:
return f"{GREEN}{RESET}" if is_active(name, kind, config) else f"{RED}{RESET}"
any_output = False
# A program's kinds are the kinds of its deployments (a program has no kind
# of its own). Sorted, de-duplicated.
def prog_kinds(name: str) -> list[str]:
return sorted({kind for _, kind in config.deployments_of(name)})
# Programs (the catalog) — filtered by a deployment kind + stack.
progs = (
{
name: comp
for name, comp in config.programs.items()
if (not filter_kind or filter_kind in prog_kinds(name))
and (not filter_stack or comp.stack == filter_stack)
}
if resource in (None, "program")
else {}
)
if progs:
any_output = True
print(f"\n{BOLD}{CYAN}Programs{RESET}")
print(f"{CYAN}{'' * 40}{RESET}")
for name, comp in progs.items():
kinds = prog_kinds(name)
kinds_str = "".join(f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds)
stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else ""
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
pk = (prog_kinds(name) or ["service"])[0]
print(f" {dot(name, pk)} {BOLD}{name}{RESET}{kinds_str}{stack_str}{desc}")
# Services + Jobs (deployment views) — independent of behavior, so only shown
# when no behavior filter is applied. Each gated by its own resource scope.
if not filter_kind and resource in (None, "service"):
services = _filter_by_stack(config.services, config, filter_stack)
if services:
any_output = True
color = KIND_COLORS["service"]
print(f"\n{BOLD}{color}Services{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, svc in services.items():
port_str = ""
if svc.expose and svc.expose.http:
port_str = f" :{svc.expose.http.internal.port}"
stack = _resolve_stack(config, name)
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {dot(name, 'service')} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
if not filter_kind and resource in (None, "job"):
jobs = _filter_by_stack(config.jobs, config, filter_stack)
if jobs:
any_output = True
print(f"\n{BOLD}{MAGENTA}Jobs{RESET}")
print(f"{MAGENTA}{'' * 40}{RESET}")
for name, job in jobs.items():
sched = f" {DIM}[{job.schedule}]{RESET}"
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
print(f" {dot(name, 'job')} {BOLD}{name}{RESET}{sched}{desc}")
if not filter_kind and resource in (None, "tool"):
tools = _filter_by_stack(_deployments_of_kind(config, "tool"), config, filter_stack)
if tools:
any_output = True
color = KIND_COLORS["tool"]
print(f"\n{BOLD}{color}Tools{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, d in tools.items():
stack = _resolve_stack(config, name)
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
desc = f" {DIM}{d.description}{RESET}" if d.description else ""
print(f" {dot(name, 'tool')} {BOLD}{name}{RESET}{stack_str}{desc}")
if not filter_kind and resource in (None, "static"):
statics = _filter_by_stack(_deployments_of_kind(config, "static"), config, filter_stack)
if statics:
any_output = True
color = KIND_COLORS["static"]
print(f"\n{BOLD}{color}Static{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, d in statics.items():
sub = f" {DIM}{name}.<domain>{RESET}"
desc = f" {DIM}{d.description}{RESET}" if d.description else ""
print(f" {dot(name, 'static')} {BOLD}{name}{RESET}{sub}{desc}")
if not any_output:
print(f"No {resource or 'program'}s found.")
print()
return 0
def _filter_by_stack(
items: dict[str, object],
config: object,
filter_stack: str | None,
) -> dict[str, object]:
"""Filter items by stack if a filter is provided."""
if not filter_stack:
return items
return {
name: item for name, item in items.items() if _resolve_stack(config, name) == filter_stack
}
def _list_json(
config: object,
filter_kind: str | None,
filter_stack: str | None,
) -> int:
"""Output JSON: the program catalog (kind-filterable) plus deployments."""
from wildpc_core.lifecycle import is_active
output = []
# Programs (catalog) — a program's kinds are its deployments' kinds.
for name, comp in config.programs.items():
kinds = sorted({kind for _, kind in config.deployments_of(name)})
if filter_kind and filter_kind not in kinds:
continue
if filter_stack and comp.stack != filter_stack:
continue
entry: dict = {
"name": name,
"kinds": kinds,
"active": is_active(name, (kinds or ["service"])[0], config),
}
if comp.stack:
entry["stack"] = comp.stack
if comp.description:
entry["description"] = comp.description
output.append(entry)
# Services + Jobs (deployments) — only when not filtering by kind
if not filter_kind:
for name, svc in config.services.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {"name": name, "kind": "service", "active": is_active(name, "service", config)}
if stack:
entry["stack"] = stack
if svc.description:
entry["description"] = svc.description
if svc.expose and svc.expose.http:
entry["port"] = svc.expose.http.internal.port
output.append(entry)
for name, job in config.jobs.items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {
"name": name,
"kind": "job",
"active": is_active(name, "job", config),
"schedule": job.schedule,
}
if stack:
entry["stack"] = stack
if job.description:
entry["description"] = job.description
output.append(entry)
for kind in ("tool", "static"):
for name, d in _deployments_of_kind(config, kind).items():
stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack:
continue
entry = {"name": name, "kind": kind, "active": is_active(name, kind, config)}
if stack:
entry["stack"] = stack
if d.description:
entry["description"] = d.description
output.append(entry)
print(json.dumps(output, indent=2))
return 0

View File

@@ -0,0 +1,86 @@
"""wildpc logs - view component logs."""
from __future__ import annotations
import argparse
import subprocess
from wildpc_cli.commands.service import UNIT_PREFIX
from wildpc_cli.config import load_config
def run_logs(args: argparse.Namespace) -> int:
"""View logs for a service or job."""
config = load_config()
name = args.name
dep = next((d for _k, d in config.deployments_named(name)), None)
if dep is not None and dep.manager == "systemd":
if dep.run.launcher == "container":
return _container_logs(name, args)
if dep.run.launcher == "compose":
return _compose_logs(name, dep, args)
return _systemd_logs(name, args)
if dep is not None:
print(f"Error: '{name}' has no logs (manager: {dep.manager}).")
return 1
print(f"Error: '{name}' not found in deployments")
return 1
def _systemd_logs(name: str, args: argparse.Namespace) -> int:
"""Show journalctl logs for a systemd service."""
unit_name = f"{UNIT_PREFIX}{name}.service"
cmd = ["journalctl", "--user", "-u", unit_name]
lines = getattr(args, "lines", 50)
if lines:
cmd.extend(["-n", str(lines)])
if getattr(args, "follow", False):
cmd.append("-f")
result = subprocess.run(cmd)
return result.returncode
def _compose_logs(name: str, svc: object, args: argparse.Namespace) -> int:
"""Show aggregated logs for a compose-runner stack (by project label)."""
import shutil
runtime = shutil.which("docker") or shutil.which("podman") or "docker"
project = getattr(svc.run, "project_name", None) or f"wildpc-{name}" # type: ignore[attr-defined]
cmd = [runtime, "compose", "-p", project, "logs"]
lines = getattr(args, "lines", 50)
if lines:
cmd.extend(["--tail", str(lines)])
if getattr(args, "follow", False):
cmd.append("-f")
result = subprocess.run(cmd)
return result.returncode
def _container_logs(name: str, args: argparse.Namespace) -> int:
"""Show container logs."""
import shutil
runtime = shutil.which("podman") or shutil.which("docker") or "podman"
container_name = f"wildpc-{name}"
cmd = [runtime, "logs"]
lines = getattr(args, "lines", 50)
if lines:
cmd.extend(["--tail", str(lines)])
if getattr(args, "follow", False):
cmd.append("-f")
cmd.append(container_name)
result = subprocess.run(cmd)
return result.returncode

View File

@@ -0,0 +1,105 @@
"""wildpc mesh — inspect the NATS mesh and manage shared config.
The mesh lives in the running wildpc-api (it holds the live peer state), so this
command talks to the local API over HTTP rather than reading files.
"""
from __future__ import annotations
import argparse
import json
import urllib.error
import urllib.request
def _api_base() -> str:
port = None
try:
from wildpc_core.config import load_config
config = load_config()
dep = next((d for _k, d in config.deployments_named("wildpc-api")), None)
internal = getattr(getattr(getattr(dep, "expose", None), "http", None), "internal", None)
port = getattr(internal, "port", None)
except Exception:
pass
return f"http://localhost:{port or 9020}"
def _get(path: str):
with urllib.request.urlopen(_api_base() + path, timeout=5) as r: # noqa: S310
return json.load(r)
def _put(path: str, body: dict):
req = urllib.request.Request( # noqa: S310
_api_base() + path,
data=json.dumps(body).encode(),
method="PUT",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=5) as r: # noqa: S310
return json.load(r)
def run_mesh(args: argparse.Namespace) -> int:
sub = getattr(args, "mesh_command", None) or "status"
try:
if sub == "status":
return _status()
if sub == "nodes":
return _nodes()
if sub == "config":
return _config(args)
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")
print(f"error: HTTP {e.code}{detail}")
return 1
except urllib.error.URLError as e:
print(f"wildpc-api not reachable ({e.reason}). Is it running + mesh enabled?")
return 1
print(f"unknown mesh command: {sub}")
return 2
def _status() -> int:
s = _get("/mesh/status")
on = "connected" if s.get("connected") else "disconnected"
print(f"mesh: {'enabled' if s.get('enabled') else 'disabled'} ({on})")
print(f" transport: {s.get('nats_url')}")
print(f" peers ({s.get('peer_count', 0)}): {', '.join(s.get('peers', [])) or ''}")
return 0
def _nodes() -> int:
nodes = _get("/nodes")
print(f"{'NODE':<14}{'STATUS':<10}{'DEPLOYED':<10}LOCAL")
for n in nodes:
if n.get("online"):
status = "online"
else:
status = "stale" if n.get("is_stale") else "offline"
local = "yes" if n.get("is_local") else ""
print(f"{n['hostname']:<14}{status:<10}{n.get('deployed_count', 0):<10}{local}")
return 0
def _config(args: argparse.Namespace) -> int:
cmd = getattr(args, "mesh_config_command", None) or "list"
if cmd == "list":
data = _get("/mesh/config")
print(f"shared config (this node role: {data.get('role')}):")
for k in data.get("keys", []):
print(f" {k}")
if not data.get("keys"):
print(" (none)")
return 0
if cmd == "get":
print(_get(f"/mesh/config/{args.key}").get("value", ""))
return 0
if cmd == "set":
_put(f"/mesh/config/{args.key}", {"value": args.value})
print(f"set {args.key}")
return 0
print(f"unknown config command: {cmd}")
return 2

View File

@@ -0,0 +1,101 @@
"""wildpc 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 wildpc_core.generators.systemd import secret_env_path
from wildpc_core.registry import REGISTRY_PATH, load_registry
from wildpc_cli.config import load_config
def _load_secret_env(name: str) -> dict[str, str]:
"""Read a deployment's generated secret env file (KEY=value lines), if any.
Secrets are kept out of the registry, so a foreground run merges them from the
file systemd/docker would otherwise load, matching the deployed environment.
"""
path = secret_env_path(name)
if not path.exists():
return {}
out: dict[str, str] = {}
for line in path.read_text().splitlines():
if "=" in line and not line.startswith("#"):
key, val = line.split("=", 1)
out[key] = val
return out
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 program's declared run, or a deployed service, in the foreground.
Scoped by `resource`: `program run` runs the program's declared `run`;
`service run` runs the deployed service's command from the registry.
"""
name = args.name
extra_args = getattr(args, "extra", []) or []
resource = getattr(args, "resource", None)
# Program: declared run command.
if resource in (None, "program"):
prog_rc = _run_program(name, extra_args)
if prog_rc is not None:
return prog_rc
if resource == "program":
print(f"Error: program '{name}' has no declared `run` command.")
return 1
# Service: deployed command from the registry.
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'wildpc apply' first.")
return 1
registry = load_registry()
matches = registry.named(name)
if not matches:
print(f"Error: '{name}' is not a deployed service. Run 'wildpc apply' first.")
return 1
# A name may span kinds; run the one that has a run command (service/job).
deployed = next((d for d in matches if d.run_cmd), matches[0])
cmd = list(deployed.run_cmd) + extra_args
env = dict(os.environ)
env.update(deployed.env)
env.update(_load_secret_env(name))
print(f"Running {name}: {' '.join(cmd)}")
return subprocess.run(cmd, env=env).returncode

View File

@@ -0,0 +1,93 @@
"""wildpc secret — read/write secrets in the *active* backend.
The active backend (file or openbao) is selected by the ``secrets:`` block of
wildpc.yaml (env overrides). Writing a secret by hand means knowing that choice
and the backend's storage layout — get it wrong and the value lands somewhere the
resolver never reads, so ``${secret:NAME}`` silently degrades to a
``<MISSING_SECRET:NAME>`` placeholder that a service then uses as if it were the
real credential (exactly how immich's DB password went to a file on an OpenBao
fleet). This command routes every read/write through
:func:`wildpc_core.config.active_secret_backend`, so there's no wrong store to
pick. ``wildpc apply`` refuses to converge a deployment whose secrets don't
resolve here.
"""
from __future__ import annotations
import argparse
import getpass
import sys
from wildpc_core.config import active_backend_name, active_secret_backend
def run_secret(args: argparse.Namespace) -> int:
sub = getattr(args, "secret_command", None)
if not sub:
print("Usage: wildpc secret {list|set|get|rm}")
return 1
if sub == "list":
return _list()
if sub == "set":
return _set(args.name, args.value)
if sub == "get":
return _get(args.name)
if sub == "rm":
return _rm(args.name, getattr(args, "yes", False))
return 1
def _list() -> int:
backend = active_backend_name()
names = active_secret_backend().list_names()
if not names:
print(f"No secrets in the active '{backend}' backend.")
return 0
print(f"Secrets in the active '{backend}' backend ({len(names)}):")
for n in names:
print(f" {n}")
return 0
def _set(name: str, value: str | None) -> int:
backend = active_backend_name()
if value is None:
# No value on the argv (keeps it out of shell history / ps). Read from a
# hidden prompt when interactive, else from stdin (pipe-friendly).
if sys.stdin.isatty():
value = getpass.getpass(f"Value for {name}: ")
else:
value = sys.stdin.read().strip()
if not value:
print("Error: empty value — refusing to set.", file=sys.stderr)
return 1
active_secret_backend().write(name, value)
print(f"Set '{name}' in the active '{backend}' backend.")
return 0
def _get(name: str) -> int:
value = active_secret_backend().read(name)
if value is None:
print(
f"Secret '{name}' not found in the active '{active_backend_name()}' backend.",
file=sys.stderr,
)
return 1
print(value)
return 0
def _rm(name: str, yes: bool) -> int:
backend = active_backend_name()
if active_secret_backend().read(name) is None:
print(f"Secret '{name}' not found in the active '{backend}' backend.", file=sys.stderr)
return 1
if not yes:
reply = input(f"Delete secret '{name}' from the '{backend}' backend? [y/N] ")
if reply.strip().lower() not in ("y", "yes"):
print("Aborted.")
return 1
active_secret_backend().delete(name)
print(f"Deleted '{name}' from the active '{backend}' backend.")
return 0

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

View File

@@ -0,0 +1,110 @@
"""wildpc stack — the stacks lens (toolchains a program's stack needs).
A *stack* (python-fastapi, react-vite, hugo, …) is creation-time guidance that
also carries the **host toolchains** its programs need to build and run (`uv`,
`pnpm`, `hugo`, …). This lens makes those dependencies visible and tells you
whether they're present *where the running service needs them* — the drift a bare
`which` in your shell misses — with a copyable fix when one is absent.
"""
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from typing import TYPE_CHECKING
from wildpc_cli.config import load_config
if TYPE_CHECKING:
from wildpc_core.stack_status import StackStatus
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
GREEN = "\033[92m"
RED = "\033[91m"
GREY = "\033[90m"
CYAN = "\033[96m"
def _record(st: StackStatus) -> dict:
d = asdict(st)
d["in_use"] = st.in_use
d["ok"] = st.ok
return d
def run_stack_list(args: argparse.Namespace) -> int:
"""List stacks with their toolchain health, program count, and dev verbs."""
from wildpc_core.stack_status import all_stack_status
config = load_config()
# Skip per-tool version probes for the list (a subprocess per tool) — keep it snappy.
stacks = all_stack_status(config, with_version=False)
if getattr(args, "json", False):
print(json.dumps([_record(s) for s in stacks], indent=2))
return 0
print(f"\n{BOLD}Stacks{RESET}")
print("" * 64)
width = max((len(s.name) for s in stacks), default=0)
for s in stacks:
if not s.tools:
dot = f"{GREY}{RESET}"
else:
dot = f"{GREEN}{RESET}" if s.ok else f"{RED}{RESET}"
missing = [t.command for t in s.tools if not t.present]
tools = ", ".join(
(f"{RED}{t.command}{RESET}" if not t.present else t.command) for t in s.tools
)
used = (
f"{len(s.programs)} program{'s' if len(s.programs) != 1 else ''}"
if s.in_use
else f"{GREY}unused{RESET}"
)
tail = f" {DIM}{used}{RESET}"
tools_str = f" {DIM}tools:{RESET} {tools}" if tools else ""
print(f" {dot} {BOLD}{s.name:<{width}}{RESET}{tools_str}{tail}")
if missing:
print(f" {RED}missing:{RESET} {', '.join(missing)}")
print()
return 0
def run_stack_info(args: argparse.Namespace) -> int:
"""Show one stack: each tool's presence + version + fix, and who uses it."""
from wildpc_core.stack_status import stack_status
config = load_config()
name = args.name
st = stack_status(config, name)
if st is None:
from wildpc_core.stacks import available_stacks
print(f"Error: no stack '{name}'. Known: {', '.join(available_stacks())}")
return 1
if getattr(args, "json", False):
print(json.dumps(_record(st), indent=2))
return 0
print(f"\n{BOLD}{st.name}{RESET}")
print("" * 48)
if st.verbs:
print(f" {BOLD}verbs{RESET}: {', '.join(st.verbs)}")
print(f" {BOLD}used by{RESET}: ", end="")
print(", ".join(st.programs) if st.programs else f"{GREY}nothing{RESET}")
print(f"\n {BOLD}toolchain{RESET}")
if not st.tools:
print(f" {GREY}no host tools required{RESET}")
for t in st.tools:
mark = f"{GREEN}{RESET}" if t.present else f"{RED}{RESET}"
ver = f" {DIM}{t.version}{RESET}" if t.version else ""
print(f" {mark} {BOLD}{t.command}{RESET} {DIM}{t.purpose} ({t.phase}){RESET}{ver}")
if not t.present:
print(f" {CYAN}{t.install_hint}{RESET}")
print()
return 0

View File

@@ -0,0 +1,79 @@
"""wildpc tls — wildpc-managed TLS certs for raw-TCP services.
Each TLS-material TCP service (postgres, redis, …) gets the gateway's ACME
wildcard cert cut onto it so it presents a *trusted* cert on its raw port.
`reconcile` refreshes those copies from the wildcard and reloads the services
whose cert changed — it's what the Caddy `cert_obtained` hook and the nightly
safety-net job both run. `status` shows each service's cert fingerprint + expiry.
"""
from __future__ import annotations
import argparse
import hashlib
from datetime import datetime, timezone
from wildpc_core.tls import _tls_of, reconcile_tls, tls_dir_for, wildcard_cert
from wildpc_cli.config import load_config
def run_tls(args: argparse.Namespace) -> int:
if getattr(args, "tls_command", None) == "status":
return _tls_status()
return _tls_reconcile()
def _tls_reconcile() -> int:
config = load_config()
for msg in reconcile_tls(config):
print(msg)
return 0
def _fingerprint(pem: bytes) -> str:
return hashlib.sha256(pem).hexdigest()[:12]
def _not_after(cert_pem: bytes) -> str:
"""Best-effort cert expiry (uses cryptography if available, else '')."""
try:
from cryptography import x509
cert = x509.load_pem_x509_certificate(cert_pem)
left = cert.not_valid_after_utc - datetime.now(timezone.utc)
return f"{cert.not_valid_after_utc:%Y-%m-%d} ({left.days}d left)"
except Exception:
return ""
def _tls_status() -> int:
config = load_config()
domain = config.gateway.domain
src = wildcard_cert(domain) if domain else None
src_fp = _fingerprint(src[0].read_bytes()) if src else None
print(f"wildcard source: *.{domain} " + (f"[{src_fp}]" if src_fp else "(not found)"))
rows = []
for _kind, name, dep in config.all_deployments():
if _tls_of(dep) is None:
continue
config_key = dep.program or name
cert = tls_dir_for(config_key) / "cert.pem"
combined = tls_dir_for(config_key) / "combined.pem"
have = cert if cert.exists() else combined if combined.exists() else None
if have is None:
rows.append((name, "not materialized", "", ""))
continue
pem = have.read_bytes()
fp = _fingerprint(pem)
drift = "" if src_fp and fp == src_fp else " ⚠ stale (run: wildpc tls reconcile)"
rows.append((name, fp, _not_after(pem), drift))
if not rows:
print(" (no TLS-material TCP services)")
return 0
print()
for name, fp, exp, drift in rows:
print(f" {name:20s} {fp:14s} {exp}{drift}")
return 0

View File

@@ -0,0 +1,156 @@
"""wildpc tool — the tools lens (programs installed on PATH).
A *tool* is a program with a `path` (manager) deployment: a CLI on your PATH.
This lens is what coding assistants use to discover what's available — so the
listing surfaces the *executable* to invoke (which can differ from the program
name, e.g. `litellm-intent-router` installs `intent-router`), the description,
and whether it's installed. `--json` gives a machine-readable context payload.
"""
from __future__ import annotations
import argparse
import json
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING
from wildpc_cli.config import load_config
if TYPE_CHECKING:
from wildpc_core.config import WildpcConfig
from wildpc_core.manifest import ProgramSpec
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
GREEN = "\033[92m"
GREY = "\033[90m"
CYAN = "\033[96m"
def _is_tool(config: WildpcConfig, name: str) -> bool:
return any(kind == "tool" for _, kind in config.deployments_of(name))
def _tool_programs(config: WildpcConfig) -> dict:
"""Programs with a tool (path) deployment, name-sorted."""
return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)}
def _executables(comp: ProgramSpec) -> list[str]:
"""The console scripts a tool exposes, from its pyproject `[project.scripts]`.
This is the command(s) to actually run — the source of truth even when the
tool isn't installed. Falls back to the program name when none are declared
(e.g. a non-python tool).
"""
src = getattr(comp, "source", None)
if src:
pyproject = Path(src) / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
scripts = data.get("project", {}).get("scripts", {})
if scripts:
return sorted(scripts.keys())
except (OSError, tomllib.TOMLDecodeError):
pass
return [comp.id]
def _tool_record(
config: WildpcConfig, name: str, comp: ProgramSpec, installed: bool, fmt: str = "openai"
) -> dict:
# The tool-call schema (for handing this CLI to an agent) is stored neutral on
# the path deployment; render it into the requested envelope for the --json
# context payload. Feed default is openai (litellm-native).
from wildpc_core.tool_schema import render_tool_schema
dep = config.deployment("tool", name)
core = getattr(dep, "tool_schema", None)
return {
"name": name,
"executables": _executables(comp),
"description": comp.description,
"installed": installed,
"stack": comp.stack,
"source": comp.source,
"system_dependencies": comp.system_dependencies,
"tool_schema": render_tool_schema(core, fmt) if core else None,
}
def run_tool_list(args: argparse.Namespace) -> int:
"""List tools (programs on PATH) with their executable + description."""
from wildpc_core.lifecycle import tool_installed
config = load_config()
tools = _tool_programs(config)
fmt = getattr(args, "format", "openai")
records = [
_tool_record(config, name, comp, tool_installed(name), fmt)
for name, comp in tools.items()
]
if getattr(args, "json", False):
print(json.dumps(records, indent=2))
return 0
if not records:
print("No tools.")
return 0
print(f"\n{BOLD}Tools{RESET}")
print("" * 60)
width = max(len(r["name"]) for r in records)
for r in records:
dot = f"{GREEN}{RESET}" if r["installed"] else f"{GREY}{RESET}"
exes = ", ".join(r["executables"])
exe_str = f" {CYAN}{exes}{RESET}" if exes and exes != [r["name"]] else ""
# Only show the executable when it differs from the name (the useful case).
if r["executables"] == [r["name"]]:
exe_str = ""
desc = f" {DIM}{r['description']}{RESET}" if r["description"] else ""
print(f" {dot} {BOLD}{r['name']:<{width}}{RESET}{exe_str}{desc}")
print()
return 0
def run_tool_info(args: argparse.Namespace) -> int:
"""Show one tool's details — executable, description, install state, source."""
from wildpc_core.lifecycle import tool_installed
config = load_config()
name = args.name
if name not in config.programs or not _is_tool(config, name):
print(f"Error: no tool '{name}'.")
return 1
comp = config.programs[name]
installed = tool_installed(name)
record = _tool_record(config, name, comp, installed, getattr(args, "format", "openai"))
if getattr(args, "json", False):
print(json.dumps(record, indent=2))
return 0
exes = record["executables"]
print(f"\n{BOLD}{name}{RESET}")
print("" * 40)
if comp.description:
print(f" {BOLD}description{RESET}: {comp.description}")
print(f" {BOLD}run{RESET}: {', '.join(exes)}")
print(f" {BOLD}installed{RESET}: {'yes' if installed else 'no'}")
if comp.source:
print(f" {BOLD}source{RESET}: {comp.source}")
if comp.stack:
print(f" {BOLD}stack{RESET}: {comp.stack}")
if comp.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(comp.system_dependencies)}")
if not installed:
print(f"\n {DIM}enable it in its deployment, then: wildpc apply{RESET}")
else:
print(f"\n {DIM}run `{exes[0]} --help` for arguments{RESET}")
print()
return 0