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:
3
cli/src/wildpc_cli/__init__.py
Normal file
3
cli/src/wildpc_cli/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Wild PC CLI - manage projects, services, and infrastructure."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
0
cli/src/wildpc_cli/commands/__init__.py
Normal file
0
cli/src/wildpc_cli/commands/__init__.py
Normal file
45
cli/src/wildpc_cli/commands/add.py
Normal file
45
cli/src/wildpc_cli/commands/add.py
Normal 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
|
||||
72
cli/src/wildpc_cli/commands/apply.py
Normal file
72
cli/src/wildpc_cli/commands/apply.py
Normal 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
|
||||
62
cli/src/wildpc_cli/commands/clone.py
Normal file
62
cli/src/wildpc_cli/commands/clone.py
Normal 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
|
||||
204
cli/src/wildpc_cli/commands/create.py
Normal file
204
cli/src/wildpc_cli/commands/create.py
Normal 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
|
||||
182
cli/src/wildpc_cli/commands/delete.py
Normal file
182
cli/src/wildpc_cli/commands/delete.py
Normal 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
|
||||
123
cli/src/wildpc_cli/commands/deploy_create.py
Normal file
123
cli/src/wildpc_cli/commands/deploy_create.py
Normal 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
|
||||
128
cli/src/wildpc_cli/commands/dev.py
Normal file
128
cli/src/wildpc_cli/commands/dev.py
Normal 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
|
||||
550
cli/src/wildpc_cli/commands/doctor.py
Normal file
550
cli/src/wildpc_cli/commands/doctor.py
Normal 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
|
||||
52
cli/src/wildpc_cli/commands/gateway.py
Normal file
52
cli/src/wildpc_cli/commands/gateway.py
Normal 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
|
||||
68
cli/src/wildpc_cli/commands/graph.py
Normal file
68
cli/src/wildpc_cli/commands/graph.py
Normal 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
|
||||
231
cli/src/wildpc_cli/commands/info.py
Normal file
231
cli/src/wildpc_cli/commands/info.py
Normal 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
|
||||
268
cli/src/wildpc_cli/commands/list_cmd.py
Normal file
268
cli/src/wildpc_cli/commands/list_cmd.py
Normal 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
|
||||
86
cli/src/wildpc_cli/commands/logs.py
Normal file
86
cli/src/wildpc_cli/commands/logs.py
Normal 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
|
||||
105
cli/src/wildpc_cli/commands/mesh.py
Normal file
105
cli/src/wildpc_cli/commands/mesh.py
Normal 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
|
||||
101
cli/src/wildpc_cli/commands/run_cmd.py
Normal file
101
cli/src/wildpc_cli/commands/run_cmd.py
Normal 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
|
||||
93
cli/src/wildpc_cli/commands/secret.py
Normal file
93
cli/src/wildpc_cli/commands/secret.py
Normal 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
|
||||
223
cli/src/wildpc_cli/commands/service.py
Normal file
223
cli/src/wildpc_cli/commands/service.py
Normal 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
|
||||
110
cli/src/wildpc_cli/commands/stack.py
Normal file
110
cli/src/wildpc_cli/commands/stack.py
Normal 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
|
||||
79
cli/src/wildpc_cli/commands/tls.py
Normal file
79
cli/src/wildpc_cli/commands/tls.py
Normal 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
|
||||
156
cli/src/wildpc_cli/commands/tool.py
Normal file
156
cli/src/wildpc_cli/commands/tool.py
Normal 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
|
||||
28
cli/src/wildpc_cli/config.py
Normal file
28
cli/src/wildpc_cli/config.py
Normal 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
437
cli/src/wildpc_cli/main.py
Normal 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()
|
||||
33
cli/src/wildpc_cli/manifest.py
Normal file
33
cli/src/wildpc_cli/manifest.py
Normal 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,
|
||||
)
|
||||
0
cli/src/wildpc_cli/templates/__init__.py
Normal file
0
cli/src/wildpc_cli/templates/__init__.py
Normal file
975
cli/src/wildpc_cli/templates/scaffold.py
Normal file
975
cli/src/wildpc_cli/templates/scaffold.py
Normal 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>© {{ 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 }} · {{ .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 }} · {{ .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)
|
||||
Reference in New Issue
Block a user