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,3 @@
"""Wild PC CLI - manage projects, services, and infrastructure."""
__version__ = "0.1.0"

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

View File

@@ -0,0 +1,28 @@
"""Re-export from wildpc-core for backward compatibility."""
from wildpc_core.config import * # noqa: F401, F403
from wildpc_core.config import ( # noqa: F401 — explicit re-exports for type checkers
ARTIFACTS_DIR,
CODE_DIR,
CONTENT_DIR,
GENERATED_DIR,
SECRETS_DIR,
SPECS_DIR,
STATIC_DIR,
WILDPC_HOME,
GatewayConfig,
WildpcConfig,
ensure_dirs,
find_wildpc_root,
load_config,
resolve_env_vars,
save_config,
)
from wildpc_core.registry import ( # noqa: F401
REGISTRY_PATH,
Deployment,
NodeConfig,
NodeRegistry,
load_registry,
save_registry,
)

437
cli/src/wildpc_cli/main.py Normal file
View File

@@ -0,0 +1,437 @@
"""Wild PC CLI entry point — resource-first command surface.
Operations live under the resource they act on. `program` is the catalog;
`service`, `job`, and `tool` are deployment lenses (systemd services, scheduled
timers, and PATH-installed CLIs); `gateway` is infrastructure. Platform-wide
lifecycle (`start`/`stop`/`restart`/`status`/`deploy`) and the cross-resource
`list` are top-level. Names can collide across resource types (a program and a
service may share a name), so the resource is always explicit.
"""
from __future__ import annotations
import argparse
import sys
from wildpc_core.stacks import available_stacks
from wildpc_cli import __version__
DEV_VERBS = ["build", "test", "lint", "format", "type-check", "check"]
def _add_name(p: argparse.ArgumentParser, help: str = "Name", optional: bool = False) -> None:
p.add_argument("name", nargs="?" if optional else None, help=help)
def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
prog = subparsers.add_parser("program", help="Manage programs (the software catalog)")
prog.set_defaults(resource="program")
sub = prog.add_subparsers(dest="program_command")
p = sub.add_parser("list", help="List programs")
p.add_argument(
"--kind",
choices=["service", "job", "tool", "static", "reference"],
help="Filter by derived kind",
)
p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON")
p = sub.add_parser("info", help="Show program details")
_add_name(p, "Program name")
p.add_argument("--json", action="store_true", help="Output as JSON")
p = sub.add_parser("create", help="Scaffold a new program")
_add_name(p, "Program name")
p.add_argument("--stack", choices=available_stacks(), default=None)
p.add_argument("--description", default="", help="Program description")
p.add_argument("--port", type=int, help="Port (service deployments only)")
p = sub.add_parser("add", help="Adopt an existing repo (path or git URL)")
p.add_argument("target", help="Local path or git URL")
p.add_argument("--name", help="Program name (default: dir/repo name)")
p.add_argument("--description", default="", help="Program description")
p = sub.add_parser("clone", help="Clone source for programs with repo:")
_add_name(p, "Program to clone (default: all with repo:)", optional=True)
p = sub.add_parser("delete", help="Remove a program from wildpc.yaml")
_add_name(p, "Program name")
p.add_argument("--source", action="store_true", help="Also delete the source directory")
p.add_argument(
"--purge-data",
action="store_true",
help="Also destroy the program's persistent data (e.g. a supabase app's DB schema)",
)
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
p = sub.add_parser("run", help="Run a program's declared run command")
_add_name(p, "Program name")
p.add_argument("extra", nargs=argparse.REMAINDER, help="Extra args passed to the program")
for verb in DEV_VERBS:
p = sub.add_parser(verb, help=f"Run {verb}")
_add_name(p, "Program (default: all)", optional=True)
def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
"""The `tool` lens — programs installed on PATH (path deployments)."""
grp = subparsers.add_parser("tool", help="Tools on your PATH (the tools lens)")
grp.set_defaults(resource="tool")
sub = grp.add_subparsers(dest="tool_command")
fmt_help = "Render each tool_schema in this envelope (default: openai)"
fmt_choices = ("openai", "anthropic", "neutral")
p = sub.add_parser("list", help="List tools with their executable + description")
p.add_argument("--json", action="store_true", help="Machine-readable output")
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
p = sub.add_parser("info", help="Show a tool's executable, description, install state")
_add_name(p, "Tool name")
p.add_argument("--json", action="store_true", help="Machine-readable output")
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
def _build_stack_group(subparsers: argparse._SubParsersAction) -> None:
"""The `stack` lens — the toolchains each stack needs + whether they're present."""
grp = subparsers.add_parser("stack", help="Stacks + the toolchains they require")
grp.set_defaults(resource="stack")
sub = grp.add_subparsers(dest="stack_command")
p = sub.add_parser("list", help="List stacks with their toolchain health")
p.add_argument("--json", action="store_true", help="Machine-readable output")
p = sub.add_parser("info", help="Show a stack's tools, versions, fixes, and users")
_add_name(p, "Stack name")
p.add_argument("--json", action="store_true", help="Machine-readable output")
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p = sub.add_parser("create", help=f"Create a {kind} in wildpc.yaml")
_add_name(p, f"{kind.capitalize()} name")
p.add_argument("--program", help="Program this deployment runs (convenience ref)")
p.add_argument("--description", default="", help="Description")
p.add_argument("--run", help="Console script / command to run (default: --program or name)")
p.add_argument("--launcher", choices=["python", "command"], default="python")
p.add_argument(
"--env",
action="append",
metavar="KEY=VALUE",
help="Env var for the program (repeatable). Use ${port}/${data_dir}/${name} placeholders.",
)
if kind == "service":
p.add_argument("--port", type=int, help="HTTP port")
p.add_argument("--health", default="/health", help="Health path (default: /health)")
p.add_argument(
"--no-proxy",
action="store_true",
help="Port-only; don't expose at <name>.<gateway.domain>",
)
else:
p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)")
def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -> None:
"""Build the `service` or `job` group (shared verb set)."""
grp = subparsers.add_parser(kind, help=f"Manage {kind}s")
grp.set_defaults(resource=kind)
sub = grp.add_subparsers(dest=f"{kind}_command")
p = sub.add_parser("list", help=f"List {kind}s")
p.add_argument("--json", action="store_true", help="Output as JSON")
p = sub.add_parser("info", help=f"Show {kind} details")
_add_name(p, f"{kind.capitalize()} name")
p.add_argument("--json", action="store_true", help="Output as JSON")
_add_service_create(sub, kind)
p = sub.add_parser("delete", help=f"Remove a {kind} from wildpc.yaml")
_add_name(p, f"{kind.capitalize()} name")
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
p.add_argument("--source", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--purge-data", action="store_true", help=argparse.SUPPRESS)
cap = f"{kind.capitalize()} name"
# Lifecycle is convergence: `wildpc apply [name]`. `restart` stays as the one
# imperative bounce that doesn't change desired state.
_add_name(sub.add_parser("restart", help=f"Restart the {kind} (imperative bounce)"), cap)
p = sub.add_parser("logs", help=f"View {kind} logs")
_add_name(p, f"{kind.capitalize()} name")
p.add_argument("-f", "--follow", action="store_true", help="Follow log output")
p.add_argument("-n", "--lines", type=int, default=50, help="Lines to show (default: 50)")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="wildpc",
description=(
"Wild PC platform CLI — programs, deployments "
"(services, jobs, tools), and infrastructure"
),
)
parser.add_argument("--version", action="version", version=f"wildpc {__version__}")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
_build_program_group(subparsers)
_build_deployment_group(subparsers, "service")
_build_deployment_group(subparsers, "job")
_build_tool_group(subparsers)
_build_stack_group(subparsers)
# Gateway (inspection). The gateway is a deployment — start/stop/reload it via
# `wildpc apply` / `wildpc restart wildpc-gateway`; this lens just shows routes.
gw = subparsers.add_parser("gateway", help="Show the gateway's status + route table")
gw_sub = gw.add_subparsers(dest="gateway_command")
gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
# Mesh — inspect nodes + manage authority-written shared config.
mesh = subparsers.add_parser("mesh", help="Inspect the mesh + shared config")
mesh_sub = mesh.add_subparsers(dest="mesh_command")
mesh_sub.add_parser("status", help="Mesh coordination status (the default)")
mesh_sub.add_parser("nodes", help="List mesh nodes (local + remote)")
mc = mesh_sub.add_parser("config", help="Shared config (only the authority writes)")
mc_sub = mc.add_subparsers(dest="mesh_config_command")
mc_sub.add_parser("list", help="List shared-config keys")
mc_get = mc_sub.add_parser("get", help="Get a shared-config value")
mc_get.add_argument("key")
mc_set = mc_sub.add_parser("set", help="Set a shared-config value (authority only)")
mc_set.add_argument("key")
mc_set.add_argument("value")
# TLS material for raw-TCP services (cert cut from the gateway wildcard).
tls = subparsers.add_parser(
"tls", help="Manage wildpc-materialized TLS certs for raw-TCP services"
)
tls_sub = tls.add_subparsers(dest="tls_command")
tls_sub.add_parser(
"reconcile", help="Refresh materialized certs from the wildcard + reload changed"
)
tls_sub.add_parser("status", help="Show each TLS service's cert fingerprint + expiry")
# Secrets — read/write the ACTIVE backend (file or openbao), so a value never
# gets hand-written to the wrong store (the mistake that silently shadows an
# OpenBao fleet's secret with a stray file the vault never reads).
sec = subparsers.add_parser("secret", help="Manage secrets in the active backend")
sec.set_defaults(resource="secret")
sec_sub = sec.add_subparsers(dest="secret_command")
sec_sub.add_parser("list", help="List secret names in the active backend")
p = sec_sub.add_parser("set", help="Set a secret (prompts if VALUE omitted)")
p.add_argument("name", help="Secret name")
p.add_argument("value", nargs="?", help="Value (omit to read from a hidden prompt / stdin)")
p = sec_sub.add_parser("get", help="Read a secret's value")
p.add_argument("name", help="Secret name")
p = sec_sub.add_parser("rm", help="Delete a secret from the active backend")
p.add_argument("name", help="Secret name")
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
# reconciles the runtime to match config (activate/restart/deactivate).
p = subparsers.add_parser(
"apply", help="Converge the running system to match config (render + reconcile)"
)
p.add_argument("name", nargs="?", help="Single deployment to converge (default: all)")
p.add_argument("--plan", action="store_true", help="Show the diff without changing anything")
# Imperative ops (don't change desired state)
p = subparsers.add_parser("restart", help="Restart deployment(s) — an imperative bounce")
p.add_argument("name", nargs="?", help="Deployment to restart (default: all)")
subparsers.add_parser("status", help="Show status across the platform")
subparsers.add_parser("doctor", help="Diagnose setup + runtime health, with next-step hints")
# Relationship model — repos, requires edges, and derived status.
p = subparsers.add_parser(
"graph", help="Show how programs/deployments relate (repos, requires, status)"
)
p.add_argument("--json", action="store_true", help="Output as JSON")
# Cross-resource overview
p = subparsers.add_parser("list", help="List programs, services, jobs, and tools")
p.add_argument(
"--kind",
choices=["service", "job", "tool", "static", "reference"],
help="Filter by derived kind",
)
p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON")
return parser
def _dispatch_program(args: argparse.Namespace) -> int:
sub = args.program_command
if not sub:
verbs = "list|info|create|add|clone|delete|run|" + "|".join(DEV_VERBS)
print(f"Usage: wildpc program {{{verbs}}}")
return 1
if sub == "list":
from wildpc_cli.commands.list_cmd import run_list
return run_list(args)
if sub == "info":
from wildpc_cli.commands.info import run_info
return run_info(args)
if sub == "create":
from wildpc_cli.commands.create import run_create
return run_create(args)
if sub == "add":
from wildpc_cli.commands.add import run_add
return run_add(args)
if sub == "clone":
from wildpc_cli.commands.clone import run_clone
return run_clone(args)
if sub == "delete":
from wildpc_cli.commands.delete import run_delete
return run_delete(args)
if sub == "run":
from wildpc_cli.commands.run_cmd import run_run
return run_run(args)
if sub in DEV_VERBS:
from wildpc_cli.commands.dev import run_verb
return run_verb(args, sub)
return 1
def _dispatch_tool(args: argparse.Namespace) -> int:
sub = args.tool_command
if not sub:
print("Usage: wildpc tool {list|info} (install/uninstall → edit config + wildpc apply)")
return 1
if sub == "list":
from wildpc_cli.commands.tool import run_tool_list
return run_tool_list(args)
if sub == "info":
from wildpc_cli.commands.tool import run_tool_info
return run_tool_info(args)
return 1
def _dispatch_stack(args: argparse.Namespace) -> int:
sub = args.stack_command
if not sub:
print("Usage: wildpc stack {list|info}")
return 1
if sub == "list":
from wildpc_cli.commands.stack import run_stack_list
return run_stack_list(args)
if sub == "info":
from wildpc_cli.commands.stack import run_stack_info
return run_stack_info(args)
return 1
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
sub = getattr(args, f"{kind}_command")
if not sub:
verbs = "list|info|create|delete|restart|logs (deploy/enable/... → wildpc apply)"
print(f"Usage: wildpc {kind} {{{verbs}}}")
return 1
if sub == "list":
from wildpc_cli.commands.list_cmd import run_list
return run_list(args)
if sub == "info":
from wildpc_cli.commands.info import run_info
return run_info(args)
if sub == "create":
from wildpc_cli.commands.deploy_create import run_job_create, run_service_create
return run_service_create(args) if kind == "service" else run_job_create(args)
if sub == "delete":
from wildpc_cli.commands.delete import run_delete
return run_delete(args)
if sub == "restart":
from wildpc_cli.commands.service import run_job_cmd, run_service_cmd
return run_service_cmd(args) if kind == "service" else run_job_cmd(args)
if sub == "logs":
from wildpc_cli.commands.logs import run_logs
return run_logs(args)
return 1
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
return 0
cmd = args.command
if cmd == "program":
return _dispatch_program(args)
if cmd in ("service", "job"):
return _dispatch_deployment(args, cmd)
if cmd == "tool":
return _dispatch_tool(args)
if cmd == "stack":
return _dispatch_stack(args)
if cmd == "gateway":
from wildpc_cli.commands.gateway import run_gateway
return run_gateway(args)
if cmd == "mesh":
from wildpc_cli.commands.mesh import run_mesh
return run_mesh(args)
if cmd == "tls":
from wildpc_cli.commands.tls import run_tls
return run_tls(args)
if cmd == "secret":
from wildpc_cli.commands.secret import run_secret
return run_secret(args)
if cmd == "apply":
from wildpc_cli.commands.apply import run_apply
return run_apply(args)
if cmd == "restart":
from wildpc_cli.commands.service import run_restart
return run_restart(args)
if cmd == "status":
from wildpc_cli.commands.service import run_status
return run_status(args)
if cmd == "doctor":
from wildpc_cli.commands.doctor import run_doctor
return run_doctor(args)
if cmd == "graph":
from wildpc_cli.commands.graph import run_graph
return run_graph(args)
if cmd == "list":
from wildpc_cli.commands.list_cmd import run_list
return run_list(args)
parser.print_help()
return 1
def cli() -> None:
sys.exit(main())
if __name__ == "__main__":
cli()

View File

@@ -0,0 +1,33 @@
"""Re-export from wildpc-core for backward compatibility."""
from wildpc_core.manifest import * # noqa: F401, F403
from wildpc_core.manifest import ( # noqa: F401 — explicit re-exports for type checkers
BuildSpec,
CaddyDeployment,
CommandsSpec,
DefaultsSpec,
DeploymentBase,
DeploymentSpec,
EnvMap,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
LaunchBase,
LaunchSpec,
ManageSpec,
PathDeployment,
ProgramSpec,
Reach,
ReadinessHttpGet,
RemoteDeployment,
Requirement,
RestartPolicy,
RunCommand,
RunCompose,
RunContainer,
RunNode,
RunPython,
SystemdDeployment,
SystemdSpec,
kind_for,
)

View File

View File

@@ -0,0 +1,975 @@
"""Project scaffolding - generates project files from templates."""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def scaffold_project(
project_dir: Path,
name: str,
package_name: str,
stack: str,
description: str,
port: int | None = None,
) -> None:
"""Scaffold a new project from templates based on stack."""
if stack == "python-fastapi":
_scaffold_service(project_dir, name, package_name, description, port or 9000)
elif stack == "python-cli":
_scaffold_tool(project_dir, name, package_name, description)
elif stack == "supabase":
_scaffold_supabase(project_dir, name, description)
elif stack == "hugo":
_scaffold_hugo(project_dir, name, description)
else:
raise ValueError(f"No scaffold template for stack: {stack}")
def _scaffold_service(
project_dir: Path,
name: str,
package_name: str,
description: str,
port: int,
) -> None:
"""Scaffold a FastAPI service."""
src_dir = project_dir / "src" / package_name
tests_dir = project_dir / "tests"
src_dir.mkdir(parents=True)
tests_dir.mkdir(parents=True)
env_prefix = package_name.upper()
# pyproject.toml
_write(
project_dir / "pyproject.toml",
f'''[project]
name = "{name}"
version = "0.1.0"
description = "{description}"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115.0",
"uvicorn>=0.34.0",
"pydantic-settings>=2.0.0",
"httpx>=0.27.0",
]
[project.scripts]
{name} = "{package_name}.main:run"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/{package_name}"]
[dependency-groups]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
]
[tool.ruff.lint.isort]
known-first-party = ["{package_name}"]
''',
)
# __init__.py
_write(
src_dir / "__init__.py",
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
)
# config.py
_write(
src_dir / "config.py",
f'''"""Configuration for {name}."""
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Service settings loaded from environment variables."""
data_dir: Path = Path("./data")
host: str = "0.0.0.0"
port: int = {port}
model_config = {{
"env_prefix": "{env_prefix}_",
"env_file": ".env",
}}
def ensure_data_dir(self) -> None:
"""Create data directory if it doesn't exist."""
self.data_dir.mkdir(parents=True, exist_ok=True)
settings = Settings()
''',
)
# main.py
_write(
src_dir / "main.py",
f'''"""Main application for {name}."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from {package_name}.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
settings.ensure_data_dir()
yield
app = FastAPI(
title="{name}",
description="{description}",
version="0.1.0",
lifespan=lifespan,
)
@app.get("/health")
async def health() -> dict[str, str]:
"""Health check endpoint."""
return {{"status": "ok"}}
def run() -> None:
"""Run the application with uvicorn."""
uvicorn.run(
"{package_name}.main:app",
host=settings.host,
port=settings.port,
reload=False,
)
if __name__ == "__main__":
run()
''',
)
# tests/__init__.py
_write(tests_dir / "__init__.py", "")
# tests/conftest.py
_write(
tests_dir / "conftest.py",
f'''"""Test fixtures for {name}."""
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from {package_name}.config import settings
from {package_name}.main import app
@pytest.fixture
def temp_data_dir(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary data directory for tests."""
data_dir = tmp_path / "data"
data_dir.mkdir()
original = settings.data_dir
settings.data_dir = data_dir
yield data_dir
settings.data_dir = original
@pytest.fixture
def client(temp_data_dir: Path) -> Generator[TestClient, None, None]:
"""Create a test client with isolated data directory."""
with TestClient(app) as client:
yield client
''',
)
# tests/test_health.py
_write(
tests_dir / "test_health.py",
f'''"""Tests for {name} health endpoint."""
from fastapi.testclient import TestClient
class TestHealth:
"""Health endpoint tests."""
def test_health(self, client: TestClient) -> None:
"""Health endpoint returns ok."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {{"status": "ok"}}
''',
)
# CLAUDE.md
_write(
project_dir / "CLAUDE.md",
f"""# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working
with code in this repository.
## Overview
{name} is a FastAPI service. {description}.
## Commands
```bash
uv sync # Install dependencies
uv run {name} # Run service (port {port})
uv run pytest tests/ -v # Run tests
uv run ruff check . # Lint
uv run ruff format . # Format
```
## Architecture
- `src/{package_name}/config.py` — Settings via pydantic-settings, env prefix `{env_prefix}_`
- `src/{package_name}/main.py` — FastAPI app, lifespan, health endpoint
- `tests/` — pytest with TestClient fixtures
## Configuration
Environment variables with `{env_prefix}_` prefix:
- `{env_prefix}_DATA_DIR` — Data directory (default: ./data)
- `{env_prefix}_HOST` — Bind host (default: 0.0.0.0)
- `{env_prefix}_PORT` — Port (default: {port})
""",
)
def _scaffold_tool(
project_dir: Path,
name: str,
package_name: str,
description: str,
) -> None:
"""Scaffold a CLI tool."""
src_dir = project_dir / "src" / package_name
tests_dir = project_dir / "tests"
src_dir.mkdir(parents=True)
tests_dir.mkdir(parents=True)
# pyproject.toml
_write(
project_dir / "pyproject.toml",
f'''[project]
name = "{name}"
version = "0.1.0"
description = "{description}"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
{name} = "{package_name}.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/{package_name}"]
[dependency-groups]
dev = [
"pytest>=7.0.0",
]
[tool.ruff.lint.isort]
known-first-party = ["{package_name}"]
''',
)
# __init__.py
_write(
src_dir / "__init__.py",
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
)
# main.py
_write(
src_dir / "main.py",
f'''#!/usr/bin/env python3
"""{name}: {description}
Usage:
{name} [options] [input]
cat input.txt | {name}
Examples:
{name} input.txt
{name} input.txt -o output.txt
cat input.txt | {name} > output.txt
"""
import argparse
import sys
from {package_name} import __version__
__all__ = ["main"]
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="{description}",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("input", nargs="?", help="Input file (default: stdin)")
parser.add_argument(
"-o", "--output", default=None, help="Output file (default: stdout)"
)
parser.add_argument(
"--version", action="version", version=f"{name} {{__version__}}"
)
args = parser.parse_args()
if args.input:
with open(args.input) as f:
data = f.read()
else:
data = sys.stdin.read()
# TODO: implement tool logic
result = data
if args.output:
with open(args.output, "w") as f:
f.write(result)
else:
print(result, end="")
return 0
if __name__ == "__main__":
sys.exit(main())
''',
)
# tests/__init__.py
_write(tests_dir / "__init__.py", "")
# tests/test_main.py
_write(
tests_dir / "test_main.py",
f'''"""Tests for {name}."""
import subprocess
import sys
class TestCLI:
"""CLI interface tests."""
def test_version(self) -> None:
"""--version prints version string."""
result = subprocess.run(
[sys.executable, "-m", "{package_name}.main", "--version"],
capture_output=True,
text=True,
)
assert "{name}" in result.stdout
assert "0.1.0" in result.stdout
def test_stdin(self) -> None:
"""Reads from stdin when no file argument."""
result = subprocess.run(
[sys.executable, "-m", "{package_name}.main"],
input="hello\\n",
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "hello" in result.stdout
def test_file_input(self, tmp_path) -> None:
"""Reads from file argument."""
input_file = tmp_path / "input.txt"
input_file.write_text("test data")
result = subprocess.run(
[sys.executable, "-m", "{package_name}.main", str(input_file)],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "test data" in result.stdout
def test_output_file(self, tmp_path) -> None:
"""Writes to output file with -o flag."""
input_file = tmp_path / "input.txt"
input_file.write_text("test data")
output_file = tmp_path / "output.txt"
result = subprocess.run(
[
sys.executable, "-m", "{package_name}.main",
str(input_file), "-o", str(output_file),
],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert output_file.read_text() == "test data"
''',
)
# CLAUDE.md
_write(
project_dir / "CLAUDE.md",
f"""# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working
with code in this repository.
## Overview
{name} is a CLI tool. {description}.
## Commands
```bash
uv sync # Install dependencies
uv run {name} # Run the tool
uv run {name} --version # Show version
uv run pytest tests/ -v # Run tests
uv run ruff check . # Lint
uv run ruff format . # Format
```
## Architecture
- `src/{package_name}/main.py` — Entry point, argparse CLI, stdin/stdout interface
- `src/{package_name}/__init__.py` — Package version (`__version__`)
- `tests/` — pytest tests
## Conventions
- Reads from stdin or file argument
- Writes to stdout or `-o/--output` file
- `--version` flag for version info
- Returns 0 on success, 1 on error
- Composable via Unix pipes
- `argparse.RawDescriptionHelpFormatter` with module docstring as epilog
""",
)
def _scaffold_library(
project_dir: Path,
name: str,
package_name: str,
description: str,
) -> None:
"""Scaffold a Python library."""
src_dir = project_dir / "src" / package_name
tests_dir = project_dir / "tests"
src_dir.mkdir(parents=True)
tests_dir.mkdir(parents=True)
# pyproject.toml
_write(
project_dir / "pyproject.toml",
f'''[project]
name = "{name}"
version = "0.1.0"
description = "{description}"
requires-python = ">=3.11"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/{package_name}"]
[dependency-groups]
dev = [
"pytest>=7.0.0",
]
[tool.ruff.lint.isort]
known-first-party = ["{package_name}"]
''',
)
# __init__.py
_write(
src_dir / "__init__.py",
f'"""{description}."""\n\n__version__ = "0.1.0"\n',
)
# tests/__init__.py
_write(tests_dir / "__init__.py", "")
# tests/test_placeholder.py
_write(
tests_dir / "test_placeholder.py",
f'''"""Tests for {name}."""
class TestPlaceholder:
"""Placeholder tests."""
def test_import(self) -> None:
"""Library can be imported."""
import {package_name}
assert {package_name}.__version__ == "0.1.0"
''',
)
# CLAUDE.md
_write(
project_dir / "CLAUDE.md",
f"""# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working
with code in this repository.
## Overview
{name} is a Python library. {description}.
## Commands
```bash
uv sync # Install dependencies
uv run pytest tests/ -v # Run tests
uv run ruff check . # Lint
uv run ruff format . # Format
```
## Architecture
- `src/{package_name}/` — Library source code
- `tests/` — pytest tests
""",
)
def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None:
"""Scaffold a Patch-shaped app that targets the shared Supabase substrate.
Produces migrations/ (applied to the substrate by `wildpc program build`),
functions/ (deno edge functions), public/ (static UI served in place at
<name>.<gateway.domain> by the gateway), and supabase.app.yaml (auth policy + wiring).
The app owns its code and stays repo-durable; only its rows/blobs live on the
shared substrate. Each app is isolated in its **own Postgres schema** (the app
id) rather than sharing `public` — so `wildpc program build` creates+grants the
schema and tracks migrations per-app, and `wildpc delete --purge-data` drops
the whole schema cleanly. Rows are further protected by RLS.
"""
ident = name.replace("-", "_") # a safe SQL/JS identifier — and the app schema
table = "entries" # unqualified; lives in the app's own schema (search_path)
def sub(text: str) -> str:
return (
text.replace("__NAME__", name)
.replace("__IDENT__", ident)
.replace("__SCHEMA__", ident)
.replace("__TABLE__", table)
.replace("__DESC__", description)
)
# --- supabase.app.yaml — app manifest (substrate wiring + auth policy) ---
_write(
project_dir / "supabase.app.yaml",
sub(
"""# Patch-shaped app targeting the shared Supabase substrate.
name: __NAME__
description: __DESC__
substrate: supabase # the shared wildpc service this app deploys against
# auth policy: public | private | shared
# public — anyone with the URL (anon read/write, still RLS-gated)
# private — only the owner; RLS locks rows to auth.uid()
# shared — owner + named people (list handles below)
auth: public
# shared: [alice, bob]
# This app is isolated in its own Postgres schema (created + exposed through
# PostgREST by `wildpc program build`). The frontend selects it via
# supabase-js `db: { schema }`.
schema: __SCHEMA__
"""
),
)
# --- migrations/0001_init.sql — versioned, idempotent, forward-only ---
_write(
project_dir / "migrations" / "0001_init.sql",
sub(
"""-- 0001_init: example table + RLS. Applied by `wildpc program build`
-- via the versioned migration runner (tracked per-app in
-- <schema>.schema_migrations; only unapplied migrations run). Forward-only —
-- never edit an applied migration; add a new numbered file instead.
--
-- The runner sets search_path to this app's own schema, so unqualified names
-- land there (NOT in public). Keep tables unqualified for schema portability.
create table if not exists __TABLE__ (
id bigint generated always as identity primary key,
message text not null check (char_length(message) <= 500),
author text,
created_at timestamptz not null default now()
);
-- RLS is necessary but NOT sufficient on its own: it protects rows, not the
-- static app shell or Storage objects. For a `public` app, anon read/write is
-- intended (still row-gated). For `private`/`shared`, replace the policies below
-- with owner checks (auth.uid()) AND gate the shell + use signed Storage URLs.
alter table __TABLE__ enable row level security;
drop policy if exists "__IDENT___public_read" on __TABLE__;
create policy "__IDENT___public_read" on __TABLE__ for select using (true);
drop policy if exists "__IDENT___public_write" on __TABLE__;
create policy "__IDENT___public_write" on __TABLE__ for insert with check (true);
"""
),
)
# --- functions/hello/index.ts — deno edge-function stub ---
_write(
project_dir / "functions" / "hello" / "index.ts",
sub(
"""// Edge function for __NAME__. Runs on the substrate's edge-runtime.
// Deployed alongside migrations; call it from the app (never expose the
// service_role key to the browser — privileged work happens here, server-side).
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
serve((_req: Request) => {
return new Response(
JSON.stringify({ ok: true, app: "__NAME__", ts: new Date().toISOString() }),
{ headers: { "content-type": "application/json" } },
);
});
"""
),
)
# --- public/config.js — substrate wiring (anon key is public-safe by design) ---
_write(
project_dir / "public" / "config.js",
sub(
"""// Substrate wiring for __NAME__. The anon key is designed to be public
// (RLS enforces access) — paste yours here. Find it in the dashboard
// Secrets page (or `wildpc mesh`/vault), secret name SUPABASE_ANON_KEY.
window.APP = {
SUPABASE_URL: "https://supabase.lan",
SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE",
SCHEMA: "__SCHEMA__", // this app's isolated Postgres schema
TABLE: "__TABLE__",
};
"""
),
)
# --- public/index.html — reads/writes the substrate via supabase-js ---
_write(
project_dir / "public" / "index.html",
sub(
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>__NAME__</title>
<script src="./config.js"></script>
<script type="module">
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const { SUPABASE_URL, SUPABASE_ANON_KEY, SCHEMA, TABLE } = window.APP;
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { db: { schema: SCHEMA } });
const list = document.getElementById("list");
async function refresh() {
const { data, error } = await db.from(TABLE)
.select("message, author, created_at")
.order("created_at", { ascending: false }).limit(50);
list.textContent = error ? "Error: " + error.message
: (data.map(r => `${r.author ?? "anon"}: ${r.message}`).join("\\n") || "(empty)");
}
document.getElementById("form").addEventListener("submit", async (e) => {
e.preventDefault();
const message = e.target.message.value.trim();
if (!message) return;
const { error } = await db.from(TABLE).insert({ message });
if (error) { alert(error.message); return; }
e.target.reset();
refresh();
});
refresh();
</script>
</head>
<body>
<h1>__NAME__</h1>
<p>__DESC__</p>
<form id="form"><input name="message" placeholder="Say something…" autocomplete="off" />
<button>Post</button></form>
<pre id="list">Loading…</pre>
</body>
</html>
"""
),
)
# --- CLAUDE.md — how this app works ---
_write(
project_dir / "CLAUDE.md",
sub(
"""# __NAME__
__DESC__
A **supabase-stack** app: it owns its code (this repo) and rents its backend from
the shared Supabase substrate (the `supabase` wildpc service). Only its rows/blobs
live on the substrate; rebuild the rest from git anytime.
## Layout
- `migrations/` — versioned, idempotent Postgres migrations (forward-only). Applied
to the substrate by `wildpc program build __NAME__`.
- `functions/` — deno edge functions deployed to the substrate's edge-runtime.
- `public/` — static UI served in place at `/__NAME__/` by the gateway. Talks to
the substrate with `@supabase/supabase-js` + the public anon key (RLS-gated).
- `supabase.app.yaml` — substrate wiring + auth policy (public/private/shared).
## Develop
- Edit `migrations/`, then `wildpc program build __NAME__` to apply new migrations
(re-running is a no-op — only unapplied migrations run).
- Set the anon key in `public/config.js` (find `SUPABASE_ANON_KEY` in the
dashboard Secrets page).
- `wildpc apply` → served at `__NAME__.<domain>`.
## Privacy note
RLS protects rows, not the static shell or Storage. For a `private`/`shared` app,
also gate the shell and use signed Storage URLs — RLS alone is not leak-proof.
Auth/WebCrypto apps should get their own HTTPS host route (secure context).
"""
),
)
def _hugo_new_site(project_dir: Path) -> bool:
"""Create the canonical site skeleton with Hugo's own scaffolder.
Delegates the standard structure (archetypes/, the content/layouts/static/…
dir tree, hugo.toml) to `hugo new site` rather than reinventing it. Returns
False if the hugo binary isn't present at create time — the caller then falls
back to a minimal hand-written skeleton (the build needs hugo regardless)."""
if shutil.which("hugo") is None:
return False
# `hugo new site` requires an empty/absent target; create runs before git init,
# so project_dir doesn't exist yet.
result = subprocess.run(
["hugo", "new", "site", str(project_dir), "--format", "toml"],
capture_output=True,
text=True,
)
return result.returncode == 0
def _scaffold_hugo(project_dir: Path, name: str, description: str) -> None:
"""Scaffold a Hugo site that builds and serves theme-less out of the box.
The canonical skeleton comes from Hugo's own `hugo new site`; on top of it we
overlay the pieces a bare skeleton lacks — the layouts needed to render a home
page without a theme, sample content, and a wildpc-flavored hugo.toml/README.
`wildpc program build` runs `hugo --gc --minify` → `public/`, which a caddy
deployment serves in place at <name>.<gateway.domain>. baseURL is `/` so assets
resolve at the root of the site's own subdomain. A theme (with its own asset
pipeline) can be dropped under themes/ later; such themes declare a two-step
`build.commands` in the program spec, which overrides the stack default."""
def sub(text: str) -> str:
return text.replace("__NAME__", name).replace("__DESC__", description)
# Prefer Hugo's native scaffolder; fall back to the bits it would have made.
if not _hugo_new_site(project_dir):
_write(
project_dir / "archetypes" / "default.md",
"""+++
date = '{{ .Date }}'
draft = true
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++
""",
)
# --- hugo.toml — overwrite Hugo's example.org default; baseURL '/' serves at
# the subdomain root ---
_write(
project_dir / "hugo.toml",
sub(
"""baseURL = "/"
languageCode = "en-us"
title = "__NAME__"
[params]
description = "__DESC__"
"""
),
)
# --- layouts/_default/baseof.html — the shared page skeleton (blocks) ---
_write(
project_dir / "layouts" / "_default" / "baseof.html",
sub(
"""<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode | default "en" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
<meta name="description" content="{{ .Site.Params.description }}">
</head>
<body>
<header><a href="{{ "/" | relURL }}">{{ .Site.Title }}</a></header>
<main>{{ block "main" . }}{{ end }}</main>
<footer>&copy; {{ now.Year }} {{ .Site.Title }}</footer>
</body>
</html>
"""
),
)
# --- layouts/index.html — the home page ---
_write(
project_dir / "layouts" / "index.html",
"""{{ define "main" }}
{{ .Content }}
<ul>
{{ range (where .Site.RegularPages "Type" "posts") }}
<li>
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
</li>
{{ end }}
</ul>
{{ end }}
""",
)
# --- layouts/_default/{single,list}.html — content pages ---
_write(
project_dir / "layouts" / "_default" / "single.html",
"""{{ define "title" }}{{ .Title }} &middot; {{ .Site.Title }}{{ end }}
{{ define "main" }}
<article>
<h1>{{ .Title }}</h1>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
{{ .Content }}
</article>
{{ end }}
""",
)
_write(
project_dir / "layouts" / "_default" / "list.html",
"""{{ define "title" }}{{ .Title }} &middot; {{ .Site.Title }}{{ end }}
{{ define "main" }}
<h1>{{ .Title }}</h1>
{{ .Content }}
<ul>
{{ range .Pages }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ end }}
""",
)
# --- content — home + an example post ---
_write(
project_dir / "content" / "_index.md",
sub(
"""---
title: "__NAME__"
---
Welcome to **__NAME__** — a Hugo site on wildpc. Edit `content/_index.md` and
add posts under `content/posts/`.
"""
),
)
_write(
project_dir / "content" / "posts" / "hello.md",
"""---
title: "Hello, world"
date: 2024-01-01
---
Your first post. Edit me in `content/posts/`, or run `hugo new posts/next.md`.
""",
)
# --- .gitignore — build output is regenerated, never committed ---
_write(
project_dir / ".gitignore",
"/public/\n/resources/\n.hugo_build.lock\n",
)
# --- README ---
_write(
project_dir / "README.md",
sub(
"""# __NAME__
__DESC__
A [Hugo](https://gohugo.io) static site managed by wildpc.
## Develop
hugo server -D # live preview at http://localhost:1313
## Build & serve
wildpc program build __NAME__ # hugo --gc --minify -> public/
wildpc apply __NAME__ # serve at __NAME__.<gateway.domain>
## Themes
Drop a theme under `themes/` (often a git submodule) and set `theme` in
`hugo.toml`. A theme with an asset pipeline (Tailwind, etc.) needs a pre-build
step; declare it in `programs/__NAME__.yaml` so it overrides the stack default:
build:
commands:
- [pnpm, build]
- [hugo, --gc, --minify]
outputs: [public]
"""
),
)
def _write(path: Path, content: str) -> None:
"""Write content to a file, creating parent directories."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)