Compare commits

...

16 Commits

Author SHA1 Message Date
Paul Payne
b48535745b Update favicon to unified Wild product family design language
Orange terminal prompt icon with white stroke-based line art on rounded rect,
matching the shared visual identity across Wild Central, Cloud, and Directory.
2026-07-21 23:33:33 +00:00
Paul Payne
592839a9d9 app: replace castle favicon with Wild PC W monogram 2026-07-19 00:07:38 -07:00
Paul Payne
05b28cb584 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.
2026-07-18 22:55:08 -07:00
Paul Payne
25d7a522cc core: write job secret env files with the -job unit suffix
A job's systemd unit is castle-<name>-job.service and its EnvironmentFile points
at castle-<name>-job.service.env, but _write_secret_env_file wrote
castle-<name>.service.env (kind defaulted to 'service'). Every secret-using job
failed to start (Failed to load environment files). Thread the deployment kind
through to secret_env_path so the filename matches the unit.
2026-07-16 21:25:47 -07:00
Paul Payne
afc623ec58 Add castle secret CLI and fail-loud unresolved-secret apply gate
Writing a secret by hand meant knowing the active backend and its layout;
getting it wrong (a file write on an OpenBao fleet) left the value where the
resolver never reads it, so ${secret:NAME} silently degraded to the literal
<MISSING_SECRET:NAME> placeholder that a service then used as its credential.

- `castle secret {list|set|get|rm}` reads/writes the ACTIVE backend, so there's
  no wrong store to pick. `set NAME` with no value reads a hidden prompt / stdin.
- `castle apply` (and --plan) now refuses to converge any deployment whose
  ${secret:...} refs don't resolve in the active backend: exits non-zero, writes
  nothing, and names each deployment + secret + the `castle secret set` fix.
- New helpers in core/config.py: active_secret_backend(), active_backend_name(),
  secret_refs(). Gate impl: deploy._unresolved_secrets() + ApplyResult.blocked.
- Tests: test_deploy_secret_gate.py, TestSecretRefs, test_secret.py. Docs: AGENTS.md.
2026-07-14 07:17:17 -07:00
Paul Payne
d152e79cee Add spa flag for static deployments; fix Hugo navigation
Static (manager: caddy) deployments always emitted the SPA fallback
`try_files {path} /index.html`, which serves the root index.html for
every unmatched path. That's correct for React/Vite apps but swallows a
multi-page content site's in-page links back to the homepage, so a Hugo
site (payne.io) couldn't navigate off its landing page.

Add an explicit `spa: bool` on CaddyDeployment (default True, preserving
existing SPA behavior). When False, the gateway serves plain file_server
— resolving directory indexes (/posts/ -> /posts/index.html) and 404ing
missing paths. Threaded through the registry snapshot + mesh and the
route computation so both the internal wildcard and public apex blocks
honor it. `castle program create --stack hugo` now sets spa: false.
2026-07-12 23:41:47 -07:00
Paul Payne
1767a0a304 Adds custom domains. 2026-07-12 17:29:34 -07:00
Paul Payne
964226d671 Add stack dependency management
Stacks declare host toolchains (uv/pnpm/node/hugo/deno/psql) that can drift
from what's installed and, worse, from what's on a running service's PATH.
Make those dependencies first-class and visible.

Model (core):
- stacks.ToolRequirement + StackHandler.tools declare each stack's toolchains
  (command, purpose, phase, install_hint); tools_for() is the single source.
- relations synthesizes them as `kind: tool` requirements so functional?/graph
  account for them; checked runtime-env-aware (run-phase tools of a systemd
  service are probed against the service's PATH, not the shell) via a shared
  generators.systemd.runtime_path helper the unit generator also uses, so the
  checker can't drift from the generator. hint_for() makes every unmet
  requirement actionable.
- stack_status: the derived per-stack health the CLI/API/UI all render.
- config: add ~/.deno/bin to USER_TOOL_PATH_DIRS so deno (supabase edge fns)
  is found by services and the check, same as ~/.local/bin and the pnpm dirs.

Surfaces:
- castle stack list|info (new resource) + GET /stacks/status, /stacks/{name}
  (GET /stacks stays a bare name list for the create-form select).
- castle doctor gains a "Stacks & dependencies" section (FAIL for an enabled
  deployment's missing tool, WARN otherwise, unused stacks skipped).
- castle apply preflight warns (advisory, like _acme_preflight) when a tool is
  missing where a service runs; ConvergePanel renders those warnings.
- Dashboard Stacks page: per-stack tool checklist with versions + copyable
  install hints, program links, and verb chips.

Tests: relations (drift + hints), doctor (ok/fail/skip), /stacks endpoints.
2026-07-12 16:04:48 -07:00
Paul Payne
f8e487071e Adds hugo stack. 2026-07-12 14:29:03 -07:00
Paul Payne
a3ff03fc74 app: enhance gateway change detection for Caddyfile routing 2026-07-12 14:09:07 -07:00
Paul Payne
9af37541fb Fixes system map handles. 2026-07-12 14:07:57 -07:00
Paul Payne
2967d1fa5e Add public domain support for gateway routes in Caddyfile generation 2026-07-11 08:30:59 +00:00
Paul Payne
e33c505949 app: rename the System map route /map -> /system
The nav label was already "System" and the page is SystemMap; only the URL
was stale. Move it to /system, add a back-compat redirect from /map, and update
the command-palette navigations. Unrelated graph node type "map" left as-is.
2026-07-07 21:08:58 -07:00
Paul Payne
a9f1e5b099 app: Add-program flow with server-side filesystem picker
Register an existing repo as a program from the /programs page — the web
equivalent of `castle program add`. An inline form with an editable,
autocompleting path bar browses the *server's* filesystem (a browser's own
file dialog only sees the client machine) and also accepts a git URL.

- core: extract the adopt logic (target parsing, stack/command detection,
  ProgramSpec build) into castle_core.adopt so the CLI and API share it; the
  CLI's add.py now delegates to it.
- castle-api: GET /fs/browse (directory listing, per-entry stat errors skipped
  so one unreadable child can't 403 the dir) and POST /programs/adopt.
- app: AddProgramForm + Add-program button on the Programs page; useBrowse /
  useAdoptProgram hooks.

Also tidy ProgramCard: deployments render below the description, and the
deployment name label is hidden when it matches the program title.
2026-07-07 20:51:59 -07:00
Paul Payne
9a8e16143b For real. Tools schema generation and ux tweaks. 2026-07-07 17:51:00 -07:00
Paul Payne
a3d01ca1b3 Tools schema generation and ux tweaks. 2026-07-07 17:48:52 -07:00
201 changed files with 7277 additions and 3770 deletions

4
.gitignore vendored
View File

@@ -4,6 +4,6 @@ __pycache__
.pytest_cache .pytest_cache
.claude .claude
node_modules node_modules
castle.yaml wildpc.yaml
!bootstrap/**/castle.yaml !bootstrap/**/wildpc.yaml
.working .working

204
AGENTS.md
View File

@@ -1,7 +1,7 @@
# AGENTS.md — Castle # AGENTS.md — Wild PC
You are working in **Castle**, a personal software platform. Castle is a You are working in **Wild PC**, a personal software platform. Wild PC is a
monorepo of independent programs managed by the `castle` CLI. From this directory monorepo of independent programs managed by the `wildpc` CLI. From this directory
you can **manage all the software on this box from source** — create programs, you can **manage all the software on this box from source** — create programs,
deploy them as services, jobs, tools, or static frontends, route them through the deploy them as services, jobs, tools, or static frontends, route them through the
gateway, expose them over TLS or a public tunnel, and coordinate across nodes. gateway, expose them over TLS or a public tunnel, and coordinate across nodes.
@@ -13,7 +13,7 @@ section links to a doc under `docs/`. Read those before non-trivial changes.
## 1. Mental model — two layers ## 1. Mental model — two layers
Castle splits every piece of software into **what it is** and **how it runs here**: Wild PC splits every piece of software into **what it is** and **how it runs here**:
- **`programs/<name>.yaml`** — the software *catalog*: source, stack, build, - **`programs/<name>.yaml`** — the software *catalog*: source, stack, build,
system dependencies. "What software exists." system dependencies. "What software exists."
@@ -32,64 +32,70 @@ stored:
| `none` | — | **reference** | an external service on another node | | `none` | — | **reference** | an external service on another node |
A program may have **no** deployment (just source you develop), or one/more A program may have **no** deployment (just source you develop), or one/more
deployments. Global settings live in **`castle.yaml`** (`gateway`, `repo`, deployments. Global settings live in **`wildpc.yaml`** (`gateway`, `repo`,
`agents`). Config root defaults to `~/.castle/`. `agents`). Config root defaults to `~/.wildpc/`.
**Prime directive:** regular programs must **never depend on castle**. They take **Prime directive:** regular programs must **never depend on wildpc**. They take
standard config (data dir, port, URLs) via **env vars**; only castle's own standard config (data dir, port, URLs) via **env vars**; only wildpc's own
programs (CLI, gateway, api) know castle internals. When you scaffold or adopt a programs (CLI, gateway, api) know wildpc internals. When you scaffold or adopt a
program, wire it with env vars — not castle imports. program, wire it with env vars — not wildpc imports.
→ Full reference: **`docs/registry.md`** (castle.yaml structure, every field, → Full reference: **`docs/registry.md`** (wildpc.yaml structure, every field,
manifest models, lifecycle). Architecture rationale: **`docs/design.md`**. manifest models, lifecycle). Architecture rationale: **`docs/design.md`**.
--- ---
## 2. The `castle` CLI ## 2. The `wildpc` CLI
Resource-first: operations live under the resource they act on. Names can collide Resource-first: operations live under the resource they act on. Names can collide
across resource types, so the resource is explicit. across resource types, so the resource is explicit.
```bash ```bash
# Programs — the software catalog # Programs — the software catalog
castle program list [--kind service] [--stack python-cli] [--json] wildpc program list [--kind service] [--stack python-cli] [--json]
castle program info <name> [--json] wildpc program info <name> [--json]
castle program create <name> [--stack ...] [--description ...] # scaffold NEW code wildpc program create <name> [--stack ...] [--description ...] # scaffold NEW code
castle program add <path|git-url> [--name ...] # adopt EXISTING repo wildpc program add <path|git-url> [--name ...] # adopt EXISTING repo
castle program clone [name] # provision repo: source wildpc program clone [name] # provision repo: source
castle program delete <name> [--source] [-y] wildpc program delete <name> [--source] [-y]
castle program run <name> [args...] # declared run command wildpc program run <name> [args...] # declared run command
castle program build|test|lint|format|type-check|check [name] # dev verbs wildpc program build|test|lint|format|type-check|check [name] # dev verbs
# Services — daemons (manager: systemd, no schedule) # Services — daemons (manager: systemd, no schedule)
castle service list|info <name> [--json] wildpc service list|info <name> [--json]
castle service create <name> [--program P] [--port N] [--health ...] [--launcher ...] wildpc service create <name> [--program P] [--port N] [--health ...] [--launcher ...]
castle service restart <name> # imperative bounce wildpc service restart <name> # imperative bounce
castle service logs <name> [-f] [-n 50] wildpc service logs <name> [-f] [-n 50]
# Jobs — scheduled tasks (manager: systemd + schedule). create takes --schedule # Jobs — scheduled tasks (manager: systemd + schedule). create takes --schedule
castle job create <name> [--program P] --schedule "0 2 * * *" [--launcher ...] wildpc job create <name> [--program P] --schedule "0 2 * * *" [--launcher ...]
castle job <list|info|delete|restart|logs> ... wildpc job <list|info|delete|restart|logs> ...
# Tools — CLIs on PATH (manager: path) # Tools — CLIs on PATH (manager: path)
castle tool list [--json] # each tool's executable + description + install state wildpc tool list [--json] # each tool's executable + description + install state
castle tool info <name> [--json] wildpc tool info <name> [--json]
# Secrets — read/write the ACTIVE backend (file or openbao); never hand-write the store
wildpc secret list # names in the active backend
wildpc secret set <NAME> [VALUE] # VALUE omitted → hidden prompt / stdin
wildpc secret get <NAME> # print a value
wildpc secret rm <NAME> [-y] # delete
# Platform-wide # Platform-wide
castle apply [name] [--plan] # converge runtime to config — the workhorse wildpc apply [name] [--plan] # converge runtime to config — the workhorse
castle list [--kind ...] [--stack ...] [--json] # all deployments wildpc list [--kind ...] [--stack ...] [--json] # all deployments
castle status # unified health/status wildpc status # unified health/status
castle doctor # diagnose setup + runtime, with fix hints wildpc doctor # diagnose setup + runtime, with fix hints
castle restart [name] # imperative bounce (one or all) wildpc restart [name] # imperative bounce (one or all)
castle gateway # gateway status + route table (inspection) wildpc gateway # gateway status + route table (inspection)
``` ```
`castle service`/`job`/`tool` are **views** over the one deployment set, filtered `wildpc service`/`job`/`tool` are **views** over the one deployment set, filtered
by derived kind. Lifecycle is **convergence**: edit config, then **`castle apply`** by derived kind. Lifecycle is **convergence**: edit config, then **`wildpc apply`**
renders units + the Caddyfile and reconciles the runtime (activate what's enabled, renders units + the Caddyfile and reconciles the runtime (activate what's enabled,
restart what changed, deactivate what's disabled). To durably turn a deployment restart what changed, deactivate what's disabled). To durably turn a deployment
off, set `enabled: false` and apply — there is no separate start/stop/enable. off, set `enabled: false` and apply — there is no separate start/stop/enable.
`castle restart` is the one imperative bounce. `wildpc restart` is the one imperative bounce.
**Dev verbs resolve per-program:** a declared `commands:` entry (or `build:`) **Dev verbs resolve per-program:** a declared `commands:` entry (or `build:`)
overrides the program's stack default, else the stack handler, else the verb is overrides the program's stack default, else the stack handler, else the verb is
@@ -103,11 +109,11 @@ commands. All projects use **uv** (Python) / **pnpm** (frontends).
### Create a new service (HTTP daemon) ### Create a new service (HTTP daemon)
```bash ```bash
castle program create my-service --stack python-fastapi --description "Does X" wildpc program create my-service --stack python-fastapi --description "Does X"
cd /data/repos/my-service && uv sync # implement it cd /data/repos/my-service && uv sync # implement it
castle program test my-service wildpc program test my-service
castle service create my-service --program my-service --port 9001 wildpc service create my-service --program my-service --port 9001
castle apply my-service # renders the unit + gateway route and starts it wildpc apply my-service # renders the unit + gateway route and starts it
``` ```
The service reads its port/data dir from env vars that `deployments/my-service.yaml` The service reads its port/data dir from env vars that `deployments/my-service.yaml`
@@ -116,12 +122,12 @@ maps via placeholders (see §6). Stack guide: **`docs/stacks/python-fastapi.md`*
### Create a CLI tool ### Create a CLI tool
```bash ```bash
castle program create my-tool --stack python-cli --description "Does Y" wildpc program create my-tool --stack python-cli --description "Does Y"
cd /data/repos/my-tool && uv sync cd /data/repos/my-tool && uv sync
castle apply my-tool # installs the path deployment on PATH wildpc apply my-tool # installs the path deployment on PATH
``` ```
`castle tool list --json` is the machine-readable tool catalog (each tool's real `wildpc tool list --json` is the machine-readable tool catalog (each tool's real
**executable**, which may differ from the program name). Stack: **executable**, which may differ from the program name). Stack:
**`docs/stacks/python-cli.md`**. **`docs/stacks/python-cli.md`**.
@@ -131,31 +137,32 @@ A job is a `manager: systemd` deployment with a `schedule` (cron). Generates a
`.service` (Type=oneshot) + a `.timer`. `.service` (Type=oneshot) + a `.timer`.
```bash ```bash
castle job create nightly --program my-tool --schedule "0 2 * * *" --launcher command wildpc job create nightly --program my-tool --schedule "0 2 * * *" --launcher command
castle apply nightly wildpc apply nightly
``` ```
### Create a static frontend ### Create a static frontend
```bash ```bash
# scaffold a Vite/React app under /data/repos/my-frontend (see docs/stacks/react-vite.md) # scaffold a Vite/React app under /data/repos/my-frontend (see docs/stacks/react-vite.md)
castle program build my-frontend # produces dist/ wildpc program build my-frontend # produces dist/
# deployments/my-frontend.yaml → manager: caddy, root: dist # deployments/my-frontend.yaml → manager: caddy, root: dist
castle apply # served at my-frontend.<domain> wildpc apply # served at my-frontend.<domain>
``` ```
The gateway serves the build **in place** from `<source>/<root>` — no copy, no The gateway serves the build **in place** from `<source>/<root>` — no copy, no
Node process. Stack: **`docs/stacks/react-vite.md`**. Database-backed apps on the Node process. Stack: **`docs/stacks/react-vite.md`**. Content-driven static sites
shared Supabase substrate: **`docs/stacks/supabase.md`**. built by Hugo: **`docs/stacks/hugo.md`**. Database-backed apps on the shared
Supabase substrate: **`docs/stacks/supabase.md`**.
### Adopt an existing repo (no stack needed) ### Adopt an existing repo (no stack needed)
```bash ```bash
castle program add ~/projects/some-rust-tool # local path wildpc program add ~/projects/some-rust-tool # local path
castle program add https://github.com/me/widget.git --name widget wildpc program add https://github.com/me/widget.git --name widget
``` ```
Castle detects dev-verb commands (pyproject→uv/ruff/pytest, Cargo.toml→cargo, …) Wild PC detects dev-verb commands (pyproject→uv/ruff/pytest, Cargo.toml→cargo, …)
or you declare them under `commands:` in `programs/<name>.yaml`. or you declare them under `commands:` in `programs/<name>.yaml`.
--- ---
@@ -184,19 +191,19 @@ public: true # ALSO expose to the internet via Cloudflare tunnel (requires pro
- There are **no path-prefix routes** — a whole subdomain maps to the backend - There are **no path-prefix routes** — a whole subdomain maps to the backend
root, so root-relative assets and `window.location` WebSocket URLs just work. root, so root-relative assets and `window.location` WebSocket URLs just work.
Inspect routes: `castle gateway` / `GET /gateway`. Regenerate routes + reload the Inspect routes: `wildpc gateway` / `GET /gateway`. Regenerate routes + reload the
gateway: `castle apply` (converge). → Field-level detail: **`docs/registry.md`**. gateway: `wildpc apply` (converge). → Field-level detail: **`docs/registry.md`**.
--- ---
## 5. DNS & TLS — making names resolve and be trusted ## 5. DNS & TLS — making names resolve and be trusted
Two orthogonal questions for `https://foo.<domain>/` to work from a LAN browser: Two orthogonal questions for `https://foo.<domain>/` to work from a LAN browser:
**resolve** (DNS) and **trust** (TLS). Castle doesn't run DNS — you add one **resolve** (DNS) and **trust** (TLS). Wild PC doesn't run DNS — you add one
**wildcard** record on the LAN's DNS server (usually the router): **wildcard** record on the LAN's DNS server (usually the router):
`address=/<domain>/<node-ip>` (dnsmasq) pinned with a DHCP reservation. `address=/<domain>/<node-ip>` (dnsmasq) pinned with a DHCP reservation.
`gateway.tls` in `castle.yaml` picks the trust mode: `gateway.tls` in `wildpc.yaml` picks the trust mode:
| `gateway.tls` | serves | client setup | when | | `gateway.tls` | serves | client setup | when |
|---------------|--------|--------------|------| |---------------|--------|--------------|------|
@@ -208,75 +215,83 @@ Two orthogonal questions for `https://foo.<domain>/` to work from a LAN browser:
the names — the public zone has no A records). HTTPS also unlocks **secure the names — the public zone has no A records). HTTPS also unlocks **secure
context** (`crypto.subtle`, service workers), which plain-HTTP LAN hosts lack. context** (`crypto.subtle`, service workers), which plain-HTTP LAN hosts lack.
`acme` operational prerequisites (castle can't do these for you): `acme` operational prerequisites (wildpc can't do these for you):
- **DNS-plugin Caddy** at `/usr/local/bin/caddy``./install.sh --with-dns-plugin=cloudflare`. - **DNS-plugin Caddy** at `/usr/local/bin/caddy``./install.sh --with-dns-plugin=cloudflare`.
- **Provider token** stored as a secret and mapped into the gateway service env - **Provider token** stored as a secret and mapped into the gateway service env
(`CLOUDFLARE_API_TOKEN`); `castle apply` warns if missing. (`CLOUDFLARE_API_TOKEN`); `wildpc apply` warns if missing.
- **Bind :443/:80** — lower the floor once: `net.ipv4.ip_unprivileged_port_start=80` - **Bind :443/:80** — lower the floor once: `net.ipv4.ip_unprivileged_port_start=80`
in `/etc/sysctl.d/` (beats `setcap`, which `NoNewPrivileges` would void). in `/etc/sysctl.d/` (beats `setcap`, which `NoNewPrivileges` would void).
- **Stage first**: `CASTLE_ACME_STAGING=1` at deploy, verify issuance, then unset - **Stage first**: `WILDPC_ACME_STAGING=1` at deploy, verify issuance, then unset
and redeploy for a production cert. and redeploy for a production cert.
→ Full conceptual + step-by-step guide: **`docs/dns-and-tls.md`**. Read the actual → Full conceptual + step-by-step guide: **`docs/dns-and-tls.md`**. Read the actual
values for *this* node in `~/.castle/castle.yaml` (`gateway.domain`, `tls`, etc.). values for *this* node in `~/.wildpc/wildpc.yaml` (`gateway.domain`, `tls`, etc.).
--- ---
## 6. Environment, secrets, data, placeholders ## 6. Environment, secrets, data, placeholders
`defaults.env` in a deployment is the **single explicit source** of the env a `defaults.env` in a deployment is the **single explicit source** of the env a
service/job runs with — castle injects nothing implicitly. Map the vars your service/job runs with — wildpc injects nothing implicitly. Map the vars your
program reads to castle's computed values with placeholders: program reads to wildpc's computed values with placeholders:
```yaml ```yaml
expose: { http: { internal: { port: 9001 }, health_path: /health } } expose: { http: { internal: { port: 9001 }, health_path: /health } }
defaults: defaults:
env: env:
MY_SERVICE_PORT: ${port} # = expose.http.internal.port MY_SERVICE_PORT: ${port} # = expose.http.internal.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/<name> MY_SERVICE_DATA_DIR: ${data_dir} # = $WILDPC_DATA_DIR/<name>
PUBLIC_URL: ${public_url} # gateway origin (CORS/allowlists) PUBLIC_URL: ${public_url} # gateway origin (CORS/allowlists)
API_KEY: ${secret:MY_API_KEY} # reads ~/.castle/secrets/MY_API_KEY API_KEY: ${secret:MY_API_KEY} # reads ~/.wildpc/secrets/MY_API_KEY
``` ```
| placeholder | expands to | | placeholder | expands to |
|-------------|-----------| |-------------|-----------|
| `${port}` | the service's `expose.http.internal.port` | | `${port}` | the service's `expose.http.internal.port` |
| `${data_dir}` | `$CASTLE_DATA_DIR/<name>` (default `/data/castle/<name>`) | | `${data_dir}` | `$WILDPC_DATA_DIR/<name>` (default `/data/wildpc/<name>`) |
| `${name}` | the deployment name | | `${name}` | the deployment name |
| `${public_url}` | `https://<name>.<domain>` under acme, else `http://localhost:<port>` | | `${public_url}` | `https://<name>.<domain>` under acme, else `http://localhost:<port>` |
| `${secret:NAME}` | the secret `NAME` from the active backend | | `${secret:NAME}` | the secret `NAME` from the active backend |
**Secret backend.** `${secret:NAME}` (and `read_secret()` for direct-read code) **Secret backend.** `${secret:NAME}` (and `read_secret()` for direct-read code)
resolve through a backend selected by the **`secrets:` block in `castle.yaml`** resolve through a backend selected by the **`secrets:` block in `wildpc.yaml`**
(`core/secret_backends.py`): (`core/secret_backends.py`):
```yaml ```yaml
secrets: secrets:
backend: openbao # or `file` (default) backend: openbao # or `file` (default)
addr: https://castle-openbao.<domain>:8200 addr: https://wildpc-openbao.<domain>:8200
mount: castle # KV-v2 mount mount: wildpc # KV-v2 mount
token_secret: OPENBAO_ROOT_TOKEN # vault token, read from a file token_secret: OPENBAO_ROOT_TOKEN # vault token, read from a file
node_prefix: nodes/<host> # optional: per-node overrides node_prefix: nodes/<host> # optional: per-node overrides
``` ```
- **file** (default) → `~/.castle/secrets/NAME`. - **file** (default) → `~/.wildpc/secrets/NAME`.
- **openbao** → vault KV-v2 `mount/NAME`, no file fallback. The vault **token** is - **openbao** → vault KV-v2 `mount/NAME`, no file fallback. The vault **token** is
still a file (`token_secret`, the bootstrap root of trust — it can't live in the still a file (`token_secret`, the bootstrap root of trust — it can't live in the
vault it opens). With `node_prefix`, a read tries `mount/<node_prefix>/NAME` vault it opens). With `node_prefix`, a read tries `mount/<node_prefix>/NAME`
then shared `mount/NAME`, so one vault serves shared secrets + per-node overrides then shared `mount/NAME`, so one vault serves shared secrets + per-node overrides
(e.g. each node's postgres password). Env vars (`CASTLE_SECRET_BACKEND`, (e.g. each node's postgres password). Env vars (`WILDPC_SECRET_BACKEND`,
`CASTLE_OPENBAO_*`) override the block (tests/CI force `file`). `WILDPC_OPENBAO_*`) override the block (tests/CI force `file`).
- **This fleet runs `openbao`**: civil is the authority (root token), primer reads - **This fleet runs `openbao`**: civil is the authority (root token), primer reads
via a least-privilege `castle-read` token. Only the bootstrap tier stays as via a least-privilege `wildpc-read` token. Only the bootstrap tier stays as
files (`OPENBAO_ROOT_TOKEN`/`OPENBAO_UNSEAL_KEY`, the `cloudflared` creds dir). files (`OPENBAO_ROOT_TOKEN`/`OPENBAO_UNSEAL_KEY`, the `cloudflared` creds dir).
**Never** put secret *values* in `castle.yaml` or project dirs — use `${secret:…}`. **Write secrets with `wildpc secret set NAME`, never by hand.** It targets the
Roots: **`CASTLE_HOME`** (config/code/artifacts/secrets, default `~/.castle`, *active* backend, so a value can't land in a store the resolver never reads — the
env-only — it *contains* castle.yaml) and **program data** (base of `${data_dir}`, failure mode where a file-written secret is silently ignored on an openbao fleet
default `/data/castle`) + **repos** (default `/data/repos`). The latter two resolve and `${secret:NAME}` degrades to a literal `<MISSING_SECRET:NAME>` that a service
**env > `castle.yaml` > default** — set `data_dir:` / `repos_dir:` in `castle.yaml` then uses as its credential. `wildpc apply` **refuses to converge** (exits
non-zero, writes nothing) any deployment whose `${secret:…}` refs don't resolve in
the active backend, naming the fix.
**Never** put secret *values* in `wildpc.yaml` or project dirs — use `${secret:…}`.
Roots: **`WILDPC_HOME`** (config/code/artifacts/secrets, default `~/.wildpc`,
env-only — it *contains* wildpc.yaml) and **program data** (base of `${data_dir}`,
default `/data/wildpc`) + **repos** (default `/data/repos`). The latter two resolve
**env > `wildpc.yaml` > default** — set `data_dir:` / `repos_dir:` in `wildpc.yaml`
(the single source of truth both the CLI and the api read), not a per-shell env var (the single source of truth both the CLI and the api read), not a per-shell env var
that only one of them sees. → **`docs/registry.md`** (castle.yaml globals). that only one of them sees. → **`docs/registry.md`** (wildpc.yaml globals).
--- ---
@@ -284,9 +299,9 @@ that only one of them sees. → **`docs/registry.md`** (castle.yaml globals).
`public: true` (requires `proxy: true`) projects a service to the internet at `public: true` (requires `proxy: true`) projects a service to the internet at
`<name>.<gateway.public_domain>` (a **separate** zone, so internal subdomain names `<name>.<gateway.public_domain>` (a **separate** zone, so internal subdomain names
stay out of public DNS). `castle apply` generates the cloudflared ingress from the stay out of public DNS). `wildpc apply` generates the cloudflared ingress from the
set of public services. Needs `gateway.public_domain` + `gateway.tunnel_id` set and set of public services. Needs `gateway.public_domain` + `gateway.tunnel_id` set and
the `castle-tunnel` service running. → One-time setup: **`docs/tunnel-setup.md`**. the `wildpc-tunnel` service running. → One-time setup: **`docs/tunnel-setup.md`**.
Routing only moves bytes — it does **not** supply a backend's own auth. Do not make Routing only moves bytes — it does **not** supply a backend's own auth. Do not make
a service public unless it authenticates or is meant to be open. a service public unless it authenticates or is meant to be open.
@@ -295,19 +310,19 @@ a service public unless it authenticates or is meant to be open.
## 8. Mesh — multi-node coordination (opt-in) ## 8. Mesh — multi-node coordination (opt-in)
Runs on **NATS JetStream** (`castle-nats`, TLS + token). Enable via env on Runs on **NATS JetStream** (`wildpc-nats`, TLS + token). Enable via env on
`castle-api`: `CASTLE_API_NATS_ENABLED=true`, `CASTLE_API_NATS_URL=tls://castle-nats.<domain>:4222`, `wildpc-api`: `WILDPC_API_NATS_ENABLED=true`, `WILDPC_API_NATS_URL=tls://wildpc-nats.<domain>:4222`,
`CASTLE_API_NATS_TOKEN=${secret:NATS_TOKEN}`. Each node publishes its `WILDPC_API_NATS_TOKEN=${secret:NATS_TOKEN}`. Each node publishes its
(secret-stripped) registry to a JetStream **KV** bucket, renews a **presence** (secret-stripped) registry to a JetStream **KV** bucket, renews a **presence**
key, and watches for peers; remote deployments surface as `manager: none` key, and watches for peers; remote deployments surface as `manager: none`
**reference** kinds. A static **`role`** (`authority`|`follower`, in `castle.yaml`) **reference** kinds. A static **`role`** (`authority`|`follower`, in `wildpc.yaml`)
gates who may write the shared-config bucket. A consumed cross-node service gates who may write the shared-config bucket. A consumed cross-node service
(`requires: - ref: X` satisfied by a peer) is routed by the gateway with a (`requires: - ref: X` satisfied by a peer) is routed by the gateway with a
presence-gated circuit-breaker. presence-gated circuit-breaker.
Inspect + drive from the CLI: **`castle mesh status`** / **`castle mesh nodes`** / Inspect + drive from the CLI: **`wildpc mesh status`** / **`wildpc mesh nodes`** /
**`castle mesh config list|get|set`** (or `GET /mesh/status`, `/nodes`, **`wildpc mesh config list|get|set`** (or `GET /mesh/status`, `/nodes`,
`/mesh/config`). Modules: `castle_api.nats_client`, `.mesh`, `.mesh_gateway`, `/mesh/config`). Modules: `wildpc_api.nats_client`, `.mesh`, `.mesh_gateway`,
`.mdns`; secrets via `core` `secret_backends` (file default, OpenBao opt-in). `.mdns`; secrets via `core` `secret_backends` (file default, OpenBao opt-in).
→ Full history + operations: **`docs/fleet-mesh-plan.md`**. → Full history + operations: **`docs/fleet-mesh-plan.md`**.
@@ -317,17 +332,18 @@ Inspect + drive from the CLI: **`castle mesh status`** / **`castle mesh nodes`**
| Topic | Doc | | Topic | Doc |
|-------|-----| |-------|-----|
| Registry model, `castle.yaml`, every field, lifecycle | **`docs/registry.md`** | | Registry model, `wildpc.yaml`, every field, lifecycle | **`docs/registry.md`** |
| Why castle is shaped this way | **`docs/design.md`** | | Why wildpc is shaped this way | **`docs/design.md`** |
| DNS resolution + the two TLS modes, acme recipe | **`docs/dns-and-tls.md`** | | DNS resolution + the two TLS modes, acme recipe | **`docs/dns-and-tls.md`** |
| Public exposure (cloudflared) one-time setup | **`docs/tunnel-setup.md`** | | Public exposure (cloudflared) one-time setup | **`docs/tunnel-setup.md`** |
| Writing FastAPI services | **`docs/stacks/python-fastapi.md`** | | Writing FastAPI services | **`docs/stacks/python-fastapi.md`** |
| Writing CLI tools | **`docs/stacks/python-cli.md`** | | Writing CLI tools | **`docs/stacks/python-cli.md`** |
| Writing React/Vite frontends | **`docs/stacks/react-vite.md`** | | Writing React/Vite frontends | **`docs/stacks/react-vite.md`** |
| Writing Hugo static sites | **`docs/stacks/hugo.md`** |
| Database-backed apps (shared Supabase) | **`docs/stacks/supabase.md`** | | Database-backed apps (shared Supabase) | **`docs/stacks/supabase.md`** |
| **Developing Castle itself** (CLI/core/api/app, key files, endpoints) | **`docs/developing-castle.md`** | | **Developing Wild PC itself** (CLI/core/api/app, key files, endpoints) | **`docs/developing-wildpc.md`** |
Castle's own programs live in this repo (`source: repo:<name>` → cli, core, Wild PC's own programs live in this repo (`source: repo:<name>` → cli, core,
castle-api, app). Your programs live under `/data/repos/<name>/` with an absolute wildpc-api, app). Your programs live under `/data/repos/<name>/` with an absolute
`source:`. When in doubt about *this* node's actual config, read `source:`. When in doubt about *this* node's actual config, read
`~/.castle/castle.yaml` and `castle status`. `~/.wildpc/wildpc.yaml` and `wildpc status`.

View File

@@ -1,5 +1,5 @@
# CLAUDE.md # CLAUDE.md
See @AGENTS.md — the single, agent-agnostic guide for this repo: how to use See @AGENTS.md — the single, agent-agnostic guide for this repo: how to use
Castle (create/deploy/expose/manage software) and how to develop Castle's own Wild PC (create/deploy/expose/manage software) and how to develop Wild PC's own
code. Everything lives there. code. Everything lives there.

150
README.md
View File

@@ -1,8 +1,8 @@
# Castle # Wild PC
> Standing to author, run, govern, and maintain your own software. > Standing to author, run, govern, and maintain your own software.
A personal software platform. Castle manages independent services, tools, and A personal software platform. Wild PC manages independent services, tools, and
frontends — and launches your coding agents — from a single CLI, with a unified frontends — and launches your coding agents — from a single CLI, with a unified
gateway, systemd integration, and a web dashboard. gateway, systemd integration, and a web dashboard.
@@ -18,27 +18,27 @@ and configurations of existing tools. Many of these programs do not need a publi
release process, an app store listing, or a conventional distribution channel. They release process, an app store listing, or a conventional distribution channel. They
need a reliable place to run. need a reliable place to run.
Castle provides that place. Wild PC provides that place.
Castle gives simple applications a consistent local environment for development, Wild PC gives simple applications a consistent local environment for development,
registration, deployment, discovery, and operation. Programs remain independent, but registration, deployment, discovery, and operation. Programs remain independent, but
Castle provides the surrounding structure: process management, routing, metadata, Wild PC provides the surrounding structure: process management, routing, metadata,
service lifecycle, logs, and a common interface for running and inspecting the service lifecycle, logs, and a common interface for running and inspecting the
software in your domain. software in your domain.
In this sense, Castle is a **personal software estate**: a practical way to organize In this sense, Wild PC is a **personal software estate**: a practical way to organize
the software you create and run yourself, without requiring every tool to become a the software you create and run yourself, without requiring every tool to become a
fully packaged product. fully packaged product.
## How it works ## How it works
Castle separates *what software exists* from *how it runs*: Wild PC separates *what software exists* from *how it runs*:
- **Programs** — the catalog. A program is a source repo Castle knows how to work - **Programs** — the catalog. A program is a source repo Wild PC knows how to work
with (dev verbs, build) and where it lives. One file per program under with (dev verbs, build) and where it lives. One file per program under
`~/.castle/programs/<name>.yaml`. `~/.wildpc/programs/<name>.yaml`.
- **Deployments** — how a program is realized on this node. One file per deployment - **Deployments** — how a program is realized on this node. One file per deployment
under `~/.castle/deployments/<name>.yaml`, discriminated by its **manager**: under `~/.wildpc/deployments/<name>.yaml`, discriminated by its **manager**:
| manager | what it is | derived **kind** | | manager | what it is | derived **kind** |
|---------|------------|------------------| |---------|------------|------------------|
@@ -55,33 +55,33 @@ A program can have several deployments — a CLI that is both a `tool` on PATH a
scheduled `job` — so a program has no single kind of its own; it *has deployments*, scheduled `job` — so a program has no single kind of its own; it *has deployments*,
each with its own. each with its own.
Standing everything up is one honest step: `castle apply` renders systemd units and Standing everything up is one honest step: `wildpc apply` renders systemd units and
gateway config from your config, then reconciles the runtime to match — activating gateway config from your config, then reconciles the runtime to match — activating
what's enabled, restarting what changed, deactivating what's disabled. Edit config, what's enabled, restarting what changed, deactivating what's disabled. Edit config,
`castle apply`; `castle apply --plan` shows the diff first. `wildpc apply`; `wildpc apply --plan` shows the diff first.
## Stacks ## Stacks
Castle **stacks** are pre-configured development environments that provide starting Wild PC **stacks** are pre-configured development environments that provide starting
points for building Castle programs. A stack can define the language, framework, points for building Wild PC programs. A stack can define the language, framework,
dependencies, tools, conventions, and Castle integration needed for a particular dependencies, tools, conventions, and Wild PC integration needed for a particular
kind of application — `python-fastapi`, `python-cli`, `react-vite`, `supabase`. kind of application — `python-fastapi`, `python-cli`, `react-vite`, `supabase`.
Stacks are designed to work well with coding assistants. They give assistants a Stacks are designed to work well with coding assistants. They give assistants a
consistent target when generating Castle programs, making it easier to produce consistent target when generating Wild PC programs, making it easier to produce
applications that are correctly structured, configured, and ready to run under applications that are correctly structured, configured, and ready to run under
Castle. If your coding assistant understands Castle, it can help you create, Wild PC. If your coding assistant understands Wild PC, it can help you create,
register, manage, and evolve custom applications more efficiently. register, manage, and evolve custom applications more efficiently.
A stack seeds a new program's scaffold and default dev-verb commands, but it's A stack seeds a new program's scaffold and default dev-verb commands, but it's
optional at runtime: a program stands on its own via its declared `commands:` and optional at runtime: a program stands on its own via its declared `commands:` and
`source:`, so you can adopt any existing repo with `castle program add` — no stack `source:`, so you can adopt any existing repo with `wildpc program add` — no stack
required. required.
## Agents ## Agents
Castle can launch your coding agents. Declare them in `castle.yaml` under `agents:` Wild PC can launch your coding agents. Declare them in `wildpc.yaml` under `agents:`
— each entry is just a command Castle runs in a terminal: — each entry is just a command Wild PC runs in a terminal:
```yaml ```yaml
agents: agents:
@@ -96,7 +96,7 @@ agents:
The dashboard has a terminal dock that launches any declared agent in a The dashboard has a terminal dock that launches any declared agent in a
pseudo-terminal (over a WebSocket) and manages live sessions (list, resume, kill). pseudo-terminal (over a WebSocket) and manages live sessions (list, resume, kill).
Castle is **assistant-agnostic** — it only ever runs `command args` in a pty and Wild PC is **assistant-agnostic** — it only ever runs `command args` in a pty and
never parses the agent's output, so any interactive CLI works. With no `agents:` never parses the agent's output, so any interactive CLI works. With no `agents:`
block set, a sensible default set (`claude`, `opencode`, `amplifier`, …) is offered. block set, a sensible default set (`claude`, `opencode`, `amplifier`, …) is offered.
@@ -104,28 +104,28 @@ block set, a sensible default set (`claude`, `opencode`, `amplifier`, …) is of
**Prerequisites:** a Debian/Ubuntu-family Linux with `apt` and `sudo`, `systemd` **Prerequisites:** a Debian/Ubuntu-family Linux with `apt` and `sudo`, `systemd`
(user services), and `git`. `install.sh` sets up everything else — Docker, Caddy, (user services), and `git`. `install.sh` sets up everything else — Docker, Caddy,
`uv`, and the `castle` CLI itself. `uv`, and the `wildpc` CLI itself.
```bash ```bash
git clone <this-repo> ~/castle && cd ~/castle git clone <this-repo> ~/wildpc && cd ~/wildpc
# One command: installs uv + the castle CLI, sets up infra (Docker, Caddy, MQTT, # One command: installs uv + the wildpc CLI, sets up infra (Docker, Caddy, MQTT,
# Postgres), creates ~/.castle, registers Castle's own control plane, builds the UI. # Postgres), creates ~/.wildpc, registers Wild PC's own control plane, builds the UI.
./install.sh ./install.sh
castle apply # converge the runtime to config (units, routes, run) wildpc apply # converge the runtime to config (units, routes, run)
castle doctor # verify — every check should be green wildpc doctor # verify — every check should be green
open http://localhost:9000 # the dashboard open http://localhost:9000 # the dashboard
``` ```
`castle doctor` is your friend at every step: it inspects setup *and* runtime and, `wildpc doctor` is your friend at every step: it inspects setup *and* runtime and,
for anything not green, prints the exact next command. Run it any time something for anything not green, prints the exact next command. Run it any time something
looks off — after an install, a deploy, or a config change. looks off — after an install, a deploy, or a config change.
### Exposure: from localhost to your own HTTPS domain ### Exposure: from localhost to your own HTTPS domain
Localhost is the first rung; you climb only as far as you need. Each rung is a small Localhost is the first rung; you climb only as far as you need. Each rung is a small
config change plus `castle apply`, and `castle doctor` tells you what a rung still config change plus `wildpc apply`, and `wildpc doctor` tells you what a rung still
needs. needs.
| Rung | You get | What it takes | | Rung | You get | What it takes |
@@ -135,86 +135,86 @@ needs.
| **Public** | a chosen service reachable from the internet | `public: true` on the service + a Cloudflare tunnel. → [docs/tunnel-setup.md](docs/tunnel-setup.md) | | **Public** | a chosen service reachable from the internet | `public: true` on the service + a Cloudflare tunnel. → [docs/tunnel-setup.md](docs/tunnel-setup.md) |
The jump to LAN HTTPS is the involved one (DNS + a token + binding `:443`). Set The jump to LAN HTTPS is the involved one (DNS + a token + binding `:443`). Set
`gateway.tls: acme` and run `castle doctor` — it enumerates exactly the pieces that `gateway.tls: acme` and run `wildpc doctor` — it enumerates exactly the pieces that
are still missing, each with its fix. are still missing, each with its fix.
## Creating programs ## Creating programs
`castle program create` scaffolds the source **and** its deployment from a stack: `wildpc program create` scaffolds the source **and** its deployment from a stack:
```bash ```bash
# A service — FastAPI app + a systemd service deployment (health, unit, route) # A service — FastAPI app + a systemd service deployment (health, unit, route)
castle program create my-api --stack python-fastapi --description "Does something" wildpc program create my-api --stack python-fastapi --description "Does something"
castle program test my-api wildpc program test my-api
castle apply my-api # render unit + route, then start it wildpc apply my-api # render unit + route, then start it
# A tool — a CLI installed on your PATH # A tool — a CLI installed on your PATH
castle program create my-tool --stack python-cli --description "Does something" wildpc program create my-tool --stack python-cli --description "Does something"
castle apply my-tool # installs its path deployment on PATH wildpc apply my-tool # installs its path deployment on PATH
# A static frontend — built once, served by the gateway # A static frontend — built once, served by the gateway
castle program create my-app --stack react-vite --description "Web interface" wildpc program create my-app --stack react-vite --description "Web interface"
castle program build my-app && castle apply wildpc program build my-app && wildpc apply
# Adopt an existing repo (no stack needed — dev verbs detected or declared) # Adopt an existing repo (no stack needed — dev verbs detected or declared)
castle program add ~/projects/some-rust-tool wildpc program add ~/projects/some-rust-tool
``` ```
## CLI ## CLI
Operations live under the resource they act on. `program` is the catalog; Operations live under the resource they act on. `program` is the catalog;
`service`, `job`, and `tool` are **views** over the one deployment set. Lifecycle `service`, `job`, and `tool` are **views** over the one deployment set. Lifecycle
is convergence — `castle apply` — not a pile of per-kind verbs. is convergence — `wildpc apply` — not a pile of per-kind verbs.
``` ```
# Programs — the software catalog # Programs — the software catalog
castle program list|info|create|add|clone|delete|run wildpc program list|info|create|add|clone|delete|run
castle program build|test|lint|type-check|check [name] # dev verbs wildpc program build|test|lint|type-check|check [name] # dev verbs
# Deployment lenses (service = systemd, job = systemd + schedule, tool = path) # Deployment lenses (service = systemd, job = systemd + schedule, tool = path)
castle service list|info|create|delete|restart|logs wildpc service list|info|create|delete|restart|logs
castle job …same verbs; create takes --schedule wildpc job …same verbs; create takes --schedule
castle tool list|info # CLIs on your PATH wildpc tool list|info # CLIs on your PATH
# Platform-wide # Platform-wide
castle apply [name] [--plan] # converge runtime to config — the workhorse wildpc apply [name] [--plan] # converge runtime to config — the workhorse
castle list [--kind K] [--stack S] [--json] # catalog + every deployment view wildpc list [--kind K] [--stack S] [--json] # catalog + every deployment view
castle status # unified runtime status wildpc status # unified runtime status
castle doctor # diagnose setup + health, with fix hints wildpc doctor # diagnose setup + health, with fix hints
castle restart [name] # imperative bounce (one or all) wildpc restart [name] # imperative bounce (one or all)
castle gateway # status + route table (inspection) wildpc gateway # status + route table (inspection)
``` ```
To turn a deployment off, set `enabled: false` in its config and `castle apply` To turn a deployment off, set `enabled: false` in its config and `wildpc apply`
there's no start/stop/enable/install verb; the manager decides the mechanism there's no start/stop/enable/install verb; the manager decides the mechanism
(systemd unit, PATH install, gateway route), the verb is always `apply`. (systemd unit, PATH install, gateway route), the verb is always `apply`.
`castle tool list --json` is the machine-readable tool catalog assistants use to `wildpc tool list --json` is the machine-readable tool catalog assistants use to
build context — it surfaces each tool's actual **executable** (which can differ from build context — it surfaces each tool's actual **executable** (which can differ from
the program name, e.g. `litellm-intent-router` installs `intent-router`), its the program name, e.g. `litellm-intent-router` installs `intent-router`), its
description, and whether it's installed. description, and whether it's installed.
## Configuration ## Configuration
The registry lives under `~/.castle/`: a global `castle.yaml` plus one file per The registry lives under `~/.wildpc/`: a global `wildpc.yaml` plus one file per
resource under `programs/` and `deployments/`. resource under `programs/` and `deployments/`.
```yaml ```yaml
# ~/.castle/castle.yaml — globals # ~/.wildpc/wildpc.yaml — globals
gateway: gateway:
port: 9000 port: 9000
repo: /data/repos/castle # resolves `source: repo:<name>` for castle's own programs repo: /data/repos/wildpc # resolves `source: repo:<name>` for wildpc's own programs
``` ```
```yaml ```yaml
# ~/.castle/programs/my-api.yaml — the catalog entry # ~/.wildpc/programs/my-api.yaml — the catalog entry
description: Does something useful description: Does something useful
source: /data/repos/my-api source: /data/repos/my-api
stack: python-fastapi stack: python-fastapi
``` ```
```yaml ```yaml
# ~/.castle/deployments/my-api.yaml — a systemd service (derived kind: service) # ~/.wildpc/deployments/my-api.yaml — a systemd service (derived kind: service)
program: my-api program: my-api
manager: systemd manager: systemd
run: { launcher: python, program: my-api } run: { launcher: python, program: my-api }
@@ -224,21 +224,21 @@ manage: { systemd: {} }
defaults: defaults:
env: env:
MY_API_PORT: ${port} # = expose.http.internal.port MY_API_PORT: ${port} # = expose.http.internal.port
MY_API_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/my-api MY_API_DATA_DIR: ${data_dir} # = $WILDPC_DATA_DIR/my-api
API_KEY: ${secret:MY_API_KEY} API_KEY: ${secret:MY_API_KEY}
``` ```
`defaults.env` is the **single, explicit source** of a deployment's environment — `defaults.env` is the **single, explicit source** of a deployment's environment —
Castle injects nothing implicitly. The placeholders `${port}`, `${data_dir}`, Wild PC injects nothing implicitly. The placeholders `${port}`, `${data_dir}`,
`${name}`, `${public_url}`, and `${secret:NAME}` map your program's own env var names `${name}`, `${public_url}`, and `${secret:NAME}` map your program's own env var names
to Castle's computed values. Secrets live in `~/.castle/secrets/` (never in a repo). to Wild PC's computed values. Secrets live in `~/.wildpc/secrets/` (never in a repo).
## Gateway, DNS & TLS ## Gateway, DNS & TLS
Every gateway-exposed deployment gets its own subdomain — `<name>.<gateway.domain>` Every gateway-exposed deployment gets its own subdomain — `<name>.<gateway.domain>`
— routed to it by the Caddy gateway (there are no path-prefix routes). Exposure is a — routed to it by the Caddy gateway (there are no path-prefix routes). Exposure is a
single checkbox: `proxy: true` on a service, while a static deployment is inherently single checkbox: `proxy: true` on a service, while a static deployment is inherently
served. The dashboard is `castle.<domain>` and the API `castle-api.<domain>`; on a served. The dashboard is `wildpc.<domain>` and the API `wildpc-api.<domain>`; on a
node with no domain, `:9000` serves the dashboard plus a `/api` proxy. node with no domain, `:9000` serves the dashboard plus a `/api` proxy.
`gateway.tls` is a per-node choice: `off` (plain HTTP on `:9000`) or `acme` (a real `gateway.tls` is a per-node choice: `off` (plain HTTP on `:9000`) or `acme` (a real
@@ -250,42 +250,42 @@ public internet is separately opt-in via a Cloudflare tunnel (`public: true`). S
## Layout ## Layout
``` ```
~/.castle/ # instance: config, artifacts, secrets ~/.wildpc/ # instance: config, artifacts, secrets
castle.yaml # globals (gateway, repo, agents) wildpc.yaml # globals (gateway, repo, agents)
programs/ deployments/ # one file per program / deployment programs/ deployments/ # one file per program / deployment
secrets/ # secret files (mode 700) secrets/ # secret files (mode 700)
artifacts/specs/ # generated Caddyfile, registry.yaml artifacts/specs/ # generated Caddyfile, registry.yaml
/data/repos/<name>/ # your program source (absolute source:) /data/repos/<name>/ # your program source (absolute source:)
/data/castle/<name>/ # per-deployment data volume /data/wildpc/<name>/ # per-deployment data volume
<repo>/ # castle itself: cli/ core/ castle-api/ app/ docs/ <repo>/ # wildpc itself: cli/ core/ wildpc-api/ app/ docs/
``` ```
**Independence principle:** your programs never depend on Castle. They accept **Independence principle:** your programs never depend on Wild PC. They accept
configuration (data dir, port, URLs) via environment variables; only Castle's own configuration (data dir, port, URLs) via environment variables; only Wild PC's own
programs (CLI, API, gateway) know Castle internals. programs (CLI, API, gateway) know Wild PC internals.
## Dashboard & API ## Dashboard & API
The **dashboard** (`app/`, served at `castle.<domain>` or `http://localhost:9000`) The **dashboard** (`app/`, served at `wildpc.<domain>` or `http://localhost:9000`)
lists programs and deployments, edits their config, drives lifecycle, shows the lists programs and deployments, edits their config, drives lifecycle, shows the
gateway route table and logs, and hosts the agent terminal dock. gateway route table and logs, and hosts the agent terminal dock.
**`castle-api`** (port 9020, proxied at `castle-api.<domain>`) is the control plane: **`wildpc-api`** (port 9020, proxied at `wildpc-api.<domain>`) is the control plane:
`/deployments`, `/programs`, `/services`, `/jobs`, `/gateway`, `/status`, an SSE `/deployments`, `/programs`, `/services`, `/jobs`, `/gateway`, `/status`, an SSE
`/stream`, config editing under `/config/…`, and the agent session endpoints under `/stream`, config editing under `/config/…`, and the agent session endpoints under
`/agents`. The full endpoint reference is in [CLAUDE.md](CLAUDE.md). `/agents`. The full endpoint reference is in [CLAUDE.md](CLAUDE.md).
## Mesh (opt-in) ## Mesh (opt-in)
Castle nodes can discover each other via MQTT + mDNS to form a personal Wild PC nodes can discover each other via MQTT + mDNS to form a personal
infrastructure mesh — the gateway can route to services on other nodes and the infrastructure mesh — the gateway can route to services on other nodes and the
dashboard shows discovered nodes and cross-node routes. It's all off by default; dashboard shows discovered nodes and cross-node routes. It's all off by default;
single-node needs none of it. Enable on `castle-api` via `CASTLE_API_MQTT_ENABLED` single-node needs none of it. Enable on `wildpc-api` via `WILDPC_API_MQTT_ENABLED`
and `CASTLE_API_MDNS_ENABLED`. and `WILDPC_API_MDNS_ENABLED`.
## Docs ## Docs
- [docs/registry.md](docs/registry.md) — the registry model, `castle.yaml`, deployment fields, lifecycle - [docs/registry.md](docs/registry.md) — the registry model, `wildpc.yaml`, deployment fields, lifecycle
- [docs/dns-and-tls.md](docs/dns-and-tls.md) — gateway routing, DNS, the `off` / `acme` TLS modes - [docs/dns-and-tls.md](docs/dns-and-tls.md) — gateway routing, DNS, the `off` / `acme` TLS modes
- [docs/stacks/](docs/stacks/) — per-stack guides (python-fastapi, python-cli, react-vite, supabase) - [docs/stacks/](docs/stacks/) — per-stack guides (python-fastapi, python-cli, react-vite, supabase)
- [AGENTS.md](AGENTS.md) — the canonical, assistant-agnostic operator guide (recipes, gateway, tunnel, mesh) - [AGENTS.md](AGENTS.md) — the canonical, assistant-agnostic operator guide (recipes, gateway, tunnel, mesh)

View File

@@ -1,4 +1,4 @@
# The API base URL is derived at runtime (see src/services/api/client.ts): # The API base URL is derived at runtime (see src/services/api/client.ts):
# - dashboard at castle-app.<domain> -> castle-api.<domain> (cross-origin, CORS) # - dashboard at wildpc-app.<domain> -> wildpc-api.<domain> (cross-origin, CORS)
# - dev / off-mode :9000 (bare host) -> /api (same-origin) # - dev / off-mode :9000 (bare host) -> /api (same-origin)
# Set VITE_API_BASE_URL only to force a specific base (rarely needed). # Set VITE_API_BASE_URL only to force a specific base (rarely needed).

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Castle</title> <title>Wild PC</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="4" y="14" width="24" height="14" rx="1" fill="#3b82f6"/> <rect width="32" height="32" rx="6" fill="#ea580c"/>
<rect x="4" y="4" width="4" height="14" fill="#3b82f6"/> <g stroke="white" fill="none" stroke-width="2"
<rect x="14" y="4" width="4" height="14" fill="#3b82f6"/> stroke-linecap="round" stroke-linejoin="round">
<rect x="24" y="4" width="4" height="14" fill="#3b82f6"/> <polyline points="7,11 14,16 7,21"/>
<rect x="12" y="20" width="8" height="8" rx="1" fill="#1e293b"/> <line x1="17" y1="21" x2="25" y2="21"/>
<rect x="12" y="20" width="8" height="4" rx="1" fill="#1e293b"/> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 449 B

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -0,0 +1,216 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { X, Folder, ArrowUp, Check, GitBranch } from "lucide-react"
import { useBrowse, useAdoptProgram } from "@/services/api/hooks"
import { TextField } from "./detail/fields"
const IS_GIT_URL = /^(https?:\/\/|git@|ssh:\/\/)|\.git$/
/** Split the typed path into the directory to browse and the trailing partial
* segment to filter by — the autocomplete engine. Typing within a directory only
* changes the filter (client-side, no refetch); crossing a "/" browses the new dir.
* "/data/repos/wid" -> browse "/data/repos", filter "wid"
* "/data/repos/" -> browse "/data/repos", filter ""
* "wid" -> browse the default repos dir, filter "wid" */
function splitPath(raw: string): { dir: string | null; filter: string } {
if (raw === "") return { dir: null, filter: "" }
if (raw.endsWith("/")) return { dir: raw.length > 1 ? raw.slice(0, -1) : "/", filter: "" }
const idx = raw.lastIndexOf("/")
if (idx === -1) return { dir: null, filter: raw }
if (idx === 0) return { dir: "/", filter: raw.slice(1) }
return { dir: raw.slice(0, idx), filter: raw.slice(idx + 1) }
}
/** Adopt an existing repo as a program (the web `wildpc program add`). Programs
* live on the *server's* filesystem, so the picker browses the server's dirs: type
* a path (autocompletes as you go), navigate the listing, or paste a git URL. */
export function AddProgramForm({
existingNames,
onCancel,
}: {
existingNames: string[]
onCancel: () => void
}) {
const navigate = useNavigate()
const adopt = useAdoptProgram()
// The path bar is the single source of truth — its value is the adopt target.
// Empty browses the server's repos dir (surfaced as the placeholder), so the
// listing is populated from the first render without seeding the field.
const [input, setInput] = useState("")
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [error, setError] = useState("")
const target = input.trim()
const isGit = IS_GIT_URL.test(target)
const { dir, filter } = splitPath(target)
const { data: browse, isFetching, error: browseError } = useBrowse(dir, !isGit)
const entries = (browse?.entries ?? []).filter((e) =>
filter ? e.name.toLowerCase().startsWith(filter.toLowerCase()) : true,
)
const suggestedName = target
? target.replace(/\/+$/, "").split("/").pop()!.replace(/\.git$/, "")
: ""
const effectiveName = name.trim() || suggestedName
const nameError =
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
? "lowercase letters, numbers, hyphens"
: effectiveName && existingNames.includes(effectiveName)
? "already exists"
: ""
const submit = async () => {
if (!target || nameError) return
setError("")
try {
const res = await adopt.mutateAsync({
target,
name: name.trim() || undefined,
description: description.trim() || undefined,
})
navigate(`/programs/${res.program}`)
} catch (e: unknown) {
let msg = e instanceof Error ? e.message : String(e)
try {
msg = JSON.parse((e as Error).message).detail ?? msg
} catch {
/* keep msg */
}
setError(msg)
}
}
return (
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Add program</h3>
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
<X size={16} />
</button>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-1.5">
{error}
</div>
)}
{/* Editable path bar + live directory listing (or a pasted git URL). */}
<div className="border border-[var(--border)] rounded">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-[var(--border)] bg-black/20">
<button
onClick={() => browse?.parent && setInput(browse.parent + "/")}
disabled={isGit || !browse?.parent}
title="Up one level"
className="text-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-30 shrink-0"
>
<ArrowUp size={14} />
</button>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
autoFocus
spellCheck={false}
placeholder={browse?.path ? `${browse.path}/ (or a git URL)` : "/data/repos/… or a git URL"}
className="flex-1 min-w-0 bg-transparent text-xs font-mono text-[var(--foreground)] focus:outline-none py-1"
/>
{isFetching && !isGit && <span className="text-[10px] text-[var(--muted)] shrink-0"></span>}
</div>
{!isGit && (
<div className="max-h-56 overflow-y-auto">
{browseError ? (
<p className="text-xs text-red-400 px-3 py-3">
{(() => {
const m = browseError instanceof Error ? browseError.message : ""
try {
return JSON.parse(m).detail ?? "Cannot read directory"
} catch {
return m || "Cannot read directory"
}
})()}
</p>
) : entries.length > 0 ? (
entries.map((e) => (
<div
key={e.path}
className="flex items-center gap-2 px-3 py-1.5 text-sm border-b border-[var(--border)]/50 last:border-0"
>
<button
onClick={() => setInput(e.path + "/")}
className="flex items-center gap-2 min-w-0 flex-1 text-left hover:text-[var(--primary)]"
title="Open"
>
{e.is_git ? (
<GitBranch size={13} className="shrink-0 text-[var(--primary)]" />
) : (
<Folder size={13} className="shrink-0 text-[var(--muted)]" />
)}
<span className="truncate font-mono">{e.name}</span>
</button>
<button
onClick={() => setInput(e.path)}
className={`shrink-0 px-2 py-0.5 text-xs rounded border transition-colors ${
e.is_program
? "border-[var(--primary)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white"
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{target === e.path ? (
<span className="flex items-center gap-1">
<Check size={11} /> picked
</span>
) : (
"pick"
)}
</button>
</div>
))
) : (
<p className="text-xs text-[var(--muted)] px-3 py-3">
{filter ? "No matching sub-directories." : "No sub-directories."}
</p>
)}
</div>
)}
</div>
{target && (
<p className="text-xs text-[var(--muted)]">
Registering <span className="font-mono text-[var(--foreground)]">{target}</span>
{isGit && " (cloned later via wildpc clone)"}
</p>
)}
<TextField
label="Name"
value={name}
onChange={(v) => setName(v.toLowerCase())}
mono
placeholder={suggestedName || "program-name"}
/>
{nameError && <p className="text-xs text-red-400 -mt-2 ml-28 sm:ml-36">{nameError}</p>}
<TextField label="Description" value={description} onChange={setDescription} />
<div className="flex justify-end gap-2 pt-2">
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
>
Cancel
</button>
<button
onClick={submit}
disabled={!target || !!nameError || adopt.isPending}
className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
>
{adopt.isPending ? "Registering…" : "Register"}
</button>
</div>
</div>
)
}

View File

@@ -133,7 +133,7 @@ export function AssistantDock() {
? "border-[var(--primary)] text-[var(--foreground)]" ? "border-[var(--primary)] text-[var(--foreground)]"
: pillIdle, : pillIdle,
)} )}
title="Plain login shell in the castle repo" title="Plain login shell in the wildpc repo"
> >
<TerminalIcon size={13} /> Shell <TerminalIcon size={13} /> Shell
</button> </button>

View File

@@ -26,7 +26,7 @@ interface AppItem {
function detailPathFor(kind: string, name: string): string { function detailPathFor(kind: string, name: string): string {
if (kind === "job") return `/jobs/${name}` if (kind === "job") return `/jobs/${name}`
if (kind === "tool") return `/tools/${name}` if (kind === "tool") return `/tools/${name}`
if (kind === "reference") return `/map` if (kind === "reference") return `/system`
return `/services/${name}` return `/services/${name}`
} }
@@ -137,13 +137,13 @@ function PaletteBody({ onClose }: { onClose: () => void }) {
const launch = (a: AppItem) => { const launch = (a: AppItem) => {
onClose() onClose()
if (a.launchUrl) window.open(a.launchUrl, "_blank", "noreferrer") if (a.launchUrl) window.open(a.launchUrl, "_blank", "noreferrer")
else if (a.mapNodeId) navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`) else if (a.mapNodeId) navigate(`/system?focus=${encodeURIComponent(a.mapNodeId)}`)
else navigate(a.detailPath) else navigate(a.detailPath)
} }
const goToMap = (a: AppItem) => { const goToMap = (a: AppItem) => {
if (!a.mapNodeId) return if (!a.mapNodeId) return
onClose() onClose()
navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`) navigate(`/system?focus=${encodeURIComponent(a.mapNodeId)}`)
} }
const details = (a: AppItem) => { const details = (a: AppItem) => {
onClose() onClose()
@@ -240,7 +240,7 @@ function PaletteBody({ onClose }: { onClose: () => void }) {
)} )}
<button <button
onClick={(e) => (e.stopPropagation(), details(a))} onClick={(e) => (e.stopPropagation(), details(a))}
title="Castle details" title="Wild PC details"
className="shrink-0 text-[var(--muted)] hover:text-[var(--card-foreground)]" className="shrink-0 text-[var(--muted)] hover:text-[var(--card-foreground)]"
> >
<Maximize2 size={12} /> <Maximize2 size={12} />

View File

@@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { Check, GitCompare, Loader2, Play } from "lucide-react" import { AlertTriangle, Check, GitCompare, Loader2, Play } from "lucide-react"
import { apiClient } from "@/services/api/client" import { apiClient } from "@/services/api/client"
import type { ApplyResult } from "@/services/api/hooks" import type { ApplyResult } from "@/services/api/hooks"
@@ -31,7 +31,7 @@ export function ConvergePanel() {
setMsg("Applied — the node is converged.") setMsg("Applied — the node is converged.")
qc.invalidateQueries() qc.invalidateQueries()
} catch (e) { } catch (e) {
// A self-apply restarts castle-api, dropping the connection — expected. // A self-apply restarts wildpc-api, dropping the connection — expected.
if (e instanceof TypeError) { if (e instanceof TypeError) {
setPlan(null) setPlan(null)
setMsg("Applied — services are restarting.") setMsg("Applied — services are restarting.")
@@ -86,6 +86,18 @@ export function ConvergePanel() {
</div> </div>
)} )}
{/* Advisory warnings from the render/preflight (missing stack toolchains,
acme prerequisites, tunnel notes) — surfaced so a service that can't
build or start doesn't fail silently at apply time. */}
{plan?.messages
?.filter((m) => m.startsWith("Warning"))
.map((m) => (
<div key={m} className="mt-2 flex items-start gap-1.5 text-sm text-amber-400">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{m.replace(/^Warning:\s*/, "")}</span>
</div>
))}
{plan && ( {plan && (
<div className="mt-3 space-y-1"> <div className="mt-3 space-y-1">
{plan.changed ? ( {plan.changed ? (

View File

@@ -4,7 +4,7 @@ import type { GatewayInfo } from "@/types"
import { useSaveGatewayConfig } from "@/services/api/hooks" import { useSaveGatewayConfig } from "@/services/api/hooks"
/** Editable gateway routing + public-exposure settings (domain / public_domain / /** Editable gateway routing + public-exposure settings (domain / public_domain /
* tunnel / tls). Saves to castle.yaml; changes take effect on the next apply. */ * tunnel / tls). Saves to wildpc.yaml; changes take effect on the next apply. */
export function GatewaySettings({ gateway }: { gateway: GatewayInfo }) { export function GatewaySettings({ gateway }: { gateway: GatewayInfo }) {
const { mutate: save, isPending, data: saved } = useSaveGatewayConfig() const { mutate: save, isPending, data: saved } = useSaveGatewayConfig()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
@@ -44,7 +44,7 @@ export function GatewaySettings({ gateway }: { gateway: GatewayInfo }) {
<button onClick={() => setEditing(false)} className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] text-[var(--muted)]"> <button onClick={() => setEditing(false)} className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] text-[var(--muted)]">
<X size={12} /> Cancel <X size={12} /> Cancel
</button> </button>
<span className="text-xs text-[var(--muted)]">Run <span className="font-mono">castle apply</span> (or the Apply button) to converge.</span> <span className="text-xs text-[var(--muted)]">Run <span className="font-mono">wildpc apply</span> (or the Apply button) to converge.</span>
</div> </div>
</div> </div>
) )

View File

@@ -4,12 +4,12 @@ import { useNodes } from "@/services/api/hooks"
import type { NodeSummary } from "@/types" import type { NodeSummary } from "@/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Each machine's castle is served at its own origin (castle.<domain>), so hopping // Each machine's wildpc is served at its own origin (wildpc.<domain>), so hopping
// between hosts is a cross-origin navigation. This shows which host you're driving // between hosts is a cross-origin navigation. This shows which host you're driving
// (the is_local node) and lets you jump to a peer's dashboard, preserving the view. // (the is_local node) and lets you jump to a peer's dashboard, preserving the view.
function dashboardUrl(n: NodeSummary): string { function dashboardUrl(n: NodeSummary): string {
const base = n.gateway_domain const base = n.gateway_domain
? `https://castle.${n.gateway_domain}` ? `https://wildpc.${n.gateway_domain}`
: `http://${n.hostname}:${n.gateway_port}` : `http://${n.hostname}:${n.gateway_port}`
return `${base}${window.location.pathname}` return `${base}${window.location.pathname}`
} }
@@ -58,7 +58,7 @@ export function HostSwitcher({ collapsed }: { collapsed: boolean }) {
{open && ( {open && (
<div className="absolute left-2 z-50 mt-1 min-w-[190px] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--card)] shadow-xl"> <div className="absolute left-2 z-50 mt-1 min-w-[190px] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--card)] shadow-xl">
<div className="border-b border-[var(--border)] px-2.5 py-1 text-[10px] uppercase tracking-wide text-[var(--muted)]"> <div className="border-b border-[var(--border)] px-2.5 py-1 text-[10px] uppercase tracking-wide text-[var(--muted)]">
Castle hosts Wild PC hosts
</div> </div>
{list.map((n) => { {list.map((n) => {
const isCur = n.is_local const isCur = n.is_local

View File

@@ -6,15 +6,16 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clock, Clock,
Gauge,
Globe, Globe,
KeyRound, KeyRound,
Layers,
LayoutDashboard, LayoutDashboard,
Menu, Menu,
Package,
Search, Search,
Server, Server,
Share2, Share2,
Map as MapIcon, SquareCode,
Wrench, Wrench,
X, X,
type LucideIcon, type LucideIcon,
@@ -32,6 +33,7 @@ type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] }
// "Deployments" parent. Programs (the catalog) stays top-level. // "Deployments" parent. Programs (the catalog) stays top-level.
const NAV: (NavLeaf | NavGroup)[] = [ const NAV: (NavLeaf | NavGroup)[] = [
{ to: "/", label: "Overview", icon: LayoutDashboard, end: true }, { to: "/", label: "Overview", icon: LayoutDashboard, end: true },
{ to: "/system", label: "System", icon: Gauge },
{ to: "/gateway", label: "Gateway", icon: Globe }, { to: "/gateway", label: "Gateway", icon: Globe },
{ {
label: "Deployments", label: "Deployments",
@@ -42,13 +44,13 @@ const NAV: (NavLeaf | NavGroup)[] = [
{ to: "/tools", label: "Tools", icon: Wrench }, { to: "/tools", label: "Tools", icon: Wrench },
], ],
}, },
{ to: "/programs", label: "Programs", icon: Package }, { to: "/programs", label: "Programs", icon: SquareCode },
{ to: "/map", label: "System Map", icon: MapIcon }, { to: "/stacks", label: "Stacks", icon: Layers },
{ to: "/mesh", label: "Mesh", icon: Share2 }, { to: "/mesh", label: "Mesh", icon: Share2 },
{ to: "/secrets", label: "Secrets", icon: KeyRound }, { to: "/secrets", label: "Secrets", icon: KeyRound },
] ]
const COLLAPSE_KEY = "castle-nav-collapsed" const COLLAPSE_KEY = "wildpc-nav-collapsed"
function NavLeafLink({ function NavLeafLink({
leaf, leaf,
@@ -160,7 +162,7 @@ function NavItems({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?:
function Brand({ collapsed, onClick }: { collapsed?: boolean; onClick?: () => void }) { function Brand({ collapsed, onClick }: { collapsed?: boolean; onClick?: () => void }) {
return ( return (
<Link to="/" onClick={onClick} className="font-bold text-lg truncate"> <Link to="/" onClick={onClick} className="font-bold text-lg truncate">
{collapsed ? "C" : "Castle"} {collapsed ? "W" : "Wild PC"}
</Link> </Link>
) )
} }

View File

@@ -45,25 +45,29 @@ export function ProgramCard({
<StackBadge stack={program.stack} /> <StackBadge stack={program.stack} />
</div> </div>
{program.description && (
<p className="text-sm text-[var(--muted)] mb-2">{program.description}</p>
)}
{/* A program has no kind of its own — show its deployments (name · kind). {/* A program has no kind of its own — show its deployments (name · kind).
When a deployment shares the program's name (the common 1:1 case) the
name is redundant with the card title, so show just the kind badge.
Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */} Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */}
{showDeployments && {showDeployments &&
(program.deployments.length > 0 ? ( (program.deployments.length > 0 ? (
<div className="flex flex-col gap-1 mb-2"> <div className="flex flex-col gap-1">
{program.deployments.map((d) => ( {program.deployments.map((d) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs"> <div key={d.name} className="flex items-center gap-1.5 text-xs">
{d.name !== program.id && (
<span className="font-mono text-[var(--muted)]">{d.name}</span> <span className="font-mono text-[var(--muted)]">{d.name}</span>
)}
<KindBadge kind={d.kind} /> <KindBadge kind={d.kind} />
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-[var(--muted)] italic mb-2">no deployment</p> <p className="text-xs text-[var(--muted)] italic">no deployment</p>
))} ))}
{program.description && (
<p className="text-sm text-[var(--muted)]">{program.description}</p>
)}
</div> </div>
) )
} }

View File

@@ -5,7 +5,6 @@ import { useServiceAction, useSetEnabled } from "@/services/api/hooks"
import { launcherLabel, subdomainUrl } from "@/lib/labels" import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge" import { HealthBadge } from "./HealthBadge"
import { StackBadge } from "./StackBadge" import { StackBadge } from "./StackBadge"
import { KindBadge } from "./KindBadge"
interface ServiceCardProps { interface ServiceCardProps {
service: ServiceSummary service: ServiceSummary
@@ -27,7 +26,15 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
> >
{service.id} {service.id}
</Link> </Link>
{health ? ( {/* A static site is served from disk by the gateway, so it's inherently
"up" — show a green pill in the same slot as a service's health, rather
than a separate kind badge, so statics and services read consistently. */}
{service.kind === "static" ? (
<span className="inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full bg-green-800/50 text-green-300">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
static
</span>
) : health ? (
<HealthBadge status={health.status} latency={health.latency_ms} /> <HealthBadge status={health.status} latency={health.latency_ms} />
) : hasHttp ? ( ) : hasHttp ? (
<HealthBadge status="unknown" /> <HealthBadge status="unknown" />
@@ -35,8 +42,6 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
</div> </div>
<div className="flex items-center gap-1.5 mb-2"> <div className="flex items-center gap-1.5 mb-2">
{/* A static (caddy-served) "service" is distinguished from a systemd one. */}
{service.kind === "static" && <KindBadge kind="static" />}
<StackBadge stack={service.stack} /> <StackBadge stack={service.stack} />
</div> </div>

View File

@@ -26,7 +26,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null) const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
// A deployment edit only persists to castle.yaml; the unit/process still need // A deployment edit only persists to wildpc.yaml; the unit/process still need
// a deploy (regenerate) + restart to actually take effect. // a deploy (regenerate) + restart to actually take effect.
const [pendingApply, setPendingApply] = useState(false) const [pendingApply, setPendingApply] = useState(false)
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
@@ -43,8 +43,8 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
setMessage({ setMessage({
type: "ok", type: "ok",
text: isDeployment text: isDeployment
? "Saved to castle.yaml — not live yet; apply to converge." ? "Saved to wildpc.yaml — not live yet; apply to converge."
: "Saved to castle.yaml", : "Saved to wildpc.yaml",
}) })
if (isDeployment) setPendingApply(true) if (isDeployment) setPendingApply(true)
onRefetch() onRefetch()
@@ -67,7 +67,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
qc.invalidateQueries() qc.invalidateQueries()
onRefetch() onRefetch()
} catch (e: unknown) { } catch (e: unknown) {
// A self-apply of castle-api restarts it, killing the connection — expected. // A self-apply of wildpc-api restarts it, killing the connection — expected.
if (e instanceof TypeError) { if (e instanceof TypeError) {
setPendingApply(false) setPendingApply(false)
setMessage({ type: "ok", text: "Applied — the service is restarting." }) setMessage({ type: "ok", text: "Applied — the service is restarting." })

View File

@@ -5,7 +5,7 @@ import { X } from "lucide-react"
import { apiClient } from "@/services/api/client" import { apiClient } from "@/services/api/client"
import { useGateway } from "@/services/api/hooks" import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels" import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField } from "./fields" import { Field, TextField, PublicHostRadios } from "./fields"
const SELECT = const SELECT =
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]" "bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
@@ -26,7 +26,7 @@ const KIND_INFO: Record<DeploymentKind, { label: string; hint: string }> = {
static: { label: "Static", hint: "Static site served by the gateway" }, static: { label: "Static", hint: "Static site served by the gateway" },
} }
/** Create a deployment in castle.yaml, then deploy (and start, for a service). /** Create a deployment in wildpc.yaml, then deploy (and start, for a service).
* A pick-a-kind wizard: the chosen kind sets the manager and shows only its * A pick-a-kind wizard: the chosen kind sets the manager and shows only its
* relevant fields. Reachable standalone or prefilled from a program page. */ * relevant fields. Reachable standalone or prefilled from a program page. */
export function CreateDeploymentForm({ export function CreateDeploymentForm({
@@ -57,6 +57,9 @@ export function CreateDeploymentForm({
const [health, setHealth] = useState("/health") const [health, setHealth] = useState("/health")
const [proxy, setProxy] = useState(true) const [proxy, setProxy] = useState(true)
const [isPublic, setIsPublic] = useState(false) const [isPublic, setIsPublic] = useState(false)
// Optional exact public FQDN (apex / another zone) overriding <name>.<public_domain>.
const [publicHost, setPublicHost] = useState("")
const [publicMode, setPublicMode] = useState<"default" | "custom">("default")
const [schedule, setSchedule] = useState("0 2 * * *") const [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState<string | null>(null) const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
@@ -81,7 +84,14 @@ export function CreateDeploymentForm({
...(description ? { description } : {}), ...(description ? { description } : {}),
} }
if (kind === "tool") return { ...base, manager: "path" } if (kind === "tool") return { ...base, manager: "path" }
if (kind === "static") return { ...base, manager: "caddy", root } if (kind === "static") {
const cfg: Record<string, unknown> = { ...base, manager: "caddy", root }
if (isPublic) {
cfg.reach = "public"
if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
}
return cfg
}
// systemd (service or job) // systemd (service or job)
const cfg: Record<string, unknown> = { const cfg: Record<string, unknown> = {
@@ -103,7 +113,10 @@ export function CreateDeploymentForm({
} }
} }
if (proxy) cfg.proxy = true if (proxy) cfg.proxy = true
if (proxy && isPublic) cfg.public = true if (proxy && isPublic) {
cfg.public = true
if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
}
return cfg return cfg
} }
@@ -231,6 +244,17 @@ export function CreateDeploymentForm({
</label> </label>
</Field> </Field>
)} )}
{proxy && isPublic && (
<Field label="Public address" hint="Where it's published on the internet.">
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
/>
</Field>
)}
</> </>
)} )}
@@ -239,6 +263,7 @@ export function CreateDeploymentForm({
)} )}
{kind === "static" && ( {kind === "static" && (
<>
<TextField <TextField
label="Root" label="Root"
value={root} value={root}
@@ -247,6 +272,24 @@ export function CreateDeploymentForm({
mono mono
placeholder="dist" placeholder="dist"
/> />
<Field label="Public" hint={`A static site is always served at ${gatewayHost(name || "<name>", domain)}. Enable to also publish it to the internet via the Cloudflare tunnel.`}>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>
</label>
</Field>
{isPublic && (
<Field label="Public address" hint="Where it's published on the internet.">
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
/>
</Field>
)}
</>
)} )}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">

View File

@@ -93,7 +93,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
width="w-48" width="w-48"
mono mono
placeholder="0 2 * * *" placeholder="0 2 * * *"
hint="Cron expression — castle generates a systemd timer that runs the job on this schedule." hint="Cron expression — wildpc generates a systemd timer that runs the job on this schedule."
/> />
<Field label="Launch" hint="How the job runs on each tick, then exits: the launcher and its target (a console script, command/argv, image, node script, or compose file)."> <Field label="Launch" hint="How the job runs on each tick, then exits: the launcher and its target (a console script, command/argv, image, node script, or compose file).">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -124,7 +124,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
onSave={handleSave} onSave={handleSave}
onDelete={onDelete ? () => onDelete(job.id) : undefined} onDelete={onDelete ? () => onDelete(job.id) : undefined}
deleteLabel="Remove job" deleteLabel="Remove job"
confirmMessage={`Remove job "${job.id}" from castle.yaml? Run a deploy afterward to tear down its timer.`} confirmMessage={`Remove job "${job.id}" from wildpc.yaml? Run a deploy afterward to tear down its timer.`}
/> />
</div> </div>
) )

View File

@@ -95,7 +95,7 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
onChange={setSource} onChange={setSource}
mono mono
placeholder="/data/repos/my-prog" placeholder="/data/repos/my-prog"
hint="The working copy on disk. Castle runs dev verbs and builds here. Absolute path, or repo:<name> for castle's own programs." hint="The working copy on disk. Wild PC runs dev verbs and builds here. Absolute path, or repo:<name> for wildpc's own programs."
/> />
<Field <Field
label="Stack" label="Stack"
@@ -119,7 +119,7 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
onChange={setRepo} onChange={setRepo}
mono mono
placeholder="https://github.com/me/x.git" placeholder="https://github.com/me/x.git"
hint="Git URL so 'castle program clone' can fetch the source on a fresh machine. An existing working copy at Source takes precedence." hint="Git URL so 'wildpc program clone' can fetch the source on a fresh machine. An existing working copy at Source takes precedence."
/> />
<TextField <TextField
label="Ref" label="Ref"
@@ -134,7 +134,7 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
value={deps} value={deps}
onChange={setDeps} onChange={setDeps}
placeholder="pandoc, poppler-utils" placeholder="pandoc, poppler-utils"
hint="OS packages the program needs. Listed for reference — castle does not install them." hint="OS packages the program needs. Listed for reference — wildpc does not install them."
/> />
<Field <Field

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import type { ServiceDetail } from "@/types" import type { ServiceDetail } from "@/types"
import { useGateway } from "@/services/api/hooks" import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels" import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields" import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields" import type { Requirement } from "./fields"
interface Props { interface Props {
@@ -77,6 +77,13 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const [reach, setReach] = useState( const [reach, setReach] = useState(
(m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"), (m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"),
) )
// Optional exact public FQDN (apex or another zone) that overrides the default
// <name>.<public_domain>. Only applies when reach is public; `publicMode` picks
// default vs a custom host, and `publicHost` holds the custom value.
const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
const [publicMode, setPublicMode] = useState<"default" | "custom">(
m.public_host ? "custom" : "default",
)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>) const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const { element: requiresEditor, value: requiresValue } = useRequires( const { element: requiresEditor, value: requiresValue } = useRequires(
@@ -111,6 +118,12 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
} }
// reach needs a port to route through the gateway; without one it's off. // reach needs a port to route through the gateway; without one it's off.
config.reach = port ? reach : "off" config.reach = port ? reach : "off"
// public_host only applies to a public service using a custom domain; else
// clear it (send null so the PATCH merge drops any stale override).
config.public_host =
config.reach === "public" && publicMode === "custom" && publicHost.trim()
? publicHost.trim()
: null
} }
delete config.proxy delete config.proxy
delete config.public delete config.public
@@ -188,7 +201,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
width="w-32" width="w-32"
mono mono
placeholder="9001" placeholder="9001"
hint="The port the service listens on. Castle health-checks and proxies this port; map it to the program's own var with ${port} in Environment." hint="The port the service listens on. Wild PC health-checks and proxies this port; map it to the program's own var with ${port} in Environment."
/> />
<TextField <TextField
label="Health path" label="Health path"
@@ -197,11 +210,11 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
width="w-48" width="w-48"
mono mono
placeholder="/health" placeholder="/health"
hint="HTTP path castle polls to report up/down." hint="HTTP path wildpc polls to report up/down."
/> />
<Field <Field
label="Reach" label="Reach"
hint={`How far this service is exposed. off: host:port only. internal: ${gatewayHost(service.id, domain)} via the gateway. public: also to the internet via the Cloudflare tunnel.`} hint="How far this service is exposed. off: host:port only. internal: via the gateway. public: also to the internet via the Cloudflare tunnel."
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<select <select
@@ -210,15 +223,36 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
disabled={!port} disabled={!port}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50" className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50"
> >
<option value="off">{port ? `localhost:${port} (local)` : "off"}</option> <option value="off">off</option>
<option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option> <option value="internal">internal</option>
<option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option> <option value="public">public</option>
</select> </select>
{!port && ( {!port && (
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span> <span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
)} )}
</div> </div>
</Field> </Field>
{port && (
<Field label="Address" hint="Where this service is reachable.">
{reach === "off" && (
<span className="font-mono text-xs text-[var(--muted)]">localhost:{port}</span>
)}
{reach === "internal" && (
<span className="font-mono text-xs text-[var(--muted)]">
{gatewayHost(service.id, domain)}
</span>
)}
{reach === "public" && (
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(service.id, publicDomain)}
/>
)}
</Field>
)}
</> </>
)} )}
{requiresEditor} {requiresEditor}
@@ -229,7 +263,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
onSave={handleSave} onSave={handleSave}
onDelete={onDelete ? () => onDelete(service.id) : undefined} onDelete={onDelete ? () => onDelete(service.id) : undefined}
deleteLabel="Remove service" deleteLabel="Remove service"
confirmMessage={`Remove service "${service.id}" from castle.yaml? Run a deploy afterward to tear down its unit.`} confirmMessage={`Remove service "${service.id}" from wildpc.yaml? Run a deploy afterward to tear down its unit.`}
/> />
</div> </div>
) )

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import type { DeploymentDetail } from "@/types" import type { DeploymentDetail } from "@/types"
import { useGateway } from "@/services/api/hooks" import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels" import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields" import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields" import type { Requirement } from "./fields"
interface Props { interface Props {
@@ -29,6 +29,11 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
const [isPublic, setIsPublic] = useState( const [isPublic, setIsPublic] = useState(
((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public", ((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public",
) )
// Optional exact public FQDN (apex or another zone) overriding <name>.<public_domain>.
const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
const [publicMode, setPublicMode] = useState<"default" | "custom">(
m.public_host ? "custom" : "default",
)
const { element: envEditor, merged } = useEnvSecrets( const { element: envEditor, merged } = useEnvSecrets(
obj(obj(m.defaults).env) as Record<string, string>, obj(obj(m.defaults).env) as Record<string, string>,
) )
@@ -46,6 +51,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
config.description = description || null config.description = description || null
config.root = root || "dist" config.root = root || "dist"
config.reach = isPublic ? "public" : "internal" config.reach = isPublic ? "public" : "internal"
// public_host only applies to a public site using a custom domain; else clear
// (null) so the merge drops any stale override.
config.public_host =
isPublic && publicMode === "custom" && publicHost.trim() ? publicHost.trim() : null
delete config.public delete config.public
config.requires = requiresValue() config.requires = requiresValue()
const env = merged() const env = merged()
@@ -73,7 +82,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
/> />
<Field <Field
label="Reach" label="Reach"
hint={`How far this static site is served. internal: ${gatewayHost(dep.id, domain)}. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)`} hint="How far this static site is served. internal: via the gateway. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)"
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<select <select
@@ -81,11 +90,24 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
onChange={(e) => setIsPublic(e.target.value === "public")} onChange={(e) => setIsPublic(e.target.value === "public")}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]" className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
> >
<option value="internal">{`${gatewayHost(dep.id, domain)} (internal)`}</option> <option value="internal">internal</option>
<option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option> <option value="public">public</option>
</select> </select>
</div> </div>
</Field> </Field>
<Field label="Address" hint="Where this site is reachable.">
{isPublic ? (
<PublicHostRadios
mode={publicMode}
onModeChange={setPublicMode}
value={publicHost}
onChange={setPublicHost}
defaultHost={publicGatewayHost(dep.id, publicDomain)}
/>
) : (
<span className="font-mono text-xs text-[var(--muted)]">{gatewayHost(dep.id, domain)}</span>
)}
</Field>
{requiresEditor} {requiresEditor}
{envEditor} {envEditor}
<FormFooter <FormFooter

View File

@@ -1,6 +1,10 @@
import { useState } from "react" import { useState } from "react"
import { Loader2 } from "lucide-react"
import type { DeploymentDetail } from "@/types" import type { DeploymentDetail } from "@/types"
import { TextField, FormFooter, useEnvSecrets } from "./fields" import { apiClient, ApiError } from "@/services/api/client"
import { Field, TextField, FormFooter } from "./fields"
type GenKind = "help" | "deep" | "ai"
interface Props { interface Props {
tool: DeploymentDetail tool: DeploymentDetail
@@ -8,30 +12,94 @@ interface Props {
onDelete?: (name: string) => Promise<void> onDelete?: (name: string) => Promise<void>
} }
type Obj = Record<string, unknown>
const obj = (v: unknown): Obj => (v as Obj) ?? {}
/** Edit a tool's (path) deployment config. A path deployment has no launcher, /** Edit a tool's (path) deployment config. A path deployment has no launcher,
* port, or schedule — only a description and env, plus its manager. */ * port, or schedule — only a description and its `tool_schema` (the neutral
* tool-call definition handed to agents), plus its manager. It has no run env:
* a tool is a CLI on PATH, invoked from a shell with wildpc out of the loop, so
* `defaults.env` is never applied (deploy.py only wires env for systemd units). */
export function ToolFields({ tool, onSave, onDelete }: Props) { export function ToolFields({ tool, onSave, onDelete }: Props) {
const m = tool.manifest const m = tool.manifest
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [description, setDescription] = useState((m.description as string) ?? "") const [description, setDescription] = useState((m.description as string) ?? "")
const { element: envEditor, merged } = useEnvSecrets( const [schemaText, setSchemaText] = useState(
obj(obj(m.defaults).env) as Record<string, string>, m.tool_schema ? JSON.stringify(m.tool_schema, null, 2) : "",
) )
const [schemaError, setSchemaError] = useState<string | null>(null)
// Which generate action is in-flight (null = idle) — lets each button show its
// own spinner/label rather than all three reacting to one shared flag.
const [genKind, setGenKind] = useState<GenKind | null>(null)
const generating = genKind !== null
const [validating, setValidating] = useState(false)
const [validation, setValidation] = useState<{ ok: boolean; msg: string } | null>(null)
const validate = async () => {
setValidation(null)
setSchemaError(null)
let parsed: unknown
try {
parsed = JSON.parse(schemaText)
} catch (e) {
setValidation({ ok: false, msg: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` })
return
}
setValidating(true)
try {
const res = await apiClient.post<{ valid: boolean; errors: string[] }>(
"/config/tools/schema/validate",
parsed,
)
setValidation(
res.valid
? { ok: true, msg: "Schema is valid." }
: { ok: false, msg: res.errors.join(" · ") },
)
} catch (e) {
setValidation({ ok: false, msg: e instanceof ApiError ? e.message : String(e) })
} finally {
setValidating(false)
}
}
const generate = async (kind: GenKind) => {
setGenKind(kind)
setSchemaError(null)
setValidation(null)
try {
const params = new URLSearchParams()
if (kind === "deep") params.set("deep", "true")
if (kind === "ai") params.set("assist", "llm")
const qs = params.toString()
const res = await apiClient.post<{ schema: unknown }>(
`/config/tools/${tool.id}/schema${qs ? `?${qs}` : ""}`,
)
setSchemaText(JSON.stringify(res.schema, null, 2))
} catch (e) {
setSchemaError(e instanceof ApiError ? e.message : String(e))
} finally {
setGenKind(null)
}
}
const handleSave = async () => { const handleSave = async () => {
// Validate the (optional) tool_schema JSON before saving.
let parsedSchema: unknown = undefined
if (schemaText.trim()) {
try {
parsedSchema = JSON.parse(schemaText)
} catch (e) {
setSchemaError(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`)
return
}
}
setSchemaError(null)
setSaving(true) setSaving(true)
setSaved(false) setSaved(false)
try { try {
const config: Record<string, unknown> = JSON.parse(JSON.stringify(m)) const config: Record<string, unknown> = JSON.parse(JSON.stringify(m))
delete config.id delete config.id
config.description = description || undefined config.description = description || undefined
const env = merged() config.tool_schema = parsedSchema ?? null // null clears (PATCH semantics)
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
else if (config.defaults) delete (config.defaults as Obj).env
await onSave(tool.id, config) await onSave(tool.id, config)
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
@@ -43,7 +111,88 @@ export function ToolFields({ tool, onSave, onDelete }: Props) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<TextField label="Description" value={description} onChange={setDescription} /> <TextField label="Description" value={description} onChange={setDescription} />
{envEditor} <Field
label="Tool schema"
hint="Neutral tool-call definition ({name, description, parameters}) handed to agents — rendered to OpenAI or Anthropic on read. Generate it from the tool's --help, then edit freely. Leave empty for none."
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => generate("help")}
disabled={generating}
className="px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
>
{genKind === "help" ? "Generating…" : "Generate from --help"}
</button>
<button
onClick={() => generate("deep")}
disabled={generating}
className="px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
title="Also walk subcommands"
>
Deep
</button>
<button
onClick={() => generate("ai")}
disabled={generating}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
title="Use the LLM to structure subcommand trees the parser can't (requires LLM assist enabled)"
>
{genKind === "ai" ? (
<>
<Loader2 size={12} className="animate-spin" /> Generating
</>
) : (
<> Generate with AI</>
)}
</button>
<button
onClick={validate}
disabled={validating || generating || !schemaText.trim()}
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
title="Deterministically check the schema in the box is a valid tool-call definition"
>
{validating ? (
<>
<Loader2 size={12} className="animate-spin" /> Validating
</>
) : (
"Validate"
)}
</button>
{schemaText && (
<button
onClick={() => {
setSchemaText("")
setSchemaError(null)
setValidation(null)
}}
className="text-xs text-red-400 hover:text-red-300"
>
Clear
</button>
)}
</div>
<textarea
value={schemaText}
onChange={(e) => {
setSchemaText(e.target.value)
setValidation(null)
}}
spellCheck={false}
rows={schemaText ? 12 : 3}
placeholder="{ } — generate from --help, or paste an Anthropic tool definition"
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
{validation && (
<p className={`text-xs ${validation.ok ? "text-green-400" : "text-amber-400"}`}>
{validation.ok ? "✓ " : "✗ "}
{validation.msg}
</p>
)}
{schemaError && <p className="text-xs text-red-400">{schemaError}</p>}
</div>
</Field>
<FormFooter <FormFooter
saving={saving} saving={saving}
saved={saved} saved={saved}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Trash2 } from "lucide-react" import { Trash2, X } from "lucide-react"
import { SecretsEditor } from "@/components/SecretsEditor" import { SecretsEditor } from "@/components/SecretsEditor"
import { ConfirmModal } from "@/components/ConfirmModal" import { ConfirmModal } from "@/components/ConfirmModal"
@@ -55,6 +55,53 @@ export function TextField({
) )
} }
/** The public-address sub-control shown when a deployment's reach is `public`:
* a default/custom choice. `default` uses `<name>.<public_domain>` (shown
* read-only); `custom` reveals a text input for an exact FQDN (apex or another
* zone) that becomes the deployment's `public_host`. */
export function PublicHostRadios({
mode,
onModeChange,
value,
onChange,
defaultHost,
}: {
mode: "default" | "custom"
onModeChange: (m: "default" | "custom") => void
value: string
onChange: (v: string) => void
defaultHost: string
}) {
return (
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" checked={mode === "default"} onChange={() => onModeChange("default")} />
<span className="font-mono text-xs text-[var(--muted)]">
{defaultHost} <span className="opacity-60">· default</span>
</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" checked={mode === "custom"} onChange={() => onModeChange("custom")} />
<span className="text-sm">custom domain</span>
</label>
{mode === "custom" && (
<div className="ml-6 space-y-1">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="example.com"
className={`w-64 ${INPUT} font-mono`}
/>
<p className="text-xs text-[var(--muted)] leading-snug">
An exact hostname an apex (example.com) or a name in another zone. The
Cloudflare token(s) need DNS:Edit on that zone.
</p>
</div>
)}
</div>
)
}
// A value that is *exactly* a secret ref → fully editable via SecretsEditor. // A value that is *exactly* a secret ref → fully editable via SecretsEditor.
const SECRET_RE = /^\$\{secret:([^}]+)\}$/ const SECRET_RE = /^\$\{secret:([^}]+)\}$/
// A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These // A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These
@@ -89,6 +136,26 @@ export function useEnvSecrets(initial: Record<string, string>) {
const [env, setEnv] = useState<Record<string, string>>(plain) const [env, setEnv] = useState<Record<string, string>>(plain)
const [secrets, setSecrets] = useState<Record<string, string>>(secretRefs) const [secrets, setSecrets] = useState<Record<string, string>>(secretRefs)
// Inline "add variable" row (replaces a native prompt). `adding` toggles it;
// the key is committed only when non-blank and not already an env/secret name.
const [adding, setAdding] = useState(false)
const [newKey, setNewKey] = useState("")
const [newVal, setNewVal] = useState("")
const trimmedKey = newKey.trim()
const duplicate = trimmedKey !== "" && (trimmedKey in env || trimmedKey in secrets)
const canCommit = trimmedKey !== "" && !duplicate
const cancelNew = () => {
setAdding(false)
setNewKey("")
setNewVal("")
}
const commitNew = () => {
if (!canCommit) return
setEnv((p) => ({ ...p, [trimmedKey]: newVal }))
cancelNew()
}
const merged = (): Record<string, string> => { const merged = (): Record<string, string> => {
const out: Record<string, string> = { ...env } const out: Record<string, string> = { ...env }
for (const [k, name] of Object.entries(secrets)) out[k] = `\${secret:${name}}` for (const [k, name] of Object.entries(secrets)) out[k] = `\${secret:${name}}`
@@ -103,7 +170,7 @@ export function useEnvSecrets(initial: Record<string, string>) {
Use <code className="font-mono">${"{port}"}</code>,{" "} Use <code className="font-mono">${"{port}"}</code>,{" "}
<code className="font-mono">${"{data_dir}"}</code>,{" "} <code className="font-mono">${"{data_dir}"}</code>,{" "}
<code className="font-mono">${"{name}"}</code>,{" "} <code className="font-mono">${"{name}"}</code>,{" "}
<code className="font-mono">${"{public_url}"}</code> for castle's computed values, <code className="font-mono">${"{public_url}"}</code> for wildpc's computed values,
and <code className="font-mono">${"{secret:NAME}"}</code> for secrets. and <code className="font-mono">${"{secret:NAME}"}</code> for secrets.
</p> </p>
{Object.entries(env).map(([key, val]) => ( {Object.entries(env).map(([key, val]) => (
@@ -134,15 +201,60 @@ export function useEnvSecrets(initial: Record<string, string>) {
</button> </button>
</div> </div>
))} ))}
<button {adding ? (
onClick={() => { <div className="space-y-1">
const key = prompt("Variable name:") <div className="flex items-center gap-2">
if (key) setEnv((p) => ({ ...p, [key]: "" })) <input
autoFocus
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitNew()
if (e.key === "Escape") cancelNew()
}} }}
placeholder="VARIABLE_NAME"
className={`w-24 sm:w-56 min-w-0 ${INPUT} text-xs font-mono`}
/>
<span className="text-[var(--muted)] shrink-0">=</span>
<input
value={newVal}
onChange={(e) => setNewVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitNew()
if (e.key === "Escape") cancelNew()
}}
placeholder="value"
className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`}
/>
<button
onClick={commitNew}
disabled={!canCommit}
className="px-2 py-1 text-xs rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40 shrink-0"
>
Add
</button>
<button
onClick={cancelNew}
className="text-[var(--muted)] hover:text-[var(--foreground)] p-0.5 shrink-0"
title="Cancel"
>
<X size={12} />
</button>
</div>
{duplicate && (
<p className="text-xs text-amber-400">
<span className="font-mono">{trimmedKey}</span> already exists.
</p>
)}
</div>
) : (
<button
onClick={() => setAdding(true)}
className="text-xs text-[var(--primary)] hover:underline" className="text-xs text-[var(--primary)] hover:underline"
> >
+ Add variable + Add variable
</button> </button>
)}
</div> </div>
</Field> </Field>
<Field label="Secrets"> <Field label="Secrets">

View File

@@ -82,7 +82,7 @@ export function detailPath(name: string, kind: string): string {
/** /**
* Full URL for a service exposed at <subdomain>.<gateway.domain>. The domain is * Full URL for a service exposed at <subdomain>.<gateway.domain>. The domain is
* derived from the dashboard's own host (it is served at castle.<domain>), so * derived from the dashboard's own host (it is served at wildpc.<domain>), so
* this returns null when the dashboard is on a bare host (off mode, no subdomains). * this returns null when the dashboard is on a bare host (off mode, no subdomains).
*/ */
export function subdomainUrl(subdomain: string): string | null { export function subdomainUrl(subdomain: string): string | null {

View File

@@ -78,7 +78,7 @@ export function Overview() {
return ( return (
<div className="max-w-6xl mx-auto px-6 py-8"> <div className="max-w-6xl mx-auto px-6 py-8">
<PageHeader title="Castle" subtitle="Personal software platform" /> <PageHeader title="Wild PC" subtitle="Personal software platform" />
{nodes && <NodeBar nodes={nodes} />} {nodes && <NodeBar nodes={nodes} />}

View File

@@ -93,7 +93,7 @@ export function ProgramDetailPage() {
Program Info Program Info
</h2> </h2>
<p className="text-xs text-[var(--muted)] mb-4"> <p className="text-xs text-[var(--muted)] mb-4">
Where the source lives and how castle works with it. Where the source lives and how wildpc works with it.
</p> </p>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4"> <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
{deployment.source && ( {deployment.source && (

View File

@@ -1,17 +1,41 @@
import { useState } from "react"
import { Plus } from "lucide-react"
import { usePrograms } from "@/services/api/hooks" import { usePrograms } from "@/services/api/hooks"
import { ProgramList } from "@/components/ProgramList" import { ProgramList } from "@/components/ProgramList"
import { MonorepoBanner } from "@/components/MonorepoBanner" import { MonorepoBanner } from "@/components/MonorepoBanner"
import { PageHeader } from "@/components/PageHeader" import { PageHeader } from "@/components/PageHeader"
import { AddProgramForm } from "@/components/AddProgramForm"
export function Programs() { export function Programs() {
const { data: programs, isLoading } = usePrograms() const { data: programs, isLoading } = usePrograms()
const [adding, setAdding] = useState(false)
return ( return (
<div className="max-w-6xl mx-auto px-6 py-8"> <div className="max-w-6xl mx-auto px-6 py-8">
<PageHeader title="Programs" subtitle="Software catalog" /> <PageHeader
title="Programs"
subtitle="Software catalog"
actions={
<button
onClick={() => setAdding((a) => !a)}
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
>
<Plus size={14} /> Add program
</button>
}
/>
<MonorepoBanner /> <MonorepoBanner />
{adding && (
<div className="mb-6 max-w-2xl">
<AddProgramForm
existingNames={(programs ?? []).map((p) => p.id)}
onCancel={() => setAdding(false)}
/>
</div>
)}
{isLoading ? ( {isLoading ? (
<p className="text-[var(--muted)]">Loading...</p> <p className="text-[var(--muted)]">Loading...</p>
) : programs && programs.length > 0 ? ( ) : programs && programs.length > 0 ? (

View File

@@ -15,7 +15,7 @@ export function ServiceDetailPage() {
const { data: deployment, isLoading, error, refetch } = useService(name ?? "") const { data: deployment, isLoading, error, refetch } = useService(name ?? "")
const { data: statusResp } = useStatus() const { data: statusResp } = useStatus()
const health = statusResp?.statuses.find((s) => s.id === name) const health = statusResp?.statuses.find((s) => s.id === name)
const isGateway = name === "castle-gateway" const isGateway = name === "wildpc-gateway"
const { data: caddyfile } = useCaddyfile(isGateway) const { data: caddyfile } = useCaddyfile(isGateway)
if (isLoading) { if (isLoading) {

158
app/src/pages/Stacks.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { useState } from "react"
import { Link } from "react-router-dom"
import { Check, Copy, Layers, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useStacksStatus, type StackStatus, type ToolStatus } from "@/services/api/hooks"
import { PageHeader } from "@/components/PageHeader"
// A stack's overall health pill: green when every tool it needs is present, red
// when one is missing, grey when nothing on this node uses it yet.
function StackBadge({ stack }: { stack: StackStatus }) {
const [label, cls] = !stack.in_use
? (["unused", "bg-gray-700/50 text-gray-400"] as const)
: stack.ok
? (["ready", "bg-green-800/50 text-green-300"] as const)
: (["missing tools", "bg-red-800/50 text-red-300"] as const)
return (
<span className={cn("inline-flex items-center text-xs px-2 py-0.5 rounded-full", cls)}>
{label}
</span>
)
}
// A copy-to-clipboard chip for a tool's install command — the "diagnose + copyable
// hint" contract: we never install for you, but the fix is one click from your shell.
function CopyHint({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
onClick={() => {
void navigator.clipboard?.writeText(text)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
}}
className="group inline-flex items-center gap-1.5 rounded border border-[var(--border)] bg-black/20 px-2 py-1 font-mono text-xs text-[var(--muted)] hover:border-[var(--primary)] hover:text-[var(--foreground)] transition-colors"
title="Copy install command"
>
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
<span className="truncate">{text}</span>
</button>
)
}
function ToolRow({ tool }: { tool: ToolStatus }) {
return (
<div className="flex flex-col gap-1 py-1.5">
<div className="flex items-center gap-2 text-sm">
{tool.present ? (
<Check size={14} className="shrink-0 text-green-400" />
) : (
<X size={14} className="shrink-0 text-red-400" />
)}
<span className="font-mono font-medium">{tool.command}</span>
<span className="text-xs text-[var(--muted)]">
{tool.purpose} · {tool.phase}
</span>
{tool.version && (
<span className="ml-auto truncate max-w-[45%] text-xs text-[var(--muted)]" title={tool.version}>
{tool.version}
</span>
)}
</div>
{!tool.present && (
<div className="pl-6">
<CopyHint text={tool.install_hint} />
</div>
)}
</div>
)
}
function StackCard({ stack }: { stack: StackStatus }) {
return (
<div
className={cn(
"rounded-lg border bg-[var(--card)] p-4",
stack.in_use ? "border-[var(--border)]" : "border-[var(--border)] opacity-60",
)}
>
<div className="flex items-center justify-between gap-2">
<h2 className="font-semibold">{stack.name}</h2>
<StackBadge stack={stack} />
</div>
<div className="mt-3 divide-y divide-[var(--border)]">
{stack.tools.length > 0 ? (
stack.tools.map((t) => <ToolRow key={t.command} tool={t} />)
) : (
<p className="py-1.5 text-sm text-[var(--muted)]">No host tools required.</p>
)}
</div>
{stack.programs.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-[var(--muted)]">used by</span>
{stack.programs.map((p) => (
<Link
key={p}
to={`/programs/${p}`}
className="rounded bg-black/20 px-1.5 py-0.5 font-mono hover:text-[var(--primary)]"
>
{p}
</Link>
))}
</div>
)}
{stack.verbs.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-[var(--muted)]">
{stack.verbs.map((v) => (
<span key={v} className="rounded-full border border-[var(--border)] px-1.5 py-0.5">
{v}
</span>
))}
</div>
)}
</div>
)
}
export function Stacks() {
const { data: stacks, isLoading } = useStacksStatus()
const missing = (stacks ?? []).filter((s) => s.in_use && !s.ok).length
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<PageHeader
title="Stacks"
subtitle="The toolchains each stack needs, and whether they're present where your services run"
actions={
missing > 0 ? (
<span className="inline-flex items-center gap-1.5 text-sm text-red-400">
<X size={14} /> {missing} stack{missing !== 1 ? "s" : ""} missing tools
</span>
) : stacks && stacks.length > 0 ? (
<span className="inline-flex items-center gap-1.5 text-sm text-green-400">
<Check size={14} /> all toolchains present
</span>
) : null
}
/>
{isLoading ? (
<p className="text-[var(--muted)]">Loading...</p>
) : stacks && stacks.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{stacks.map((s) => (
<StackCard key={s.name} stack={s} />
))}
</div>
) : (
<div className="flex flex-col items-center gap-2 py-16 text-[var(--muted)]">
<Layers size={28} />
<p>No stacks.</p>
</div>
)}
</div>
)
}

View File

@@ -84,7 +84,7 @@ const KIND_COLOR: Record<string, string> = {
// Dependency edges are colored by the protocol of the thing being consumed (the // Dependency edges are colored by the protocol of the thing being consumed (the
// target's endpoint). "system" = a socket-less dep (a tool/package) or a target // target's endpoint). "system" = a socket-less dep (a tool/package) or a target
// castle doesn't model an endpoint for. // wildpc doesn't model an endpoint for.
const PROTO_COLOR: Record<string, string> = { const PROTO_COLOR: Record<string, string> = {
http: "#58a6ff", http: "#58a6ff",
pg: "#3b82f6", pg: "#3b82f6",
@@ -100,7 +100,7 @@ const HANDLE = { opacity: 0, width: 8, height: 8 } as const
// User's manual node arrangement. Positions default to the computed lane layout; // User's manual node arrangement. Positions default to the computed lane layout;
// any node the user drags is remembered here and survives refetches + reloads. // any node the user drags is remembered here and survives refetches + reloads.
const POS_KEY = "castle-map-positions" const POS_KEY = "wildpc-map-positions"
type PosMap = Record<string, { x: number; y: number }> type PosMap = Record<string, { x: number; y: number }>
function loadPositions(): PosMap { function loadPositions(): PosMap {
try { try {
@@ -198,10 +198,13 @@ function MapNode({ data }: NodeProps) {
</div> </div>
{d.sub && <div className="truncate text-[10px] text-[var(--muted)]">{d.sub}</div>} {d.sub && <div className="truncate text-[10px] text-[var(--muted)]">{d.sub}</div>}
</div> </div>
{/* Right handle: drag OUT to connect — to a hub (expose) or another node {/* Right handles: `rs` (source) is what you drag OUT to connect — to a hub
(declare "requires"). Right target lands incoming dependency lines. */} (expose) or another node (declare "requires"). `rt` (target) only anchors
incoming dependency lines, so it's nudged below `rs`: stacked on top it
would swallow the pointer-down (you can't START a drag from a target
handle in strict mode) and dragging a fresh line would do nothing. */}
<Handle id="rs" type="source" position={Position.Right} style={{ ...HANDLE, opacity: 0.9, background: color }} /> <Handle id="rs" type="source" position={Position.Right} style={{ ...HANDLE, opacity: 0.9, background: color }} />
<Handle id="rt" type="target" position={Position.Right} style={{ ...HANDLE, opacity: 0 }} /> <Handle id="rt" type="target" position={Position.Right} style={{ ...HANDLE, opacity: 0, top: "78%" }} />
<Handle id="lt" type="target" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} /> <Handle id="lt" type="target" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
<Handle id="ls" type="source" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} /> <Handle id="ls" type="source" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
</div> </div>
@@ -292,7 +295,7 @@ function protoOf(url: string | null): string {
} }
} }
// An external resource — a `reference` (manager: none) castle doesn't run. Sits in // An external resource — a `reference` (manager: none) wildpc doesn't run. Sits in
// the External zone as a registry entry + a drop target for consumes edges. // the External zone as a registry entry + a drop target for consumes edges.
function ExternalNode({ data }: NodeProps) { function ExternalNode({ data }: NodeProps) {
const d = data as { label: string; host: string; focusDim?: boolean; focused?: boolean } const d = data as { label: string; host: string; focusDim?: boolean; focused?: boolean }
@@ -322,7 +325,7 @@ function ExternalNode({ data }: NodeProps) {
) )
} }
// A deployment on another castle node (mesh-discovered). Read-only — you manage // A deployment on another wildpc node (mesh-discovered). Read-only — you manage
// remote deployments from that node. // remote deployments from that node.
function RemoteNode({ data }: NodeProps) { function RemoteNode({ data }: NodeProps) {
const d = data as { const d = data as {
@@ -474,9 +477,13 @@ export function SystemMapPage() {
// fallback, but there's no ProgramSpec, so no grip/link. // fallback, but there's no ProgramSpec, so no grip/link.
const catalog = new Set((programs ?? []).map((p) => p.id)) const catalog = new Set((programs ?? []).map((p) => p.id))
const routes = gateway?.routes ?? [] const routes = gateway?.routes ?? []
const exposed = new Set(routes.map((r) => r.name).filter(Boolean) as string[]) // The gateway's authoritative public (tunnel) URL per exposed name, so a public
const publicSet = new Set( // service's launch link reads as its internet address, not the internal one.
routes.filter((r) => r.public_url).map((r) => r.name).filter(Boolean) as string[], // (Only the *address* comes from the gateway; exposure state is `n.reach`.)
const publicUrlOf = new Map(
routes
.filter((r) => r.public_url && r.name)
.map((r) => [r.name as string, r.public_url as string]),
) )
// Per-node lookups from the graph itself (authoritative for sockets/reach now). // Per-node lookups from the graph itself (authoritative for sockets/reach now).
@@ -487,11 +494,15 @@ export function SystemMapPage() {
// http-exposed service served at <name>.<domain>. TCP/tools/jobs aren't. // http-exposed service served at <name>.<domain>. TCP/tools/jobs aren't.
const domain = gateway?.domain ?? null const domain = gateway?.domain ?? null
const launchOf = (n: (typeof graph.nodes)[number]): string | undefined => { const launchOf = (n: (typeof graph.nodes)[number]): string | undefined => {
if (!domain || !n.reach || n.reach === "off") return undefined if (!n.reach || n.reach === "off") return undefined
if (n.kind === "static") return `https://${n.name}.${domain}` const isHttp =
if (n.kind === "service" && n.endpoints.some((e) => e.protocol === "http")) n.kind === "static" ||
return `https://${n.name}.${domain}` (n.kind === "service" && n.endpoints.some((e) => e.protocol === "http"))
return undefined if (!isHttp) return undefined
// Public services live at both URLs; prefer the internet one so the link
// matches the green Internet edge instead of reading as internal-only.
if (n.reach === "public" && publicUrlOf.has(n.name)) return publicUrlOf.get(n.name)
return domain ? `https://${n.name}.${domain}` : undefined
} }
// Remote launch: <subdomain>.<node-domain>, using the node's own acme domain // Remote launch: <subdomain>.<node-domain>, using the node's own acme domain
@@ -745,17 +756,19 @@ export function SystemMapPage() {
} }
} }
// Reach is modal: one line per exposed node. public → a single green line to // Reach is modal: one line per exposed node, driven by the node's own `reach`
// Internet; internal → a single blue line to Gateway. Dragging a new line to // (the single source of truth). public → a single green line to Internet;
// the other target switches the mode; deleting the line removes exposure. // internal → a single blue line to Gateway. Dragging a new line to the other
// target switches the mode; deleting the line removes exposure.
const reachOf: Record<string, "internal" | "public"> = {} const reachOf: Record<string, "internal" | "public"> = {}
for (const name of exposed) { for (const n of graph.nodes) {
if (!present.has(name)) continue if (!present.has(n.name)) continue
const isPub = publicSet.has(name) if (n.reach !== "internal" && n.reach !== "public") continue
reachOf[name] = isPub ? "public" : "internal" const isPub = n.reach === "public"
reachOf[n.name] = isPub ? "public" : "internal"
edges.push({ edges.push({
id: `exp:${name}`, id: `exp:${n.name}`,
source: name, source: n.name,
target: isPub ? "__internet__" : "__gateway__", target: isPub ? "__internet__" : "__gateway__",
sourceHandle: "rs", sourceHandle: "rs",
targetHandle: "lt", targetHandle: "lt",
@@ -987,7 +1000,7 @@ export function SystemMapPage() {
setFocus(picks.length === 1 ? picks[0].id : null) setFocus(picks.length === 1 ? picks[0].id : null)
}, []) }, [])
// "Go to on map" from the command palette (/map?focus=<id>): select + center it. // "Go to on map" from the command palette (/system?focus=<id>): select + center it.
useEffect(() => { useEffect(() => {
const f = searchParams.get("focus") const f = searchParams.get("focus")
if (!f) return if (!f) return
@@ -1323,7 +1336,7 @@ export function SystemMapPage() {
open={!!confirmDel} open={!!confirmDel}
danger danger
title={`Delete ${confirmDel?.name}?`} title={`Delete ${confirmDel?.name}?`}
body={`This removes the ${confirmDel?.kind} deployment from castle.yaml and tears it down. The program's source is kept.`} body={`This removes the ${confirmDel?.kind} deployment from wildpc.yaml and tears it down. The program's source is kept.`}
confirmLabel="Delete" confirmLabel="Delete"
onConfirm={doDelete} onConfirm={doDelete}
onCancel={() => setConfirmDel(null)} onCancel={() => setConfirmDel(null)}
@@ -1332,7 +1345,7 @@ export function SystemMapPage() {
) )
} }
const LEGEND_KEY = "castle-map-legend-open" const LEGEND_KEY = "wildpc-map-legend-open"
function Legend() { function Legend() {
const [open, setOpen] = useState(() => { const [open, setOpen] = useState(() => {
@@ -1448,7 +1461,7 @@ function InspectPanel({
<ExternalLink size={13} /> <ExternalLink size={13} />
</a> </a>
)} )}
<button onClick={() => onOpen(info.name, info.kind, info.node)} title="Castle details" className="text-[var(--muted)] hover:text-[var(--card-foreground)]"> <button onClick={() => onOpen(info.name, info.kind, info.node)} title="Wild PC details" className="text-[var(--muted)] hover:text-[var(--card-foreground)]">
<Maximize2 size={12} /> <Maximize2 size={12} />
</button> </button>
<button onClick={onClose} title="Close" className="text-[var(--muted)] hover:text-[var(--card-foreground)]"> <button onClick={onClose} title="Close" className="text-[var(--muted)] hover:text-[var(--card-foreground)]">
@@ -1580,7 +1593,7 @@ function AddExternalModal({
> >
<h3 className="mb-1 font-semibold">Add external resource</h3> <h3 className="mb-1 font-semibold">Add external resource</h3>
<p className="mb-3 text-xs text-[var(--muted)]"> <p className="mb-3 text-xs text-[var(--muted)]">
An endpoint castle doesn&apos;t run (a SaaS API, a remote service). Draw a consumes edge to An endpoint wildpc doesn&apos;t run (a SaaS API, a remote service). Draw a consumes edge to
it from any deployment. it from any deployment.
</p> </p>
<label className="mb-1 block text-xs text-[var(--muted)]">Name</label> <label className="mb-1 block text-xs text-[var(--muted)]">Name</label>

View File

@@ -1,9 +1,10 @@
import { createBrowserRouter } from "react-router-dom" import { createBrowserRouter, Navigate } from "react-router-dom"
import { Layout } from "@/components/Layout" import { Layout } from "@/components/Layout"
import { Overview } from "@/pages/Overview" import { Overview } from "@/pages/Overview"
import { Services } from "@/pages/Services" import { Services } from "@/pages/Services"
import { Scheduled } from "@/pages/Scheduled" import { Scheduled } from "@/pages/Scheduled"
import { Tools } from "@/pages/Tools" import { Tools } from "@/pages/Tools"
import { Stacks } from "@/pages/Stacks"
import { Programs } from "@/pages/Programs" import { Programs } from "@/pages/Programs"
import { GatewayPage } from "@/pages/GatewayPage" import { GatewayPage } from "@/pages/GatewayPage"
import { MeshPage } from "@/pages/MeshPage" import { MeshPage } from "@/pages/MeshPage"
@@ -25,11 +26,14 @@ export const router = createBrowserRouter([
{ path: "services", element: <Services /> }, { path: "services", element: <Services /> },
{ path: "scheduled", element: <Scheduled /> }, { path: "scheduled", element: <Scheduled /> },
{ path: "tools", element: <Tools /> }, { path: "tools", element: <Tools /> },
{ path: "stacks", element: <Stacks /> },
{ path: "programs", element: <Programs /> }, { path: "programs", element: <Programs /> },
{ path: "gateway", element: <GatewayPage /> }, { path: "gateway", element: <GatewayPage /> },
{ path: "mesh", element: <MeshPage /> }, { path: "mesh", element: <MeshPage /> },
{ path: "secrets", element: <SecretsPage /> }, { path: "secrets", element: <SecretsPage /> },
{ path: "map", element: <SystemMapPage /> }, { path: "system", element: <SystemMapPage /> },
// Back-compat: the page was formerly at /map. Redirect old links/bookmarks.
{ path: "map", element: <Navigate to="/system" replace /> },
{ path: "services/:name", element: <ServiceDetailPage /> }, { path: "services/:name", element: <ServiceDetailPage /> },
{ path: "jobs/:name", element: <ScheduledDetailPage /> }, { path: "jobs/:name", element: <ScheduledDetailPage /> },
{ path: "tools/:name", element: <ToolDetailPage /> }, { path: "tools/:name", element: <ToolDetailPage /> },

View File

@@ -1,6 +1,6 @@
// Resolve the castle-api base URL. The gateway serves each service at its own // Resolve the wildpc-api base URL. The gateway serves each service at its own
// subdomain (<name>.<domain>), so when the dashboard runs at castle.<domain> // subdomain (<name>.<domain>), so when the dashboard runs at wildpc.<domain>
// the API lives at castle-api.<domain> — a cross-origin call (castle-api allows // the API lives at wildpc-api.<domain> — a cross-origin call (wildpc-api allows
// CORS *). When served at a bare host (dev, or the off-mode :9000 gateway), the // CORS *). When served at a bare host (dev, or the off-mode :9000 gateway), the
// API is reachable same-origin at /api. // API is reachable same-origin at /api.
function resolveApiBase(): string { function resolveApiBase(): string {
@@ -10,7 +10,7 @@ function resolveApiBase(): string {
const { protocol, hostname } = window.location const { protocol, hostname } = window.location
const labels = hostname.split(".") const labels = hostname.split(".")
if (labels.length > 2) { if (labels.length > 2) {
return `${protocol}//castle-api.${labels.slice(1).join(".")}` return `${protocol}//wildpc-api.${labels.slice(1).join(".")}`
} }
} }
return "/api" return "/api"
@@ -78,7 +78,7 @@ class ApiClient {
} }
// Build a ws(s):// URL for a WebSocket endpoint. Handles both the cross-origin // Build a ws(s):// URL for a WebSocket endpoint. Handles both the cross-origin
// base (https://castle-api.<domain>) and the same-origin "/api" base. // base (https://wildpc-api.<domain>) and the same-origin "/api" base.
wsUrl(path: string): string { wsUrl(path: string): string {
const absolute = this.baseUrl.startsWith("http") const absolute = this.baseUrl.startsWith("http")
? `${this.baseUrl}${path}` ? `${this.baseUrl}${path}`

View File

@@ -59,7 +59,7 @@ export function useJobs() {
}) })
} }
// The stacks castle has handlers for — authoritative source for the program // The stacks wildpc has handlers for — authoritative source for the program
// stack select, so a new backend stack appears without a frontend change. // stack select, so a new backend stack appears without a frontend change.
export function useStacks() { export function useStacks() {
return useQuery({ return useQuery({
@@ -69,6 +69,36 @@ export function useStacks() {
}) })
} }
// A host toolchain a stack needs + whether it's present where its programs run.
export interface ToolStatus {
command: string
purpose: string
phase: "run" | "build" | "both"
present: boolean
install_hint: string
version: string | null
}
// A stack's dependency health — powers the Stacks page.
export interface StackStatus {
name: string
tools: ToolStatus[]
programs: string[]
deployments: string[]
verbs: string[]
has_enabled_deployment: boolean
in_use: boolean
ok: boolean
}
// Per-stack toolchain health (tools present-where-needed + who uses each stack).
export function useStacksStatus() {
return useQuery({
queryKey: ["stacks", "status"],
queryFn: () => apiClient.get<StackStatus[]>("/stacks/status"),
})
}
export function useJob(name: string) { export function useJob(name: string) {
return useQuery({ return useQuery({
queryKey: ["jobs", name], queryKey: ["jobs", name],
@@ -230,7 +260,7 @@ const REACH_SECTION: Record<string, string> = {
} }
// Create/update an external resource — a `reference` deployment (manager: none) // Create/update an external resource — a `reference` deployment (manager: none)
// that points at an endpoint castle doesn't run (a SaaS API, a remote service). // that points at an endpoint wildpc doesn't run (a SaaS API, a remote service).
// Behind the System Map's "add external resource" authoring. No apply needed — // Behind the System Map's "add external resource" authoring. No apply needed —
// a reference has no runtime unit; it just declares the endpoint. // a reference has no runtime unit; it just declares the endpoint.
export function useSaveReference() { export function useSaveReference() {
@@ -284,7 +314,7 @@ export function useSetReach() {
}) })
} }
// Delete a deployment from castle.yaml (the kind-scoped removal; keeps the // Delete a deployment from wildpc.yaml (the kind-scoped removal; keeps the
// program). Behind the System Map's node deletion. // program). Behind the System Map's node deletion.
export function useDeleteDeployment() { export function useDeleteDeployment() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -385,6 +415,56 @@ export function useProgramSync() {
}) })
} }
// Server-side directory browser for the "Add program" flow. Programs live on the
// server's filesystem, so the picker browses the server's dirs (the browser's own
// file dialog only sees the client machine). `path` null => the repos dir.
export interface BrowseEntry {
name: string
path: string
is_program: boolean
is_git: boolean
}
export interface BrowseResult {
path: string
parent: string | null
repos_dir: string
entries: BrowseEntry[]
}
export function useBrowse(path: string | null, enabled = true) {
return useQuery({
queryKey: ["browse", path ?? "@repos"],
queryFn: () =>
apiClient.get<BrowseResult>(
`/fs/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`,
),
enabled,
})
}
export interface AdoptResult {
ok: boolean
program: string
source: string
stack: string | null
repo: string | null
commands: string[]
is_git_url: boolean
}
// Adopt an existing repo as a program (the web `wildpc program add`). Refreshes
// the catalog + the derived graph/repo views.
export function useAdoptProgram() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { target: string; name?: string; description?: string }) =>
apiClient.post<AdoptResult>("/programs/adopt", body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["programs"] })
qc.invalidateQueries({ queryKey: ["graph"] })
qc.invalidateQueries({ queryKey: ["repos"] })
},
})
}
export function useRepos() { export function useRepos() {
return useQuery({ return useQuery({
queryKey: ["repos"], queryKey: ["repos"],
@@ -428,7 +508,7 @@ export function useSuggestions() {
}) })
} }
// Deployments on other (mesh-discovered) castle nodes, for the multi-node map. // Deployments on other (mesh-discovered) wildpc nodes, for the multi-node map.
export function useMeshDeployments() { export function useMeshDeployments() {
return useQuery({ return useQuery({
queryKey: ["mesh", "deployments"], queryKey: ["mesh", "deployments"],

View File

@@ -163,7 +163,7 @@ export interface GraphSuggestion {
protocol: string protocol: string
} }
// GET /mesh/deployments — deployments on other (mesh-discovered) castle nodes. // GET /mesh/deployments — deployments on other (mesh-discovered) wildpc nodes.
export interface MeshDeployment { export interface MeshDeployment {
name: string name: string
kind: string kind: string
@@ -311,7 +311,7 @@ export interface SSEServiceActionEvent {
export interface NodeSummary { export interface NodeSummary {
hostname: string hostname: string
gateway_port: number gateway_port: number
gateway_domain: string | null // acme domain → dashboard at castle.<domain> gateway_domain: string | null // acme domain → dashboard at wildpc.<domain>
deployed_count: number deployed_count: number
service_count: number service_count: number
is_local: boolean is_local: boolean

View File

@@ -4,8 +4,8 @@ import { defineConfig } from "vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
export default defineConfig({ export default defineConfig({
// Castle sets VITE_BASE to the gateway serve prefix at build time so absolute // Wild PC sets VITE_BASE to the gateway serve prefix at build time so absolute
// asset URLs resolve at the subpath (castle-app is the root app → "/"). // asset URLs resolve at the subpath (wildpc-app is the root app → "/").
base: process.env.VITE_BASE ?? "/", base: process.env.VITE_BASE ?? "/",
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {

View File

@@ -1,9 +1,9 @@
program: castle-api program: wildpc-api
description: Castle API description: Wild PC API
manager: systemd manager: systemd
run: run:
launcher: python launcher: python
program: castle-api program: wildpc-api
expose: expose:
http: http:
internal: internal:
@@ -14,5 +14,5 @@ manage:
systemd: {} systemd: {}
defaults: defaults:
env: env:
CASTLE_API_PORT: ${port} WILDPC_API_PORT: ${port}
CASTLE_API_DATA_DIR: ${data_dir} WILDPC_API_DATA_DIR: ${data_dir}

View File

@@ -1,4 +1,4 @@
# The Caddy gateway. `__SPECS_DIR__` is substituted with $CASTLE_HOME/artifacts/specs # The Caddy gateway. `__SPECS_DIR__` is substituted with $WILDPC_HOME/artifacts/specs
# at install time (install.sh seed_control_plane). For acme TLS, add a # at install time (install.sh seed_control_plane). For acme TLS, add a
# CLOUDFLARE_API_TOKEN to defaults.env later — see docs/dns-and-tls.md. # CLOUDFLARE_API_TOKEN to defaults.env later — see docs/dns-and-tls.md.
description: Caddy reverse proxy gateway description: Caddy reverse proxy gateway

View File

@@ -1,3 +0,0 @@
program: castle
description: Castle web app
manager: caddy

View File

@@ -0,0 +1,3 @@
program: wildpc
description: Wild PC web app
manager: caddy

View File

@@ -1,3 +0,0 @@
description: Castle API
source: repo:castle-api
stack: python-fastapi

View File

@@ -0,0 +1,3 @@
description: Wild PC API
source: repo:wildpc-api
stack: python-fastapi

View File

@@ -1,4 +1,4 @@
description: Castle web app description: Wild PC web app
source: repo:app source: repo:app
stack: react-vite stack: react-vite
build: build:

View File

@@ -1,31 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working
with code in this repository.
## Overview
castle-api is a FastAPI service. Castle API.
## Commands
```bash
uv sync # Install dependencies
uv run castle-api # Run service (port 9020)
uv run pytest tests/ -v # Run tests
uv run ruff check . # Lint
uv run ruff format . # Format
```
## Architecture
- `src/castle_api/config.py` — Settings via pydantic-settings, env prefix `CASTLE_API_`
- `src/castle_api/main.py` — FastAPI app, lifespan, health endpoint
- `tests/` — pytest with TestClient fixtures
## Configuration
Environment variables with `CASTLE_API_` prefix:
- `CASTLE_API_DATA_DIR` — Data directory (default: ./data)
- `CASTLE_API_HOST` — Bind host (default: 0.0.0.0)
- `CASTLE_API_PORT` — Port (default: 9020)

View File

@@ -1,58 +0,0 @@
"""Configuration for castle-api."""
from pathlib import Path
from pydantic_settings import BaseSettings
from castle_core.config import CastleConfig, load_config
from castle_core.registry import NodeRegistry, load_registry
class Settings(BaseSettings):
"""Service settings loaded from environment variables."""
host: str = "0.0.0.0"
port: int = 9020
# Mesh coordination (all off by default — single-node works without them)
nats_enabled: bool = False
nats_url: str = "nats://localhost:4222"
nats_token: str | None = None
mdns_enabled: bool = False
model_config = {
"env_prefix": "CASTLE_API_",
"env_file": ".env",
}
settings = Settings()
def get_registry() -> NodeRegistry:
"""Load the node registry. Raises if not found."""
return load_registry()
def get_castle_root() -> Path | None:
"""Get the castle repo root from the registry, if available."""
try:
registry = load_registry()
if registry.node.castle_root:
return Path(registry.node.castle_root)
except (FileNotFoundError, ValueError):
pass
return None
def get_config() -> CastleConfig:
"""Load castle.yaml via the registry's castle_root.
Raises FileNotFoundError if repo not available.
"""
root = get_castle_root()
if root is None:
raise FileNotFoundError(
"Castle repo not available. Set castle_root in registry."
)
return load_config(root)

View File

@@ -1,158 +0,0 @@
"""Program action endpoints."""
from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter, HTTPException, status
from castle_core import git
from castle_core.stacks import available_actions, available_stacks, run_action
from castle_api import stream
from castle_api.config import get_config
programs_router = APIRouter(tags=["programs"])
@programs_router.get("/stacks")
def list_stacks() -> list[str]:
"""Stack names castle has handlers for — populates the dashboard's stack select
and keeps it in sync with the backend (no hardcoded frontend list)."""
return available_stacks()
# ---------------------------------------------------------------------------
# Git sync — pull a program's source working copy up to date (pull only; no
# build/apply/restart — converge stays an explicit, separate step). Declared
# BEFORE the generic /{action} route below so the literal `git`/`sync` segments
# win over the `{action}` path param (Starlette matches in declaration order).
# ---------------------------------------------------------------------------
def _program_source(name: str):
"""The (program, config) for a named program, or raise the standard 404/400s."""
config = get_config()
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
return config.programs[name], config
@programs_router.get("/programs/{name}/git")
def program_git_status(name: str) -> dict:
"""Git status of a program's working copy (branch, dirty, ahead/behind).
Fetches from the remote first so ``behind`` is current. A program with no
source or a non-git source returns a benign ``{"is_repo": false}`` (not an
error) so the dashboard can simply hide the sync control."""
comp, config = _program_source(name)
if not comp.source:
return {"is_repo": False}
out = asdict(git.git_status(comp.source, fetch=True))
# Repo context: which repo this program's source lives in and who else shares it
# (a monorepo). Sync is a repo operation — the UI labels it and lists siblings.
from castle_core.relations import derive_repos
for key, repo in derive_repos(config).items():
if name in repo.programs:
out["repo"] = {
"key": key,
"programs": repo.programs,
"multi": repo.multi,
"deployments": repo.deployments,
}
break
return out
@programs_router.post("/programs/{name}/sync")
async def program_sync(name: str) -> dict:
"""Fast-forward a program's working copy (``git pull --ff-only``).
Pull-only: it updates the source on disk and reports which deployments may now
need a restart/apply, but does not build, apply, or restart anything itself."""
comp, config = _program_source(name)
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
if not git.is_git_repo(comp.source):
raise HTTPException(
status_code=400, detail=f"'{name}' source is not a git repository"
)
before = git.head(comp.source)
ok, output = git.pull(comp.source)
if not ok:
raise HTTPException(status_code=500, detail=output or "git pull failed")
pulled = git.head(comp.source) != before
deployments = [dname for dname, _ in config.deployments_of(name)]
if pulled:
# Nudge other clients to refresh this program's git status.
await stream.broadcast(
"program-sync", {"program": name, "deployments": deployments}
)
return {
"program": name,
"status": "ok",
"output": output,
"pulled": pulled,
"deployments": deployments if pulled else [],
}
# ---------------------------------------------------------------------------
# Unified program action endpoint
# ---------------------------------------------------------------------------
# Dev verbs only. Activation (install/uninstall of tools/statics) is convergence:
# it happens through `POST /apply`, not as a program action.
_VALID_ACTIONS = {
"build",
"test",
"lint",
"type-check",
"check",
}
@programs_router.post("/programs/{name}/{action}")
async def program_action(name: str, action: str) -> dict:
"""Run a lifecycle action on a program.
Resolution-aware: a declared `commands:` entry overrides the stack default,
so a program with no stack can still be linted/tested/built/installed.
"""
if action not in _VALID_ACTIONS:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
config = get_config()
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
comp = config.programs[name]
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
if action not in available_actions(comp):
raise HTTPException(
status_code=400,
detail=f"Action '{action}' not available for '{name}' "
f"(no declared command and no stack handler provides it)",
)
result = await run_action(action, name, comp, config.root)
if result.status != "ok":
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
return {
"program": result.program,
"action": result.action,
"status": result.status,
"output": result.output,
}

487
castle-api/uv.lock generated
View File

@@ -1,487 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "castle-api"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "castle-cli" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic-settings" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyyaml" },
]
[package.metadata]
requires-dist = [
{ name = "castle-cli", editable = "../cli" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "uvicorn", specifier = ">=0.34.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
]
[[package]]
name = "castle-cli"
version = "0.1.0"
source = { editable = "../cli" }
dependencies = [
{ name = "jinja2" },
{ name = "pydantic" },
{ name = "pyyaml" },
]
[package.metadata]
requires-dist = [
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.11.0" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.129.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "starlette"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]

View File

@@ -1,27 +1,27 @@
[project] [project]
name = "castle-cli" name = "wildpc-cli"
version = "0.1.0" version = "0.1.0"
description = "Castle platform CLI - manage projects, services, and infrastructure" description = "Wild PC platform CLI - manage projects, services, and infrastructure"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"pyyaml>=6.0.0", "pyyaml>=6.0.0",
"jinja2>=3.1.0", "jinja2>=3.1.0",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"castle-core", "wildpc-core",
] ]
[tool.uv.sources] [tool.uv.sources]
castle-core = { workspace = true } wildpc-core = { workspace = true }
[project.scripts] [project.scripts]
castle = "castle_cli.main:main" wildpc = "wildpc_cli.main:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/castle_cli"] packages = ["src/wildpc_cli"]
[dependency-groups] [dependency-groups]
dev = [ dev = [

View File

@@ -1,3 +0,0 @@
"""Castle CLI - manage projects, services, and infrastructure."""
__version__ = "0.1.0"

View File

@@ -1,127 +0,0 @@
"""castle program add — adopt an existing repo as a program (no scaffolding).
`castle program create` makes new code from a stack. `castle 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-castle project becomes usable without writing them by hand.
"""
from __future__ import annotations
import argparse
import tomllib
from pathlib import Path
from castle_cli.config import load_config, save_config
from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec
def _is_git_url(s: str) -> bool:
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
"""Detect (stack, commands) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. `add` adopts source only; the
deployment (and thus kind) is declared separately, so no kind is inferred here.
"""
commands: dict[str, list[list[str]]] = {}
pyproject = src / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
except (OSError, tomllib.TOMLDecodeError):
data = {}
deps = " ".join(data.get("project", {}).get("dependencies", []))
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
return stack, commands
if (src / "Cargo.toml").exists():
commands = {
"build": [["cargo", "build", "--release"]],
"test": [["cargo", "test"]],
"lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]],
}
return None, commands
if (src / "package.json").exists():
commands = {
"build": [["pnpm", "build"]],
"test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]],
}
return None, commands
if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands
return None, commands
def run_add(args: argparse.Namespace) -> int:
"""Adopt an existing repo as a program."""
config = load_config()
target = args.target
repo_url: str | None = None
source: str | None = None
if _is_git_url(target):
repo_url = target
name = args.name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`.
source = str(config.repos_dir / name)
src_path = Path(source)
else:
src_path = Path(target).expanduser().resolve()
if not src_path.exists():
print(f"Error: path does not exist: {src_path}")
return 1
source = str(src_path)
name = args.name or src_path.name
if name in config.programs or config.deployments_named(name):
print(f"Error: '{name}' already exists in castle.yaml")
return 1
# Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so `castle add` adopts the
# source only; declare a deployment separately (castle service/job create).
stack: str | None = None
detected_commands: dict[str, list[list[str]]] = {}
if src_path.exists():
stack, detected_commands = _detect(src_path)
prog = ProgramSpec(
id=name,
description=args.description or f"Adopted from {target}",
source=source,
stack=stack,
repo=repo_url,
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected_commands:
build_cmds = detected_commands.pop("build", None)
if build_cmds:
prog.build = BuildSpec(commands=build_cmds)
if detected_commands:
prog.commands = CommandsSpec.model_validate(detected_commands)
config.programs[name] = prog
save_config(config)
print(f"Adopted '{name}' as a program.")
print(f" source: {source}")
if repo_url:
print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)")
if stack:
print(f" stack: {stack} (verbs inherited from stack defaults)")
elif detected_commands:
print(f" commands detected: {', '.join(sorted(detected_commands))}")
else:
print(" no stack/commands detected — declare verbs in castle.yaml as needed")
return 0

View File

@@ -0,0 +1,3 @@
"""Wild PC CLI - manage projects, services, and infrastructure."""
__version__ = "0.1.0"

View File

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

View File

@@ -1,4 +1,4 @@
"""castle apply — converge the running system to match config. """wildpc apply — converge the running system to match config.
The one workhorse verb: renders systemd units + the Caddyfile + tunnel config, The one workhorse verb: renders systemd units + the Caddyfile + tunnel config,
then reconciles the runtime (activate what's enabled and down, restart what then reconciles the runtime (activate what's enabled and down, restart what
@@ -28,13 +28,21 @@ def _line(verb_color: str, verb: str, names: list[str]) -> None:
def run_apply(args: argparse.Namespace) -> int: def run_apply(args: argparse.Namespace) -> int:
from castle_core.deploy import apply from wildpc_core.deploy import apply
target = getattr(args, "name", None) target = getattr(args, "name", None)
plan = getattr(args, "plan", False) plan = getattr(args, "plan", False)
result = apply(target_name=target, plan=plan) 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). # Surface any warnings the render produced (acme prerequisites, tunnel notes).
for msg in result.messages: for msg in result.messages:
if msg.startswith("Warning"): if msg.startswith("Warning"):
@@ -48,6 +56,8 @@ def run_apply(args: argparse.Namespace) -> int:
_line(_C["activate"], "would activate ", result.activated) _line(_C["activate"], "would activate ", result.activated)
_line(_C["restart"], "would restart ", result.restarted) _line(_C["restart"], "would restart ", result.restarted)
_line(_C["deactivate"], "would deactivate", result.deactivated) _line(_C["deactivate"], "would deactivate", result.deactivated)
if result.gateway_changed:
print(f" {_C['restart']}would reload {_C['reset']} gateway routes")
return 0 return 0
print("\n\033[1mApplied\033[0m") print("\n\033[1mApplied\033[0m")
@@ -57,4 +67,6 @@ def run_apply(args: argparse.Namespace) -> int:
_line(_C["activate"], "activated ", result.activated) _line(_C["activate"], "activated ", result.activated)
_line(_C["restart"], "restarted ", result.restarted) _line(_C["restart"], "restarted ", result.restarted)
_line(_C["deactivate"], "deactivated", result.deactivated) _line(_C["deactivate"], "deactivated", result.deactivated)
if result.gateway_changed:
print(f" {_C['restart']}reloaded {_C['reset']} gateway routes")
return 0 return 0

View File

@@ -1,4 +1,4 @@
"""castle clone — clone source for programs that declare a `repo:` URL. """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 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. local `source:` gets cloned. A program whose source already exists is skipped.
@@ -10,7 +10,7 @@ import argparse
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from castle_cli.config import load_config from wildpc_cli.config import load_config
def _clone_one(name: str, repo: str, source: str | None, ref: str | None, repos_dir: Path) -> bool: def _clone_one(name: str, repo: str, source: str | None, ref: str | None, repos_dir: Path) -> bool:

View File

@@ -1,12 +1,12 @@
"""castle program create — scaffold a new program from templates.""" """wildpc program create — scaffold a new program from templates."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import subprocess import subprocess
from castle_cli.config import load_config, save_config from wildpc_cli.config import load_config, save_config
from castle_cli.manifest import ( from wildpc_cli.manifest import (
BuildSpec, BuildSpec,
CaddyDeployment, CaddyDeployment,
DefaultsSpec, DefaultsSpec,
@@ -21,7 +21,7 @@ from castle_cli.manifest import (
SystemdDeployment, SystemdDeployment,
SystemdSpec, SystemdSpec,
) )
from castle_cli.templates.scaffold import scaffold_project from wildpc_cli.templates.scaffold import scaffold_project
# Stack determines the default deployment kind + scaffold template. # Stack determines the default deployment kind + scaffold template.
STACK_DEFAULTS: dict[str, str] = { STACK_DEFAULTS: dict[str, str] = {
@@ -29,16 +29,23 @@ STACK_DEFAULTS: dict[str, str] = {
"python-cli": "tool", "python-cli": "tool",
"react-vite": "static", "react-vite": "static",
"supabase": "static", "supabase": "static",
"hugo": "static",
} }
# Static build output per stack, for `static` (caddy) deployments. The gateway # Static build output per stack, for `static` (caddy) deployments. The gateway
# serves this dir in place at <name>.<gateway.domain> (no service, no process). # 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/`. # A supabase app ships a raw `public/`; react-vite builds to `dist/`; hugo to `public/`.
STACK_BUILD_OUTPUTS: dict[str, str] = { STACK_BUILD_OUTPUTS: dict[str, str] = {
"supabase": "public", "supabase": "public",
"react-vite": "dist", "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 # 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 # 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 # model: the stack declares the edge once here; the graph only ever reads the encoded
@@ -72,7 +79,7 @@ def run_create(args: argparse.Namespace) -> int:
kind = STACK_DEFAULTS.get(stack) if stack else None kind = STACK_DEFAULTS.get(stack) if stack else None
if name in config.programs or config.deployments_named(name): if name in config.programs or config.deployments_named(name):
print(f"Error: '{name}' already exists in castle.yaml") print(f"Error: '{name}' already exists in wildpc.yaml")
return 1 return 1
config.repos_dir.mkdir(parents=True, exist_ok=True) config.repos_dir.mkdir(parents=True, exist_ok=True)
@@ -87,7 +94,7 @@ def run_create(args: argparse.Namespace) -> int:
port = next_available_port(config) port = next_available_port(config)
package_name = name.replace("-", "_") package_name = name.replace("-", "_")
description = args.description or (f"A castle {stack} program" if stack else f"{name}") description = args.description or (f"A wildpc {stack} program" if stack else f"{name}")
if stack: if stack:
scaffold_project( scaffold_project(
@@ -140,6 +147,7 @@ def run_create(args: argparse.Namespace) -> int:
manager="caddy", manager="caddy",
program=name, program=name,
root=static_root or "dist", root=static_root or "dist",
spa=stack not in CONTENT_SITE_STACKS,
description=description, description=description,
requires=seeded_requires, requires=seeded_requires,
) )
@@ -160,7 +168,7 @@ def run_create(args: argparse.Namespace) -> int:
requires=seeded_requires, requires=seeded_requires,
proxy=True, # expose at <name>.<gateway.domain> proxy=True, # expose at <name>.<gateway.domain>
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
# python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's # python-fastapi scaffold reads env_prefix MY_SERVICE_ — map wildpc's
# computed port/data dir to those vars (explicit, no hidden injection). # computed port/data dir to those vars (explicit, no hidden injection).
defaults=DefaultsSpec( defaults=DefaultsSpec(
env={f"{prefix}_PORT": "${port}", f"{prefix}_DATA_DIR": "${data_dir}"} env={f"{prefix}_PORT": "${port}", f"{prefix}_DATA_DIR": "${data_dir}"}
@@ -173,20 +181,24 @@ def run_create(args: argparse.Namespace) -> int:
print(f"Created {label} '{name}' at {project_dir}") print(f"Created {label} '{name}' at {project_dir}")
if port: if port:
print(f" Port: {port}") print(f" Port: {port}")
print(" Registered in castle.yaml") print(" Registered in wildpc.yaml")
print("\nNext steps:") print("\nNext steps:")
print(f" cd {project_dir}") print(f" cd {project_dir}")
if stack == "supabase": if stack == "supabase":
print(" # edit migrations/, functions/, public/ — targets the shared substrate") print(" # edit migrations/, functions/, public/ — targets the shared substrate")
print(f" castle program build {name} # apply migrations to the substrate") print(f" wildpc program build {name} # apply migrations to the substrate")
print(f" castle apply # serve at {name}.<gateway.domain>") 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: elif stack:
print(" uv sync") print(" uv sync")
if kind == "service": if kind == "service":
print(f" uv run {name} # starts on port {port}") print(f" uv run {name} # starts on port {port}")
print(f" castle apply {name}") print(f" wildpc apply {name}")
print(f" castle test {name}") print(f" wildpc test {name}")
else: else:
print(" # add code, then declare commands: in castle.yaml") print(" # add code, then declare commands: in wildpc.yaml")
return 0 return 0

View File

@@ -1,4 +1,4 @@
"""castle delete — remove a program/deployment from the registry AND tear it down. """wildpc delete — remove a program/deployment from the registry AND tear it down.
Deleting cascades: a program's referencing deployments are taken offline (stop + 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 disable a service/job, uninstall a tool from PATH, drop a static route) before the
@@ -18,7 +18,7 @@ import argparse
import shutil import shutil
from pathlib import Path from pathlib import Path
from castle_cli.config import load_config, save_config from wildpc_cli.config import load_config, save_config
def run_delete(args: argparse.Namespace) -> int: def run_delete(args: argparse.Namespace) -> int:
@@ -35,7 +35,7 @@ def run_delete(args: argparse.Namespace) -> int:
in_deployment = bool(named) and resource in _DEPLOY_RESOURCES in_deployment = bool(named) and resource in _DEPLOY_RESOURCES
if not (in_programs or in_deployment): if not (in_programs or in_deployment):
where = f" {resource}" if resource else "" where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in castle.yaml") print(f"Error: no{where} '{name}' in wildpc.yaml")
return 1 return 1
where = [ where = [
@@ -60,7 +60,7 @@ def run_delete(args: argparse.Namespace) -> int:
# entry itself — its stack may own persistent data (a DB schema) that survives # 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`. # a code delete unless --purge-data drops it via the stack's `teardown`.
public_hosts = _public_hosts(config, deployments_to_remove) public_hosts = _public_hosts(config, deployments_to_remove)
from castle_core.stacks import get_handler from wildpc_core.stacks import get_handler
program_spec = config.programs.get(name) if in_programs else None program_spec = config.programs.get(name) if in_programs else None
stack = program_spec.stack if program_spec else None stack = program_spec.stack if program_spec else None
@@ -73,7 +73,7 @@ def run_delete(args: argparse.Namespace) -> int:
source_dir = Path(config.programs[name].source) source_dir = Path(config.programs[name].source)
purge_data = getattr(args, "purge_data", False) purge_data = getattr(args, "purge_data", False)
print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).") print(f"Will remove '{name}' from wildpc.yaml ({', '.join(where)}).")
if deployments_to_remove: if deployments_to_remove:
print( print(
"Will tear down deployment(s): " "Will tear down deployment(s): "
@@ -100,7 +100,7 @@ def run_delete(args: argparse.Namespace) -> int:
if deployments_to_remove: if deployments_to_remove:
import asyncio import asyncio
from castle_core.lifecycle import deactivate from wildpc_core.lifecycle import deactivate
for kind, d in deployments_to_remove: for kind, d in deployments_to_remove:
try: try:
@@ -114,18 +114,18 @@ def run_delete(args: argparse.Namespace) -> int:
if in_programs: if in_programs:
del config.programs[name] del config.programs[name]
save_config(config) save_config(config)
print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).") print(f"Removed '{name}' from wildpc.yaml ({', '.join(where)}).")
# Converge the runtime: prune orphan units, regenerate the Caddyfile (dropping # Converge the runtime: prune orphan units, regenerate the Caddyfile (dropping
# the static route), reload the gateway + tunnel. # the static route), reload the gateway + tunnel.
if deployments_to_remove: if deployments_to_remove:
from castle_core.deploy import deploy from wildpc_core.deploy import deploy
try: try:
deploy() deploy()
print("Reconciled runtime (castle apply).") print("Reconciled runtime (wildpc apply).")
except Exception as e: except Exception as e:
print(f"warning: reconcile failed — run 'castle apply': {e}") print(f"warning: reconcile failed — run 'wildpc apply': {e}")
# Optional: delete the source directory. # Optional: delete the source directory.
if args.source and source_dir: if args.source and source_dir:
@@ -163,17 +163,20 @@ def run_delete(args: argparse.Namespace) -> int:
def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]: def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]:
"""The Cloudflare CNAMEs (<subdomain>.<public_domain>) of any public deployments """The Cloudflare CNAMEs (a ``public_host`` override, else
being removed surfaced so the operator can clean up DNS.""" <subdomain>.<public_domain>) of any public deployments being removed
surfaced so the operator can clean up DNS."""
gw = getattr(config, "gateway", None) gw = getattr(config, "gateway", None)
public_domain = getattr(gw, "public_domain", None) if gw else None public_domain = getattr(gw, "public_domain", None) if gw else None
if not public_domain:
return []
hosts: list[str] = [] hosts: list[str] = []
for kind, d in deployments: for kind, d in deployments:
spec = config.deployment(kind, d) spec = config.deployment(kind, d)
if spec is None or not getattr(spec, "public", False): if spec is None or not getattr(spec, "public", False):
continue continue
override = getattr(spec, "public_host", None)
if override:
hosts.append(override)
elif public_domain:
sub = getattr(spec, "subdomain", None) or d sub = getattr(spec, "subdomain", None) or d
hosts.append(f"{sub}.{public_domain}") hosts.append(f"{sub}.{public_domain}")
return hosts return hosts

View File

@@ -1,6 +1,6 @@
"""castle service create / castle job create — declare a deployment. """wildpc service create / wildpc job create — declare a deployment.
A service or job can run anything (a castle program or not). `--program` A service or job can run anything (a wildpc program or not). `--program`
records a convenience reference for description fallthrough; the run target is records a convenience reference for description fallthrough; the run target is
the console script (python) or argv (command) to execute. the console script (python) or argv (command) to execute.
""" """
@@ -9,8 +9,8 @@ from __future__ import annotations
import argparse import argparse
from castle_cli.config import load_config, save_config from wildpc_cli.config import load_config, save_config
from castle_cli.manifest import ( from wildpc_cli.manifest import (
DefaultsSpec, DefaultsSpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
@@ -49,7 +49,7 @@ def _check_new(config: object, name: str, label: str) -> str | None:
def run_service_create(args: argparse.Namespace) -> int: def run_service_create(args: argparse.Namespace) -> int:
"""Create a service entry in castle.yaml.""" """Create a service entry in wildpc.yaml."""
config = load_config() config = load_config()
name = args.name name = args.name
if err := _check_new(config, name, "service"): if err := _check_new(config, name, "service"):
@@ -89,12 +89,12 @@ def run_service_create(args: argparse.Namespace) -> int:
print(f" port: {args.port}") print(f" port: {args.port}")
if reach != Reach.OFF: if reach != Reach.OFF:
print(f" subdomain: {name}.<gateway.domain>") print(f" subdomain: {name}.<gateway.domain>")
print(f"\nNext: castle apply {name}") print(f"\nNext: wildpc apply {name}")
return 0 return 0
def run_job_create(args: argparse.Namespace) -> int: def run_job_create(args: argparse.Namespace) -> int:
"""Create a job entry in castle.yaml.""" """Create a job entry in wildpc.yaml."""
config = load_config() config = load_config()
name = args.name name = args.name
if err := _check_new(config, name, "job"): if err := _check_new(config, name, "job"):
@@ -119,5 +119,5 @@ def run_job_create(args: argparse.Namespace) -> int:
print(f"Created job '{name}'.") print(f"Created job '{name}'.")
print(f" runs: {args.launcher} ({args.run or args.program or name})") print(f" runs: {args.launcher} ({args.run or args.program or name})")
print(f" schedule: {args.schedule}") print(f" schedule: {args.schedule}")
print(f"\nNext: castle apply {name}") print(f"\nNext: wildpc apply {name}")
return 0 return 0

View File

@@ -1,4 +1,4 @@
"""castle build/test/lint/type-check/check/run/install/uninstall — dev verbs. """wildpc build/test/lint/type-check/check/run/install/uninstall — dev verbs.
Verbs resolve per-program: a declared command (manifest `commands:` / `build:`) Verbs resolve per-program: a declared command (manifest `commands:` / `build:`)
overrides the stack default, falling back to the stack handler, else unavailable. overrides the stack default, falling back to the stack handler, else unavailable.
@@ -9,12 +9,12 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
from castle_core.stacks import is_available, run_action from wildpc_core.stacks import is_available, run_action
from castle_cli.config import CastleConfig, load_config from wildpc_cli.config import WildpcConfig, load_config
def _run_verb(config: CastleConfig, project_name: str, verb: str) -> bool: def _run_verb(config: WildpcConfig, project_name: str, verb: str) -> bool:
"""Run a verb for a single program. Returns True on success.""" """Run a verb for a single program. Returns True on success."""
if project_name not in config.programs: if project_name not in config.programs:
print(f"Unknown program: {project_name}") print(f"Unknown program: {project_name}")
@@ -39,7 +39,7 @@ def _run_verb(config: CastleConfig, project_name: str, verb: str) -> bool:
return result.status == "ok" return result.status == "ok"
def _run_verb_all(config: CastleConfig, verb: str) -> bool: def _run_verb_all(config: WildpcConfig, verb: str) -> bool:
"""Run a verb across every program that supports it. Returns True if all pass.""" """Run a verb across every program that supports it. Returns True if all pass."""
all_passed = True all_passed = True
ran_any = False ran_any = False
@@ -89,10 +89,10 @@ def run_check(args: argparse.Namespace) -> int:
return run_verb(args, "check") return run_verb(args, "check")
def _lifecycle(config: CastleConfig, name: str, deactivate: bool) -> bool: def _lifecycle(config: WildpcConfig, name: str, deactivate: bool) -> bool:
"""Activate or deactivate a single program. Returns True on success.""" """Activate or deactivate a single program. Returns True on success."""
from castle_core.lifecycle import activate from wildpc_core.lifecycle import activate
from castle_core.lifecycle import deactivate as do_deactivate 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: if name not in config.programs and name not in config.services and name not in config.jobs:
print(f"Unknown program: {name}") print(f"Unknown program: {name}")

View File

@@ -1,4 +1,4 @@
"""castle doctor — diagnose whether this node is set up and running. """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 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 correctly configured, and if not, what's the exact next command?* Runs a series
@@ -6,7 +6,7 @@ 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. 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 Exit code: 0 when nothing FAILed (warnings are allowed), 1 otherwise so it
doubles as a scriptable smoke test after `./install.sh` or `castle apply`. doubles as a scriptable smoke test after `./install.sh` or `wildpc apply`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,9 +26,9 @@ _ICON = {
FAIL: "\033[31m✗\033[0m", FAIL: "\033[31m✗\033[0m",
} }
_GATEWAY = "castle-gateway" _GATEWAY = "wildpc-gateway"
_API = "castle-api" _API = "wildpc-api"
_DASHBOARD = "castle" _DASHBOARD = "wildpc"
@dataclass @dataclass
@@ -62,13 +62,13 @@ def _port_open(port: int, host: str = "127.0.0.1") -> bool:
def _check_environment() -> list[Check]: def _check_environment() -> list[Check]:
checks: list[Check] = [] checks: list[Check] = []
if shutil.which("castle"): if shutil.which("wildpc"):
checks.append(Check(OK, "castle CLI on PATH")) checks.append(Check(OK, "wildpc CLI on PATH"))
else: else:
checks.append( checks.append(
Check( Check(
WARN, WARN,
"castle CLI not on PATH", "wildpc CLI not on PATH",
hint="ensure ~/.local/bin is on PATH (uv tool install target)", hint="ensure ~/.local/bin is on PATH (uv tool install target)",
) )
) )
@@ -119,7 +119,7 @@ def _check_configuration(config) -> list[Check]:
checks.append( checks.append(
Check( Check(
OK, OK,
"castle.yaml loaded", "wildpc.yaml loaded",
detail=f"gateway :{gw.port}, tls={tls}" detail=f"gateway :{gw.port}, tls={tls}"
+ (f", domain={gw.domain}" if gw.domain else ""), + (f", domain={gw.domain}" if gw.domain else ""),
) )
@@ -131,9 +131,9 @@ def _check_configuration(config) -> list[Check]:
checks.append( checks.append(
Check( Check(
FAIL, FAIL,
"repo: not set in castle.yaml", "repo: not set in wildpc.yaml",
detail="source: repo:<name> cannot resolve castle's own programs", detail="source: repo:<name> cannot resolve wildpc's own programs",
hint="add 'repo: <path-to-castle-checkout>' to ~/.castle/castle.yaml", hint="add 'repo: <path-to-wildpc-checkout>' to ~/.wildpc/wildpc.yaml",
) )
) )
@@ -148,22 +148,22 @@ def _check_configuration(config) -> list[Check]:
FAIL, FAIL,
"data dir missing or not writable", "data dir missing or not writable",
detail=str(ddir), detail=str(ddir),
hint=f"set data_dir: in ~/.castle/castle.yaml, or: " hint=f"set data_dir: in ~/.wildpc/wildpc.yaml, or: "
f"sudo mkdir -p {ddir} && sudo chown $(id -un) {ddir}", f"sudo mkdir -p {ddir} && sudo chown $(id -un) {ddir}",
) )
) )
# Drift guard: castle.yaml is the single source of truth for the roots. An env var # 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 # 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). # diverge (env set in your shell, absent in the service unit — the original bug).
for var in ("CASTLE_DATA_DIR", "CASTLE_REPOS_DIR"): for var in ("WILDPC_DATA_DIR", "WILDPC_REPOS_DIR"):
if var in os.environ: if var in os.environ:
checks.append( checks.append(
Check( Check(
WARN, WARN,
f"{var} overrides castle.yaml", f"{var} overrides wildpc.yaml",
detail=f"{var}={os.environ[var]}", detail=f"{var}={os.environ[var]}",
hint=f"set data_dir:/repos_dir: in castle.yaml and unset {var}, so " hint=f"set data_dir:/repos_dir: in wildpc.yaml and unset {var}, so "
"every process (CLI and api) resolves the same roots", "every process (CLI and api) resolves the same roots",
) )
) )
@@ -186,7 +186,7 @@ def _check_configuration(config) -> list[Check]:
def _check_dashboard_built(config) -> Check: def _check_dashboard_built(config) -> Check:
from castle_core.lifecycle import _static_built from wildpc_core.lifecycle import _static_built
if not config.deployments_named(_DASHBOARD): if not config.deployments_named(_DASHBOARD):
return Check(WARN, "dashboard not registered", detail="skipping build check") return Check(WARN, "dashboard not registered", detail="skipping build check")
@@ -196,7 +196,7 @@ def _check_dashboard_built(config) -> Check:
WARN, WARN,
"dashboard not built", "dashboard not built",
detail="gateway has no UI to serve at /", detail="gateway has no UI to serve at /",
hint=f"castle program build {_DASHBOARD}", hint=f"wildpc program build {_DASHBOARD}",
) )
@@ -212,8 +212,8 @@ def _deployment_port(config, name: str) -> int | None:
def _check_runtime(config) -> list[Check]: def _check_runtime(config) -> list[Check]:
from castle_core.config import SPECS_DIR from wildpc_core.config import SPECS_DIR
from castle_core.lifecycle import is_active from wildpc_core.lifecycle import is_active
checks: list[Check] = [] checks: list[Check] = []
@@ -228,7 +228,7 @@ def _check_runtime(config) -> list[Check]:
WARN, WARN,
"gateway active but not listening", "gateway active but not listening",
detail=f":{gw_port} refused", detail=f":{gw_port} refused",
hint="castle gateway reload; check 'castle service logs castle-gateway'", hint="wildpc gateway reload; check 'wildpc service logs wildpc-gateway'",
) )
) )
else: else:
@@ -236,7 +236,7 @@ def _check_runtime(config) -> list[Check]:
Check( Check(
FAIL, FAIL,
"gateway not running", "gateway not running",
hint="castle apply", hint="wildpc apply",
) )
) )
@@ -247,16 +247,16 @@ def _check_runtime(config) -> list[Check]:
checks.append( checks.append(
Check( Check(
WARN, WARN,
"castle-api active but not listening", "wildpc-api active but not listening",
detail=f":{api_port} refused", detail=f":{api_port} refused",
hint="castle service logs castle-api", hint="wildpc service logs wildpc-api",
) )
) )
else: else:
detail = f"listening on :{api_port}" if api_port else "" detail = f"listening on :{api_port}" if api_port else ""
checks.append(Check(OK, "castle-api running", detail=detail)) checks.append(Check(OK, "wildpc-api running", detail=detail))
else: else:
checks.append(Check(FAIL, "castle-api not running", hint="castle apply")) checks.append(Check(FAIL, "wildpc-api not running", hint="wildpc apply"))
# Generated artifacts. # Generated artifacts.
registry = SPECS_DIR / "registry.yaml" registry = SPECS_DIR / "registry.yaml"
@@ -270,7 +270,7 @@ def _check_runtime(config) -> list[Check]:
FAIL, FAIL,
"generated specs missing", "generated specs missing",
detail=", ".join(missing), detail=", ".join(missing),
hint="castle apply", hint="wildpc apply",
) )
) )
@@ -293,7 +293,7 @@ def _check_tls_exposure(config) -> list[Check]:
Check( Check(
FAIL, FAIL,
"tls=acme but no domain set", "tls=acme but no domain set",
hint="add 'domain: <your-zone>' under gateway: in castle.yaml", hint="add 'domain: <your-zone>' under gateway: in wildpc.yaml",
) )
) )
@@ -327,7 +327,7 @@ def _check_tls_exposure(config) -> list[Check]:
token_name = {"cloudflare": "CLOUDFLARE_API_TOKEN"}.get( token_name = {"cloudflare": "CLOUDFLARE_API_TOKEN"}.get(
provider, f"{provider.upper()}_API_TOKEN" provider, f"{provider.upper()}_API_TOKEN"
) )
from castle_core.config import read_secret from wildpc_core.config import read_secret
if read_secret(token_name): if read_secret(token_name):
checks.append(Check(OK, "provider token present", detail=token_name)) checks.append(Check(OK, "provider token present", detail=token_name))
@@ -345,15 +345,20 @@ def _check_tls_exposure(config) -> list[Check]:
checks.append(_check_privileged_ports()) checks.append(_check_privileged_ports())
# Public exposure (only relevant if a deployment opts in). # Public exposure (only relevant if a deployment opts in).
public = [n for _k, n, d in config.all_deployments() if getattr(d, "public", False)] 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: if public:
from castle_core.lifecycle import is_active from wildpc_core.lifecycle import is_active
if gw.public_domain and gw.tunnel_id: # 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)")) checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)"))
else: else:
missing = [] missing = []
if not gw.public_domain: if need_default and not gw.public_domain:
missing.append("public_domain") missing.append("public_domain")
if not gw.tunnel_id: if not gw.tunnel_id:
missing.append("tunnel_id") missing.append("tunnel_id")
@@ -365,13 +370,13 @@ def _check_tls_exposure(config) -> list[Check]:
hint="see docs/tunnel-setup.md", hint="see docs/tunnel-setup.md",
) )
) )
if not is_active("castle-tunnel", "service", config): if not is_active("wildpc-tunnel", "service", config):
checks.append( checks.append(
Check( Check(
WARN, WARN,
"castle-tunnel not running", "wildpc-tunnel not running",
detail="public routes are down", detail="public routes are down",
hint="enable castle-tunnel in its deployment, then: castle apply", hint="enable wildpc-tunnel in its deployment, then: wildpc apply",
) )
) )
checks.append(_check_public_dns(config)) checks.append(_check_public_dns(config))
@@ -382,18 +387,18 @@ def _check_tls_exposure(config) -> list[Check]:
def _check_public_dns(config) -> Check: def _check_public_dns(config) -> Check:
"""Whether Castle can manage the public CNAMEs itself. """Whether Wild PC can manage the public CNAMEs itself.
Read-only: confirms the token exists and can reach the public zone + its DNS 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 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 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 passes this probe. Write itself isn't exercised (that would mutate); a
DNS:Read-only token would false-pass here but `castle apply` then surfaces 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. 403 with the fix. Absent token WARN (CNAMEs stay manual), not a failure.
""" """
import urllib.error import urllib.error
from castle_core.generators.dns import PUBLIC_DNS_TOKEN, _api, public_dns_token from wildpc_core.generators.dns import PUBLIC_DNS_TOKEN, _api, public_dns_token
gw = config.gateway gw = config.gateway
token = public_dns_token() token = public_dns_token()
@@ -403,20 +408,26 @@ def _check_public_dns(config) -> Check:
"public DNS not automated", "public DNS not automated",
detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual", detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual",
hint=( hint=(
f"add a Cloudflare token with DNS:Edit on {gw.public_domain} " f"add a Cloudflare token with DNS:Edit on "
f"('Edit zone DNS' template) → ~/.castle/secrets/{PUBLIC_DNS_TOKEN} " 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)" "(else route each host by hand)"
), ),
) )
try: try:
zres = (_api(token, "GET", f"/zones?name={gw.public_domain}").get("result")) or [] # 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: if not zres:
where = gw.public_domain or "any zone"
return Check( return Check(
FAIL, FAIL,
"public DNS token can't see the zone", "public DNS token can't see the zone",
detail=f"{gw.public_domain} not visible", detail=f"{where} not visible",
hint=( hint=(
f"token needs DNS:Edit scoped to {gw.public_domain}, in that " f"token needs DNS:Edit scoped to {where}, in that "
"zone's account ('Edit zone DNS' template)" "zone's account ('Edit zone DNS' template)"
), ),
) )
@@ -436,7 +447,7 @@ def _check_public_dns(config) -> Check:
return Check( return Check(
OK, OK,
"public DNS token valid", "public DNS token valid",
detail=f"can reach {gw.public_domain} + its records", detail=f"can reach {gw.public_domain or 'its zones'} + records",
) )
@@ -452,17 +463,50 @@ def _check_privileged_ports() -> Check:
"cannot bind :80/:443", "cannot bind :80/:443",
detail=f"unprivileged floor is {val}", detail=f"unprivileged floor is {val}",
hint="echo 'net.ipv4.ip_unprivileged_port_start=80' | " hint="echo 'net.ipv4.ip_unprivileged_port_start=80' | "
"sudo tee /etc/sysctl.d/50-castle-gateway.conf && sudo sysctl --system", "sudo tee /etc/sysctl.d/50-wildpc-gateway.conf && sudo sysctl --system",
) )
# --- Driver ----------------------------------------------------------------- # --- Driver -----------------------------------------------------------------
def run_doctor(args: argparse.Namespace) -> int: def _check_stacks(config: object) -> list[Check]:
from castle_core.config import load_config """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
print("\n\033[1mCastle Doctor\033[0m") 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: try:
config = load_config() config = load_config()
@@ -471,9 +515,9 @@ def run_doctor(args: argparse.Namespace) -> int:
_print( _print(
Check( Check(
FAIL, FAIL,
"castle.yaml failed to load", "wildpc.yaml failed to load",
detail=str(exc), detail=str(exc),
hint="check ~/.castle/castle.yaml — re-run ./install.sh to reseed", hint="check ~/.wildpc/wildpc.yaml — re-run ./install.sh to reseed",
) )
) )
print() print()
@@ -482,6 +526,7 @@ def run_doctor(args: argparse.Namespace) -> int:
sections: list[tuple[str, list[Check]]] = [ sections: list[tuple[str, list[Check]]] = [
("Environment", _check_environment()), ("Environment", _check_environment()),
("Configuration", _check_configuration(config)), ("Configuration", _check_configuration(config)),
("Stacks & dependencies", _check_stacks(config)),
("Runtime", _check_runtime(config)), ("Runtime", _check_runtime(config)),
("TLS & exposure", _check_tls_exposure(config)), ("TLS & exposure", _check_tls_exposure(config)),
] ]
@@ -499,7 +544,7 @@ def run_doctor(args: argparse.Namespace) -> int:
print(f"\033[31m{fails} problem(s)\033[0m" + (f", {warns} warning(s)" if warns else "")) print(f"\033[31m{fails} problem(s)\033[0m" + (f", {warns} warning(s)" if warns else ""))
return 1 return 1
if warns: if warns:
print(f"\033[33m{warns} warning(s)\033[0m — Castle is up; address when convenient") print(f"\033[33m{warns} warning(s)\033[0m — Wild PC is up; address when convenient")
return 0 return 0
print("\033[32mAll checks passed.\033[0m") print("\033[32mAll checks passed.\033[0m")
return 0 return 0

View File

@@ -1,8 +1,8 @@
"""castle gateway — inspect the Caddy reverse proxy gateway. """wildpc gateway — inspect the Caddy reverse proxy gateway.
The gateway is itself a deployment (`castle-gateway`): start/stop/reload it the The gateway is itself a deployment (`wildpc-gateway`): start/stop/reload it the
same way as anything else `castle apply` (render routes + reload), `castle same way as anything else `wildpc apply` (render routes + reload), `wildpc
restart castle-gateway` (bounce), or `enabled: false` + apply (stop). This command 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. is the read-only inspection lens: is it up, and what's the route table.
""" """
@@ -11,9 +11,9 @@ from __future__ import annotations
import argparse import argparse
import subprocess import subprocess
from castle_core.registry import REGISTRY_PATH, load_registry from wildpc_core.registry import REGISTRY_PATH, load_registry
GATEWAY_UNIT = "castle-castle-gateway.service" GATEWAY_UNIT = "wildpc-wildpc-gateway.service"
def run_gateway(args: argparse.Namespace) -> int: def run_gateway(args: argparse.Namespace) -> int:
@@ -32,10 +32,10 @@ def _gateway_status() -> int:
print(f"Gateway: {'running' if status == 'active' else status}") print(f"Gateway: {'running' if status == 'active' else status}")
if not REGISTRY_PATH.exists(): if not REGISTRY_PATH.exists():
print(" (no registry — run 'castle apply')") print(" (no registry — run 'wildpc apply')")
return 0 return 0
from castle_core.generators.caddyfile import compute_routes from wildpc_core.generators.caddyfile import compute_routes
routes = compute_routes(load_registry()) routes = compute_routes(load_registry())
if not routes: if not routes:

View File

@@ -1,4 +1,4 @@
"""castle graph — the relationship model: repos, `requires` edges, derived status. """wildpc graph — the relationship model: repos, `requires` edges, derived status.
A read-only diagnostic (see docs/relationships.md). Nothing here is stored repos 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. come from git, predicates (functional/fresh/deployed) are computed on the fly.
@@ -10,14 +10,14 @@ import argparse
import dataclasses import dataclasses
import json import json
from castle_cli.config import load_config from wildpc_cli.config import load_config
BOLD, DIM, RESET = "\033[1m", "\033[2m", "\033[0m" BOLD, DIM, RESET = "\033[1m", "\033[2m", "\033[0m"
GREEN, RED, YELLOW, CYAN = "\033[32m", "\033[31m", "\033[33m", "\033[36m" GREEN, RED, YELLOW, CYAN = "\033[32m", "\033[31m", "\033[33m", "\033[36m"
def run_graph(args: argparse.Namespace) -> int: def run_graph(args: argparse.Namespace) -> int:
from castle_core.relations import build_model from wildpc_core.relations import build_model
config = load_config() config = load_config()
model = build_model(config, check=True, freshness=True) model = build_model(config, check=True, freshness=True)

View File

@@ -1,4 +1,4 @@
"""castle info - show detailed program information.""" """wildpc info - show detailed program information."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,7 @@ import argparse
import json import json
from pathlib import Path from pathlib import Path
from castle_cli.config import load_config from wildpc_cli.config import load_config
# Terminal colors # Terminal colors
BOLD = "\033[1m" BOLD = "\033[1m"
@@ -20,7 +20,7 @@ RED = "\033[91m"
def _load_deployed_program(name: str) -> object | None: def _load_deployed_program(name: str) -> object | None:
"""Try to load a specific deployed program from registry.""" """Try to load a specific deployed program from registry."""
try: try:
from castle_core.registry import load_registry from wildpc_core.registry import load_registry
registry = load_registry() registry = load_registry()
return next(iter(registry.named(name)), None) return next(iter(registry.named(name)), None)
@@ -41,7 +41,7 @@ def run_info(args: argparse.Namespace) -> int:
if not program and not service and not job: if not program and not service and not job:
where = f" {resource}" if resource else "" where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in castle.yaml") print(f"Error: no{where} '{name}' in wildpc.yaml")
return 1 return 1
deployed = _load_deployed_program(name) deployed = _load_deployed_program(name)
@@ -153,7 +153,7 @@ def run_info(args: argparse.Namespace) -> int:
if deployed.secret_env_keys: if deployed.secret_env_keys:
print(f" {BOLD}secrets{RESET}: {', '.join(deployed.secret_env_keys)}") print(f" {BOLD}secrets{RESET}: {', '.join(deployed.secret_env_keys)}")
else: else:
print(f"\n {DIM}not applied (run 'castle apply'){RESET}") print(f"\n {DIM}not applied (run 'wildpc apply'){RESET}")
# Show CLAUDE.md if it exists # Show CLAUDE.md if it exists
source_dir = None source_dir = None

View File

@@ -1,4 +1,4 @@
"""castle list - the program catalog plus every deployment view (services, jobs, """wildpc list - the program catalog plus every deployment view (services, jobs,
tools, static).""" tools, static)."""
from __future__ import annotations from __future__ import annotations
@@ -7,7 +7,7 @@ import argparse
import json import json
import logging import logging
from castle_cli.config import load_config from wildpc_cli.config import load_config
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -75,7 +75,7 @@ def run_list(args: argparse.Namespace) -> int:
and the **Services**/**Jobs** deployment views. `--kind` filters the catalog and the **Services**/**Jobs** deployment views. `--kind` filters the catalog
by a program's derived kind (service/job/tool/static/reference). by a program's derived kind (service/job/tool/static/reference).
""" """
from castle_core.lifecycle import is_active from wildpc_core.lifecycle import is_active
config = load_config() config = load_config()
@@ -199,7 +199,7 @@ def _list_json(
filter_stack: str | None, filter_stack: str | None,
) -> int: ) -> int:
"""Output JSON: the program catalog (kind-filterable) plus deployments.""" """Output JSON: the program catalog (kind-filterable) plus deployments."""
from castle_core.lifecycle import is_active from wildpc_core.lifecycle import is_active
output = [] output = []

View File

@@ -1,12 +1,12 @@
"""castle logs - view component logs.""" """wildpc logs - view component logs."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import subprocess import subprocess
from castle_cli.commands.service import UNIT_PREFIX from wildpc_cli.commands.service import UNIT_PREFIX
from castle_cli.config import load_config from wildpc_cli.config import load_config
def run_logs(args: argparse.Namespace) -> int: def run_logs(args: argparse.Namespace) -> int:
@@ -51,7 +51,7 @@ def _compose_logs(name: str, svc: object, args: argparse.Namespace) -> int:
import shutil import shutil
runtime = shutil.which("docker") or shutil.which("podman") or "docker" runtime = shutil.which("docker") or shutil.which("podman") or "docker"
project = getattr(svc.run, "project_name", None) or f"castle-{name}" # type: ignore[attr-defined] project = getattr(svc.run, "project_name", None) or f"wildpc-{name}" # type: ignore[attr-defined]
cmd = [runtime, "compose", "-p", project, "logs"] cmd = [runtime, "compose", "-p", project, "logs"]
lines = getattr(args, "lines", 50) lines = getattr(args, "lines", 50)
@@ -70,7 +70,7 @@ def _container_logs(name: str, args: argparse.Namespace) -> int:
import shutil import shutil
runtime = shutil.which("podman") or shutil.which("docker") or "podman" runtime = shutil.which("podman") or shutil.which("docker") or "podman"
container_name = f"castle-{name}" container_name = f"wildpc-{name}"
cmd = [runtime, "logs"] cmd = [runtime, "logs"]
lines = getattr(args, "lines", 50) lines = getattr(args, "lines", 50)

View File

@@ -1,6 +1,6 @@
"""castle mesh — inspect the NATS mesh and manage shared config. """wildpc mesh — inspect the NATS mesh and manage shared config.
The mesh lives in the running castle-api (it holds the live peer state), so this 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. command talks to the local API over HTTP rather than reading files.
""" """
@@ -15,10 +15,10 @@ import urllib.request
def _api_base() -> str: def _api_base() -> str:
port = None port = None
try: try:
from castle_core.config import load_config from wildpc_core.config import load_config
config = load_config() config = load_config()
dep = next((d for _k, d in config.deployments_named("castle-api")), None) dep = next((d for _k, d in config.deployments_named("wildpc-api")), None)
internal = getattr(getattr(getattr(dep, "expose", None), "http", None), "internal", None) internal = getattr(getattr(getattr(dep, "expose", None), "http", None), "internal", None)
port = getattr(internal, "port", None) port = getattr(internal, "port", None)
except Exception: except Exception:
@@ -56,7 +56,7 @@ def run_mesh(args: argparse.Namespace) -> int:
print(f"error: HTTP {e.code}{detail}") print(f"error: HTTP {e.code}{detail}")
return 1 return 1
except urllib.error.URLError as e: except urllib.error.URLError as e:
print(f"castle-api not reachable ({e.reason}). Is it running + mesh enabled?") print(f"wildpc-api not reachable ({e.reason}). Is it running + mesh enabled?")
return 1 return 1
print(f"unknown mesh command: {sub}") print(f"unknown mesh command: {sub}")
return 2 return 2

View File

@@ -1,4 +1,4 @@
"""castle run - run a program or service in the foreground. """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 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 foreground (dev-run). Otherwise fall back to the deployed-service run from
@@ -12,10 +12,10 @@ import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from castle_core.generators.systemd import secret_env_path from wildpc_core.generators.systemd import secret_env_path
from castle_core.registry import REGISTRY_PATH, load_registry from wildpc_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import load_config from wildpc_cli.config import load_config
def _load_secret_env(name: str) -> dict[str, str]: def _load_secret_env(name: str) -> dict[str, str]:
@@ -82,13 +82,13 @@ def run_run(args: argparse.Namespace) -> int:
# Service: deployed command from the registry. # Service: deployed command from the registry.
if not REGISTRY_PATH.exists(): if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle apply' first.") print("Error: no registry found. Run 'wildpc apply' first.")
return 1 return 1
registry = load_registry() registry = load_registry()
matches = registry.named(name) matches = registry.named(name)
if not matches: if not matches:
print(f"Error: '{name}' is not a deployed service. Run 'castle apply' first.") print(f"Error: '{name}' is not a deployed service. Run 'wildpc apply' first.")
return 1 return 1
# A name may span kinds; run the one that has a run command (service/job). # A name may span kinds; run the one that has a run command (service/job).

View File

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

View File

@@ -1,23 +1,23 @@
"""castle service / castle job — manage systemd service & timer units.""" """wildpc service / wildpc job — manage systemd service & timer units."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import subprocess import subprocess
from castle_core.generators.systemd import ( from wildpc_core.generators.systemd import (
SYSTEMD_USER_DIR, SYSTEMD_USER_DIR,
timer_name, timer_name,
unit_name, unit_name,
) )
from castle_cli.config import ( from wildpc_cli.config import (
CastleConfig, WildpcConfig,
load_config, load_config,
) )
# Re-export for use by other commands # Re-export for use by other commands
UNIT_PREFIX = "castle-" UNIT_PREFIX = "wildpc-"
def _install_unit(uname: str, content: str) -> None: def _install_unit(uname: str, content: str) -> None:
@@ -40,25 +40,25 @@ _PAST = {"start": "started", "stop": "stopped", "restart": "restarted"}
def run_service_cmd(args: argparse.Namespace) -> int: def run_service_cmd(args: argparse.Namespace) -> int:
"""`castle service restart <name>` — the imperative bounce (only verb left). """`wildpc service restart <name>` — the imperative bounce (only verb left).
Lifecycle (deploy/enable/disable/start/stop) is now convergence: `castle apply`. Lifecycle (deploy/enable/disable/start/stop) is now convergence: `wildpc apply`.
""" """
config = load_config() config = load_config()
return _unit_action(config, args.name, "restart", "service") return _unit_action(config, args.name, "restart", "service")
def run_job_cmd(args: argparse.Namespace) -> int: def run_job_cmd(args: argparse.Namespace) -> int:
"""`castle job restart <name>` — bounce the job's timer.""" """`wildpc job restart <name>` — bounce the job's timer."""
config = load_config() config = load_config()
return _unit_action(config, args.name, "restart", "job") return _unit_action(config, args.name, "restart", "job")
def run_restart(args: argparse.Namespace) -> int: def run_restart(args: argparse.Namespace) -> int:
"""Top-level `castle restart [name]` — bounce one deployment, or all of them. """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 An imperative op: it re-actualizes current desired state, it does not change it
(that's `castle apply`). A bare name bounces every kind sharing it. (that's `wildpc apply`). A bare name bounces every kind sharing it.
""" """
config = load_config() config = load_config()
name = getattr(args, "name", None) name = getattr(args, "name", None)
@@ -74,10 +74,10 @@ def run_restart(args: argparse.Namespace) -> int:
return rc return rc
_GATEWAY_NAME = "castle-gateway" _GATEWAY_NAME = "wildpc-gateway"
def _unit_action(config: CastleConfig, name: str, action: str, kind: str) -> int: def _unit_action(config: WildpcConfig, name: str, action: str, kind: str) -> int:
"""start/stop/restart one deployment (name, kind), dispatched by its manager. """start/stop/restart one deployment (name, kind), dispatched by its manager.
systemd (a process/timer) `systemctl`; caddy (static) reload the gateway; systemd (a process/timer) `systemctl`; caddy (static) reload the gateway;
@@ -101,7 +101,7 @@ def _unit_action(config: CastleConfig, name: str, action: str, kind: str) -> int
def _managed_lifecycle( def _managed_lifecycle(
config: CastleConfig, name: str, action: str, manager: str, kind: str config: WildpcConfig, name: str, action: str, manager: str, kind: str
) -> int: ) -> int:
"""Lifecycle for non-systemd managers (no unit to systemctl).""" """Lifecycle for non-systemd managers (no unit to systemctl)."""
if manager == "caddy": if manager == "caddy":
@@ -119,11 +119,11 @@ def _managed_lifecycle(
return 0 return 0
def _path_lifecycle(config: CastleConfig, name: str, action: str, kind: str) -> int: def _path_lifecycle(config: WildpcConfig, name: str, action: str, kind: str) -> int:
"""A `path` (tool) deployment's lifecycle is install/uninstall on PATH.""" """A `path` (tool) deployment's lifecycle is install/uninstall on PATH."""
import asyncio import asyncio
from castle_core.lifecycle import activate, deactivate from wildpc_core.lifecycle import activate, deactivate
# stop → uninstall; start/restart → ensure installed (activate skips if on PATH). # stop → uninstall; start/restart → ensure installed (activate skips if on PATH).
coro = deactivate if action == "stop" else activate coro = deactivate if action == "stop" else activate
@@ -132,7 +132,7 @@ def _path_lifecycle(config: CastleConfig, name: str, action: str, kind: str) ->
return 0 if res.status == "ok" else 1 return 0 if res.status == "ok" else 1
def _services_restart(config: CastleConfig) -> int: def _services_restart(config: WildpcConfig) -> int:
"""Restart every systemd-managed deployment (service or job) unit. """Restart every systemd-managed deployment (service or job) unit.
caddy/path/none deployments have no unit they ride along with the gateway caddy/path/none deployments have no unit they ride along with the gateway
@@ -152,7 +152,7 @@ def _services_restart(config: CastleConfig) -> int:
def run_status(args: argparse.Namespace) -> int: def run_status(args: argparse.Namespace) -> int:
"""Unified status across the platform: services + jobs + programs.""" """Unified status across the platform: services + jobs + programs."""
from castle_core.lifecycle import is_active from wildpc_core.lifecycle import is_active
config = load_config() config = load_config()
@@ -180,11 +180,11 @@ def run_status(args: argparse.Namespace) -> int:
return 0 return 0
def _service_status(config: CastleConfig) -> int: def _service_status(config: WildpcConfig) -> int:
"""Show status of all services and jobs, dispatched by manager.""" """Show status of all services and jobs, dispatched by manager."""
from castle_core.lifecycle import is_active from wildpc_core.lifecycle import is_active
print("\nCastle Services") print("\nWild PC Services")
print("=" * 50) print("=" * 50)
for name, svc in config.services.items(): for name, svc in config.services.items():

View File

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

View File

@@ -1,4 +1,4 @@
"""castle tls — castle-managed TLS certs for raw-TCP services. """wildpc tls — wildpc-managed TLS certs for raw-TCP services.
Each TLS-material TCP service (postgres, redis, ) gets the gateway's ACME 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. wildcard cert cut onto it so it presents a *trusted* cert on its raw port.
@@ -13,9 +13,9 @@ import argparse
import hashlib import hashlib
from datetime import datetime, timezone from datetime import datetime, timezone
from castle_core.tls import _tls_of, reconcile_tls, tls_dir_for, wildcard_cert from wildpc_core.tls import _tls_of, reconcile_tls, tls_dir_for, wildcard_cert
from castle_cli.config import load_config from wildpc_cli.config import load_config
def run_tls(args: argparse.Namespace) -> int: def run_tls(args: argparse.Namespace) -> int:
@@ -67,7 +67,7 @@ def _tls_status() -> int:
continue continue
pem = have.read_bytes() pem = have.read_bytes()
fp = _fingerprint(pem) fp = _fingerprint(pem)
drift = "" if src_fp and fp == src_fp else " ⚠ stale (run: castle tls reconcile)" drift = "" if src_fp and fp == src_fp else " ⚠ stale (run: wildpc tls reconcile)"
rows.append((name, fp, _not_after(pem), drift)) rows.append((name, fp, _not_after(pem), drift))
if not rows: if not rows:

View File

@@ -1,4 +1,4 @@
"""castle tool — the tools lens (programs installed on PATH). """wildpc tool — the tools lens (programs installed on PATH).
A *tool* is a program with a `path` (manager) deployment: a CLI on your 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 This lens is what coding assistants use to discover what's available — so the
@@ -13,8 +13,13 @@ import argparse
import json import json
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from castle_cli.config import load_config 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" BOLD = "\033[1m"
DIM = "\033[2m" DIM = "\033[2m"
@@ -24,16 +29,16 @@ GREY = "\033[90m"
CYAN = "\033[96m" CYAN = "\033[96m"
def _is_tool(config: object, name: str) -> bool: def _is_tool(config: WildpcConfig, name: str) -> bool:
return any(kind == "tool" for _, kind in config.deployments_of(name)) return any(kind == "tool" for _, kind in config.deployments_of(name))
def _tool_programs(config: object) -> dict: def _tool_programs(config: WildpcConfig) -> dict:
"""Programs with a tool (path) deployment, name-sorted.""" """Programs with a tool (path) deployment, name-sorted."""
return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)} return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)}
def _executables(comp: object) -> list[str]: def _executables(comp: ProgramSpec) -> list[str]:
"""The console scripts a tool exposes, from its pyproject `[project.scripts]`. """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 This is the command(s) to actually run the source of truth even when the
@@ -54,7 +59,16 @@ def _executables(comp: object) -> list[str]:
return [comp.id] return [comp.id]
def _tool_record(config: object, name: str, comp: object, installed: bool) -> dict: 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 { return {
"name": name, "name": name,
"executables": _executables(comp), "executables": _executables(comp),
@@ -63,17 +77,20 @@ def _tool_record(config: object, name: str, comp: object, installed: bool) -> di
"stack": comp.stack, "stack": comp.stack,
"source": comp.source, "source": comp.source,
"system_dependencies": comp.system_dependencies, "system_dependencies": comp.system_dependencies,
"tool_schema": render_tool_schema(core, fmt) if core else None,
} }
def run_tool_list(args: argparse.Namespace) -> int: def run_tool_list(args: argparse.Namespace) -> int:
"""List tools (programs on PATH) with their executable + description.""" """List tools (programs on PATH) with their executable + description."""
from castle_core.lifecycle import tool_installed from wildpc_core.lifecycle import tool_installed
config = load_config() config = load_config()
tools = _tool_programs(config) tools = _tool_programs(config)
fmt = getattr(args, "format", "openai")
records = [ records = [
_tool_record(config, name, comp, tool_installed(name)) for name, comp in tools.items() _tool_record(config, name, comp, tool_installed(name), fmt)
for name, comp in tools.items()
] ]
if getattr(args, "json", False): if getattr(args, "json", False):
@@ -102,7 +119,7 @@ def run_tool_list(args: argparse.Namespace) -> int:
def run_tool_info(args: argparse.Namespace) -> int: def run_tool_info(args: argparse.Namespace) -> int:
"""Show one tool's details — executable, description, install state, source.""" """Show one tool's details — executable, description, install state, source."""
from castle_core.lifecycle import tool_installed from wildpc_core.lifecycle import tool_installed
config = load_config() config = load_config()
name = args.name name = args.name
@@ -112,7 +129,7 @@ def run_tool_info(args: argparse.Namespace) -> int:
comp = config.programs[name] comp = config.programs[name]
installed = tool_installed(name) installed = tool_installed(name)
record = _tool_record(config, name, comp, installed) record = _tool_record(config, name, comp, installed, getattr(args, "format", "openai"))
if getattr(args, "json", False): if getattr(args, "json", False):
print(json.dumps(record, indent=2)) print(json.dumps(record, indent=2))
@@ -132,7 +149,7 @@ def run_tool_info(args: argparse.Namespace) -> int:
if comp.system_dependencies: if comp.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(comp.system_dependencies)}") print(f" {BOLD}requires{RESET}: {', '.join(comp.system_dependencies)}")
if not installed: if not installed:
print(f"\n {DIM}enable it in its deployment, then: castle apply{RESET}") print(f"\n {DIM}enable it in its deployment, then: wildpc apply{RESET}")
else: else:
print(f"\n {DIM}run `{exes[0]} --help` for arguments{RESET}") print(f"\n {DIM}run `{exes[0]} --help` for arguments{RESET}")
print() print()

View File

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

View File

@@ -1,4 +1,4 @@
"""Castle CLI entry point — resource-first command surface. """Wild PC CLI entry point — resource-first command surface.
Operations live under the resource they act on. `program` is the catalog; Operations live under the resource they act on. `program` is the catalog;
`service`, `job`, and `tool` are deployment lenses (systemd services, scheduled `service`, `job`, and `tool` are deployment lenses (systemd services, scheduled
@@ -13,9 +13,9 @@ from __future__ import annotations
import argparse import argparse
import sys import sys
from castle_core.stacks import available_stacks from wildpc_core.stacks import available_stacks
from castle_cli import __version__ from wildpc_cli import __version__
DEV_VERBS = ["build", "test", "lint", "format", "type-check", "check"] DEV_VERBS = ["build", "test", "lint", "format", "type-check", "check"]
@@ -56,7 +56,7 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
p = sub.add_parser("clone", help="Clone source for programs with repo:") p = sub.add_parser("clone", help="Clone source for programs with repo:")
_add_name(p, "Program to clone (default: all with repo:)", optional=True) _add_name(p, "Program to clone (default: all with repo:)", optional=True)
p = sub.add_parser("delete", help="Remove a program from castle.yaml") p = sub.add_parser("delete", help="Remove a program from wildpc.yaml")
_add_name(p, "Program name") _add_name(p, "Program name")
p.add_argument("--source", action="store_true", help="Also delete the source directory") p.add_argument("--source", action="store_true", help="Also delete the source directory")
p.add_argument( p.add_argument(
@@ -81,16 +81,35 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
grp.set_defaults(resource="tool") grp.set_defaults(resource="tool")
sub = grp.add_subparsers(dest="tool_command") 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 = 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("--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") p = sub.add_parser("info", help="Show a tool's executable, description, install state")
_add_name(p, "Tool name") _add_name(p, "Tool name")
p.add_argument("--json", action="store_true", help="Machine-readable output") 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: def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml") p = sub.add_parser("create", help=f"Create a {kind} in wildpc.yaml")
_add_name(p, f"{kind.capitalize()} name") _add_name(p, f"{kind.capitalize()} name")
p.add_argument("--program", help="Program this deployment runs (convenience ref)") p.add_argument("--program", help="Program this deployment runs (convenience ref)")
p.add_argument("--description", default="", help="Description") p.add_argument("--description", default="", help="Description")
@@ -129,14 +148,14 @@ def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -
_add_service_create(sub, kind) _add_service_create(sub, kind)
p = sub.add_parser("delete", help=f"Remove a {kind} from castle.yaml") p = sub.add_parser("delete", help=f"Remove a {kind} from wildpc.yaml")
_add_name(p, f"{kind.capitalize()} name") _add_name(p, f"{kind.capitalize()} name")
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
p.add_argument("--source", action="store_true", help=argparse.SUPPRESS) p.add_argument("--source", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--purge-data", action="store_true", help=argparse.SUPPRESS) p.add_argument("--purge-data", action="store_true", help=argparse.SUPPRESS)
cap = f"{kind.capitalize()} name" cap = f"{kind.capitalize()} name"
# Lifecycle is convergence: `castle apply [name]`. `restart` stays as the one # Lifecycle is convergence: `wildpc apply [name]`. `restart` stays as the one
# imperative bounce that doesn't change desired state. # imperative bounce that doesn't change desired state.
_add_name(sub.add_parser("restart", help=f"Restart the {kind} (imperative bounce)"), cap) _add_name(sub.add_parser("restart", help=f"Restart the {kind} (imperative bounce)"), cap)
@@ -148,22 +167,23 @@ def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="castle", prog="wildpc",
description=( description=(
"Castle platform CLI — programs, deployments " "Wild PC platform CLI — programs, deployments "
"(services, jobs, tools), and infrastructure" "(services, jobs, tools), and infrastructure"
), ),
) )
parser.add_argument("--version", action="version", version=f"castle {__version__}") parser.add_argument("--version", action="version", version=f"wildpc {__version__}")
subparsers = parser.add_subparsers(dest="command", help="Available commands") subparsers = parser.add_subparsers(dest="command", help="Available commands")
_build_program_group(subparsers) _build_program_group(subparsers)
_build_deployment_group(subparsers, "service") _build_deployment_group(subparsers, "service")
_build_deployment_group(subparsers, "job") _build_deployment_group(subparsers, "job")
_build_tool_group(subparsers) _build_tool_group(subparsers)
_build_stack_group(subparsers)
# Gateway (inspection). The gateway is a deployment — start/stop/reload it via # Gateway (inspection). The gateway is a deployment — start/stop/reload it via
# `castle apply` / `castle restart castle-gateway`; this lens just shows routes. # `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 = subparsers.add_parser("gateway", help="Show the gateway's status + route table")
gw_sub = gw.add_subparsers(dest="gateway_command") gw_sub = gw.add_subparsers(dest="gateway_command")
gw_sub.add_parser("status", help="Show gateway status + routes (the default)") gw_sub.add_parser("status", help="Show gateway status + routes (the default)")
@@ -184,7 +204,7 @@ def build_parser() -> argparse.ArgumentParser:
# TLS material for raw-TCP services (cert cut from the gateway wildcard). # TLS material for raw-TCP services (cert cut from the gateway wildcard).
tls = subparsers.add_parser( tls = subparsers.add_parser(
"tls", help="Manage castle-materialized TLS certs for raw-TCP services" "tls", help="Manage wildpc-materialized TLS certs for raw-TCP services"
) )
tls_sub = tls.add_subparsers(dest="tls_command") tls_sub = tls.add_subparsers(dest="tls_command")
tls_sub.add_parser( tls_sub.add_parser(
@@ -192,6 +212,22 @@ def build_parser() -> argparse.ArgumentParser:
) )
tls_sub.add_parser("status", help="Show each TLS service's cert fingerprint + expiry") 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 # Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
# reconciles the runtime to match config (activate/restart/deactivate). # reconciles the runtime to match config (activate/restart/deactivate).
p = subparsers.add_parser( p = subparsers.add_parser(
@@ -229,38 +265,38 @@ def _dispatch_program(args: argparse.Namespace) -> int:
sub = args.program_command sub = args.program_command
if not sub: if not sub:
verbs = "list|info|create|add|clone|delete|run|" + "|".join(DEV_VERBS) verbs = "list|info|create|add|clone|delete|run|" + "|".join(DEV_VERBS)
print(f"Usage: castle program {{{verbs}}}") print(f"Usage: wildpc program {{{verbs}}}")
return 1 return 1
if sub == "list": if sub == "list":
from castle_cli.commands.list_cmd import run_list from wildpc_cli.commands.list_cmd import run_list
return run_list(args) return run_list(args)
if sub == "info": if sub == "info":
from castle_cli.commands.info import run_info from wildpc_cli.commands.info import run_info
return run_info(args) return run_info(args)
if sub == "create": if sub == "create":
from castle_cli.commands.create import run_create from wildpc_cli.commands.create import run_create
return run_create(args) return run_create(args)
if sub == "add": if sub == "add":
from castle_cli.commands.add import run_add from wildpc_cli.commands.add import run_add
return run_add(args) return run_add(args)
if sub == "clone": if sub == "clone":
from castle_cli.commands.clone import run_clone from wildpc_cli.commands.clone import run_clone
return run_clone(args) return run_clone(args)
if sub == "delete": if sub == "delete":
from castle_cli.commands.delete import run_delete from wildpc_cli.commands.delete import run_delete
return run_delete(args) return run_delete(args)
if sub == "run": if sub == "run":
from castle_cli.commands.run_cmd import run_run from wildpc_cli.commands.run_cmd import run_run
return run_run(args) return run_run(args)
if sub in DEV_VERBS: if sub in DEV_VERBS:
from castle_cli.commands.dev import run_verb from wildpc_cli.commands.dev import run_verb
return run_verb(args, sub) return run_verb(args, sub)
return 1 return 1
@@ -269,47 +305,63 @@ def _dispatch_program(args: argparse.Namespace) -> int:
def _dispatch_tool(args: argparse.Namespace) -> int: def _dispatch_tool(args: argparse.Namespace) -> int:
sub = args.tool_command sub = args.tool_command
if not sub: if not sub:
print("Usage: castle tool {list|info} (install/uninstall → edit config + castle apply)") print("Usage: wildpc tool {list|info} (install/uninstall → edit config + wildpc apply)")
return 1 return 1
if sub == "list": if sub == "list":
from castle_cli.commands.tool import run_tool_list from wildpc_cli.commands.tool import run_tool_list
return run_tool_list(args) return run_tool_list(args)
if sub == "info": if sub == "info":
from castle_cli.commands.tool import run_tool_info from wildpc_cli.commands.tool import run_tool_info
return run_tool_info(args) return run_tool_info(args)
return 1 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: def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
sub = getattr(args, f"{kind}_command") sub = getattr(args, f"{kind}_command")
if not sub: if not sub:
verbs = "list|info|create|delete|restart|logs (deploy/enable/... → castle apply)" verbs = "list|info|create|delete|restart|logs (deploy/enable/... → wildpc apply)"
print(f"Usage: castle {kind} {{{verbs}}}") print(f"Usage: wildpc {kind} {{{verbs}}}")
return 1 return 1
if sub == "list": if sub == "list":
from castle_cli.commands.list_cmd import run_list from wildpc_cli.commands.list_cmd import run_list
return run_list(args) return run_list(args)
if sub == "info": if sub == "info":
from castle_cli.commands.info import run_info from wildpc_cli.commands.info import run_info
return run_info(args) return run_info(args)
if sub == "create": if sub == "create":
from castle_cli.commands.deploy_create import run_job_create, run_service_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) return run_service_create(args) if kind == "service" else run_job_create(args)
if sub == "delete": if sub == "delete":
from castle_cli.commands.delete import run_delete from wildpc_cli.commands.delete import run_delete
return run_delete(args) return run_delete(args)
if sub == "restart": if sub == "restart":
from castle_cli.commands.service import run_job_cmd, run_service_cmd 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) return run_service_cmd(args) if kind == "service" else run_job_cmd(args)
if sub == "logs": if sub == "logs":
from castle_cli.commands.logs import run_logs from wildpc_cli.commands.logs import run_logs
return run_logs(args) return run_logs(args)
return 1 return 1
@@ -330,40 +382,46 @@ def main() -> int:
return _dispatch_deployment(args, cmd) return _dispatch_deployment(args, cmd)
if cmd == "tool": if cmd == "tool":
return _dispatch_tool(args) return _dispatch_tool(args)
if cmd == "stack":
return _dispatch_stack(args)
if cmd == "gateway": if cmd == "gateway":
from castle_cli.commands.gateway import run_gateway from wildpc_cli.commands.gateway import run_gateway
return run_gateway(args) return run_gateway(args)
if cmd == "mesh": if cmd == "mesh":
from castle_cli.commands.mesh import run_mesh from wildpc_cli.commands.mesh import run_mesh
return run_mesh(args) return run_mesh(args)
if cmd == "tls": if cmd == "tls":
from castle_cli.commands.tls import run_tls from wildpc_cli.commands.tls import run_tls
return run_tls(args) return run_tls(args)
if cmd == "secret":
from wildpc_cli.commands.secret import run_secret
return run_secret(args)
if cmd == "apply": if cmd == "apply":
from castle_cli.commands.apply import run_apply from wildpc_cli.commands.apply import run_apply
return run_apply(args) return run_apply(args)
if cmd == "restart": if cmd == "restart":
from castle_cli.commands.service import run_restart from wildpc_cli.commands.service import run_restart
return run_restart(args) return run_restart(args)
if cmd == "status": if cmd == "status":
from castle_cli.commands.service import run_status from wildpc_cli.commands.service import run_status
return run_status(args) return run_status(args)
if cmd == "doctor": if cmd == "doctor":
from castle_cli.commands.doctor import run_doctor from wildpc_cli.commands.doctor import run_doctor
return run_doctor(args) return run_doctor(args)
if cmd == "graph": if cmd == "graph":
from castle_cli.commands.graph import run_graph from wildpc_cli.commands.graph import run_graph
return run_graph(args) return run_graph(args)
if cmd == "list": if cmd == "list":
from castle_cli.commands.list_cmd import run_list from wildpc_cli.commands.list_cmd import run_list
return run_list(args) return run_list(args)

View File

@@ -1,7 +1,7 @@
"""Re-export from castle-core for backward compatibility.""" """Re-export from wildpc-core for backward compatibility."""
from castle_core.manifest import * # noqa: F401, F403 from wildpc_core.manifest import * # noqa: F401, F403
from castle_core.manifest import ( # noqa: F401 — explicit re-exports for type checkers from wildpc_core.manifest import ( # noqa: F401 — explicit re-exports for type checkers
BuildSpec, BuildSpec,
CaddyDeployment, CaddyDeployment,
CommandsSpec, CommandsSpec,

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import shutil
import subprocess
from pathlib import Path from pathlib import Path
@@ -20,6 +22,8 @@ def scaffold_project(
_scaffold_tool(project_dir, name, package_name, description) _scaffold_tool(project_dir, name, package_name, description)
elif stack == "supabase": elif stack == "supabase":
_scaffold_supabase(project_dir, name, description) _scaffold_supabase(project_dir, name, description)
elif stack == "hugo":
_scaffold_hugo(project_dir, name, description)
else: else:
raise ValueError(f"No scaffold template for stack: {stack}") raise ValueError(f"No scaffold template for stack: {stack}")
@@ -571,14 +575,14 @@ uv run ruff format . # Format
def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None: def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None:
"""Scaffold a Patch-shaped app that targets the shared Supabase substrate. """Scaffold a Patch-shaped app that targets the shared Supabase substrate.
Produces migrations/ (applied to the substrate by `castle program build`), Produces migrations/ (applied to the substrate by `wildpc program build`),
functions/ (deno edge functions), public/ (static UI served in place at functions/ (deno edge functions), public/ (static UI served in place at
<name>.<gateway.domain> by the gateway), and supabase.app.yaml (auth policy + wiring). <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 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 shared substrate. Each app is isolated in its **own Postgres schema** (the app
id) rather than sharing `public` so `castle program build` creates+grants the id) rather than sharing `public` so `wildpc program build` creates+grants the
schema and tracks migrations per-app, and `castle delete --purge-data` drops schema and tracks migrations per-app, and `wildpc delete --purge-data` drops
the whole schema cleanly. Rows are further protected by RLS. the whole schema cleanly. Rows are further protected by RLS.
""" """
ident = name.replace("-", "_") # a safe SQL/JS identifier — and the app schema ident = name.replace("-", "_") # a safe SQL/JS identifier — and the app schema
@@ -600,7 +604,7 @@ def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None:
"""# Patch-shaped app targeting the shared Supabase substrate. """# Patch-shaped app targeting the shared Supabase substrate.
name: __NAME__ name: __NAME__
description: __DESC__ description: __DESC__
substrate: supabase # the shared castle service this app deploys against substrate: supabase # the shared wildpc service this app deploys against
# auth policy: public | private | shared # auth policy: public | private | shared
# public — anyone with the URL (anon read/write, still RLS-gated) # public — anyone with the URL (anon read/write, still RLS-gated)
@@ -610,7 +614,7 @@ auth: public
# shared: [alice, bob] # shared: [alice, bob]
# This app is isolated in its own Postgres schema (created + exposed through # This app is isolated in its own Postgres schema (created + exposed through
# PostgREST by `castle program build`). The frontend selects it via # PostgREST by `wildpc program build`). The frontend selects it via
# supabase-js `db: { schema }`. # supabase-js `db: { schema }`.
schema: __SCHEMA__ schema: __SCHEMA__
""" """
@@ -621,7 +625,7 @@ schema: __SCHEMA__
_write( _write(
project_dir / "migrations" / "0001_init.sql", project_dir / "migrations" / "0001_init.sql",
sub( sub(
"""-- 0001_init: example table + RLS. Applied by `castle program build` """-- 0001_init: example table + RLS. Applied by `wildpc program build`
-- via the versioned migration runner (tracked per-app in -- via the versioned migration runner (tracked per-app in
-- <schema>.schema_migrations; only unapplied migrations run). Forward-only -- <schema>.schema_migrations; only unapplied migrations run). Forward-only
-- never edit an applied migration; add a new numbered file instead. -- never edit an applied migration; add a new numbered file instead.
@@ -676,7 +680,7 @@ serve((_req: Request) => {
sub( sub(
"""// Substrate wiring for __NAME__. The anon key is designed to be public """// Substrate wiring for __NAME__. The anon key is designed to be public
// (RLS enforces access) paste yours here. Find it in the dashboard // (RLS enforces access) paste yours here. Find it in the dashboard
// Secrets page (or `castle mesh`/vault), secret name SUPABASE_ANON_KEY. // Secrets page (or `wildpc mesh`/vault), secret name SUPABASE_ANON_KEY.
window.APP = { window.APP = {
SUPABASE_URL: "https://supabase.lan", SUPABASE_URL: "https://supabase.lan",
SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE", SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE",
@@ -744,23 +748,23 @@ window.APP = {
__DESC__ __DESC__
A **supabase-stack** app: it owns its code (this repo) and rents its backend from A **supabase-stack** app: it owns its code (this repo) and rents its backend from
the shared Supabase substrate (the `supabase` castle service). Only its rows/blobs the shared Supabase substrate (the `supabase` wildpc service). Only its rows/blobs
live on the substrate; rebuild the rest from git anytime. live on the substrate; rebuild the rest from git anytime.
## Layout ## Layout
- `migrations/` versioned, idempotent Postgres migrations (forward-only). Applied - `migrations/` versioned, idempotent Postgres migrations (forward-only). Applied
to the substrate by `castle program build __NAME__`. to the substrate by `wildpc program build __NAME__`.
- `functions/` deno edge functions deployed to the substrate's edge-runtime. - `functions/` deno edge functions deployed to the substrate's edge-runtime.
- `public/` static UI served in place at `/__NAME__/` by the gateway. Talks to - `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). the substrate with `@supabase/supabase-js` + the public anon key (RLS-gated).
- `supabase.app.yaml` substrate wiring + auth policy (public/private/shared). - `supabase.app.yaml` substrate wiring + auth policy (public/private/shared).
## Develop ## Develop
- Edit `migrations/`, then `castle program build __NAME__` to apply new migrations - Edit `migrations/`, then `wildpc program build __NAME__` to apply new migrations
(re-running is a no-op only unapplied migrations run). (re-running is a no-op only unapplied migrations run).
- Set the anon key in `public/config.js` (find `SUPABASE_ANON_KEY` in the - Set the anon key in `public/config.js` (find `SUPABASE_ANON_KEY` in the
dashboard Secrets page). dashboard Secrets page).
- `castle apply` served at `__NAME__.<domain>`. - `wildpc apply` served at `__NAME__.<domain>`.
## Privacy note ## Privacy note
RLS protects rows, not the static shell or Storage. For a `private`/`shared` app, RLS protects rows, not the static shell or Storage. For a `private`/`shared` app,
@@ -771,6 +775,200 @@ Auth/WebCrypto apps should get their own HTTPS host route (secure context).
) )
def _hugo_new_site(project_dir: Path) -> bool:
"""Create the canonical site skeleton with Hugo's own scaffolder.
Delegates the standard structure (archetypes/, the content/layouts/static/
dir tree, hugo.toml) to `hugo new site` rather than reinventing it. Returns
False if the hugo binary isn't present at create time — the caller then falls
back to a minimal hand-written skeleton (the build needs hugo regardless)."""
if shutil.which("hugo") is None:
return False
# `hugo new site` requires an empty/absent target; create runs before git init,
# so project_dir doesn't exist yet.
result = subprocess.run(
["hugo", "new", "site", str(project_dir), "--format", "toml"],
capture_output=True,
text=True,
)
return result.returncode == 0
def _scaffold_hugo(project_dir: Path, name: str, description: str) -> None:
"""Scaffold a Hugo site that builds and serves theme-less out of the box.
The canonical skeleton comes from Hugo's own `hugo new site`; on top of it we
overlay the pieces a bare skeleton lacks the layouts needed to render a home
page without a theme, sample content, and a wildpc-flavored hugo.toml/README.
`wildpc program build` runs `hugo --gc --minify` `public/`, which a caddy
deployment serves in place at <name>.<gateway.domain>. baseURL is `/` so assets
resolve at the root of the site's own subdomain. A theme (with its own asset
pipeline) can be dropped under themes/ later; such themes declare a two-step
`build.commands` in the program spec, which overrides the stack default."""
def sub(text: str) -> str:
return text.replace("__NAME__", name).replace("__DESC__", description)
# Prefer Hugo's native scaffolder; fall back to the bits it would have made.
if not _hugo_new_site(project_dir):
_write(
project_dir / "archetypes" / "default.md",
"""+++
date = '{{ .Date }}'
draft = true
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
+++
""",
)
# --- hugo.toml — overwrite Hugo's example.org default; baseURL '/' serves at
# the subdomain root ---
_write(
project_dir / "hugo.toml",
sub(
"""baseURL = "/"
languageCode = "en-us"
title = "__NAME__"
[params]
description = "__DESC__"
"""
),
)
# --- layouts/_default/baseof.html — the shared page skeleton (blocks) ---
_write(
project_dir / "layouts" / "_default" / "baseof.html",
sub(
"""<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode | default "en" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
<meta name="description" content="{{ .Site.Params.description }}">
</head>
<body>
<header><a href="{{ "/" | relURL }}">{{ .Site.Title }}</a></header>
<main>{{ block "main" . }}{{ end }}</main>
<footer>&copy; {{ now.Year }} {{ .Site.Title }}</footer>
</body>
</html>
"""
),
)
# --- layouts/index.html — the home page ---
_write(
project_dir / "layouts" / "index.html",
"""{{ define "main" }}
{{ .Content }}
<ul>
{{ range (where .Site.RegularPages "Type" "posts") }}
<li>
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
</li>
{{ end }}
</ul>
{{ end }}
""",
)
# --- layouts/_default/{single,list}.html — content pages ---
_write(
project_dir / "layouts" / "_default" / "single.html",
"""{{ define "title" }}{{ .Title }} &middot; {{ .Site.Title }}{{ end }}
{{ define "main" }}
<article>
<h1>{{ .Title }}</h1>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
{{ .Content }}
</article>
{{ end }}
""",
)
_write(
project_dir / "layouts" / "_default" / "list.html",
"""{{ define "title" }}{{ .Title }} &middot; {{ .Site.Title }}{{ end }}
{{ define "main" }}
<h1>{{ .Title }}</h1>
{{ .Content }}
<ul>
{{ range .Pages }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ end }}
""",
)
# --- content — home + an example post ---
_write(
project_dir / "content" / "_index.md",
sub(
"""---
title: "__NAME__"
---
Welcome to **__NAME__** a Hugo site on wildpc. Edit `content/_index.md` and
add posts under `content/posts/`.
"""
),
)
_write(
project_dir / "content" / "posts" / "hello.md",
"""---
title: "Hello, world"
date: 2024-01-01
---
Your first post. Edit me in `content/posts/`, or run `hugo new posts/next.md`.
""",
)
# --- .gitignore — build output is regenerated, never committed ---
_write(
project_dir / ".gitignore",
"/public/\n/resources/\n.hugo_build.lock\n",
)
# --- README ---
_write(
project_dir / "README.md",
sub(
"""# __NAME__
__DESC__
A [Hugo](https://gohugo.io) static site managed by wildpc.
## Develop
hugo server -D # live preview at http://localhost:1313
## Build & serve
wildpc program build __NAME__ # hugo --gc --minify -> public/
wildpc apply __NAME__ # serve at __NAME__.<gateway.domain>
## Themes
Drop a theme under `themes/` (often a git submodule) and set `theme` in
`hugo.toml`. A theme with an asset pipeline (Tailwind, etc.) needs a pre-build
step; declare it in `programs/__NAME__.yaml` so it overrides the stack default:
build:
commands:
- [pnpm, build]
- [hugo, --gc, --minify]
outputs: [public]
"""
),
)
def _write(path: Path, content: str) -> None: def _write(path: Path, content: str) -> None:
"""Write content to a file, creating parent directories.""" """Write content to a file, creating parent directories."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -1,4 +1,4 @@
"""Shared fixtures for castle CLI tests.""" """Shared fixtures for wildpc CLI tests."""
from __future__ import annotations from __future__ import annotations
@@ -51,11 +51,11 @@ def _store_for(spec: dict) -> str:
] ]
def _write_castle_config(root: Path, config: dict) -> None: def _write_wildpc_config(root: Path, config: dict) -> None:
"""Scatter a nested castle config dict into the on-disk layout: castle.yaml globals, """Scatter a nested wildpc config dict into the on-disk layout: wildpc.yaml globals,
programs/<name>.yaml, and deployments/<kind>/<name>.yaml (fields modernized).""" programs/<name>.yaml, and deployments/<kind>/<name>.yaml (fields modernized)."""
globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")} globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")}
(root / "castle.yaml").write_text(yaml.dump(globals_data, default_flow_style=False)) (root / "wildpc.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
programs = config.get("programs") or {} programs = config.get("programs") or {}
if programs: if programs:
(root / "programs").mkdir(parents=True, exist_ok=True) (root / "programs").mkdir(parents=True, exist_ok=True)
@@ -70,8 +70,8 @@ def _write_castle_config(root: Path, config: dict) -> None:
@pytest.fixture @pytest.fixture
def castle_root(tmp_path: Path) -> Generator[Path, None, None]: def wildpc_root(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary castle root with directory-per-resource config.""" """Create a temporary wildpc root with directory-per-resource config."""
config = { config = {
"gateway": {"port": 18000}, "gateway": {"port": 18000},
"programs": { "programs": {
@@ -129,7 +129,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
}, },
}, },
} }
_write_castle_config(tmp_path, config) _write_wildpc_config(tmp_path, config)
# Create project directories # Create project directories
svc_dir = tmp_path / "test-svc" svc_dir = tmp_path / "test-svc"
@@ -143,9 +143,9 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
@pytest.fixture @pytest.fixture
def castle_home(tmp_path: Path) -> Generator[Path, None, None]: def wildpc_home(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary ~/.castle directory.""" """Create a temporary ~/.wildpc directory."""
home = tmp_path / ".castle" home = tmp_path / ".wildpc"
home.mkdir() home.mkdir()
(home / "generated").mkdir() (home / "generated").mkdir()
(home / "secrets").mkdir() (home / "secrets").mkdir()

View File

@@ -1,4 +1,4 @@
"""Tests for castle add — adopting existing repos as programs.""" """Tests for wildpc add — adopting existing repos as programs."""
from __future__ import annotations from __future__ import annotations
@@ -6,17 +6,17 @@ from argparse import Namespace
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from castle_cli.config import load_config from wildpc_cli.config import load_config
def _run_add(castle_root: Path, **kwargs: object) -> object: def _run_add(wildpc_root: Path, **kwargs: object) -> object:
with ( with (
patch("castle_cli.commands.add.load_config") as mock_load, patch("wildpc_cli.commands.add.load_config") as mock_load,
patch("castle_cli.commands.add.save_config"), patch("wildpc_cli.commands.add.save_config"),
): ):
config = load_config(castle_root) config = load_config(wildpc_root)
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.add import run_add from wildpc_cli.commands.add import run_add
args = Namespace(name=None, description="", **kwargs) args = Namespace(name=None, description="", **kwargs)
rc = run_add(args) rc = run_add(args)
@@ -24,49 +24,49 @@ def _run_add(castle_root: Path, **kwargs: object) -> object:
class TestAdd: class TestAdd:
def test_adopt_python_path_assigns_stack(self, castle_root: Path, tmp_path: Path) -> None: def test_adopt_python_path_assigns_stack(self, wildpc_root: Path, tmp_path: Path) -> None:
repo = tmp_path / "mytool" repo = tmp_path / "mytool"
repo.mkdir() repo.mkdir()
(repo / "pyproject.toml").write_text('[project]\nname = "mytool"\ndependencies = []\n') (repo / "pyproject.toml").write_text('[project]\nname = "mytool"\ndependencies = []\n')
rc, config = _run_add(castle_root, target=str(repo)) rc, config = _run_add(wildpc_root, target=str(repo))
assert rc == 0 assert rc == 0
prog = config.programs["mytool"] prog = config.programs["mytool"]
assert prog.stack == "python-cli" # detected assert prog.stack == "python-cli" # detected
assert prog.source == str(repo.resolve()) assert prog.source == str(repo.resolve())
def test_adopt_fastapi_detects_daemon(self, castle_root: Path, tmp_path: Path) -> None: def test_adopt_fastapi_detects_daemon(self, wildpc_root: Path, tmp_path: Path) -> None:
repo = tmp_path / "svc" repo = tmp_path / "svc"
repo.mkdir() repo.mkdir()
(repo / "pyproject.toml").write_text( (repo / "pyproject.toml").write_text(
'[project]\nname = "svc"\ndependencies = ["fastapi>=0.1"]\n' '[project]\nname = "svc"\ndependencies = ["fastapi>=0.1"]\n'
) )
rc, config = _run_add(castle_root, target=str(repo)) rc, config = _run_add(wildpc_root, target=str(repo))
assert rc == 0 assert rc == 0
# `add` adopts source only — no deployment yet (kind is a deployment # `add` adopts source only — no deployment yet (kind is a deployment
# property); a fastapi project is detected as the python-fastapi stack. # property); a fastapi project is detected as the python-fastapi stack.
assert config.programs["svc"].stack == "python-fastapi" assert config.programs["svc"].stack == "python-fastapi"
assert config.deployments_of("svc") == [] assert config.deployments_of("svc") == []
def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None: def test_adopt_rust_declares_commands(self, wildpc_root: Path, tmp_path: Path) -> None:
repo = tmp_path / "rusty" repo = tmp_path / "rusty"
repo.mkdir() repo.mkdir()
(repo / "Cargo.toml").write_text('[package]\nname = "rusty"\n') (repo / "Cargo.toml").write_text('[package]\nname = "rusty"\n')
rc, config = _run_add(castle_root, target=str(repo)) rc, config = _run_add(wildpc_root, target=str(repo))
assert rc == 0 assert rc == 0
prog = config.programs["rusty"] prog = config.programs["rusty"]
assert prog.stack is None # no castle stack for rust assert prog.stack is None # no wildpc stack for rust
# build lands in BuildSpec; other verbs in CommandsSpec # build lands in BuildSpec; other verbs in CommandsSpec
assert prog.build is not None assert prog.build is not None
assert prog.build.commands == [["cargo", "build", "--release"]] assert prog.build.commands == [["cargo", "build", "--release"]]
assert prog.commands is not None assert prog.commands is not None
assert prog.commands.run == [["cargo", "run"]] assert prog.commands.run == [["cargo", "run"]]
def test_adopt_git_url_records_repo(self, castle_root: Path) -> None: def test_adopt_git_url_records_repo(self, wildpc_root: Path) -> None:
rc, config = _run_add(castle_root, target="https://github.com/someone/widget.git") rc, config = _run_add(wildpc_root, target="https://github.com/someone/widget.git")
assert rc == 0 assert rc == 0
prog = config.programs["widget"] prog = config.programs["widget"]
assert prog.repo == "https://github.com/someone/widget.git" assert prog.repo == "https://github.com/someone/widget.git"
def test_missing_path_fails(self, castle_root: Path, tmp_path: Path) -> None: def test_missing_path_fails(self, wildpc_root: Path, tmp_path: Path) -> None:
rc, _ = _run_add(castle_root, target=str(tmp_path / "nope")) rc, _ = _run_add(wildpc_root, target=str(tmp_path / "nope"))
assert rc == 1 assert rc == 1

View File

@@ -1,4 +1,4 @@
"""Tests for castle create command.""" """Tests for wildpc create command."""
from __future__ import annotations from __future__ import annotations
@@ -6,24 +6,24 @@ from argparse import Namespace
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from castle_cli.config import load_config from wildpc_cli.config import load_config
class TestCreateCommand: class TestCreateCommand:
"""Tests for the create command.""" """Tests for the create command."""
def test_create_service(self, castle_root: Path, tmp_path: Path) -> None: def test_create_service(self, wildpc_root: Path, tmp_path: Path) -> None:
"""Create a new service project.""" """Create a new service project."""
repos = tmp_path / "repos" repos = tmp_path / "repos"
with ( with (
patch("castle_cli.commands.create.load_config") as mock_load, patch("wildpc_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config") as mock_save, patch("wildpc_cli.commands.create.save_config") as mock_save,
): ):
config = load_config(castle_root) config = load_config(wildpc_root)
config.repos_dir = repos config.repos_dir = repos
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.create import run_create from wildpc_cli.commands.create import run_create
args = Namespace( args = Namespace(
name="my-api", name="my-api",
@@ -51,18 +51,18 @@ class TestCreateCommand:
assert svc.program == "my-api" assert svc.program == "my-api"
mock_save.assert_called_once() mock_save.assert_called_once()
def test_create_tool(self, castle_root: Path, tmp_path: Path) -> None: def test_create_tool(self, wildpc_root: Path, tmp_path: Path) -> None:
"""Create a new tool project.""" """Create a new tool project."""
repos = tmp_path / "repos" repos = tmp_path / "repos"
with ( with (
patch("castle_cli.commands.create.load_config") as mock_load, patch("wildpc_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"), patch("wildpc_cli.commands.create.save_config"),
): ):
config = load_config(castle_root) config = load_config(wildpc_root)
config.repos_dir = repos config.repos_dir = repos
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.create import run_create from wildpc_cli.commands.create import run_create
args = Namespace(name="my-tool2", stack="python-cli", description="My tool", port=None) args = Namespace(name="my-tool2", stack="python-cli", description="My tool", port=None)
result = run_create(args) result = run_create(args)
@@ -77,19 +77,19 @@ class TestCreateCommand:
assert config.tools["my-tool2"].manager == "path" assert config.tools["my-tool2"].manager == "path"
assert config.deployments_of("my-tool2") == [("my-tool2", "tool")] assert config.deployments_of("my-tool2") == [("my-tool2", "tool")]
def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None: def test_create_supabase_app(self, wildpc_root: Path, tmp_path: Path) -> None:
"""A supabase app scaffolds a Patch-shaped project registered as a static """A supabase app scaffolds a Patch-shaped project registered as a static
frontend (build.outputs=[public]) with no service.""" frontend (build.outputs=[public]) with no service."""
repos = tmp_path / "repos" repos = tmp_path / "repos"
with ( with (
patch("castle_cli.commands.create.load_config") as mock_load, patch("wildpc_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"), patch("wildpc_cli.commands.create.save_config"),
): ):
config = load_config(castle_root) config = load_config(wildpc_root)
config.repos_dir = repos config.repos_dir = repos
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.create import run_create from wildpc_cli.commands.create import run_create
args = Namespace(name="guestbook", stack="supabase", description="Guestbook", port=None) args = Namespace(name="guestbook", stack="supabase", description="Guestbook", port=None)
result = run_create(args) result = run_create(args)
@@ -114,13 +114,13 @@ class TestCreateCommand:
assert [(r.kind, r.ref) for r in dep.requires] == [("deployment", "supabase")] assert [(r.kind, r.ref) for r in dep.requires] == [("deployment", "supabase")]
assert config.deployments_of("guestbook") == [("guestbook", "static")] assert config.deployments_of("guestbook") == [("guestbook", "static")]
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None: def test_create_duplicate_fails(self, wildpc_root: Path, capsys: object) -> None:
"""Creating a project with existing name fails.""" """Creating a project with existing name fails."""
with patch("castle_cli.commands.create.load_config") as mock_load: with patch("wildpc_cli.commands.create.load_config") as mock_load:
config = load_config(castle_root) config = load_config(wildpc_root)
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.create import run_create from wildpc_cli.commands.create import run_create
# test-svc exists in the services section # test-svc exists in the services section
args = Namespace( args = Namespace(
@@ -133,17 +133,17 @@ class TestCreateCommand:
assert result == 1 assert result == 1
def test_create_auto_port(self, castle_root: Path, tmp_path: Path) -> None: def test_create_auto_port(self, wildpc_root: Path, tmp_path: Path) -> None:
"""Service creation auto-assigns next available port.""" """Service creation auto-assigns next available port."""
with ( with (
patch("castle_cli.commands.create.load_config") as mock_load, patch("wildpc_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"), patch("wildpc_cli.commands.create.save_config"),
): ):
config = load_config(castle_root) config = load_config(wildpc_root)
config.repos_dir = tmp_path / "repos" config.repos_dir = tmp_path / "repos"
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.create import run_create from wildpc_cli.commands.create import run_create
args = Namespace( args = Namespace(
name="auto-port-svc", name="auto-port-svc",

View File

@@ -1,4 +1,4 @@
"""Tests for castle delete.""" """Tests for wildpc delete."""
from __future__ import annotations from __future__ import annotations
@@ -6,17 +6,17 @@ from argparse import Namespace
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from castle_cli.config import load_config from wildpc_cli.config import load_config
def _run_delete(castle_root: Path, **kwargs: object) -> object: def _run_delete(wildpc_root: Path, **kwargs: object) -> object:
with ( with (
patch("castle_cli.commands.delete.load_config") as mock_load, patch("wildpc_cli.commands.delete.load_config") as mock_load,
patch("castle_cli.commands.delete.save_config"), patch("wildpc_cli.commands.delete.save_config"),
): ):
config = load_config(castle_root) config = load_config(wildpc_root)
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.delete import run_delete from wildpc_cli.commands.delete import run_delete
args = Namespace(source=False, yes=True, **kwargs) args = Namespace(source=False, yes=True, **kwargs)
rc = run_delete(args) rc = run_delete(args)
@@ -24,43 +24,43 @@ def _run_delete(castle_root: Path, **kwargs: object) -> object:
class TestDelete: class TestDelete:
def test_delete_program(self, castle_root: Path) -> None: def test_delete_program(self, wildpc_root: Path) -> None:
rc, config = _run_delete(castle_root, name="test-tool") rc, config = _run_delete(wildpc_root, name="test-tool")
assert rc == 0 assert rc == 0
assert "test-tool" not in config.programs assert "test-tool" not in config.programs
def test_delete_unknown_fails(self, castle_root: Path) -> None: def test_delete_unknown_fails(self, wildpc_root: Path) -> None:
rc, _ = _run_delete(castle_root, name="does-not-exist") rc, _ = _run_delete(wildpc_root, name="does-not-exist")
assert rc == 1 assert rc == 1
def test_delete_source_removes_dir(self, castle_root: Path, tmp_path: Path) -> None: def test_delete_source_removes_dir(self, wildpc_root: Path, tmp_path: Path) -> None:
# Point a program's source at a real temp dir, then delete with --source. # Point a program's source at a real temp dir, then delete with --source.
src = tmp_path / "victim" src = tmp_path / "victim"
src.mkdir() src.mkdir()
(src / "file.txt").write_text("x") (src / "file.txt").write_text("x")
with ( with (
patch("castle_cli.commands.delete.load_config") as mock_load, patch("wildpc_cli.commands.delete.load_config") as mock_load,
patch("castle_cli.commands.delete.save_config"), patch("wildpc_cli.commands.delete.save_config"),
): ):
config = load_config(castle_root) config = load_config(wildpc_root)
config.programs["test-tool"].source = str(src) config.programs["test-tool"].source = str(src)
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.delete import run_delete from wildpc_cli.commands.delete import run_delete
rc = run_delete(Namespace(name="test-tool", source=True, yes=True)) rc = run_delete(Namespace(name="test-tool", source=True, yes=True))
assert rc == 0 assert rc == 0
assert not src.exists() assert not src.exists()
def test_abort_without_yes_and_no_input(self, castle_root: Path) -> None: def test_abort_without_yes_and_no_input(self, wildpc_root: Path) -> None:
# No --yes and no stdin → aborts safely (returns 1, leaves entry). # No --yes and no stdin → aborts safely (returns 1, leaves entry).
with ( with (
patch("castle_cli.commands.delete.load_config") as mock_load, patch("wildpc_cli.commands.delete.load_config") as mock_load,
patch("castle_cli.commands.delete.save_config"), patch("wildpc_cli.commands.delete.save_config"),
patch("builtins.input", side_effect=EOFError), patch("builtins.input", side_effect=EOFError),
): ):
config = load_config(castle_root) config = load_config(wildpc_root)
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.delete import run_delete from wildpc_cli.commands.delete import run_delete
rc = run_delete(Namespace(name="test-tool", source=False, yes=False)) rc = run_delete(Namespace(name="test-tool", source=False, yes=False))
assert rc == 1 assert rc == 1

View File

@@ -1,4 +1,4 @@
"""Tests for castle doctor.""" """Tests for wildpc doctor."""
from __future__ import annotations from __future__ import annotations
@@ -7,19 +7,26 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from castle_cli.commands.doctor import FAIL, OK, WARN, _check_configuration, run_doctor from wildpc_cli.commands.doctor import (
FAIL,
OK,
WARN,
_check_configuration,
_check_stacks,
run_doctor,
)
class TestDoctor: class TestDoctor:
"""The diagnosis path — a bare, unconfigured node should fail loudly.""" """The diagnosis path — a bare, unconfigured node should fail loudly."""
def test_bare_node_reports_problems(self, castle_root: Path, capsys: object) -> None: def test_bare_node_reports_problems(self, wildpc_root: Path, capsys: object) -> None:
"""No repo:, no control plane, nothing running → exit 1 with fix hints.""" """No repo:, no control plane, nothing running → exit 1 with fix hints."""
from castle_cli.config import load_config from wildpc_cli.config import load_config
# The shared fixture has no repo: and no castle-gateway/api/dashboard, so the # The shared fixture has no repo: and no wildpc-gateway/api/dashboard, so the
# Configuration and Runtime sections must FAIL. Patch where doctor imports it. # Configuration and Runtime sections must FAIL. Patch where doctor imports it.
with patch("castle_core.config.load_config", return_value=load_config(castle_root)): with patch("wildpc_core.config.load_config", return_value=load_config(wildpc_root)):
result = run_doctor(Namespace()) result = run_doctor(Namespace())
assert result == 1 assert result == 1
@@ -27,11 +34,11 @@ class TestDoctor:
assert "repo: not set" in out assert "repo: not set" in out
assert "control plane missing" in out assert "control plane missing" in out
# Every failing check offers a concrete next command. # Every failing check offers a concrete next command.
assert "castle apply" in out assert "wildpc apply" in out
def test_load_failure_is_first_fail(self, capsys: object) -> None: def test_load_failure_is_first_fail(self, capsys: object) -> None:
"""A castle.yaml that won't load is surfaced as a FAIL, not a traceback.""" """A wildpc.yaml that won't load is surfaced as a FAIL, not a traceback."""
with patch("castle_core.config.load_config", side_effect=ValueError("bad yaml")): with patch("wildpc_core.config.load_config", side_effect=ValueError("bad yaml")):
result = run_doctor(Namespace()) result = run_doctor(Namespace())
assert result == 1 assert result == 1
@@ -41,31 +48,31 @@ class TestDoctor:
class TestDataDirChecks: class TestDataDirChecks:
"""The drift-prevention checks: data_dir must be writable, and a CASTLE_DATA_DIR env """The drift-prevention checks: data_dir must be writable, and a WILDPC_DATA_DIR env
override (the one way the CLI and api can still diverge) must be surfaced.""" override (the one way the CLI and api can still diverge) must be surfaced."""
def _config(self, castle_root: Path): def _config(self, wildpc_root: Path):
from castle_cli.config import load_config from wildpc_cli.config import load_config
return load_config(castle_root) return load_config(wildpc_root)
def test_writable_dir_ok_no_warn( def test_writable_dir_ok_no_warn(
self, castle_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, wildpc_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.delenv("CASTLE_DATA_DIR", raising=False) monkeypatch.delenv("WILDPC_DATA_DIR", raising=False)
monkeypatch.delenv("CASTLE_REPOS_DIR", raising=False) monkeypatch.delenv("WILDPC_REPOS_DIR", raising=False)
cfg = self._config(castle_root) cfg = self._config(wildpc_root)
cfg.data_dir = tmp_path # exists + writable cfg.data_dir = tmp_path # exists + writable
checks = _check_configuration(cfg) checks = _check_configuration(cfg)
by_label = {c.label: c for c in checks} by_label = {c.label: c for c in checks}
assert by_label["data dir writable"].status == OK assert by_label["data dir writable"].status == OK
assert not any("overrides castle.yaml" in c.label for c in checks) assert not any("overrides wildpc.yaml" in c.label for c in checks)
def test_missing_dir_fails_with_hint( def test_missing_dir_fails_with_hint(
self, castle_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, wildpc_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.delenv("CASTLE_DATA_DIR", raising=False) monkeypatch.delenv("WILDPC_DATA_DIR", raising=False)
cfg = self._config(castle_root) cfg = self._config(wildpc_root)
missing = tmp_path / "nope" missing = tmp_path / "nope"
cfg.data_dir = missing cfg.data_dir = missing
fail = next( fail = next(
@@ -75,13 +82,67 @@ class TestDataDirChecks:
assert fail.hint # offers a concrete fix assert fail.hint # offers a concrete fix
def test_env_override_warns( def test_env_override_warns(
self, castle_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, wildpc_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
"""A CASTLE_DATA_DIR env var overrides the single-source-of-truth file — the """A WILDPC_DATA_DIR env var overrides the single-source-of-truth file — the
exact CLI/api divergence we fixed. Doctor must WARN.""" exact CLI/api divergence we fixed. Doctor must WARN."""
monkeypatch.setenv("CASTLE_DATA_DIR", str(tmp_path)) monkeypatch.setenv("WILDPC_DATA_DIR", str(tmp_path))
cfg = self._config(castle_root) cfg = self._config(wildpc_root)
cfg.data_dir = tmp_path cfg.data_dir = tmp_path
warn = next(c for c in _check_configuration(cfg) if "overrides castle.yaml" in c.label) warn = next(c for c in _check_configuration(cfg) if "overrides wildpc.yaml" in c.label)
assert warn.status == WARN assert warn.status == WARN
assert "CASTLE_DATA_DIR" in warn.detail assert "WILDPC_DATA_DIR" in warn.detail
class TestStackChecks:
"""The stacks section: a stack's toolchain must be present where its programs
run. Missing tooling for an enabled deployment is a FAIL with a copyable fix."""
def _fastapi_cfg(self):
import wildpc_core.config as C
from wildpc_core.manifest import ProgramSpec, SystemdDeployment
prog = ProgramSpec(id="svc", stack="python-fastapi")
dep = SystemdDeployment.model_validate(
{
"manager": "systemd",
"program": "svc",
"run": {"launcher": "command", "argv": ["svc"]},
}
)
return C.WildpcConfig(
root=None,
gateway=C.GatewayConfig(port=9000),
repo=None,
programs={"svc": prog},
deployments={"svc": dep},
)
def test_present_tool_is_ok(self, monkeypatch: pytest.MonkeyPatch) -> None:
import wildpc_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
fa = next(
c for c in _check_stacks(self._fastapi_cfg()) if c.label.startswith("python-fastapi")
)
assert fa.status == OK
def test_missing_tool_for_enabled_deployment_fails_with_hint(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
import wildpc_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: False)
fa = next(
c for c in _check_stacks(self._fastapi_cfg()) if c.label.startswith("python-fastapi")
)
assert fa.status == FAIL
assert "uv" in fa.detail and fa.hint # names the tool + offers a fix
def test_unused_stacks_are_skipped(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""No react-vite program → no pnpm nag."""
import wildpc_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
labels = [c.label for c in _check_stacks(self._fastapi_cfg())]
assert not any(label.startswith("react-vite") for label in labels)

View File

@@ -1,4 +1,4 @@
"""Tests for castle info command.""" """Tests for wildpc info command."""
from __future__ import annotations from __future__ import annotations
@@ -11,14 +11,14 @@ from unittest.mock import patch
class TestInfoCommand: class TestInfoCommand:
"""Tests for the info command.""" """Tests for the info command."""
def test_info_service(self, castle_root: Path, capsys) -> None: def test_info_service(self, wildpc_root: Path, capsys) -> None:
"""Show info for a service.""" """Show info for a service."""
from castle_cli.config import load_config from wildpc_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("wildpc_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
from castle_cli.commands.info import run_info from wildpc_cli.commands.info import run_info
result = run_info(Namespace(name="test-svc", json=False)) result = run_info(Namespace(name="test-svc", json=False))
@@ -28,14 +28,14 @@ class TestInfoCommand:
assert "service" in output assert "service" in output
assert "19000" in output assert "19000" in output
def test_info_tool(self, castle_root: Path, capsys) -> None: def test_info_tool(self, wildpc_root: Path, capsys) -> None:
"""Show info for a tool.""" """Show info for a tool."""
from castle_cli.config import load_config from wildpc_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("wildpc_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
from castle_cli.commands.info import run_info from wildpc_cli.commands.info import run_info
result = run_info(Namespace(name="test-tool", json=False)) result = run_info(Namespace(name="test-tool", json=False))
@@ -44,14 +44,14 @@ class TestInfoCommand:
assert "test-tool" in output assert "test-tool" in output
assert "tool" in output assert "tool" in output
def test_info_not_found(self, castle_root: Path, capsys) -> None: def test_info_not_found(self, wildpc_root: Path, capsys) -> None:
"""Info for nonexistent component returns error.""" """Info for nonexistent component returns error."""
from castle_cli.config import load_config from wildpc_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("wildpc_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
from castle_cli.commands.info import run_info from wildpc_cli.commands.info import run_info
result = run_info(Namespace(name="nope", json=False)) result = run_info(Namespace(name="nope", json=False))
@@ -59,14 +59,14 @@ class TestInfoCommand:
output = capsys.readouterr().out output = capsys.readouterr().out
assert "nope" in output assert "nope" in output
def test_info_json_service(self, castle_root: Path, capsys) -> None: def test_info_json_service(self, wildpc_root: Path, capsys) -> None:
"""--json produces valid JSON with service fields.""" """--json produces valid JSON with service fields."""
from castle_cli.config import load_config from wildpc_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("wildpc_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
from castle_cli.commands.info import run_info from wildpc_cli.commands.info import run_info
result = run_info(Namespace(name="test-svc", json=True)) result = run_info(Namespace(name="test-svc", json=True))
@@ -76,14 +76,14 @@ class TestInfoCommand:
assert data["kind"] == "service" assert data["kind"] == "service"
assert data["service"]["expose"]["http"]["internal"]["port"] == 19000 assert data["service"]["expose"]["http"]["internal"]["port"] == 19000
def test_info_shows_env(self, castle_root: Path, capsys) -> None: def test_info_shows_env(self, wildpc_root: Path, capsys) -> None:
"""Info displays environment variables.""" """Info displays environment variables."""
from castle_cli.config import load_config from wildpc_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("wildpc_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
from castle_cli.commands.info import run_info from wildpc_cli.commands.info import run_info
result = run_info(Namespace(name="test-svc", json=False)) result = run_info(Namespace(name="test-svc", json=False))
@@ -91,14 +91,14 @@ class TestInfoCommand:
output = capsys.readouterr().out output = capsys.readouterr().out
assert "TEST_SVC_DATA_DIR" in output assert "TEST_SVC_DATA_DIR" in output
def test_info_shows_kind(self, castle_root: Path, capsys) -> None: def test_info_shows_kind(self, wildpc_root: Path, capsys) -> None:
"""Info displays the derived kind.""" """Info displays the derived kind."""
from castle_cli.config import load_config from wildpc_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("wildpc_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
from castle_cli.commands.info import run_info from wildpc_cli.commands.info import run_info
result = run_info(Namespace(name="test-svc", json=False)) result = run_info(Namespace(name="test-svc", json=False))

View File

@@ -1,4 +1,4 @@
"""Tests for castle list command.""" """Tests for wildpc list command."""
from __future__ import annotations from __future__ import annotations
@@ -7,18 +7,18 @@ from argparse import Namespace
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from castle_cli.commands.list_cmd import run_list from wildpc_cli.commands.list_cmd import run_list
class TestListCommand: class TestListCommand:
"""Tests for the list command.""" """Tests for the list command."""
def test_list_all(self, castle_root: Path, capsys: object) -> None: def test_list_all(self, wildpc_root: Path, capsys: object) -> None:
"""List all components.""" """List all components."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load: with patch("wildpc_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config from wildpc_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
args = Namespace(kind=None, stack=None, json=False) args = Namespace(kind=None, stack=None, json=False)
result = run_list(args) result = run_list(args)
@@ -27,12 +27,12 @@ class TestListCommand:
assert "test-svc" in captured.out assert "test-svc" in captured.out
assert "test-tool" in captured.out assert "test-tool" in captured.out
def test_list_filter_daemon(self, castle_root: Path, capsys: object) -> None: def test_list_filter_daemon(self, wildpc_root: Path, capsys: object) -> None:
"""--behavior filters the program catalog by its real behavior field.""" """--behavior filters the program catalog by its real behavior field."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load: with patch("wildpc_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config from wildpc_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
args = Namespace(kind="service", stack=None, json=False) args = Namespace(kind="service", stack=None, json=False)
result = run_list(args) result = run_list(args)
@@ -43,12 +43,12 @@ class TestListCommand:
# Services/jobs are deployment views, not behaviors — hidden under a filter. # Services/jobs are deployment views, not behaviors — hidden under a filter.
assert "test-svc" not in captured.out assert "test-svc" not in captured.out
def test_list_filter_tool(self, castle_root: Path, capsys: object) -> None: def test_list_filter_tool(self, wildpc_root: Path, capsys: object) -> None:
"""List filtered to tools.""" """List filtered to tools."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load: with patch("wildpc_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config from wildpc_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
args = Namespace(kind="tool", stack=None, json=False) args = Namespace(kind="tool", stack=None, json=False)
result = run_list(args) result = run_list(args)
@@ -57,12 +57,12 @@ class TestListCommand:
assert "test-tool" in captured.out assert "test-tool" in captured.out
assert "test-svc" not in captured.out assert "test-svc" not in captured.out
def test_list_jobs_are_deployments(self, castle_root: Path, capsys: object) -> None: def test_list_jobs_are_deployments(self, wildpc_root: Path, capsys: object) -> None:
"""Jobs are a deployment view: shown unfiltered, hidden under a behavior filter.""" """Jobs are a deployment view: shown unfiltered, hidden under a behavior filter."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load: with patch("wildpc_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config from wildpc_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
# Unfiltered: the job appears. # Unfiltered: the job appears.
run_list(Namespace(kind=None, stack=None, json=False)) run_list(Namespace(kind=None, stack=None, json=False))
assert "test-job" in capsys.readouterr().out # type: ignore[attr-defined] assert "test-job" in capsys.readouterr().out # type: ignore[attr-defined]
@@ -73,12 +73,12 @@ class TestListCommand:
assert "test-svc" not in out assert "test-svc" not in out
assert "test-tool" in out assert "test-tool" in out
def test_list_json(self, castle_root: Path, capsys: object) -> None: def test_list_json(self, wildpc_root: Path, capsys: object) -> None:
"""JSON output tags each entry with its derived kind.""" """JSON output tags each entry with its derived kind."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load: with patch("wildpc_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config from wildpc_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(wildpc_root)
args = Namespace(kind=None, stack=None, json=True) args = Namespace(kind=None, stack=None, json=True)
result = run_list(args) result = run_list(args)

View File

@@ -1,4 +1,4 @@
"""Tests for the castle mesh command (API calls mocked).""" """Tests for the wildpc mesh command (API calls mocked)."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,7 @@ import urllib.error
from argparse import Namespace from argparse import Namespace
from unittest.mock import patch from unittest.mock import patch
from castle_cli.commands.mesh import run_mesh from wildpc_cli.commands.mesh import run_mesh
class TestMeshCommand: class TestMeshCommand:
@@ -15,7 +15,7 @@ class TestMeshCommand:
"enabled": True, "connected": True, "nats_url": "tls://x:4222", "enabled": True, "connected": True, "nats_url": "tls://x:4222",
"peer_count": 1, "peers": ["primer"], "peer_count": 1, "peers": ["primer"],
} }
with patch("castle_cli.commands.mesh._get", return_value=data): with patch("wildpc_cli.commands.mesh._get", return_value=data):
rc = run_mesh(Namespace(mesh_command="status")) rc = run_mesh(Namespace(mesh_command="status"))
assert rc == 0 assert rc == 0
out = capsys.readouterr().out # type: ignore[attr-defined] out = capsys.readouterr().out # type: ignore[attr-defined]
@@ -28,7 +28,7 @@ class TestMeshCommand:
{"hostname": "primer", "online": True, "is_stale": False, {"hostname": "primer", "online": True, "is_stale": False,
"deployed_count": 3, "is_local": False}, "deployed_count": 3, "is_local": False},
] ]
with patch("castle_cli.commands.mesh._get", return_value=data): with patch("wildpc_cli.commands.mesh._get", return_value=data):
rc = run_mesh(Namespace(mesh_command="nodes")) rc = run_mesh(Namespace(mesh_command="nodes"))
assert rc == 0 assert rc == 0
out = capsys.readouterr().out # type: ignore[attr-defined] out = capsys.readouterr().out # type: ignore[attr-defined]
@@ -36,7 +36,7 @@ class TestMeshCommand:
def test_config_list(self, capsys: object) -> None: def test_config_list(self, capsys: object) -> None:
data = {"role": "authority", "keys": ["fleet/motd"]} data = {"role": "authority", "keys": ["fleet/motd"]}
with patch("castle_cli.commands.mesh._get", return_value=data): with patch("wildpc_cli.commands.mesh._get", return_value=data):
rc = run_mesh( rc = run_mesh(
Namespace(mesh_command="config", mesh_config_command="list") Namespace(mesh_command="config", mesh_config_command="list")
) )
@@ -45,7 +45,7 @@ class TestMeshCommand:
def test_config_set_calls_put(self) -> None: def test_config_set_calls_put(self) -> None:
with patch( with patch(
"castle_cli.commands.mesh._put", return_value={"ok": True} "wildpc_cli.commands.mesh._put", return_value={"ok": True}
) as put: ) as put:
rc = run_mesh( rc = run_mesh(
Namespace( Namespace(
@@ -60,7 +60,7 @@ class TestMeshCommand:
def test_api_unreachable_returns_1(self, capsys: object) -> None: def test_api_unreachable_returns_1(self, capsys: object) -> None:
with patch( with patch(
"castle_cli.commands.mesh._get", "wildpc_cli.commands.mesh._get",
side_effect=urllib.error.URLError("refused"), side_effect=urllib.error.URLError("refused"),
): ):
rc = run_mesh(Namespace(mesh_command="status")) rc = run_mesh(Namespace(mesh_command="status"))

56
cli/tests/test_secret.py Normal file
View File

@@ -0,0 +1,56 @@
"""Tests for the `wildpc secret` command (reads/writes the active backend)."""
from __future__ import annotations
from argparse import Namespace
from pathlib import Path
import pytest
@pytest.fixture
def file_secrets(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Force the active backend to a temp file store."""
secrets = tmp_path / "secrets"
secrets.mkdir()
monkeypatch.setenv("WILDPC_SECRET_BACKEND", "file")
monkeypatch.setattr("wildpc_core.config.SECRETS_DIR", secrets)
return secrets
def _run(**kw: object) -> int:
from wildpc_cli.commands.secret import run_secret
return run_secret(Namespace(**kw))
class TestSecretRoundtrip:
def test_set_get_list_rm(self, file_secrets: Path, capsys: object) -> None:
assert _run(secret_command="set", name="MY_KEY", value="s3cret") == 0
capsys.readouterr() # type: ignore[attr-defined] # drain the "Set …" line
assert _run(secret_command="get", name="MY_KEY") == 0
assert capsys.readouterr().out.strip() == "s3cret" # type: ignore[attr-defined]
assert _run(secret_command="list") == 0
assert "MY_KEY" in capsys.readouterr().out # type: ignore[attr-defined]
assert _run(secret_command="rm", name="MY_KEY", yes=True) == 0
# Gone → non-zero.
assert _run(secret_command="get", name="MY_KEY") == 1
class TestSecretEdges:
def test_get_missing_is_nonzero(self, file_secrets: Path) -> None:
assert _run(secret_command="get", name="ABSENT") == 1
def test_empty_value_refused(self, file_secrets: Path) -> None:
# Explicit empty string on argv is refused (not silently stored).
assert _run(secret_command="set", name="K", value="") == 1
def test_rm_missing_is_nonzero(self, file_secrets: Path) -> None:
assert _run(secret_command="rm", name="ABSENT", yes=True) == 1
def test_no_subcommand_usage(self, file_secrets: Path, capsys: object) -> None:
assert _run(secret_command=None) == 1
assert "Usage" in capsys.readouterr().out # type: ignore[attr-defined]

View File

@@ -1,4 +1,4 @@
"""Tests for the `castle tool` lens.""" """Tests for the `wildpc tool` lens."""
from __future__ import annotations from __future__ import annotations
@@ -8,16 +8,16 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
def _config(castle_root: Path): def _config(wildpc_root: Path):
from castle_cli.config import load_config from wildpc_cli.config import load_config
return load_config(castle_root) return load_config(wildpc_root)
class TestToolList: class TestToolList:
def test_list_includes_tool(self, castle_root: Path, capsys: object) -> None: def test_list_includes_tool(self, wildpc_root: Path, capsys: object) -> None:
with patch("castle_cli.commands.tool.load_config", return_value=_config(castle_root)): with patch("wildpc_cli.commands.tool.load_config", return_value=_config(wildpc_root)):
from castle_cli.commands.tool import run_tool_list from wildpc_cli.commands.tool import run_tool_list
rc = run_tool_list(Namespace(json=False)) rc = run_tool_list(Namespace(json=False))
assert rc == 0 assert rc == 0
@@ -26,9 +26,9 @@ class TestToolList:
# test-svc is a service (systemd), not a tool — must not appear. # test-svc is a service (systemd), not a tool — must not appear.
assert "test-svc" not in out assert "test-svc" not in out
def test_list_json_payload(self, castle_root: Path, capsys: object) -> None: def test_list_json_payload(self, wildpc_root: Path, capsys: object) -> None:
with patch("castle_cli.commands.tool.load_config", return_value=_config(castle_root)): with patch("wildpc_cli.commands.tool.load_config", return_value=_config(wildpc_root)):
from castle_cli.commands.tool import run_tool_list from wildpc_cli.commands.tool import run_tool_list
rc = run_tool_list(Namespace(json=True)) rc = run_tool_list(Namespace(json=True))
assert rc == 0 assert rc == 0
@@ -41,9 +41,9 @@ class TestToolList:
class TestToolInfo: class TestToolInfo:
def test_info_json(self, castle_root: Path, capsys: object) -> None: def test_info_json(self, wildpc_root: Path, capsys: object) -> None:
with patch("castle_cli.commands.tool.load_config", return_value=_config(castle_root)): with patch("wildpc_cli.commands.tool.load_config", return_value=_config(wildpc_root)):
from castle_cli.commands.tool import run_tool_info from wildpc_cli.commands.tool import run_tool_info
rc = run_tool_info(Namespace(name="test-tool", json=True)) rc = run_tool_info(Namespace(name="test-tool", json=True))
assert rc == 0 assert rc == 0
@@ -51,10 +51,10 @@ class TestToolInfo:
assert data["name"] == "test-tool" assert data["name"] == "test-tool"
assert data["executables"] assert data["executables"]
def test_info_rejects_non_tool(self, castle_root: Path, capsys: object) -> None: def test_info_rejects_non_tool(self, wildpc_root: Path, capsys: object) -> None:
# test-svc is a service deployment, not a tool. # test-svc is a service deployment, not a tool.
with patch("castle_cli.commands.tool.load_config", return_value=_config(castle_root)): with patch("wildpc_cli.commands.tool.load_config", return_value=_config(wildpc_root)):
from castle_cli.commands.tool import run_tool_info from wildpc_cli.commands.tool import run_tool_info
rc = run_tool_info(Namespace(name="test-svc", json=False)) rc = run_tool_info(Namespace(name="test-svc", json=False))
assert rc == 1 assert rc == 1

425
cli/uv.lock generated
View File

@@ -1,425 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "castle-cli"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "jinja2" },
{ name = "pydantic" },
{ name = "pyyaml" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.11.0" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]

View File

@@ -1,11 +1,12 @@
[project] [project]
name = "castle-core" name = "wildpc-core"
version = "0.1.0" version = "0.1.0"
description = "Castle platform core library - manifest models, config, and generators" description = "Wild PC platform core library - manifest models, config, and generators"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"pyyaml>=6.0.0", "pyyaml>=6.0.0",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"jsonschema>=4.0.0",
] ]
[build-system] [build-system]
@@ -13,7 +14,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/castle_core"] packages = ["src/wildpc_core"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -22,4 +23,4 @@ dev = [
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["castle_core"] known-first-party = ["wildpc_core"]

View File

@@ -1,3 +0,0 @@
"""Castle core library - manifest models, configuration, and generators."""
__version__ = "0.1.0"

View File

@@ -1,120 +0,0 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
Castle owns the CNAMEs in the public zone that point at its Cloudflare tunnel:
on deploy it creates one per public service and deletes any that point at this
tunnel but no longer correspond to a public service. It **only ever touches
records whose content is `<tunnel_id>.cfargotunnel.com`** — never other records in
the zone — so a hand-managed A/CNAME in the same zone is safe.
Needs a Cloudflare API token with **DNS:Edit** on the public zone (Cloudflare's
"Edit zone DNS" template — that single permission both resolves the zone by name
and edits records; no separate Zone:Read is needed), stored at
`~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent → this is a no-op and the
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
_API = "https://api.cloudflare.com/client/v4"
# Secret holding a Cloudflare token scoped to the PUBLIC zone (Zone:DNS:Edit).
# Distinct from CLOUDFLARE_API_TOKEN (ACME, the internal civil zone) because the
# public zone is typically a separate zone/account.
PUBLIC_DNS_TOKEN = "CLOUDFLARE_PUBLIC_DNS_TOKEN"
def public_dns_token() -> str | None:
"""The public-zone DNS token from the active secret backend, or None."""
from castle_core.config import read_secret
return read_secret(PUBLIC_DNS_TOKEN) or None
def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{_API}{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 (fixed API host)
return json.loads(resp.read())
def reconcile_public_dns(
public_domain: str | None,
tunnel_id: str | None,
desired_hosts: list[str],
messages: list[str],
token: str | None = None,
) -> bool:
"""Make the public zone's tunnel CNAMEs exactly `desired_hosts`.
Creates missing CNAMEs (proxied → the tunnel) and deletes castle-managed ones
(content == `<tunnel_id>.cfargotunnel.com`) not in `desired_hosts`. Never
touches records pointing elsewhere.
Returns True if reconciliation was attempted (a token was configured) — the
caller then suppresses the manual route hints — or False if skipped (no token /
no tunnel / no public domain), so the caller can fall back to those hints.
"""
token = token or public_dns_token()
if not (token and public_domain and tunnel_id):
return False
target = f"{tunnel_id}.cfargotunnel.com"
try:
zres = (_api(token, "GET", f"/zones?name={public_domain}").get("result")) or []
if not zres:
messages.append(
f"Warning: DNS token can't see zone '{public_domain}' — public "
"CNAMEs not reconciled. The token needs DNS:Edit (Cloudflare's "
f"'Edit zone DNS' template) scoped to {public_domain}."
)
return False
zone_id = zres[0]["id"]
# Castle-managed set = existing CNAMEs whose content is our tunnel.
recs = _api(
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
).get("result") or []
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
desired = set(desired_hosts)
created = sorted(desired - set(managed))
removed = sorted(set(managed) - desired)
for host in created:
_api(
token,
"POST",
f"/zones/{zone_id}/dns_records",
{"type": "CNAME", "name": host, "content": target, "proxied": True},
)
for host in removed:
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
if created or removed:
parts = []
if created:
parts.append(f"+{len(created)} ({', '.join(created)})")
if removed:
parts.append(f"-{len(removed)} ({', '.join(removed)})")
messages.append(f"Public DNS reconciled: {' '.join(parts)}")
else:
messages.append(f"Public DNS up to date ({len(desired)} CNAME(s)).")
return True
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200]
hint = ""
if e.code == 403:
# Reads can succeed while writes 403 — the token has DNS:Read but not
# DNS:Edit. Point at the fix rather than the raw body.
hint = (
" → the token needs DNS:Edit (write), not just read — use "
"Cloudflare's 'Edit zone DNS' template."
)
messages.append(f"Warning: public DNS reconcile failed (HTTP {e.code}): {body}{hint}")
return True # token was present; don't also print stale manual hints
except Exception as e: # noqa: BLE001 — DNS is best-effort; never fail a deploy
messages.append(f"Warning: public DNS reconcile failed: {e}")
return True

View File

@@ -0,0 +1,3 @@
"""Wild PC core library - manifest models, configuration, and generators."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,157 @@
"""Adopt an existing repo as a program — shared by the CLI (`wildpc program add`)
and the API (`POST /programs/adopt`).
`create` scaffolds new code from a stack; `add` adopts code that already exists —
a local path, or a git URL to clone later. Keeping the target parsing, stack /
command sniffing, and ProgramSpec build here means both front-ends behave
identically (the logic used to live only in the CLI).
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from wildpc_core.config import WildpcConfig
from wildpc_core.manifest import BuildSpec, CommandsSpec, ProgramSpec
class AdoptError(ValueError):
"""A program can't be adopted (bad path, or the name already exists)."""
def is_git_url(s: str) -> bool:
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
def looks_like_program(src: Path) -> bool:
"""Whether a directory holds something wildpc can adopt (a project manifest or
a git repo). Used to flag candidates in the filesystem browser."""
return (
(src / ".git").exists()
or (src / "pyproject.toml").exists()
or (src / "Cargo.toml").exists()
or (src / "package.json").exists()
or (src / "Makefile").exists()
or (src / "makefile").exists()
)
def detect_stack_commands(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
"""Detect (stack, commands) for a source dir.
Returns a stack name when the project fits a known one (so it inherits those
defaults), otherwise an explicit commands map. Adoption takes source only; the
deployment (and thus kind) is declared separately, so no kind is inferred here.
"""
commands: dict[str, list[list[str]]] = {}
pyproject = src / "pyproject.toml"
if pyproject.exists():
try:
data = tomllib.loads(pyproject.read_text())
except (OSError, tomllib.TOMLDecodeError):
data = {}
deps = " ".join(data.get("project", {}).get("dependencies", []))
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
return stack, commands
if (src / "Cargo.toml").exists():
commands = {
"build": [["cargo", "build", "--release"]],
"test": [["cargo", "test"]],
"lint": [["cargo", "clippy"]],
"run": [["cargo", "run"]],
}
return None, commands
if (src / "package.json").exists():
commands = {
"build": [["pnpm", "build"]],
"test": [["pnpm", "test"]],
"lint": [["pnpm", "lint"]],
}
return None, commands
if (src / "Makefile").exists() or (src / "makefile").exists():
commands = {"build": [["make"]], "test": [["make", "test"]]}
return None, commands
return None, commands
@dataclass
class Adopted:
"""The result of building a program spec from an adopt target — the caller
persists it (``config.programs[name] = spec``; then save/write)."""
name: str
spec: ProgramSpec
source: str
stack: str | None = None
repo: str | None = None
commands: list[str] = field(default_factory=list)
def build_adopted_program(
config: WildpcConfig,
target: str,
name: str | None = None,
description: str = "",
) -> Adopted:
"""Build (but do not save) a ProgramSpec adopting ``target``.
``target`` is a local path or a git URL. Raises :class:`AdoptError` if a local
path doesn't exist or the resolved name already exists in the config. Does not
mutate ``config`` — the caller assigns ``config.programs[name]`` and persists.
"""
repo_url: str | None = None
if is_git_url(target):
repo_url = target
name = name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `wildpc clone`.
source = str(config.repos_dir / name)
src_path = Path(source)
else:
src_path = Path(target).expanduser().resolve()
if not src_path.exists():
raise AdoptError(f"path does not exist: {src_path}")
source = str(src_path)
name = name or src_path.name
if name in config.programs or config.deployments_named(name):
raise AdoptError(f"'{name}' already exists in wildpc.yaml")
# Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so adoption takes source only;
# declare a deployment separately (wildpc service/job create).
stack: str | None = None
detected: dict[str, list[list[str]]] = {}
if src_path.exists():
stack, detected = detect_stack_commands(src_path)
spec = ProgramSpec(
id=name,
description=description or f"Adopted from {target}",
source=source,
stack=stack,
repo=repo_url,
)
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected:
build_cmds = detected.pop("build", None)
if build_cmds:
spec.build = BuildSpec(commands=build_cmds)
if detected:
spec.commands = CommandsSpec.model_validate(detected)
return Adopted(
name=name,
spec=spec,
source=source,
stack=stack,
repo=repo_url,
commands=sorted(detected),
)

View File

@@ -1,7 +1,7 @@
"""Consumption audit — *suggests* undeclared ``requires`` by matching a """Consumption audit — *suggests* undeclared ``requires`` by matching a
deployment's env endpoint values against known provider sockets. deployment's env endpoint values against known provider sockets.
This is the one place castle looks at env dependency, and it does so **only to This is the one place wildpc looks at env dependency, and it does so **only to
propose a declaration the user confirms** never to write, and never to feed the propose a declaration the user confirms** never to write, and never to feed the
graph or ``functional?``. The relationship graph stays strictly declaration-derived graph or ``functional?``. The relationship graph stays strictly declaration-derived
(see docs/relationships.md, "Env is derived *from* requires, never scraped *into* (see docs/relationships.md, "Env is derived *from* requires, never scraped *into*
@@ -15,8 +15,8 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from castle_core.config import CastleConfig from wildpc_core.config import WildpcConfig
from castle_core.relations import build_model from wildpc_core.relations import build_model
# Hosts that mean "a provider on this node" — a port match against them is a strong # Hosts that mean "a provider on this node" — a port match against them is a strong
# signal (ports are unique per host). 172.17.0.1 is the docker bridge to the host. # signal (ports are unique per host). 172.17.0.1 is the docker bridge to the host.
@@ -62,13 +62,13 @@ def _resolve(
out.append(Suggestion(consumer, pname, env_var, f"{host}:{port}", proto)) out.append(Suggestion(consumer, pname, env_var, f"{host}:{port}", proto))
def suggest_consumption(config: CastleConfig) -> list[Suggestion]: def suggest_consumption(config: WildpcConfig) -> list[Suggestion]:
"""Undeclared consumption suggestions, derived from env endpoint values. """Undeclared consumption suggestions, derived from env endpoint values.
Two shapes are recognized in a deployment's ``defaults.env``: Two shapes are recognized in a deployment's ``defaults.env``:
(a) ``host:port`` inside a single value (a URL, a ``DATABASE_URL``); and (a) ``host:port`` inside a single value (a URL, a ``DATABASE_URL``); and
(b) a split ``X_HOST`` + ``X_PORT`` pair (e.g. ``CASTLE_API_MQTT_HOST`` + (b) a split ``X_HOST`` + ``X_PORT`` pair (e.g. ``WILDPC_API_MQTT_HOST`` +
``CASTLE_API_MQTT_PORT``). When the port resolves to a *local* provider's socket ``WILDPC_API_MQTT_PORT``). When the port resolves to a *local* provider's socket
and the consumer doesn't already declare it, propose the edge — deduped per and the consumer doesn't already declare it, propose the edge — deduped per
(consumer, provider). A bare ``*_PORT`` with no ``*_HOST`` is ignored: without an (consumer, provider). A bare ``*_PORT`` with no ``*_HOST`` is ignored: without an
explicit host it can't be told apart from the deployment's own listen port.""" explicit host it can't be told apart from the deployment's own listen port."""

View File

@@ -1,4 +1,4 @@
"""Castle configuration and registry management.""" """Wild PC configuration and registry management."""
from __future__ import annotations from __future__ import annotations
@@ -6,11 +6,15 @@ import os
import re import re
from dataclasses import InitVar, dataclass, field from dataclasses import InitVar, dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import yaml import yaml
from pydantic import BaseModel, TypeAdapter from pydantic import BaseModel, TypeAdapter
from castle_core.manifest import ( if TYPE_CHECKING:
from wildpc_core.secret_backends import SecretBackend
from wildpc_core.manifest import (
AgentSpec, AgentSpec,
DeploymentSpec, DeploymentSpec,
ProgramSpec, ProgramSpec,
@@ -22,24 +26,24 @@ from castle_core.manifest import (
_DEPLOYMENT_ADAPTER: TypeAdapter[DeploymentSpec] = TypeAdapter(DeploymentSpec) _DEPLOYMENT_ADAPTER: TypeAdapter[DeploymentSpec] = TypeAdapter(DeploymentSpec)
def _resolve_castle_home() -> Path: def _resolve_wildpc_home() -> Path:
"""Resolve the castle home directory (config, code, artifacts, secrets). """Resolve the wildpc home directory (config, code, artifacts, secrets).
Defaults to ~/.castle. Override with the CASTLE_HOME environment variable Defaults to ~/.wildpc. Override with the WILDPC_HOME environment variable
(supports ~ and relative paths, which are expanded and made absolute). (supports ~ and relative paths, which are expanded and made absolute).
""" """
override = os.environ.get("CASTLE_HOME") override = os.environ.get("WILDPC_HOME")
if override: if override:
return Path(override).expanduser().resolve() return Path(override).expanduser().resolve()
return Path.home() / ".castle" return Path.home() / ".wildpc"
_DEFAULT_DATA_DIR = Path("/data/castle") _DEFAULT_DATA_DIR = Path("/data/wildpc")
_DEFAULT_REPOS_DIR = Path("/data/repos") _DEFAULT_REPOS_DIR = Path("/data/repos")
class CastleDirError(RuntimeError): class WildpcDirError(RuntimeError):
"""A required castle directory can't be created (e.g. data_dir outside a writable """A required wildpc directory can't be created (e.g. data_dir outside a writable
parent). Carries an actionable message; surfaced to the CLI and the api instead of parent). Carries an actionable message; surfaced to the CLI and the api instead of
a bare PermissionError traceback.""" a bare PermissionError traceback."""
@@ -47,10 +51,10 @@ class CastleDirError(RuntimeError):
def _resolve_root_path( def _resolve_root_path(
env_var: str, yaml_value: object, anchor: Path, default: Path env_var: str, yaml_value: object, anchor: Path, default: Path
) -> Path: ) -> Path:
"""Resolve a configurable root with precedence: env var > castle.yaml > default. """Resolve a configurable root with precedence: env var > wildpc.yaml > default.
`~` is expanded; a relative path is anchored to `anchor` (the dir containing `~` is expanded; a relative path is anchored to `anchor` (the dir containing
castle.yaml) never cwd, so the CLI (shell cwd) and the api service (unit cwd) wildpc.yaml) never cwd, so the CLI (shell cwd) and the api service (unit cwd)
resolve identically. The built-in default is returned as-is (so it compares equal resolve identically. The built-in default is returned as-is (so it compares equal
for the "persist only when non-default" check in save_config).""" for the "persist only when non-default" check in save_config)."""
raw = os.environ.get(env_var) or yaml_value raw = os.environ.get(env_var) or yaml_value
@@ -62,33 +66,34 @@ def _resolve_root_path(
return p.resolve() return p.resolve()
CASTLE_HOME = _resolve_castle_home() WILDPC_HOME = _resolve_wildpc_home()
CODE_DIR = CASTLE_HOME / "code" CODE_DIR = WILDPC_HOME / "code"
ARTIFACTS_DIR = CASTLE_HOME / "artifacts" ARTIFACTS_DIR = WILDPC_HOME / "artifacts"
SPECS_DIR = ARTIFACTS_DIR / "specs" SPECS_DIR = ARTIFACTS_DIR / "specs"
CONTENT_DIR = ARTIFACTS_DIR / "content" CONTENT_DIR = ARTIFACTS_DIR / "content"
SECRETS_DIR = CASTLE_HOME / "secrets" SECRETS_DIR = WILDPC_HOME / "secrets"
# data_dir and repos_dir are deliberately NOT module constants. Unlike the CASTLE_HOME # data_dir and repos_dir are deliberately NOT module constants. Unlike the WILDPC_HOME
# family above (env-or-default — the dir that *holds* castle.yaml can't be configured # family above (env-or-default — the dir that *holds* wildpc.yaml can't be configured
# inside it), these are per-instance settings read from castle.yaml. A module global # inside it), these are per-instance settings read from wildpc.yaml. A module global
# would be a second copy of that value, resolved once at import against one process's # would be a second copy of that value, resolved once at import against one process's
# environment — exactly what let the CLI and the api service drift. They live only on # environment — exactly what let the CLI and the api service drift. They live only on
# the loaded CastleConfig; read config.data_dir / config.repos_dir (see load_config). # the loaded WildpcConfig; read config.data_dir / config.repos_dir (see load_config).
# User tool directories — the single source of truth for "where our CLIs live". # User tool directories — the single source of truth for "where our CLIs live".
# Used both at build time (dev-verb subprocess PATH) and at run time (generated # Used both at build time (dev-verb subprocess PATH) and at run time (generated
# systemd unit PATH) so a service sees the same tools castle used to build it. # systemd unit PATH) so a service sees the same tools wildpc used to build it.
# Order matters: pnpm's modern standalone installer puts its shim in # Order matters: pnpm's modern standalone installer puts its shim in
# $PNPM_HOME/bin, which must win over the bare dir (older installs leave a stale # $PNPM_HOME/bin, which must win over the bare dir (older installs leave a stale
# version wrapper there). nvm/node is intentionally omitted — it's versioned and # version wrapper there). nvm/node is intentionally omitted — it's versioned and
# brittle to hardcode. A program's node is resolved per-program from its declared # brittle to hardcode. A program's node is resolved per-program from its declared
# pin (.node-version/.nvmrc/engines) and prepended ahead of these dirs at both the # pin (.node-version/.nvmrc/engines) and prepended ahead of these dirs at both the
# build subprocess (stacks._build_env) and the runtime unit PATH (Deployment # build subprocess (stacks._build_env) and the runtime unit PATH (Deployment
# .path_prepend) — see castle_core.toolchains. # .path_prepend) — see wildpc_core.toolchains.
USER_TOOL_PATH_DIRS = [ USER_TOOL_PATH_DIRS = [
Path.home() / ".local" / "bin", Path.home() / ".local" / "bin",
Path.home() / ".local" / "share" / "pnpm" / "bin", Path.home() / ".local" / "share" / "pnpm" / "bin",
Path.home() / ".local" / "share" / "pnpm", Path.home() / ".local" / "share" / "pnpm",
Path.home() / ".deno" / "bin", # deno's installer target (supabase edge fns)
Path("/usr/local/go/bin"), Path("/usr/local/go/bin"),
] ]
@@ -97,24 +102,24 @@ GENERATED_DIR = SPECS_DIR
STATIC_DIR = CONTENT_DIR STATIC_DIR = CONTENT_DIR
def find_castle_root() -> Path: def find_wildpc_root() -> Path:
"""Find the castle config root (directory containing castle.yaml). """Find the wildpc config root (directory containing wildpc.yaml).
Search order: Search order:
1. ~/.castle/castle.yaml (the canonical instance location) 1. ~/.wildpc/wildpc.yaml (the canonical instance location)
2. Walk up from cwd (for development/testing) 2. Walk up from cwd (for development/testing)
""" """
# Canonical location first # Canonical location first
if (CASTLE_HOME / "castle.yaml").exists(): if (WILDPC_HOME / "wildpc.yaml").exists():
return CASTLE_HOME return WILDPC_HOME
# Fallback: walk up from cwd # Fallback: walk up from cwd
current = Path.cwd() current = Path.cwd()
while current != current.parent: while current != current.parent:
if (current / "castle.yaml").exists(): if (current / "wildpc.yaml").exists():
return current return current
current = current.parent current = current.parent
raise FileNotFoundError( raise FileNotFoundError(
f"Could not find castle.yaml.\nExpected at: {CASTLE_HOME / 'castle.yaml'}" f"Could not find wildpc.yaml.\nExpected at: {WILDPC_HOME / 'wildpc.yaml'}"
) )
@@ -137,7 +142,7 @@ class GatewayConfig:
# names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID. # names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID.
public_domain: str | None = None public_domain: str | None = None
tunnel_id: str | None = None tunnel_id: str | None = None
# acme mode only: emit the `events { on cert_obtained exec castle tls reconcile }` # acme mode only: emit the `events { on cert_obtained exec wildpc tls reconcile }`
# hook so certs materialized onto raw-TCP services refresh on renewal. Requires # hook so certs materialized onto raw-TCP services refresh on renewal. Requires
# the events-exec plugin in the gateway's Caddy build — set true only once that # the events-exec plugin in the gateway's Caddy build — set true only once that
# Caddy is installed (see docs/tcp-exposure.md §5). Default false keeps a # Caddy is installed (see docs/tcp-exposure.md §5). Default false keeps a
@@ -145,7 +150,7 @@ class GatewayConfig:
cert_hook: bool = False cert_hook: bool = False
# Deployment kinds and the CastleConfig store each lives in. Kind is STRUCTURAL — # Deployment kinds and the WildpcConfig store each lives in. Kind is STRUCTURAL —
# a deployment's identity is (name, kind), so names are unique within a kind but may # a deployment's identity is (name, kind), so names are unique within a kind but may
# collide across kinds (a `backup` tool + service + job coexist). `kind_for` (manifest) # collide across kinds (a `backup` tool + service + job coexist). `kind_for` (manifest)
# stays only to validate that a spec's manager/schedule matches the store it's in. # stays only to validate that a spec's manager/schedule matches the store it's in.
@@ -160,8 +165,8 @@ _KIND_STORE = {
@dataclass @dataclass
class CastleConfig: class WildpcConfig:
"""Full castle configuration.""" """Full wildpc configuration."""
root: Path root: Path
gateway: GatewayConfig gateway: GatewayConfig
@@ -180,7 +185,7 @@ class CastleConfig:
# Optional; empty means the API falls back to a built-in default set. # Optional; empty means the API falls back to a built-in default set.
agents: dict[str, AgentSpec] = field(default_factory=dict) agents: dict[str, AgentSpec] = field(default_factory=dict)
# Configurable roots — the single source of truth (no module-constant twin). # Configurable roots — the single source of truth (no module-constant twin).
# load_config sets them (env > castle.yaml > default); a bare constructor gets the # load_config sets them (env > wildpc.yaml > default); a bare constructor gets the
# built-in defaults so tests/callers that don't care stay valid. # built-in defaults so tests/callers that don't care stay valid.
data_dir: Path = field(default_factory=lambda: _DEFAULT_DATA_DIR) data_dir: Path = field(default_factory=lambda: _DEFAULT_DATA_DIR)
repos_dir: Path = field(default_factory=lambda: _DEFAULT_REPOS_DIR) repos_dir: Path = field(default_factory=lambda: _DEFAULT_REPOS_DIR)
@@ -254,9 +259,9 @@ def resolve_env_split(
- ``${secret:NAME}`` resolves via the active secret backend (file or OpenBao). - ``${secret:NAME}`` resolves via the active secret backend (file or OpenBao).
- ``${port}`` / ``${data_dir}`` / ``${name}`` / ``${public_url}`` (and - ``${port}`` / ``${data_dir}`` / ``${name}`` / ``${public_url}`` (and
anything else in ``context``) expand to castle's computed values, so a anything else in ``context``) expand to wildpc's computed values, so a
service maps them to whatever env var its program reads (e.g. service maps them to whatever env var its program reads (e.g.
``MY_PORT: ${port}``) without hardcoding or castle silently injecting a ``MY_PORT: ${port}``) without hardcoding or wildpc silently injecting a
guessed var name. ``${public_url}`` is the service's gateway-facing base guessed var name. ``${public_url}`` is the service's gateway-facing base
URL (``https://<name>.<domain>`` under acme) the origin an app allowlists. URL (``https://<name>.<domain>`` under acme) the origin an app allowlists.
""" """
@@ -319,23 +324,54 @@ def resolve_env_vars(
def _secrets_settings() -> dict: def _secrets_settings() -> dict:
"""The ``secrets:`` block of castle.yaml — selects the backend.""" """The ``secrets:`` block of wildpc.yaml — selects the backend."""
try: try:
data = yaml.safe_load((CASTLE_HOME / "castle.yaml").read_text()) or {} data = yaml.safe_load((WILDPC_HOME / "wildpc.yaml").read_text()) or {}
return data.get("secrets") or {} return data.get("secrets") or {}
except Exception: except Exception:
return {} return {}
def active_secret_backend() -> SecretBackend:
"""The active secret backend (file or openbao), per ``secrets:`` + env.
The one place to get a handle for reading/writing/listing secrets, so callers
(the ``wildpc secret`` CLI, preflight checks) act on the *active* backend
rather than guessing the filesystem the mistake that silently shadows an
OpenBao fleet's secret with a stray file.
"""
from wildpc_core.secret_backends import build_backend
return build_backend(SECRETS_DIR, _secrets_settings())
def active_backend_name() -> str:
"""Human name of the active backend (``openbao`` | ``file``) for messages."""
return (os.environ.get("WILDPC_SECRET_BACKEND") or _secrets_settings().get("backend") or "file").lower()
def read_secret(name: str) -> str | None: def read_secret(name: str) -> str | None:
"""Resolve a secret via the active backend, or None if unset. """Resolve a secret via the active backend, or None if unset.
The public helper for code that reads secrets directly (DNS/supabase/etc.) so The public helper for code that reads secrets directly (DNS/supabase/etc.) so
everything goes through the one backend rather than the filesystem. everything goes through the one backend rather than the filesystem.
""" """
from castle_core.secret_backends import build_backend return active_secret_backend().read(name)
return build_backend(SECRETS_DIR, _secrets_settings()).read(name)
# ``${secret:NAME}`` references embedded in an env value (the grammar
# :func:`resolve_env_split` resolves). Shared by the deploy-time preflight that
# refuses to converge a deployment whose secrets the active backend can't resolve.
_SECRET_REF_RE = re.compile(r"\$\{secret:([^}]+)\}")
def secret_refs(value: str) -> list[str]:
"""The secret names a value references via ``${secret:NAME}`` (order-preserving,
deduped)."""
seen: dict[str, None] = {}
for m in _SECRET_REF_RE.finditer(value):
seen.setdefault(m.group(1), None)
return list(seen)
def _read_secret(name: str) -> str: def _read_secret(name: str) -> str:
@@ -378,7 +414,7 @@ def _load_resource_dir(directory: Path) -> dict[str, dict]:
def parse_gateway(gateway_data: dict) -> GatewayConfig: def parse_gateway(gateway_data: dict) -> GatewayConfig:
"""Build a GatewayConfig from a castle.yaml ``gateway:`` mapping. """Build a GatewayConfig from a wildpc.yaml ``gateway:`` mapping.
The single parser shared by ``load_config`` and the API's whole-file editor, The single parser shared by ``load_config`` and the API's whole-file editor,
so a newly added gateway field can't be honored in one place and silently so a newly added gateway field can't be honored in one place and silently
@@ -397,14 +433,14 @@ def parse_gateway(gateway_data: dict) -> GatewayConfig:
) )
def load_config(root: Path | None = None) -> CastleConfig: def load_config(root: Path | None = None) -> WildpcConfig:
"""Load castle config: global castle.yaml + programs/ and deployments/ dirs.""" """Load wildpc config: global wildpc.yaml + programs/ and deployments/ dirs."""
if root is None: if root is None:
root = find_castle_root() root = find_wildpc_root()
config_path = root / "castle.yaml" config_path = root / "wildpc.yaml"
if not config_path.exists(): if not config_path.exists():
raise FileNotFoundError(f"Castle config not found: {config_path}") raise FileNotFoundError(f"Wild PC config not found: {config_path}")
with open(config_path) as f: with open(config_path) as f:
data = yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
@@ -417,13 +453,13 @@ def load_config(root: Path | None = None) -> CastleConfig:
repo_path = Path(data["repo"]).expanduser() repo_path = Path(data["repo"]).expanduser()
# Configurable roots: env > this file's data_dir/repos_dir > default, anchored to # Configurable roots: env > this file's data_dir/repos_dir > default, anchored to
# `root` (the dir holding this castle.yaml) so a per-call load_config is correct # `root` (the dir holding this wildpc.yaml) so a per-call load_config is correct
# regardless of the import-time constants (which resolved against CASTLE_HOME). # regardless of the import-time constants (which resolved against WILDPC_HOME).
data_dir = _resolve_root_path( data_dir = _resolve_root_path(
"CASTLE_DATA_DIR", data.get("data_dir"), root, _DEFAULT_DATA_DIR "WILDPC_DATA_DIR", data.get("data_dir"), root, _DEFAULT_DATA_DIR
) )
repos_dir = _resolve_root_path( repos_dir = _resolve_root_path(
"CASTLE_REPOS_DIR", data.get("repos_dir"), root, _DEFAULT_REPOS_DIR "WILDPC_REPOS_DIR", data.get("repos_dir"), root, _DEFAULT_REPOS_DIR
) )
programs: dict[str, ProgramSpec] = {} programs: dict[str, ProgramSpec] = {}
@@ -432,7 +468,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
# Resolve source paths to absolute # Resolve source paths to absolute
if prog.source: if prog.source:
if prog.source.startswith("repo:") and repo_path: if prog.source.startswith("repo:") and repo_path:
# repo:castle-api → /data/repos/castle/castle-api # repo:wildpc-api → /data/repos/wildpc/wildpc-api
prog.source = str(repo_path / prog.source[5:]) prog.source = str(repo_path / prog.source[5:])
elif not Path(prog.source).is_absolute(): elif not Path(prog.source).is_absolute():
prog.source = str(root / prog.source) prog.source = str(root / prog.source)
@@ -446,7 +482,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
for name, spec in (data.get("agents") or {}).items() for name, spec in (data.get("agents") or {}).items()
} }
config = CastleConfig( config = WildpcConfig(
root=root, root=root,
repo=repo_path, repo=repo_path,
gateway=gateway, gateway=gateway,
@@ -564,7 +600,7 @@ def _spec_to_yaml_dict(spec: BaseModel) -> dict:
return cleaned if isinstance(cleaned, dict) else {} return cleaned if isinstance(cleaned, dict) else {}
def _program_to_yaml_dict(spec: ProgramSpec, config: CastleConfig) -> dict: def _program_to_yaml_dict(spec: ProgramSpec, config: WildpcConfig) -> dict:
"""Serialize a ProgramSpec, rewriting absolute source paths to relative.""" """Serialize a ProgramSpec, rewriting absolute source paths to relative."""
d = _spec_to_yaml_dict(spec) d = _spec_to_yaml_dict(spec)
if d.get("source") and Path(d["source"]).is_absolute(): if d.get("source") and Path(d["source"]).is_absolute():
@@ -596,7 +632,7 @@ def _write_resource_dir(directory: Path, specs: dict[str, dict]) -> None:
path.unlink() path.unlink()
# Top-level castle.yaml keys that save_config owns and rewrites; anything else # Top-level wildpc.yaml keys that save_config owns and rewrites; anything else
# (e.g. `secrets:`) is preserved verbatim so a rewrite can't drop it. # (e.g. `secrets:`) is preserved verbatim so a rewrite can't drop it.
_MANAGED_GLOBALS = { _MANAGED_GLOBALS = {
"gateway", "repo", "data_dir", "repos_dir", "agents", "role", "gateway", "repo", "data_dir", "repos_dir", "agents", "role",
@@ -604,10 +640,10 @@ _MANAGED_GLOBALS = {
} }
def write_deployment_file(config: CastleConfig, kind: str, name: str) -> None: def write_deployment_file(config: WildpcConfig, kind: str, name: str) -> None:
"""Write (or remove) a single deployment file — globals and other resources are """Write (or remove) a single deployment file — globals and other resources are
left untouched. This is the PATCH primitive: a deployment edit persists only left untouched. This is the PATCH primitive: a deployment edit persists only
that deployment, never rewriting castle.yaml globals.""" that deployment, never rewriting wildpc.yaml globals."""
directory = config.root / "deployments" / _KIND_STORE[kind] directory = config.root / "deployments" / _KIND_STORE[kind]
path = directory / f"{name}.yaml" path = directory / f"{name}.yaml"
spec = config.store_for(kind).get(name) spec = config.store_for(kind).get(name)
@@ -619,7 +655,7 @@ def write_deployment_file(config: CastleConfig, kind: str, name: str) -> None:
yaml.dump(_spec_to_yaml_dict(spec), f, default_flow_style=False, sort_keys=False) yaml.dump(_spec_to_yaml_dict(spec), f, default_flow_style=False, sort_keys=False)
def write_program_file(config: CastleConfig, name: str) -> None: def write_program_file(config: WildpcConfig, name: str) -> None:
"""Write (or remove) a single program file — nothing else is touched.""" """Write (or remove) a single program file — nothing else is touched."""
directory = config.root / "programs" directory = config.root / "programs"
path = directory / f"{name}.yaml" path = directory / f"{name}.yaml"
@@ -634,8 +670,8 @@ def write_program_file(config: CastleConfig, name: str) -> None:
) )
def save_config(config: CastleConfig) -> None: def save_config(config: WildpcConfig) -> None:
"""Save castle config: global castle.yaml + programs/ and deployments/ dirs.""" """Save wildpc config: global wildpc.yaml + programs/ and deployments/ dirs."""
gateway_data: dict = {"port": config.gateway.port} gateway_data: dict = {"port": config.gateway.port}
if config.gateway.tls: if config.gateway.tls:
gateway_data["tls"] = config.gateway.tls gateway_data["tls"] = config.gateway.tls
@@ -643,7 +679,7 @@ def save_config(config: CastleConfig) -> None:
gateway_data["domain"] = config.gateway.domain gateway_data["domain"] = config.gateway.domain
if config.gateway.acme_email: if config.gateway.acme_email:
gateway_data["acme_email"] = config.gateway.acme_email gateway_data["acme_email"] = config.gateway.acme_email
# Only persist the provider when non-default, to keep castle.yaml minimal. # Only persist the provider when non-default, to keep wildpc.yaml minimal.
if ( if (
config.gateway.acme_dns_provider config.gateway.acme_dns_provider
and config.gateway.acme_dns_provider != "cloudflare" and config.gateway.acme_dns_provider != "cloudflare"
@@ -658,7 +694,7 @@ def save_config(config: CastleConfig) -> None:
data: dict = {"gateway": gateway_data} data: dict = {"gateway": gateway_data}
if config.repo: if config.repo:
data["repo"] = str(config.repo) data["repo"] = str(config.repo)
# Persist the configurable roots only when non-default, keeping castle.yaml minimal. # Persist the configurable roots only when non-default, keeping wildpc.yaml minimal.
# These MUST round-trip: save_config rewrites the file from scratch, so a root that # These MUST round-trip: save_config rewrites the file from scratch, so a root that
# isn't re-emitted here would be silently dropped on the next apply. # isn't re-emitted here would be silently dropped on the next apply.
if config.data_dir != _DEFAULT_DATA_DIR: if config.data_dir != _DEFAULT_DATA_DIR:
@@ -677,17 +713,17 @@ def save_config(config: CastleConfig) -> None:
# from the existing file. # from the existing file.
if config.role and config.role != "follower": if config.role and config.role != "follower":
data["role"] = config.role data["role"] = config.role
# Preserve any top-level castle.yaml keys this writer doesn't model (e.g. # Preserve any top-level wildpc.yaml keys this writer doesn't model (e.g.
# `secrets:`) — a full rewrite must never silently drop an unmanaged global. # `secrets:`) — a full rewrite must never silently drop an unmanaged global.
try: try:
existing = yaml.safe_load((config.root / "castle.yaml").read_text()) or {} existing = yaml.safe_load((config.root / "wildpc.yaml").read_text()) or {}
for k, v in existing.items(): for k, v in existing.items():
if k not in _MANAGED_GLOBALS and k not in data: if k not in _MANAGED_GLOBALS and k not in data:
data[k] = v data[k] = v
except Exception: except Exception:
pass pass
config_path = config.root / "castle.yaml" config_path = config.root / "wildpc.yaml"
with open(config_path, "w") as f: with open(config_path, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False) yaml.dump(data, f, default_flow_style=False, sort_keys=False)
@@ -704,10 +740,10 @@ def save_config(config: CastleConfig) -> None:
) )
def ensure_dirs(config: CastleConfig) -> None: def ensure_dirs(config: WildpcConfig) -> None:
"""Ensure castle directories exist. Takes the config so the data dir comes from the """Ensure wildpc directories exist. Takes the config so the data dir comes from the
one source of truth (config.data_dir), not a process-resolved global.""" one source of truth (config.data_dir), not a process-resolved global."""
CASTLE_HOME.mkdir(parents=True, exist_ok=True) WILDPC_HOME.mkdir(parents=True, exist_ok=True)
CODE_DIR.mkdir(parents=True, exist_ok=True) CODE_DIR.mkdir(parents=True, exist_ok=True)
SPECS_DIR.mkdir(parents=True, exist_ok=True) SPECS_DIR.mkdir(parents=True, exist_ok=True)
CONTENT_DIR.mkdir(parents=True, exist_ok=True) CONTENT_DIR.mkdir(parents=True, exist_ok=True)
@@ -717,9 +753,9 @@ def ensure_dirs(config: CastleConfig) -> None:
try: try:
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
except OSError as e: except OSError as e:
raise CastleDirError( raise WildpcDirError(
f"Cannot create data dir {data_dir}: {e.strerror or e}. " f"Cannot create data dir {data_dir}: {e.strerror or e}. "
f"Set data_dir: in {CASTLE_HOME / 'castle.yaml'} (or export CASTLE_DATA_DIR) " f"Set data_dir: in {WILDPC_HOME / 'wildpc.yaml'} (or export WILDPC_DATA_DIR) "
f"to a writable path, or create it: " f"to a writable path, or create it: "
f"sudo mkdir -p {data_dir} && sudo chown $(id -un) {data_dir}" f"sudo mkdir -p {data_dir} && sudo chown $(id -un) {data_dir}"
) from e ) from e

View File

@@ -1,7 +1,7 @@
"""Deploy logic — bridge castle.yaml spec to runtime (~/.castle/). """Deploy logic — bridge wildpc.yaml spec to runtime (~/.wildpc/).
This module contains the core deploy logic shared by the CLI and API. This module contains the core deploy logic shared by the CLI and API.
It reads castle.yaml, resolves services/jobs into Deployments, It reads wildpc.yaml, resolves services/jobs into Deployments,
writes the registry, generates systemd units and the Caddyfile, and writes the registry, generates systemd units and the Caddyfile, and
copies frontend build outputs. copies frontend build outputs.
""" """
@@ -11,27 +11,28 @@ from __future__ import annotations
import os import os
import shutil import shutil
import subprocess import subprocess
from collections.abc import Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from castle_core.config import ( from wildpc_core.config import (
SPECS_DIR, SPECS_DIR,
CastleConfig, WildpcConfig,
ensure_dirs, ensure_dirs,
load_config, load_config,
resolve_env_split, resolve_env_split,
resolve_placeholders, resolve_placeholders,
) )
from castle_core.generators.caddyfile import ( from wildpc_core.generators.caddyfile import (
_DNS_TOKEN_ENV, _DNS_TOKEN_ENV,
generate_caddyfile_from_registry, generate_caddyfile_from_registry,
) )
from castle_core.generators.dns import reconcile_public_dns from wildpc_core.generators.dns import reconcile_public_dns
from castle_core.generators.tunnel import ( from wildpc_core.generators.tunnel import (
generate_tunnel_config, generate_tunnel_config,
public_hostnames, public_hostnames,
) )
from castle_core.generators.systemd import ( from wildpc_core.generators.systemd import (
SECRET_ENV_DIR, SECRET_ENV_DIR,
generate_timer, generate_timer,
generate_unit_from_deployed, generate_unit_from_deployed,
@@ -40,7 +41,7 @@ from castle_core.generators.systemd import (
unit_env_file, unit_env_file,
unit_name, unit_name,
) )
from castle_core.manifest import ( from wildpc_core.manifest import (
CaddyDeployment, CaddyDeployment,
DeploymentBase, DeploymentBase,
DeploymentSpec, DeploymentSpec,
@@ -49,7 +50,7 @@ from castle_core.manifest import (
TlsMaterial, TlsMaterial,
kind_for, kind_for,
) )
from castle_core.registry import ( from wildpc_core.registry import (
REGISTRY_PATH, REGISTRY_PATH,
Deployment, Deployment,
NodeConfig, NodeConfig,
@@ -57,7 +58,7 @@ from castle_core.registry import (
load_registry, load_registry,
save_registry, save_registry,
) )
from castle_core.toolchains import ToolchainError, resolve_node_bin from wildpc_core.toolchains import ToolchainError, resolve_node_bin
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
@@ -73,7 +74,7 @@ class DeployResult:
@dataclass @dataclass
class ApplyResult: class ApplyResult:
"""Result of a converge (`castle apply`): what actually changed. """Result of a converge (`wildpc apply`): what actually changed.
`deploy` renders config artifacts; `apply` renders *and then* reconciles the `deploy` renders config artifacts; `apply` renders *and then* reconciles the
running system to match, so the interesting output is the diff it enacted. running system to match, so the interesting output is the diff it enacted.
@@ -86,21 +87,39 @@ class ApplyResult:
pruned: list[str] = field(default_factory=list) pruned: list[str] = field(default_factory=list)
messages: list[str] = field(default_factory=list) messages: list[str] = field(default_factory=list)
registry: NodeRegistry | None = None registry: NodeRegistry | None = None
# Gateway routing (the Caddyfile / cloudflared ingress) differs from what's live:
# a caddy static/proxy route was added, removed, or had its root/reach changed.
# Tracked separately because such a delta touches no systemd unit, so the
# activate/restart/deactivate reconcile never classifies it as a change. apply()
# rewrites the artifacts and reloads the gateway regardless; this flag just lets
# the summary report it instead of a false "already converged".
gateway_changed: bool = False
# True for a `--plan` run: the diff was computed but nothing was written or # True for a `--plan` run: the diff was computed but nothing was written or
# activated. Lets callers render "would activate…" vs "activated…". # activated. Lets callers render "would activate…" vs "activated…".
planned: bool = False planned: bool = False
# Deployments refused because their ``${secret:NAME}`` references don't resolve
# in the active backend. A non-empty list means apply made NO changes and the
# caller should exit non-zero — better than starting a service with a bogus
# ``<MISSING_SECRET:…>`` value silently standing in for the real credential.
blocked: list[str] = field(default_factory=list)
@property @property
def changed(self) -> bool: def changed(self) -> bool:
return bool(self.activated or self.restarted or self.deactivated or self.pruned) return bool(
self.activated
or self.restarted
or self.deactivated
or self.pruned
or self.gateway_changed
)
def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult: def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult:
"""Deploy from castle.yaml to ~/.castle/. """Deploy from wildpc.yaml to ~/.wildpc/.
Args: Args:
target_name: Deploy a single service/job by name, or None for all. target_name: Deploy a single service/job by name, or None for all.
root: Config root path. If None, uses find_castle_root(). root: Config root path. If None, uses find_wildpc_root().
Returns: Returns:
DeployResult with deployed count, messages, and the registry. DeployResult with deployed count, messages, and the registry.
@@ -176,10 +195,10 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
return result return result
def _node_config(config: CastleConfig) -> NodeConfig: def _node_config(config: WildpcConfig) -> NodeConfig:
"""The registry NodeConfig derived from a config's gateway settings.""" """The registry NodeConfig derived from a config's gateway settings."""
return NodeConfig( return NodeConfig(
castle_root=str(config.root), wildpc_root=str(config.root),
gateway_port=config.gateway.port, gateway_port=config.gateway.port,
gateway_tls=config.gateway.tls, gateway_tls=config.gateway.tls,
gateway_domain=config.gateway.domain, gateway_domain=config.gateway.domain,
@@ -205,6 +224,48 @@ def _unit_bytes(name: str, kind: str) -> str | None:
return path.read_text() if path.exists() else None return path.read_text() if path.exists() else None
def _desired_registry(config: WildpcConfig, target_name: str | None) -> NodeRegistry:
"""The registry ``deploy()`` would write for this (optionally scoped) run.
Mirrors deploy()'s registry build: a scoped run merges the updated target over
the existing on-disk registry; a full run starts fresh. Used to predict
gateway-route deltas without writing anything."""
node = _node_config(config)
if target_name and REGISTRY_PATH.exists():
try:
registry = NodeRegistry(node=node, deployed=dict(load_registry().deployed))
except (FileNotFoundError, ValueError):
registry = NodeRegistry(node=node)
else:
registry = NodeRegistry(node=node)
for _kind, name, dep in config.all_deployments():
if target_name and name != target_name:
continue
deployed = _build_deployed(config, name, dep, [])
deployed.name = name
registry.put(deployed)
return registry
def _gateway_would_change(config: WildpcConfig, target_name: str | None) -> bool:
"""Whether applying would rewrite the gateway's routing artifacts — the
Caddyfile or the cloudflared ingress vs. what's on disk.
A pure caddy route change (new static, changed ``root``/``reach``, toggled
public) touches no systemd unit, so the activate/restart/deactivate reconcile
can't see it; without this the summary reports "already converged" despite a
live routing change. Compared before ``deploy()`` rewrites the artifacts, so it
reflects the pre-apply delta for both the plan and the real run."""
registry = _desired_registry(config, target_name)
caddyfile = SPECS_DIR / "Caddyfile"
current_caddy = caddyfile.read_text() if caddyfile.exists() else None
if generate_caddyfile_from_registry(registry) != current_caddy:
return True
tunnel_path = SPECS_DIR / "cloudflared.yml"
current_tunnel = tunnel_path.read_text() if tunnel_path.exists() else None
return generate_tunnel_config(registry) != current_tunnel
def apply( def apply(
target_name: str | None = None, target_name: str | None = None,
root: Path | None = None, root: Path | None = None,
@@ -223,7 +284,8 @@ def apply(
""" """
import asyncio import asyncio
from castle_core.lifecycle import activate, deactivate, is_active from wildpc_core.config import active_backend_name
from wildpc_core.lifecycle import activate, deactivate, is_active
config = load_config(root) config = load_config(root)
# Each item is (kind, name, spec); target_name matches every kind of that name. # Each item is (kind, name, spec); target_name matches every kind of that name.
@@ -235,6 +297,28 @@ def apply(
] ]
names = [n for _k, n, _d in items] names = [n for _k, n, _d in items]
# Fail loud on unresolved secrets BEFORE any render/write. Building a deployment
# writes its secret env file, so this gate runs first — a service is never
# rendered or (re)started with a bogus `<MISSING_SECRET:…>` value silently
# standing in for a real credential (the failure that shipped immich's DB
# password to the wrong backend). Aborts the whole run with a fix hint; nothing
# is written or reconciled.
blocked = _unresolved_secrets(items)
if blocked:
backend = active_backend_name()
result = ApplyResult(planned=plan)
result.blocked = [n for n, _ in blocked]
result.messages.append(
f"Error: unresolved secrets in the active '{backend}' backend — "
f"nothing was {'planned' if plan else 'applied'}."
)
for n, secrets in blocked:
for s in secrets:
result.messages.append(
f" {n}: ${{secret:{s}}} → not found. Fix: wildpc secret set {s}"
)
return result
# Snapshot BEFORE rendering: liveness + current unit bytes (for restart-on-change). # Snapshot BEFORE rendering: liveness + current unit bytes (for restart-on-change).
before_active = {(k, n): is_active(n, k, config) for k, n, _ in items} before_active = {(k, n): is_active(n, k, config) for k, n, _ in items}
before_unit = {(k, n): _unit_bytes(n, k) for k, n, _ in items} before_unit = {(k, n): _unit_bytes(n, k) for k, n, _ in items}
@@ -264,11 +348,17 @@ def apply(
deployed={NodeRegistry.key(k, n): d for (k, n), d in desired.items()}, deployed={NodeRegistry.key(k, n): d for (k, n), d in desired.items()},
) )
) )
# Gateway routing lives in the Caddyfile / cloudflared ingress, not a systemd
# unit, so _classify above can't see a route-only change. Detect it here against
# the on-disk artifacts (before deploy() rewrites them) so both the plan and the
# real run report it instead of a false "already converged".
result.gateway_changed = _gateway_would_change(config, target_name)
if plan: if plan:
# No writes: for systemd, predict the new unit bytes by rendering to a string # No writes: for systemd, predict the new unit bytes by rendering to a string
# so "would restart" is accurate; other managers never restart. # so "would restart" is accurate; other managers never restart.
result.planned = True result.planned = True
_stack_preflight(config, items, result.messages)
for k, n, _ in items: for k, n, _ in items:
after = _render_unit_preview(config, n, desired[(k, n)], k) after = _render_unit_preview(config, n, desired[(k, n)], k)
_record(result, n, _classify((k, n), after)) _record(result, n, _classify((k, n), after))
@@ -279,6 +369,7 @@ def apply(
deploy_result = deploy(target_name, root) deploy_result = deploy(target_name, root)
result.messages = list(deploy_result.messages) result.messages = list(deploy_result.messages)
result.registry = deploy_result.registry result.registry = deploy_result.registry
_stack_preflight(config, items, result.messages)
# Materialize TLS cert files before (re)starting so a TLS service finds them on # Materialize TLS cert files before (re)starting so a TLS service finds them on
# start. On a fresh node the gateway reload above only kicks off ACME issuance, # start. On a fresh node the gateway reload above only kicks off ACME issuance,
@@ -287,8 +378,8 @@ def apply(
# materialization to the deployments being applied so a scoped apply doesn't # materialization to the deployments being applied so a scoped apply doesn't
# rewrite an unrelated service's cert without reloading it. No reload here — the # rewrite an unrelated service's cert without reloading it. No reload here — the
# activation loop below starts/restarts as needed; rotation-driven reloads are # activation loop below starts/restarts as needed; rotation-driven reloads are
# the `castle tls reconcile` / cert_obtained path. # the `wildpc tls reconcile` / cert_obtained path.
from castle_core.tls import materialize_all, wait_for_wildcard from wildpc_core.tls import materialize_all, wait_for_wildcard
wait_for_wildcard(config, names, result.messages) wait_for_wildcard(config, names, result.messages)
materialize_all(config, result.messages, only=names) materialize_all(config, result.messages, only=names)
@@ -312,6 +403,73 @@ def apply(
return result return result
def _stack_preflight(
config: WildpcConfig,
items: Sequence[tuple[str, str, object]],
messages: list[str],
) -> None:
"""Warn (never fail) when an enabled deployment's stack toolchain is missing
*where it runs* the moment drift actually bites: a service whose `uv`/`pnpm`
isn't on its runtime PATH won't build or start. Mirrors `_acme_preflight`: an
advisory message, no writes, no gate. The exact fix comes from the tool's hint."""
from wildpc_core.relations import _tool_available
from wildpc_core.stacks import tools_for
for _k, n, spec in items:
if not getattr(spec, "enabled", True):
continue
prog = config.programs.get(getattr(spec, "program", None) or n)
if not prog or not prog.stack:
continue
for tool in tools_for(prog.stack):
if not _tool_available(spec, tool):
messages.append(
f"Warning: {n} ({prog.stack}) needs '{tool.command}' but it's "
f"missing where the service runs — {tool.install_hint}"
)
def _unresolved_secrets(
items: Sequence[tuple[str, str, object]],
) -> list[tuple[str, list[str]]]:
"""Enabled deployments whose ``${secret:NAME}`` env references the active backend
can't resolve, as ``[(deployment, [missing_secret, ...]), ...]``.
This is the fail-loud gate for the footgun that shipped a service with a bogus
``<MISSING_SECRET:>`` string standing in for a real credential: a secret
written to the *wrong* backend (a file on an OpenBao fleet) never resolves, yet
the old path substituted the marker and started the service anyway. Checked
against the active backend so it's correct whichever backend is configured.
"""
from wildpc_core.config import active_secret_backend, secret_refs
backend = active_secret_backend()
resolved: dict[str, bool] = {} # cache: one backend read per distinct secret
def _missing(name: str) -> bool:
if name not in resolved:
try:
resolved[name] = backend.read(name) is not None
except Exception:
resolved[name] = False
return not resolved[name]
out: list[tuple[str, list[str]]] = []
for _k, n, spec in items:
if not getattr(spec, "enabled", True):
continue
defaults = getattr(spec, "defaults", None)
env = dict(defaults.env) if defaults and defaults.env else {}
missing: list[str] = []
for value in env.values():
for ref in secret_refs(str(value)):
if _missing(ref) and ref not in missing:
missing.append(ref)
if missing:
out.append((n, missing))
return out
def _record(result: ApplyResult, name: str, action: str) -> None: def _record(result: ApplyResult, name: str, action: str) -> None:
{ {
"activate": result.activated, "activate": result.activated,
@@ -322,7 +480,7 @@ def _record(result: ApplyResult, name: str, action: str) -> None:
def _render_unit_preview( def _render_unit_preview(
config: CastleConfig, name: str, dep: Deployment, kind: str config: WildpcConfig, name: str, dep: Deployment, kind: str
) -> str | None: ) -> str | None:
"""The unit bytes `deploy` would write for the deployment we'd restart (the """The unit bytes `deploy` would write for the deployment we'd restart (the
.timer for a job, the .service for a service), for --plan restart detection. .timer for a job, the .service for a service), for --plan restart detection.
@@ -333,16 +491,16 @@ def _render_unit_preview(
return files.get(timer_name(name) if kind == "job" else unit_name(name, kind)) return files.get(timer_name(name) if kind == "job" else unit_name(name, kind))
# Gateway service name in the registry → its systemd unit (castle-castle-gateway). # Gateway service name in the registry → its systemd unit (wildpc-wildpc-gateway).
_GATEWAY_NAME = "castle-gateway" _GATEWAY_NAME = "wildpc-gateway"
def _acme_preflight(config: CastleConfig, messages: list[str]) -> None: def _acme_preflight(config: WildpcConfig, messages: list[str]) -> None:
"""Warn (never fail, never write) if acme-mode prerequisites are missing. """Warn (never fail, never write) if acme-mode prerequisites are missing.
acme mode needs a `gateway.domain`, the DNS-provider token mapped into the acme mode needs a `gateway.domain`, the DNS-provider token mapped into the
castle-gateway service env, and the matching secret on disk all operator wildpc-gateway service env, and the matching secret on disk all operator
steps (castle never rewrites the user-authored gateway service YAML).""" steps (wildpc never rewrites the user-authored gateway service YAML)."""
gw = config.gateway gw = config.gateway
if not gw.domain: if not gw.domain:
messages.append( messages.append(
@@ -361,7 +519,7 @@ def _acme_preflight(config: CastleConfig, messages: list[str]) -> None:
f"Add to services/{_GATEWAY_NAME}.yaml → defaults.env: " f"Add to services/{_GATEWAY_NAME}.yaml → defaults.env: "
f"{token_env}: ${{secret:{token_env}}}" f"{token_env}: ${{secret:{token_env}}}"
) )
from castle_core.config import read_secret from wildpc_core.config import read_secret
if not read_secret(token_env): if not read_secret(token_env):
messages.append( messages.append(
@@ -370,8 +528,8 @@ def _acme_preflight(config: CastleConfig, messages: list[str]) -> None:
) )
# Tunnel service name in the registry → its systemd unit (castle-castle-tunnel). # Tunnel service name in the registry → its systemd unit (wildpc-wildpc-tunnel).
_TUNNEL_NAME = "castle-tunnel" _TUNNEL_NAME = "wildpc-tunnel"
def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None: def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
@@ -390,8 +548,8 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
if config_path.exists(): if config_path.exists():
config_path.unlink() config_path.unlink()
messages.append("No public services — removed cloudflared config.") messages.append("No public services — removed cloudflared config.")
# Still reconcile so any CNAMEs castle created earlier are cleaned up. # Still reconcile so any CNAMEs wildpc created earlier are cleaned up.
reconcile_public_dns(node.public_domain, node.tunnel_id, [], messages) reconcile_public_dns(node.tunnel_id, [], messages)
return return
config_path.write_text(content) config_path.write_text(content)
@@ -399,7 +557,7 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)") messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)")
# Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual # Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual
# `cloudflared tunnel route dns` commands when no DNS token is configured. # `cloudflared tunnel route dns` commands when no DNS token is configured.
if not reconcile_public_dns(node.public_domain, node.tunnel_id, hosts, messages): if not reconcile_public_dns(node.tunnel_id, hosts, messages):
for h in hosts: for h in hosts:
messages.append( messages.append(
f" public: {h} " f" public: {h} "
@@ -417,17 +575,17 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
messages.append("Tunnel reloaded.") messages.append("Tunnel reloaded.")
else: else:
messages.append( messages.append(
"Tunnel not running — enable it with 'castle service enable tunnel'." "Tunnel not running — enable it with 'wildpc service enable tunnel'."
) )
def _gateway_env(config: CastleConfig) -> dict[str, str]: def _gateway_env(config: WildpcConfig) -> dict[str, str]:
"""The process env for validating/handling the gateway's Caddyfile — the """The process env for validating/handling the gateway's Caddyfile — the
current environment plus the castle-gateway service's own resolved env. current environment plus the wildpc-gateway service's own resolved env.
Under acme the Caddyfile references ``{env.CLOUDFLARE_API_TOKEN}`` (or another Under acme the Caddyfile references ``{env.CLOUDFLARE_API_TOKEN}`` (or another
DNS-provider token). `caddy validate` provisions the acme module and rejects an DNS-provider token). `caddy validate` provisions the acme module and rejects an
empty token, so validating in castle-api's bare environment always fails and the empty token, so validating in wildpc-api's bare environment always fails and the
reload is skipped. Injecting the gateway service's env (secrets resolved) gives reload is skipped. Injecting the gateway service's env (secrets resolved) gives
validate the same token the running service starts with.""" validate the same token the running service starts with."""
svc = config.services.get(_GATEWAY_NAME) svc = config.services.get(_GATEWAY_NAME)
@@ -437,7 +595,7 @@ def _gateway_env(config: CastleConfig) -> dict[str, str]:
return {**os.environ, **resolved} return {**os.environ, **resolved}
def _reload_gateway(config: CastleConfig, messages: list[str]) -> None: def _reload_gateway(config: WildpcConfig, messages: list[str]) -> None:
"""Reload Caddy if the gateway is running, so new routes take effect.""" """Reload Caddy if the gateway is running, so new routes take effect."""
gw_unit = unit_name(_GATEWAY_NAME) gw_unit = unit_name(_GATEWAY_NAME)
# Validate the generated Caddyfile before reloading. An invalid config (most # Validate the generated Caddyfile before reloading. An invalid config (most
@@ -472,7 +630,7 @@ def _reload_gateway(config: CastleConfig, messages: list[str]) -> None:
) )
if active.stdout.strip() != "active": if active.stdout.strip() != "active":
messages.append( messages.append(
"Gateway not running — skipped reload (start it with 'castle gateway start')." "Gateway not running — skipped reload (start it with 'wildpc gateway start')."
) )
return return
result = subprocess.run( result = subprocess.run(
@@ -490,7 +648,7 @@ def _reload_gateway(config: CastleConfig, messages: list[str]) -> None:
def _public_url( def _public_url(
config: CastleConfig, name: str, exposed: bool, port: int | None config: WildpcConfig, name: str, exposed: bool, port: int | None
) -> str | None: ) -> str | None:
"""The service's publicly-reachable base URL — the ``${public_url}`` placeholder. """The service's publicly-reachable base URL — the ``${public_url}`` placeholder.
@@ -509,7 +667,7 @@ def _public_url(
return None return None
def _target_url(config: CastleConfig, target_name: str) -> str | None: def _target_url(config: WildpcConfig, target_name: str) -> str | None:
"""The base URL another deployment is reachable at — how a ``{kind: deployment, """The base URL another deployment is reachable at — how a ``{kind: deployment,
bind: VAR}`` requirement projects its target into the consumer's env. A name may bind: VAR}`` requirement projects its target into the consumer's env. A name may
span kinds; the HTTP-exposed one is what has a URL, so prefer it.""" span kinds; the HTTP-exposed one is what has a URL, so prefer it."""
@@ -530,7 +688,7 @@ def _target_url(config: CastleConfig, target_name: str) -> str | None:
return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport) return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport)
def _requires_env(config: CastleConfig, dep: DeploymentSpec) -> dict[str, str]: def _requires_env(config: WildpcConfig, dep: DeploymentSpec) -> dict[str, str]:
"""Env generated FROM a deployment's ``requires`` — a ``{ref, bind: VAR}`` """Env generated FROM a deployment's ``requires`` — a ``{ref, bind: VAR}``
requirement sets ``VAR`` to the target deployment's URL. Env is derived from the requirement sets ``VAR`` to the target deployment's URL. Env is derived from the
dependency, never scraped back into one (see docs/relationships.md).""" dependency, never scraped back into one (see docs/relationships.md)."""
@@ -543,7 +701,7 @@ def _requires_env(config: CastleConfig, dep: DeploymentSpec) -> dict[str, str]:
return out return out
def _supabase_app_schemas(config: CastleConfig) -> str: def _supabase_app_schemas(config: WildpcConfig) -> str:
"""The ``${supabase_app_schemas}`` placeholder: each registered supabase app's """The ``${supabase_app_schemas}`` placeholder: each registered supabase app's
own schema, comma-prefixed and joined (or '' when there are none). own schema, comma-prefixed and joined (or '' when there are none).
@@ -552,9 +710,9 @@ def _supabase_app_schemas(config: CastleConfig) -> str:
``public,storage,graphql_public${supabase_app_schemas}``. Comma-prefixing each ``public,storage,graphql_public${supabase_app_schemas}``. Comma-prefixing each
entry keeps the base list valid when zero apps are registered (no trailing entry keeps the base list valid when zero apps are registered (no trailing
comma). Adding/removing a supabase app thus changes this list the substrate comma). Adding/removing a supabase app thus changes this list the substrate
needs a restart (recreate) after `castle deploy` to pick it up. needs a restart (recreate) after `wildpc deploy` to pick it up.
""" """
from castle_core.stacks import app_schema from wildpc_core.stacks import app_schema
schemas = sorted( schemas = sorted(
app_schema(pn) for pn, ps in config.programs.items() if ps.stack == "supabase" app_schema(pn) for pn, ps in config.programs.items() if ps.stack == "supabase"
@@ -587,15 +745,21 @@ def _env_context(
return ctx return ctx
def _write_secret_env_file(name: str, secret_env: dict[str, str]) -> Path | None: def _write_secret_env_file(
name: str, secret_env: dict[str, str], kind: str = "service"
) -> Path | None:
"""Write a deployment's resolved secrets to a mode-0600 env file. """Write a deployment's resolved secrets to a mode-0600 env file.
Keeps secrets out of the generated unit file and the process argv: systemd Keeps secrets out of the generated unit file and the process argv: systemd
loads it via ``EnvironmentFile=`` and docker via ``--env-file``. Returns the loads it via ``EnvironmentFile=`` and docker via ``--env-file``. Returns the
path, or ``None`` (after removing any stale file) when there are no secrets, path, or ``None`` (after removing any stale file) when there are no secrets,
so a service that drops its last secret doesn't leave a dangling file. so a service that drops its last secret doesn't leave a dangling file.
``kind`` must match the unit's kind so the filename lines up with the unit's
``EnvironmentFile=`` (jobs carry a ``-job`` marker); otherwise systemd looks
for a file that was never written and the unit fails to start.
""" """
path = secret_env_path(name) path = secret_env_path(name, kind)
if not secret_env: if not secret_env:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
return None return None
@@ -610,7 +774,7 @@ def _write_secret_env_file(name: str, secret_env: dict[str, str]) -> Path | None
return path return path
def _resolve_description(config: CastleConfig, spec: DeploymentBase) -> str | None: def _resolve_description(config: WildpcConfig, spec: DeploymentBase) -> str | None:
"""Get description, falling through to program if referenced.""" """Get description, falling through to program if referenced."""
if spec.description: if spec.description:
return spec.description return spec.description
@@ -629,7 +793,7 @@ def _registry_requires(dep: DeploymentSpec) -> list[dict]:
def _build_deployed( def _build_deployed(
config: CastleConfig, name: str, dep: DeploymentSpec, messages: list[str] config: WildpcConfig, name: str, dep: DeploymentSpec, messages: list[str]
) -> Deployment: ) -> Deployment:
"""Build a runtime Deployment from a DeploymentSpec, dispatched by its manager.""" """Build a runtime Deployment from a DeploymentSpec, dispatched by its manager."""
description = _resolve_description(config, dep) description = _resolve_description(config, dep)
@@ -652,7 +816,9 @@ def _build_deployed(
stack=stack, stack=stack,
subdomain=name, subdomain=name,
public=bool(dep.public), public=bool(dep.public),
public_host=(dep.public_host if dep.public else None),
static_root=static_root, static_root=static_root,
spa=dep.spa,
managed=False, managed=False,
enabled=dep.enabled, enabled=dep.enabled,
requires=_registry_requires(dep), requires=_registry_requires(dep),
@@ -685,7 +851,7 @@ def _build_deployed(
run = dep.run run = dep.run
# ${data_dir} is keyed by the program the deployment runs, not the deployment # ${data_dir} is keyed by the program the deployment runs, not the deployment
# name (e.g. job `protonmail-sync` runs program `protonmail` → # name (e.g. job `protonmail-sync` runs program `protonmail` →
# /data/castle/protonmail). Falls back to the deployment name. # /data/wildpc/protonmail). Falls back to the deployment name.
config_key = dep.program or name config_key = dep.program or name
managed = True managed = True
@@ -705,7 +871,7 @@ def _build_deployed(
# Env is exactly what's in defaults.env — no hidden convention injection. # Env is exactly what's in defaults.env — no hidden convention injection.
# ${port}/${data_dir}/${name}/${public_url} map the program's own env var # ${port}/${data_dir}/${name}/${public_url} map the program's own env var
# names to castle's computed values. Secret-bearing vars split out to a # names to wildpc's computed values. Secret-bearing vars split out to a
# mode-0600 file (never in the unit or argv). # mode-0600 file (never in the unit or argv).
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {} raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
# Env generated from `requires` ({kind: deployment, bind: VAR} → target URL). # Env generated from `requires` ({kind: deployment, bind: VAR} → target URL).
@@ -721,12 +887,12 @@ def _build_deployed(
public_url, public_url,
_supabase_app_schemas(config), _supabase_app_schemas(config),
) )
# ${tls_*}: paths to castle-materialized cert files for a TLS-material TCP # ${tls_*}: paths to wildpc-materialized cert files for a TLS-material TCP
# service. The deployment maps them into its own config (mount ${tls_dir} for a # service. The deployment maps them into its own config (mount ${tls_dir} for a
# container, or reference ${tls_cert}/${tls_key} directly for a native service). # container, or reference ${tls_cert}/${tls_key} directly for a native service).
tls = dep.expose.tcp.tls if (dep.expose and dep.expose.tcp) else None tls = dep.expose.tcp.tls if (dep.expose and dep.expose.tcp) else None
if tls and tls.material != TlsMaterial.OFF: if tls and tls.material != TlsMaterial.OFF:
from castle_core.tls import tls_dir_for from wildpc_core.tls import tls_dir_for
tls_dir = tls_dir_for(config.data_dir, config_key) tls_dir = tls_dir_for(config.data_dir, config_key)
ctx.update( ctx.update(
@@ -739,7 +905,7 @@ def _build_deployed(
} }
) )
env, secret_env = resolve_env_split(raw_env, ctx) env, secret_env = resolve_env_split(raw_env, ctx)
secret_env_file = _write_secret_env_file(name, secret_env) secret_env_file = _write_secret_env_file(name, secret_env, kind)
# `command` launchers resolve a tool on PATH → ensure it's installed. # `command` launchers resolve a tool on PATH → ensure it's installed.
# `python` launchers run in place via `uv run` (below) — no tool venv. # `python` launchers run in place via `uv run` (below) — no tool venv.
@@ -785,6 +951,7 @@ def _build_deployed(
health_path=health_path, health_path=health_path,
subdomain=(name if expose else None), subdomain=(name if expose else None),
public=bool(dep.public and expose), public=bool(dep.public and expose),
public_host=(dep.public_host if (dep.public and expose) else None),
tcp_port=tcp_port, tcp_port=tcp_port,
schedule=getattr(dep, "schedule", None), schedule=getattr(dep, "schedule", None),
managed=managed, managed=managed,
@@ -814,7 +981,7 @@ def _python_tool_needs_install(program: str) -> bool:
return False return False
def _program_source_dir(config: CastleConfig, program: str | None) -> Path | None: def _program_source_dir(config: WildpcConfig, program: str | None) -> Path | None:
"""The absolute source dir of a referenced program, if any. """The absolute source dir of a referenced program, if any.
`load_config` has already resolved `source` to an absolute path (repo: and `load_config` has already resolved `source` to an absolute path (repo: and
@@ -827,7 +994,7 @@ def _program_source_dir(config: CastleConfig, program: str | None) -> Path | Non
def _ensure_python_tool( def _ensure_python_tool(
config: CastleConfig, program: str | None, messages: list[str] config: WildpcConfig, program: str | None, messages: list[str]
) -> None: ) -> None:
"""Ensure a Python program's editable install is current. """Ensure a Python program's editable install is current.
@@ -862,7 +1029,7 @@ def _ensure_python_tool(
def _subst(value: str, placeholders: dict[str, str] | None) -> str: def _subst(value: str, placeholders: dict[str, str] | None) -> str:
"""Expand ``${key}`` in a run-spec string field from castle's computed values """Expand ``${key}`` in a run-spec string field from wildpc's computed values
(``${uid}``/``${gid}``/``${data_dir}``/``${tls_dir}``/), via the one shared (``${uid}``/``${gid}``/``${data_dir}``/``${tls_dir}``/), via the one shared
``${...}`` resolver (:func:`resolve_placeholders`). Unknown refs pass through ``${...}`` resolver (:func:`resolve_placeholders`). Unknown refs pass through
unchanged (secrets never belong in argv they go via --env-file); write unchanged (secrets never belong in argv they go via --env-file); write
@@ -918,12 +1085,12 @@ def _build_run_cmd(
case "container": case "container":
runtime = shutil.which("docker") or shutil.which("podman") or "docker" runtime = shutil.which("docker") or shutil.which("podman") or "docker"
# Container name derives from the SERVICE name (matches the systemd unit), # Container name derives from the SERVICE name (matches the systemd unit),
# not the image name — so `castle-<service>` is stable and collision-free. # not the image name — so `wildpc-<service>` is stable and collision-free.
cmd = [runtime, "run", "--rm", f"--name=castle-{name}"] cmd = [runtime, "run", "--rm", f"--name=wildpc-{name}"]
if run.user: # type: ignore[union-attr] if run.user: # type: ignore[union-attr]
# Run as the invoking user (uid uniformity → bind-mounted # Run as the invoking user (uid uniformity → bind-mounted
# certs/data/secrets readable with no chown). ${uid}/${gid} expand # certs/data/secrets readable with no chown). ${uid}/${gid} expand
# to the castle process's own ids. # to the wildpc process's own ids.
cmd.extend(["--user", _subst(run.user, placeholders)]) # type: ignore[union-attr] cmd.extend(["--user", _subst(run.user, placeholders)]) # type: ignore[union-attr]
for tp in run.tmpfs: # type: ignore[union-attr] for tp in run.tmpfs: # type: ignore[union-attr]
cmd.extend(["--tmpfs", _subst(tp, placeholders)]) cmd.extend(["--tmpfs", _subst(tp, placeholders)])
@@ -980,7 +1147,7 @@ def _compose_base(name: str, run: object, source_dir: Path | None) -> list[str]:
namespaced and collision-free. namespaced and collision-free.
""" """
runtime = shutil.which("docker") or shutil.which("podman") or "docker" runtime = shutil.which("docker") or shutil.which("podman") or "docker"
project = run.project_name or f"castle-{name}" # type: ignore[union-attr] project = run.project_name or f"wildpc-{name}" # type: ignore[union-attr]
compose_file = Path(run.file) # type: ignore[union-attr] compose_file = Path(run.file) # type: ignore[union-attr]
if not compose_file.is_absolute() and source_dir is not None: if not compose_file.is_absolute() and source_dir is not None:
compose_file = source_dir / compose_file compose_file = source_dir / compose_file
@@ -1037,9 +1204,9 @@ def _teardown_unit(unit_file: str, messages: list[str]) -> None:
def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None: def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None:
"""Remove castle-* units no longer backed by a managed registry entry. """Remove wildpc-* units no longer backed by a managed registry entry.
The `castle-` prefix is the ownership namespace: any castle-*.service/.timer on The `wildpc-` prefix is the ownership namespace: any wildpc-*.service/.timer on
disk that isn't in the desired set is an orphan (a removed/unmanaged/unscheduled disk that isn't in the desired set is an orphan (a removed/unmanaged/unscheduled
component) and is torn down. Only call on a FULL deploy the desired set must component) and is torn down. Only call on a FULL deploy the desired set must
reflect the whole registry, not a single --target. reflect the whole registry, not a single --target.
@@ -1047,14 +1214,14 @@ def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None:
desired = _desired_unit_files(registry) desired = _desired_unit_files(registry)
if not SYSTEMD_USER_DIR.is_dir(): if not SYSTEMD_USER_DIR.is_dir():
return return
for pattern in ("castle-*.service", "castle-*.timer"): for pattern in ("wildpc-*.service", "wildpc-*.timer"):
for path in sorted(SYSTEMD_USER_DIR.glob(pattern)): for path in sorted(SYSTEMD_USER_DIR.glob(pattern)):
if path.name not in desired: if path.name not in desired:
_teardown_unit(path.name, messages) _teardown_unit(path.name, messages)
def _render_unit_files( def _render_unit_files(
config: CastleConfig, name: str, deployed: Deployment config: WildpcConfig, name: str, deployed: Deployment
) -> dict[str, str]: ) -> dict[str, str]:
"""The exact unit files `deploy` would write for a deployment: {filename: content}. """The exact unit files `deploy` would write for a deployment: {filename: content}.
@@ -1083,7 +1250,7 @@ def _render_unit_files(
return files return files
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None: def _generate_systemd_units(config: WildpcConfig, registry: NodeRegistry) -> None:
"""Generate systemd units from the registry.""" """Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -1,9 +1,9 @@
"""Castle infrastructure generators.""" """Wild PC infrastructure generators."""
from castle_core.generators.caddyfile import ( from wildpc_core.generators.caddyfile import (
generate_caddyfile_from_registry, generate_caddyfile_from_registry,
) )
from castle_core.generators.systemd import ( from wildpc_core.generators.systemd import (
cron_to_interval_sec, cron_to_interval_sec,
cron_to_oncalendar, cron_to_oncalendar,
generate_timer, generate_timer,

View File

@@ -18,15 +18,15 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from castle_core.config import SPECS_DIR, CastleConfig from wildpc_core.config import SPECS_DIR, WildpcConfig
from castle_core.manifest import CaddyDeployment, SystemdDeployment from wildpc_core.manifest import CaddyDeployment, SystemdDeployment
from castle_core.registry import NodeRegistry from wildpc_core.registry import NodeRegistry
# DNS-01 provider → the env var the Caddyfile reads its API token from. The token # DNS-01 provider → the env var the Caddyfile reads its API token from. The token
# reaches Caddy via the gateway service's defaults.env (a mode-0600 EnvironmentFile). # reaches Caddy via the gateway service's defaults.env (a mode-0600 EnvironmentFile).
_DNS_TOKEN_ENV = {"cloudflare": "CLOUDFLARE_API_TOKEN"} _DNS_TOKEN_ENV = {"cloudflare": "CLOUDFLARE_API_TOKEN"}
# Let's Encrypt staging directory — opt in via CASTLE_ACME_STAGING=1 to avoid the # Let's Encrypt staging directory — opt in via WILDPC_ACME_STAGING=1 to avoid the
# production rate limits while testing, then cut over to prod (unset the env var). # production rate limits while testing, then cut over to prod (unset the env var).
_ACME_STAGING_CA = "https://acme-staging-v02.api.letsencrypt.org/directory" _ACME_STAGING_CA = "https://acme-staging-v02.api.letsencrypt.org/directory"
@@ -40,6 +40,12 @@ class GatewayRoute:
target: str # static: serve dir; proxy: "localhost:PORT"/base_url; remote: "host:PORT" target: str # static: serve dir; proxy: "localhost:PORT"/base_url; remote: "host:PORT"
name: str | None = None # backing program/service name: str | None = None # backing program/service
node: str | None = None node: str | None = None
public: bool = False # also served under public_domain
# Optional exact public-facing FQDN override (apex allowed). When set, the
# public name is this instead of the derived <address>.<public_domain>.
public_host: str | None = None
# static routes only: SPA fallback (True) vs content-site serving (False).
spa: bool = True
@property @property
def is_host(self) -> bool: def is_host(self) -> bool:
@@ -65,45 +71,47 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
def _local_routes( def _local_routes(
config: CastleConfig | None, registry: NodeRegistry config: WildpcConfig | None, registry: NodeRegistry
) -> list[tuple[str, str, str]]: ) -> list[tuple[str, str, str, bool, str | None, bool]]:
"""Each local deployment's route as ``(name, kind, target)``, name-sorted. """Each local deployment's route as ``(name, kind, target, public, public_host, spa)``.
``kind`` is ``static`` (a caddy deployment file-serve a built dir) or ``kind`` is ``static`` (a caddy deployment file-serve a built dir) or
``proxy`` (a proxied systemd process). Prefers ``castle.yaml`` ``proxy`` (a proxied systemd process). Prefers ``wildpc.yaml``
(``config.deployments``) so a regenerated Caddyfile reflects the current spec; (``config.deployments``) so a regenerated Caddyfile reflects the current spec;
falls back to the deployed registry snapshot when config isn't available. falls back to the deployed registry snapshot when config isn't available.
""" """
out: list[tuple[str, str, str]] = [] out: list[tuple[str, str, str, bool, str | None, bool]] = []
if config is not None: if config is not None:
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy). # Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
# jobs/tools/references never do. # jobs/tools/references never do.
for kind, name, dep in config.all_deployments(): for kind, name, dep in config.all_deployments():
# A disabled deployment is defined but not running — no route (else it # A disabled deployment is defined but not running — no route (else it
# would 502). `castle apply` converges it off. # would 502). `wildpc apply` converges it off.
if not dep.enabled: if not dep.enabled:
continue continue
if kind == "static" and isinstance(dep, CaddyDeployment): if kind == "static" and isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program) src = _program_source(config, dep.program)
if src is not None: if src is not None:
out.append((name, "static", str(src / dep.root))) pub_host = dep.public_host if dep.public else None
out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host, dep.spa))
elif kind == "service" and isinstance(dep, SystemdDeployment): elif kind == "service" and isinstance(dep, SystemdDeployment):
expose, port, base_url = service_proxy_targets(name, dep) expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url): if expose and (port or base_url):
out.append((name, "proxy", base_url or f"localhost:{port}")) pub_host = dep.public_host if dep.public else None
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host, True))
return out return out
# No config → route from the deployed registry snapshot. # No config → route from the deployed registry snapshot.
for _kind, name, d in registry.all(): for _kind, name, d in registry.all():
if not d.enabled: if not d.enabled:
continue continue
if d.static_root: if d.static_root:
out.append((name, "static", d.static_root)) out.append((name, "static", d.static_root, d.public, d.public_host, d.spa))
elif d.subdomain and (d.port or d.base_url): elif d.subdomain and (d.port or d.base_url):
out.append((name, "proxy", d.base_url or f"localhost:{d.port}")) out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host, True))
return out return out
def _program_source(config: CastleConfig | None, program: str | None): def _program_source(config: WildpcConfig | None, program: str | None):
"""Absolute source dir of a referenced program (already resolved), or None.""" """Absolute source dir of a referenced program (already resolved), or None."""
if config is None or not program: if config is None or not program:
return None return None
@@ -115,7 +123,7 @@ def _program_source(config: CastleConfig | None, program: str | None):
def compute_routes( def compute_routes(
registry: NodeRegistry, registry: NodeRegistry,
config: CastleConfig | None = None, config: WildpcConfig | None = None,
remote_registries: dict[str, NodeRegistry] | None = None, remote_registries: dict[str, NodeRegistry] | None = None,
) -> list[GatewayRoute]: ) -> list[GatewayRoute]:
"""Build the ordered list of gateway routes. Every route is a host route whose """Build the ordered list of gateway routes. Every route is a host route whose
@@ -127,7 +135,7 @@ def compute_routes(
so a consumed cross-node service is reachable at ``<ref>.<domain>``.""" so a consumed cross-node service is reachable at ``<ref>.<domain>``."""
if config is None: if config is None:
try: try:
from castle_core.config import load_config from wildpc_core.config import load_config
config = load_config() config = load_config()
except Exception: except Exception:
@@ -140,8 +148,8 @@ def compute_routes(
# built dir; everything else that's exposed reverse-proxies its port/base_url. # built dir; everything else that's exposed reverse-proxies its port/base_url.
# (Static frontends are `runner: static` services now — no separate program # (Static frontends are `runner: static` services now — no separate program
# branch, so routing derives from one place.) # branch, so routing derives from one place.)
for name, kind, target in _local_routes(config, registry): for name, kind, target, is_public, pub_host, spa in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node)) routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host, spa))
if remote_registries: if remote_registries:
routes.extend(_remote_routes(config, registry, remote_registries)) routes.extend(_remote_routes(config, registry, remote_registries))
@@ -150,7 +158,7 @@ def compute_routes(
def _remote_routes( def _remote_routes(
config: CastleConfig | None, config: WildpcConfig | None,
registry: NodeRegistry, registry: NodeRegistry,
remote_registries: dict[str, NodeRegistry], remote_registries: dict[str, NodeRegistry],
) -> list[GatewayRoute]: ) -> list[GatewayRoute]:
@@ -223,25 +231,58 @@ def _host_remote_block(label: str, host: str, target: str) -> list[str]:
] ]
def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]: def _static_serve_lines(serve_dir: str, spa: bool, indent: str) -> list[str]:
"""A host matcher that file-serves a frontend's dist (with SPA fallback).""" """The `root`/`file_server` body for a static site. SPA sites get a fallback to
the root `index.html` (client-side routing); content sites (Hugo) get plain
`file_server`, which resolves directory indexes and 404s missing paths."""
lines = [f"{indent}root * {serve_dir}"]
if spa:
lines.append(indent + "try_files {path} /index.html")
lines.append(f"{indent}file_server")
return lines
def _host_static_block(label: str, host: str, serve_dir: str, spa: bool) -> list[str]:
"""A host matcher that file-serves a frontend's dist."""
matcher = f"@host_{label.replace('-', '_').replace('.', '_')}" matcher = f"@host_{label.replace('-', '_').replace('.', '_')}"
return [ return [
f" {matcher} host {host}", f" {matcher} host {host}",
f" handle {matcher} {{", f" handle {matcher} {{",
f" root * {serve_dir}", *_static_serve_lines(serve_dir, spa, " "),
" try_files {path} /index.html",
" file_server",
" }", " }",
"", "",
] ]
# Castle's own control plane: the dashboard frontend and the API it calls. These def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> list[str]:
"""A standalone Caddy site for a custom ``public_host`` (apex or another zone).
Unlike the ``*.<public_domain>`` wildcard site, an apex/foreign host isn't
covered by a wildcard cert, so it gets its own site. Caddy issues that exact
host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's
Cloudflare token can edit the host's zone."""
if kind == "static":
body = _static_serve_lines(target, spa, " ")
elif kind == "remote":
body = [
f" reverse_proxy {target} {{",
" lb_try_duration 1s",
" fail_duration 30s",
" transport http {",
" dial_timeout 2s",
" }",
" }",
]
else:
body = [f" reverse_proxy {target}"]
return [f"{host} {{", *body, "}", ""]
# Wild PC's own control plane: the dashboard frontend and the API it calls. These
# names are the subdomains they're published at in acme mode, and the pair served # names are the subdomains they're published at in acme mode, and the pair served
# on the :<port> site in off mode (no domain → no subdomains). # on the :<port> site in off mode (no domain → no subdomains).
_DASHBOARD = "castle" _DASHBOARD = "wildpc"
_API = "castle-api" _API = "wildpc-api"
def generate_caddyfile_from_registry( def generate_caddyfile_from_registry(
@@ -258,8 +299,8 @@ def generate_caddyfile_from_registry(
The `:<port>` site just redirects to the dashboard subdomain (the "browse to The `:<port>` site just redirects to the dashboard subdomain (the "browse to
the box by IP" entry). the box by IP" entry).
- **off / no domain** no subdomains available, so the `:<port>` site serves - **off / no domain** no subdomains available, so the `:<port>` site serves
castle's control plane only: the dashboard at `/` + `reverse_proxy /api/*` → wildpc's control plane only: the dashboard at `/` + `reverse_proxy /api/*` →
castle-api (the one surviving path, for the dashboard's own backend). Other wildpc-api (the one surviving path, for the dashboard's own backend). Other
services are reachable at their `host:port` directly. services are reachable at their `host:port` directly.
""" """
routes = compute_routes(registry, None, remote_registries) routes = compute_routes(registry, None, remote_registries)
@@ -278,7 +319,7 @@ def generate_caddyfile_from_registry(
if node.acme_email: if node.acme_email:
lines.append(f" email {node.acme_email}") lines.append(f" email {node.acme_email}")
lines.append(f" acme_dns {provider} {{env.{token_env}}}") lines.append(f" acme_dns {provider} {{env.{token_env}}}")
if os.environ.get("CASTLE_ACME_STAGING") == "1": if os.environ.get("WILDPC_ACME_STAGING") == "1":
lines.append(f" acme_ca {_ACME_STAGING_CA}") lines.append(f" acme_ca {_ACME_STAGING_CA}")
# On issuance/renewal, refresh certs materialized onto raw-TCP services and # On issuance/renewal, refresh certs materialized onto raw-TCP services and
# reload them (idempotent — a no-op when nothing rotated). Requires the # reload them (idempotent — a no-op when nothing rotated). Requires the
@@ -289,7 +330,7 @@ def generate_caddyfile_from_registry(
if getattr(node, "cert_hook", False): if getattr(node, "cert_hook", False):
lines += [ lines += [
" events {", " events {",
" on cert_obtained exec castle tls reconcile", " on cert_obtained exec wildpc tls reconcile",
" }", " }",
] ]
lines += ["}", ""] lines += ["}", ""]
@@ -300,13 +341,41 @@ def generate_caddyfile_from_registry(
for r in routes: for r in routes:
host = f"{r.address}.{domain}" host = f"{r.address}.{domain}"
if r.kind == "static": if r.kind == "static":
lines += _host_static_block(r.name or r.address, host, r.target) lines += _host_static_block(r.name or r.address, host, r.target, r.spa)
elif r.kind == "remote": elif r.kind == "remote":
lines += _host_remote_block(r.name or r.address, host, r.target) lines += _host_remote_block(r.name or r.address, host, r.target)
else: else:
lines += _host_matcher_block(r.name or r.address, host, r.target) lines += _host_matcher_block(r.name or r.address, host, r.target)
lines.append("}") lines.append("}")
lines.append("") lines.append("")
# Public exposure: public deployments are also reachable by their public
# name so LAN clients can use it directly. Two shapes:
# - default → <address>.<public_domain>, all served by one wildcard site
# (*.<public_domain>, its own wildcard cert), when a node-wide
# public_domain distinct from the internal zone is configured.
# - override → an exact `public_host` (an apex or a name in another zone),
# which a wildcard can't cover, so each gets a standalone site with its
# own cert (DNS-01 via the global acme_dns).
public_domain = node.public_domain
public_routes = [r for r in routes if r.public]
default_pub = [r for r in public_routes if not r.public_host]
custom_pub = [r for r in public_routes if r.public_host]
if public_domain and public_domain != domain and default_pub:
lines.append(f"*.{public_domain} {{")
for r in default_pub:
host = f"{r.address}.{public_domain}"
label = f"{r.name or r.address}_pub"
if r.kind == "static":
lines += _host_static_block(label, host, r.target, r.spa)
elif r.kind == "remote":
lines += _host_remote_block(label, host, r.target)
else:
lines += _host_matcher_block(label, host, r.target)
lines.append("}")
lines.append("")
for r in custom_pub:
if r.public_host: # always true (custom_pub filter); narrows the type
lines += _public_site_block(r.public_host, r.kind, r.target, r.spa)
# Redirect the bare gateway port to the dashboard subdomain. # Redirect the bare gateway port to the dashboard subdomain.
lines += [ lines += [
f":{gw_port} {{", f":{gw_port} {{",

View File

@@ -0,0 +1,157 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
Wild PC owns the CNAMEs — across every zone the token can see — that point at its
Cloudflare tunnel: on deploy it creates one per public host (each routed to the
accessible zone whose name is its longest suffix, so apex and multi-zone hosts both
work) and deletes any that point at this tunnel but no longer correspond to a
public service. It **only ever touches records whose content is
`<tunnel_id>.cfargotunnel.com`** — never other records in a zone — so a
hand-managed A/CNAME in the same zone is safe.
Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's
"Edit zone DNS" template — that single permission both lists the accessible zones
and edits records; no separate Zone:Read is needed), stored at
`~/.wildpc/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent → this is a no-op and the
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
_API = "https://api.cloudflare.com/client/v4"
# Secret holding a Cloudflare token scoped to the PUBLIC zone (Zone:DNS:Edit).
# Distinct from CLOUDFLARE_API_TOKEN (ACME, the internal civil zone) because the
# public zone is typically a separate zone/account.
PUBLIC_DNS_TOKEN = "CLOUDFLARE_PUBLIC_DNS_TOKEN"
def public_dns_token() -> str | None:
"""The public-zone DNS token from the active secret backend, or None."""
from wildpc_core.config import read_secret
return read_secret(PUBLIC_DNS_TOKEN) or None
def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{_API}{path}", data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 (fixed API host)
return json.loads(resp.read())
def _zone_for(host: str, zones: list[dict]) -> dict | None:
"""The visible zone whose name is the longest suffix of ``host`` (or None).
Longest-suffix so an apex (``example.com`` in zone ``example.com``) and a
subdomain in any accessible zone both resolve, even when zones nest.
"""
matches = [
z
for z in zones
if host == z["name"] or host.endswith("." + z["name"])
]
return max(matches, key=lambda z: len(z["name"])) if matches else None
def reconcile_public_dns(
tunnel_id: str | None,
desired_hosts: list[str],
messages: list[str],
token: str | None = None,
) -> bool:
"""Make the tunnel CNAMEs across every accessible zone exactly `desired_hosts`.
Each desired host is routed to the accessible zone whose name is its longest
suffix (so apex hosts and hosts in different zones are handled), then per zone
wildpc creates missing CNAMEs (proxied → the tunnel; Cloudflare flattens apex
CNAMEs) and deletes wildpc-managed ones (content == `<tunnel_id>.cfargotunnel.com`)
no longer desired. Never touches records pointing elsewhere. Scanning every
visible zone also cleans up stale CNAMEs after a host moves zones or all public
services are removed.
Returns True if reconciliation was attempted (a token was configured) — the
caller then suppresses the manual route hints — or False if skipped (no token /
no tunnel), so the caller can fall back to those hints.
"""
token = token or public_dns_token()
if not (token and tunnel_id):
return False
target = f"{tunnel_id}.cfargotunnel.com"
try:
zones = (_api(token, "GET", "/zones?per_page=50").get("result")) or []
if not zones:
messages.append(
"Warning: DNS token can't see any zone — public CNAMEs not "
"reconciled. The token needs DNS:Edit (Cloudflare's 'Edit zone "
"DNS' template) on the target zone(s)."
)
return True
# Route each desired host to its zone (longest-suffix match). Hosts with no
# accessible zone can't be created — surface them rather than silently drop.
desired_by_zone: dict[str, set[str]] = {z["id"]: set() for z in zones}
for host in desired_hosts:
z = _zone_for(host, zones)
if z is None:
messages.append(
f"Warning: no accessible Cloudflare zone for public host "
f"'{host}' — its CNAME was not created. The DNS token needs "
f"DNS:Edit on that host's zone."
)
continue
desired_by_zone[z["id"]].add(host)
created: list[str] = []
removed: list[str] = []
# Reconcile every visible zone (not just those with desired hosts) so a
# CNAME orphaned by a host moving zones / going internal is cleaned up.
for z in zones:
zone_id = z["id"]
recs = _api(
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
).get("result") or []
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
desired = desired_by_zone[zone_id]
for host in sorted(desired - set(managed)):
_api(
token,
"POST",
f"/zones/{zone_id}/dns_records",
{"type": "CNAME", "name": host, "content": target, "proxied": True},
)
created.append(host)
for host in sorted(set(managed) - desired):
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
removed.append(host)
if created or removed:
parts = []
if created:
parts.append(f"+{len(created)} ({', '.join(sorted(created))})")
if removed:
parts.append(f"-{len(removed)} ({', '.join(sorted(removed))})")
messages.append(f"Public DNS reconciled: {' '.join(parts)}")
else:
messages.append(f"Public DNS up to date ({len(desired_hosts)} CNAME(s)).")
return True
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200]
hint = ""
if e.code == 403:
# Reads can succeed while writes 403 — the token has DNS:Read but not
# DNS:Edit. Point at the fix rather than the raw body.
hint = (
" → the token needs DNS:Edit (write), not just read — use "
"Cloudflare's 'Edit zone DNS' template."
)
messages.append(f"Warning: public DNS reconcile failed (HTTP {e.code}): {body}{hint}")
return True # token was present; don't also print stale manual hints
except Exception as e: # noqa: BLE001 — DNS is best-effort; never fail a deploy
messages.append(f"Warning: public DNS reconcile failed: {e}")
return True

View File

@@ -5,22 +5,35 @@ from __future__ import annotations
import shutil import shutil
from pathlib import Path from pathlib import Path
from castle_core.config import SECRETS_DIR, USER_TOOL_PATH_DIRS from wildpc_core.config import SECRETS_DIR, USER_TOOL_PATH_DIRS
from castle_core.manifest import RestartPolicy, SystemdSpec from wildpc_core.manifest import RestartPolicy, SystemdSpec
from castle_core.registry import Deployment from wildpc_core.registry import Deployment
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-" UNIT_PREFIX = "wildpc-"
# Generated mode-0600 env files holding a deployment's resolved secrets, kept out # Generated mode-0600 env files holding a deployment's resolved secrets, kept out
# of the unit file and the process argv (loaded via EnvironmentFile= / --env-file). # of the unit file and the process argv (loaded via EnvironmentFile= / --env-file).
SECRET_ENV_DIR = SECRETS_DIR / "env" SECRET_ENV_DIR = SECRETS_DIR / "env"
def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str:
"""The PATH a wildpc service runs with: resolved toolchain dirs (e.g. a pinned
node bin) + the user tool dirs that exist + system bins. This is the single
definition of a service's runtime PATH — the unit generator writes it into
``Environment=PATH`` and the dependency checker (``relations``) probes tools
against it, so the two can never disagree about where a service finds its tools.
"""
dirs = list(path_prepend)
dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
dirs += ["/usr/local/bin", "/usr/bin", "/bin"]
return ":".join(dirs)
def unit_basename(name: str, kind: str = "service") -> str: def unit_basename(name: str, kind: str = "service") -> str:
"""The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a """The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a
service and a job can share a name (`castle-<name>.service` vs service and a job can share a name (`wildpc-<name>.service` vs
`castle-<name>-job.{service,timer}`); everything else is `castle-<name>`.""" `wildpc-<name>-job.{service,timer}`); everything else is `wildpc-<name>`."""
return f"{UNIT_PREFIX}{name}-job" if kind == "job" else f"{UNIT_PREFIX}{name}" return f"{UNIT_PREFIX}{name}-job" if kind == "job" else f"{UNIT_PREFIX}{name}"
@@ -119,7 +132,7 @@ def generate_unit_from_deployed(
env_lines = "" env_lines = ""
for key, value in deployed.env.items(): for key, value in deployed.env.items():
env_lines += f"Environment={key}={value}\n" env_lines += f"Environment={key}={value}\n"
# Castle supplies a sensible default PATH (tool dirs + system bins). It is an # Wild PC supplies a sensible default PATH (tool dirs + system bins). It is an
# escape hatch, not a mandate: if the service pins its own PATH in defaults.env # escape hatch, not a mandate: if the service pins its own PATH in defaults.env
# (e.g. to add a versioned nvm node the tool dirs intentionally omit), respect # (e.g. to add a versioned nvm node the tool dirs intentionally omit), respect
# it rather than clobbering it with a trailing Environment=PATH line that would # it rather than clobbering it with a trailing Environment=PATH line that would
@@ -127,10 +140,7 @@ def generate_unit_from_deployed(
# ${PATH} across Environment= lines, so a service that overrides PATH must # ${PATH} across Environment= lines, so a service that overrides PATH must
# spell out the full value, tool dirs included. # spell out the full value, tool dirs included.
if "PATH" not in deployed.env: if "PATH" not in deployed.env:
dirs = list(deployed.path_prepend) # resolved toolchain (e.g. pinned node bin) env_lines += f'Environment="PATH={runtime_path(deployed.path_prepend)}"\n'
dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
dirs += ["/usr/local/bin", "/usr/bin", "/bin"]
env_lines += f'Environment="PATH={":".join(dirs)}"\n'
if env_file is not None: if env_file is not None:
env_lines += f"EnvironmentFile={env_file}\n" env_lines += f"EnvironmentFile={env_file}\n"
@@ -141,7 +151,7 @@ def generate_unit_from_deployed(
if deployed.schedule: if deployed.schedule:
unit = f"""[Unit] unit = f"""[Unit]
Description=Castle: {description} Description=Wild PC: {description}
After={after} After={after}
[Service] [Service]
@@ -152,7 +162,7 @@ ExecStart={exec_start}
restart = (sd.restart if sd else RestartPolicy.ON_FAILURE).value restart = (sd.restart if sd else RestartPolicy.ON_FAILURE).value
restart_sec = sd.restart_sec if sd else 5 restart_sec = sd.restart_sec if sd else 5
unit = f"""[Unit] unit = f"""[Unit]
Description=Castle: {description} Description=Wild PC: {description}
After={after} After={after}
[Service] [Service]
@@ -176,7 +186,7 @@ SuccessExitStatus=143
# Post-start hooks (e.g. OpenBao auto-unseal). `-` prefix → failure is ignored, # Post-start hooks (e.g. OpenBao auto-unseal). `-` prefix → failure is ignored,
# so a hiccup in the hook never fails the unit. # so a hiccup in the hook never fails the unit.
for cmd in (sd.exec_start_post if sd else []): for cmd in sd.exec_start_post if sd else []:
argv = cmd.split() argv = cmd.split()
resolved = shutil.which(argv[0]) resolved = shutil.which(argv[0])
if resolved: if resolved:
@@ -214,7 +224,7 @@ def generate_timer(
timer_lines = "OnBootSec=60\nOnUnitActiveSec=300\n" timer_lines = "OnBootSec=60\nOnUnitActiveSec=300\n"
return f"""[Unit] return f"""[Unit]
Description=Castle timer: {description} Description=Wild PC timer: {description}
[Timer] [Timer]
{timer_lines}Persistent=false {timer_lines}Persistent=false

Some files were not shown because too many files have changed in this diff Show More