Compare commits

...

6 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
191 changed files with 2851 additions and 3343 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

198
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,17 +137,17 @@ 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
@@ -152,11 +158,11 @@ 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`.
--- ---
@@ -185,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 |
|---------------|--------|--------------|------| |---------------|--------|--------------|------|
@@ -209,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).
--- ---
@@ -285,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.
@@ -296,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`**.
@@ -318,8 +332,8 @@ 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`** |
@@ -327,9 +341,9 @@ Inspect + drive from the CLI: **`castle mesh status`** / **`castle mesh nodes`**
| 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`** | | 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

@@ -21,7 +21,7 @@ function splitPath(raw: string): { dir: string | null; filter: string } {
return { dir: raw.slice(0, idx), filter: raw.slice(idx + 1) } return { dir: raw.slice(0, idx), filter: raw.slice(idx + 1) }
} }
/** Adopt an existing repo as a program (the web `castle program add`). Programs /** 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 * 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. */ * a path (autocompletes as you go), navigate the listing, or paste a git URL. */
export function AddProgramForm({ export function AddProgramForm({
@@ -181,7 +181,7 @@ export function AddProgramForm({
{target && ( {target && (
<p className="text-xs text-[var(--muted)]"> <p className="text-xs text-[var(--muted)]">
Registering <span className="font-mono text-[var(--foreground)]">{target}</span> Registering <span className="font-mono text-[var(--foreground)]">{target}</span>
{isGit && " (cloned later via castle clone)"} {isGit && " (cloned later via wildpc clone)"}
</p> </p>
)} )}

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

@@ -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

@@ -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.")

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

@@ -50,7 +50,7 @@ const NAV: (NavLeaf | NavGroup)[] = [
{ 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,
@@ -162,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

@@ -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

@@ -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({

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

@@ -201,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"
@@ -210,7 +210,7 @@ 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"
@@ -263,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

@@ -15,7 +15,7 @@ interface Props {
/** 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 its `tool_schema` (the neutral * 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: * 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 castle out of the loop, so * 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). */ * `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

View File

@@ -170,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]) => (

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

@@ -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) {

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 {
@@ -295,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 }
@@ -325,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 {
@@ -1336,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)}
@@ -1345,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(() => {
@@ -1461,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)]">
@@ -1593,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,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({
@@ -260,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() {
@@ -314,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()
@@ -450,7 +450,7 @@ export interface AdoptResult {
commands: string[] commands: string[]
is_git_url: boolean is_git_url: boolean
} }
// Adopt an existing repo as a program (the web `castle program add`). Refreshes // Adopt an existing repo as a program (the web `wildpc program add`). Refreshes
// the catalog + the derived graph/repo views. // the catalog + the derived graph/repo views.
export function useAdoptProgram() { export function useAdoptProgram() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -508,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)

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

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

View File

@@ -1,10 +1,10 @@
"""castle program add — adopt an existing repo as a program (no scaffolding). """wildpc 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 `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 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. verb commands so a non-wildpc project becomes usable without writing them by hand.
The adopt logic itself lives in ``castle_core.adopt`` so the CLI and the API 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. (`POST /programs/adopt`, the dashboard's "Add program") behave identically.
""" """
@@ -12,9 +12,9 @@ from __future__ import annotations
import argparse import argparse
from castle_core.adopt import AdoptError, build_adopted_program from wildpc_core.adopt import AdoptError, build_adopted_program
from castle_cli.config import load_config, save_config from wildpc_cli.config import load_config, save_config
def run_add(args: argparse.Namespace) -> int: def run_add(args: argparse.Namespace) -> int:
@@ -35,11 +35,11 @@ def run_add(args: argparse.Namespace) -> int:
print(f"Adopted '{adopted.name}' as a program.") print(f"Adopted '{adopted.name}' as a program.")
print(f" source: {adopted.source}") print(f" source: {adopted.source}")
if adopted.repo: if adopted.repo:
print(f" repo: {adopted.repo} (run 'castle clone {adopted.name}' to fetch it)") print(f" repo: {adopted.repo} (run 'wildpc clone {adopted.name}' to fetch it)")
if adopted.stack: if adopted.stack:
print(f" stack: {adopted.stack} (verbs inherited from stack defaults)") print(f" stack: {adopted.stack} (verbs inherited from stack defaults)")
elif adopted.commands: elif adopted.commands:
print(f" commands detected: {', '.join(adopted.commands)}") print(f" commands detected: {', '.join(adopted.commands)}")
else: else:
print(" no stack/commands detected — declare verbs in castle.yaml as needed") print(" no stack/commands detected — declare verbs in wildpc.yaml as needed")
return 0 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"):

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] = {
@@ -41,6 +41,11 @@ STACK_BUILD_OUTPUTS: dict[str, str] = {
"hugo": "public", "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
@@ -74,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)
@@ -89,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(
@@ -142,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,
) )
@@ -162,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}"}
@@ -175,24 +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": elif stack == "hugo":
print(" # edit content/, layouts/ (or add a theme under themes/)") print(" # edit content/, layouts/ (or add a theme under themes/)")
print(f" castle program build {name} # hugo --gc --minify -> public/") print(f" wildpc program build {name} # hugo --gc --minify -> public/")
print(f" castle apply {name} # serve at {name}.<gateway.domain>") 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:

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))
@@ -348,7 +348,7 @@ def _check_tls_exposure(config) -> list[Check]:
public_specs = [(n, d) 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] 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
# A public deployment gets its public name from its own `public_host` # A public deployment gets its public name from its own `public_host`
# override or the node-wide `public_domain`; the default domain is only # override or the node-wide `public_domain`; the default domain is only
@@ -370,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))
@@ -387,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()
@@ -410,7 +410,7 @@ def _check_public_dns(config) -> Check:
hint=( hint=(
f"add a Cloudflare token with DNS:Edit on " f"add a Cloudflare token with DNS:Edit on "
f"{gw.public_domain or 'the public zone(s)'} " f"{gw.public_domain or 'the public zone(s)'} "
f"('Edit zone DNS' template) → ~/.castle/secrets/{PUBLIC_DNS_TOKEN} " f"('Edit zone DNS' template) → ~/.wildpc/secrets/{PUBLIC_DNS_TOKEN} "
"(else route each host by hand)" "(else route each host by hand)"
), ),
) )
@@ -463,7 +463,7 @@ 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",
) )
@@ -476,7 +476,7 @@ def _check_stacks(config: object) -> list[Check]:
tool for an enabled deployment is a FAIL (its service can't build/run); 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 for a not-yet-enabled program is a WARN. Unused stacks are skipped no nagging
about pnpm when there are no frontends.""" about pnpm when there are no frontends."""
from castle_core.stack_status import all_stack_status from wildpc_core.stack_status import all_stack_status
checks: list[Check] = [] checks: list[Check] = []
for st in all_stack_status(config, with_version=False): for st in all_stack_status(config, with_version=False):
@@ -504,9 +504,9 @@ def _check_stacks(config: object) -> list[Check]:
def run_doctor(args: argparse.Namespace) -> int: def run_doctor(args: argparse.Namespace) -> int:
from castle_core.config import load_config from wildpc_core.config import load_config
print("\n\033[1mCastle Doctor\033[0m") print("\n\033[1mWild PC Doctor\033[0m")
try: try:
config = load_config() config = load_config()
@@ -515,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()
@@ -544,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

@@ -1,4 +1,4 @@
"""castle stack — the stacks lens (toolchains a program's stack needs). """wildpc stack — the stacks lens (toolchains a program's stack needs).
A *stack* (python-fastapi, react-vite, hugo, ) is creation-time guidance that 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`, also carries the **host toolchains** its programs need to build and run (`uv`,
@@ -14,10 +14,10 @@ import json
from dataclasses import asdict from dataclasses import asdict
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from castle_cli.config import load_config from wildpc_cli.config import load_config
if TYPE_CHECKING: if TYPE_CHECKING:
from castle_core.stack_status import StackStatus from wildpc_core.stack_status import StackStatus
BOLD = "\033[1m" BOLD = "\033[1m"
DIM = "\033[2m" DIM = "\033[2m"
@@ -37,7 +37,7 @@ def _record(st: StackStatus) -> dict:
def run_stack_list(args: argparse.Namespace) -> int: def run_stack_list(args: argparse.Namespace) -> int:
"""List stacks with their toolchain health, program count, and dev verbs.""" """List stacks with their toolchain health, program count, and dev verbs."""
from castle_core.stack_status import all_stack_status from wildpc_core.stack_status import all_stack_status
config = load_config() config = load_config()
# Skip per-tool version probes for the list (a subprocess per tool) — keep it snappy. # Skip per-tool version probes for the list (a subprocess per tool) — keep it snappy.
@@ -75,13 +75,13 @@ def run_stack_list(args: argparse.Namespace) -> int:
def run_stack_info(args: argparse.Namespace) -> int: def run_stack_info(args: argparse.Namespace) -> int:
"""Show one stack: each tool's presence + version + fix, and who uses it.""" """Show one stack: each tool's presence + version + fix, and who uses it."""
from castle_core.stack_status import stack_status from wildpc_core.stack_status import stack_status
config = load_config() config = load_config()
name = args.name name = args.name
st = stack_status(config, name) st = stack_status(config, name)
if st is None: if st is None:
from castle_core.stacks import available_stacks from wildpc_core.stacks import available_stacks
print(f"Error: no stack '{name}'. Known: {', '.join(available_stacks())}") print(f"Error: no stack '{name}'. Known: {', '.join(available_stacks())}")
return 1 return 1

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
@@ -15,11 +15,11 @@ import tomllib
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from castle_cli.config import load_config from wildpc_cli.config import load_config
if TYPE_CHECKING: if TYPE_CHECKING:
from castle_core.config import CastleConfig from wildpc_core.config import WildpcConfig
from castle_core.manifest import ProgramSpec from wildpc_core.manifest import ProgramSpec
BOLD = "\033[1m" BOLD = "\033[1m"
DIM = "\033[2m" DIM = "\033[2m"
@@ -29,11 +29,11 @@ GREY = "\033[90m"
CYAN = "\033[96m" CYAN = "\033[96m"
def _is_tool(config: CastleConfig, 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: CastleConfig) -> 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)}
@@ -60,12 +60,12 @@ def _executables(comp: ProgramSpec) -> list[str]:
def _tool_record( def _tool_record(
config: CastleConfig, name: str, comp: ProgramSpec, installed: bool, fmt: str = "openai" config: WildpcConfig, name: str, comp: ProgramSpec, installed: bool, fmt: str = "openai"
) -> dict: ) -> dict:
# The tool-call schema (for handing this CLI to an agent) is stored neutral on # 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 # the path deployment; render it into the requested envelope for the --json
# context payload. Feed default is openai (litellm-native). # context payload. Feed default is openai (litellm-native).
from castle_core.tool_schema import render_tool_schema from wildpc_core.tool_schema import render_tool_schema
dep = config.deployment("tool", name) dep = config.deployment("tool", name)
core = getattr(dep, "tool_schema", None) core = getattr(dep, "tool_schema", None)
@@ -83,7 +83,7 @@ def _tool_record(
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)
@@ -119,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
@@ -149,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(
@@ -109,7 +109,7 @@ def _build_stack_group(subparsers: argparse._SubParsersAction) -> None:
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")
@@ -148,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)
@@ -167,13 +167,13 @@ 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)
@@ -183,7 +183,7 @@ def build_parser() -> argparse.ArgumentParser:
_build_stack_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)")
@@ -204,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(
@@ -212,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(
@@ -249,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
@@ -289,14 +305,14 @@ 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
@@ -305,14 +321,14 @@ def _dispatch_tool(args: argparse.Namespace) -> int:
def _dispatch_stack(args: argparse.Namespace) -> int: def _dispatch_stack(args: argparse.Namespace) -> int:
sub = args.stack_command sub = args.stack_command
if not sub: if not sub:
print("Usage: castle stack {list|info}") print("Usage: wildpc stack {list|info}")
return 1 return 1
if sub == "list": if sub == "list":
from castle_cli.commands.stack import run_stack_list from wildpc_cli.commands.stack import run_stack_list
return run_stack_list(args) return run_stack_list(args)
if sub == "info": if sub == "info":
from castle_cli.commands.stack import run_stack_info from wildpc_cli.commands.stack import run_stack_info
return run_stack_info(args) return run_stack_info(args)
return 1 return 1
@@ -321,31 +337,31 @@ def _dispatch_stack(args: argparse.Namespace) -> int:
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
@@ -369,39 +385,43 @@ def main() -> int:
if cmd == "stack": if cmd == "stack":
return _dispatch_stack(args) 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

@@ -575,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
@@ -604,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)
@@ -614,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__
""" """
@@ -625,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.
@@ -680,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",
@@ -748,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,
@@ -799,9 +799,9 @@ def _scaffold_hugo(project_dir: Path, name: str, description: str) -> None:
The canonical skeleton comes from Hugo's own `hugo new site`; on top of it we 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 overlay the pieces a bare skeleton lacks the layouts needed to render a home
page without a theme, sample content, and a castle-flavored hugo.toml/README. page without a theme, sample content, and a wildpc-flavored hugo.toml/README.
`castle program build` runs `hugo --gc --minify` `public/`, which a caddy `wildpc program build` runs `hugo --gc --minify` `public/`, which a caddy
deployment serves in place at <name>.<gateway.domain>. baseURL is `/` so assets 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 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 pipeline) can be dropped under themes/ later; such themes declare a two-step
@@ -912,7 +912,7 @@ description = "__DESC__"
title: "__NAME__" title: "__NAME__"
--- ---
Welcome to **__NAME__** a Hugo site on castle. Edit `content/_index.md` and Welcome to **__NAME__** a Hugo site on wildpc. Edit `content/_index.md` and
add posts under `content/posts/`. add posts under `content/posts/`.
""" """
), ),
@@ -942,7 +942,7 @@ Your first post. Edit me in `content/posts/`, or run `hugo new posts/next.md`.
__DESC__ __DESC__
A [Hugo](https://gohugo.io) static site managed by castle. A [Hugo](https://gohugo.io) static site managed by wildpc.
## Develop ## Develop
@@ -950,8 +950,8 @@ A [Hugo](https://gohugo.io) static site managed by castle.
## Build & serve ## Build & serve
castle program build __NAME__ # hugo --gc --minify -> public/ wildpc program build __NAME__ # hugo --gc --minify -> public/
castle apply __NAME__ # serve at __NAME__.<gateway.domain> wildpc apply __NAME__ # serve at __NAME__.<gateway.domain>
## Themes ## Themes

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,7 +7,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from castle_cli.commands.doctor import ( from wildpc_cli.commands.doctor import (
FAIL, FAIL,
OK, OK,
WARN, WARN,
@@ -20,13 +20,13 @@ from castle_cli.commands.doctor import (
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
@@ -34,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
@@ -48,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(
@@ -82,16 +82,16 @@ 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: class TestStackChecks:
@@ -99,8 +99,8 @@ class TestStackChecks:
run. Missing tooling for an enabled deployment is a FAIL with a copyable fix.""" run. Missing tooling for an enabled deployment is a FAIL with a copyable fix."""
def _fastapi_cfg(self): def _fastapi_cfg(self):
import castle_core.config as C import wildpc_core.config as C
from castle_core.manifest import ProgramSpec, SystemdDeployment from wildpc_core.manifest import ProgramSpec, SystemdDeployment
prog = ProgramSpec(id="svc", stack="python-fastapi") prog = ProgramSpec(id="svc", stack="python-fastapi")
dep = SystemdDeployment.model_validate( dep = SystemdDeployment.model_validate(
@@ -110,7 +110,7 @@ class TestStackChecks:
"run": {"launcher": "command", "argv": ["svc"]}, "run": {"launcher": "command", "argv": ["svc"]},
} }
) )
return C.CastleConfig( return C.WildpcConfig(
root=None, root=None,
gateway=C.GatewayConfig(port=9000), gateway=C.GatewayConfig(port=9000),
repo=None, repo=None,
@@ -119,7 +119,7 @@ class TestStackChecks:
) )
def test_present_tool_is_ok(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_present_tool_is_ok(self, monkeypatch: pytest.MonkeyPatch) -> None:
import castle_core.stack_status as SS import wildpc_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True) monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
fa = next( fa = next(
@@ -130,7 +130,7 @@ class TestStackChecks:
def test_missing_tool_for_enabled_deployment_fails_with_hint( def test_missing_tool_for_enabled_deployment_fails_with_hint(
self, monkeypatch: pytest.MonkeyPatch self, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
import castle_core.stack_status as SS import wildpc_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: False) monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: False)
fa = next( fa = next(
@@ -141,7 +141,7 @@ class TestStackChecks:
def test_unused_stacks_are_skipped(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_unused_stacks_are_skipped(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""No react-vite program → no pnpm nag.""" """No react-vite program → no pnpm nag."""
import castle_core.stack_status as SS import wildpc_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True) monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
labels = [c.label for c in _check_stacks(self._fastapi_cfg())] labels = [c.label for c in _check_stacks(self._fastapi_cfg())]

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,7 +1,7 @@
[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",
@@ -14,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 = [
@@ -23,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

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

View File

@@ -1,4 +1,4 @@
"""Adopt an existing repo as a program — shared by the CLI (`castle program add`) """Adopt an existing repo as a program — shared by the CLI (`wildpc program add`)
and the API (`POST /programs/adopt`). and the API (`POST /programs/adopt`).
`create` scaffolds new code from a stack; `add` adopts code that already exists `create` scaffolds new code from a stack; `add` adopts code that already exists
@@ -13,8 +13,8 @@ import tomllib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from castle_core.config import CastleConfig from wildpc_core.config import WildpcConfig
from castle_core.manifest import BuildSpec, CommandsSpec, ProgramSpec from wildpc_core.manifest import BuildSpec, CommandsSpec, ProgramSpec
class AdoptError(ValueError): class AdoptError(ValueError):
@@ -26,7 +26,7 @@ def is_git_url(s: str) -> bool:
def looks_like_program(src: Path) -> bool: def looks_like_program(src: Path) -> bool:
"""Whether a directory holds something castle can adopt (a project manifest or """Whether a directory holds something wildpc can adopt (a project manifest or
a git repo). Used to flag candidates in the filesystem browser.""" a git repo). Used to flag candidates in the filesystem browser."""
return ( return (
(src / ".git").exists() (src / ".git").exists()
@@ -95,7 +95,7 @@ class Adopted:
def build_adopted_program( def build_adopted_program(
config: CastleConfig, config: WildpcConfig,
target: str, target: str,
name: str | None = None, name: str | None = None,
description: str = "", description: str = "",
@@ -111,7 +111,7 @@ def build_adopted_program(
if is_git_url(target): if is_git_url(target):
repo_url = target repo_url = target
name = name or Path(target.rstrip("/")).name.removesuffix(".git") name = name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`. # Default local clone location; cloned later via `wildpc clone`.
source = str(config.repos_dir / name) source = str(config.repos_dir / name)
src_path = Path(source) src_path = Path(source)
else: else:
@@ -122,11 +122,11 @@ def build_adopted_program(
name = name or src_path.name name = name or src_path.name
if name in config.programs or config.deployments_named(name): if name in config.programs or config.deployments_named(name):
raise AdoptError(f"'{name}' already exists in castle.yaml") raise AdoptError(f"'{name}' already exists in wildpc.yaml")
# Detect verbs from the working copy if we have one on disk. `kind` is derived # 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; # from a deployment, not stored on the program — so adoption takes source only;
# declare a deployment separately (castle service/job create). # declare a deployment separately (wildpc service/job create).
stack: str | None = None stack: str | None = None
detected: dict[str, list[list[str]]] = {} detected: dict[str, list[list[str]]] = {}
if src_path.exists(): if src_path.exists():

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,29 +66,29 @@ 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",
@@ -98,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'}"
) )
@@ -138,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
@@ -146,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.
@@ -161,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
@@ -181,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)
@@ -255,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.
""" """
@@ -320,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:
@@ -379,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
@@ -398,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 {}
@@ -418,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] = {}
@@ -433,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)
@@ -447,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,
@@ -565,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():
@@ -597,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",
@@ -605,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)
@@ -620,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"
@@ -635,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
@@ -644,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"
@@ -659,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:
@@ -678,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)
@@ -705,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)
@@ -718,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.
""" """
@@ -15,24 +15,24 @@ 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,
@@ -41,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,
@@ -50,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,
@@ -58,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"
@@ -74,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.
@@ -97,6 +97,11 @@ class ApplyResult:
# 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:
@@ -110,11 +115,11 @@ class ApplyResult:
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.
@@ -190,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,
@@ -219,7 +224,7 @@ 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: CastleConfig, target_name: str | None) -> NodeRegistry: def _desired_registry(config: WildpcConfig, target_name: str | None) -> NodeRegistry:
"""The registry ``deploy()`` would write for this (optionally scoped) run. """The registry ``deploy()`` would write for this (optionally scoped) run.
Mirrors deploy()'s registry build: a scoped run merges the updated target over Mirrors deploy()'s registry build: a scoped run merges the updated target over
@@ -242,7 +247,7 @@ def _desired_registry(config: CastleConfig, target_name: str | None) -> NodeRegi
return registry return registry
def _gateway_would_change(config: CastleConfig, target_name: str | None) -> bool: def _gateway_would_change(config: WildpcConfig, target_name: str | None) -> bool:
"""Whether applying would rewrite the gateway's routing artifacts — the """Whether applying would rewrite the gateway's routing artifacts — the
Caddyfile or the cloudflared ingress vs. what's on disk. Caddyfile or the cloudflared ingress vs. what's on disk.
@@ -279,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.
@@ -291,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}
@@ -350,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)
@@ -376,7 +404,7 @@ def apply(
def _stack_preflight( def _stack_preflight(
config: CastleConfig, config: WildpcConfig,
items: Sequence[tuple[str, str, object]], items: Sequence[tuple[str, str, object]],
messages: list[str], messages: list[str],
) -> None: ) -> None:
@@ -384,8 +412,8 @@ def _stack_preflight(
*where it runs* the moment drift actually bites: a service whose `uv`/`pnpm` *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 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.""" advisory message, no writes, no gate. The exact fix comes from the tool's hint."""
from castle_core.relations import _tool_available from wildpc_core.relations import _tool_available
from castle_core.stacks import tools_for from wildpc_core.stacks import tools_for
for _k, n, spec in items: for _k, n, spec in items:
if not getattr(spec, "enabled", True): if not getattr(spec, "enabled", True):
@@ -401,6 +429,47 @@ def _stack_preflight(
) )
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,
@@ -411,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.
@@ -422,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(
@@ -450,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(
@@ -459,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:
@@ -479,7 +548,7 @@ 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.tunnel_id, [], messages) reconcile_public_dns(node.tunnel_id, [], messages)
return return
@@ -506,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)
@@ -526,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
@@ -561,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(
@@ -579,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.
@@ -598,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."""
@@ -619,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)."""
@@ -632,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).
@@ -641,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"
@@ -676,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
@@ -699,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
@@ -718,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)
@@ -743,6 +818,7 @@ def _build_deployed(
public=bool(dep.public), public=bool(dep.public),
public_host=(dep.public_host if dep.public else None), 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),
@@ -775,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
@@ -795,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).
@@ -811,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(
@@ -829,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.
@@ -905,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
@@ -918,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.
@@ -953,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
@@ -1009,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)])
@@ -1071,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
@@ -1128,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.
@@ -1138,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}.
@@ -1174,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"
@@ -44,6 +44,8 @@ class GatewayRoute:
# Optional exact public-facing FQDN override (apex allowed). When set, the # Optional exact public-facing FQDN override (apex allowed). When set, the
# public name is this instead of the derived <address>.<public_domain>. # public name is this instead of the derived <address>.<public_domain>.
public_host: str | None = None 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:
@@ -69,47 +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, bool, str | None]]: ) -> list[tuple[str, str, str, bool, str | None, bool]]:
"""Each local deployment's route as ``(name, kind, target, public, public_host)``. """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, bool, str | None]] = [] 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:
pub_host = dep.public_host if dep.public else None pub_host = dep.public_host if dep.public else None
out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host)) 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):
pub_host = dep.public_host if dep.public else None 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)) 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, d.public, d.public_host)) 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}", d.public, d.public_host)) 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
@@ -121,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
@@ -133,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:
@@ -146,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, is_public, pub_host 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, is_public, pub_host)) 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))
@@ -156,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]:
@@ -229,21 +231,30 @@ 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",
" }", " }",
"", "",
] ]
def _public_site_block(host: str, kind: str, target: str) -> list[str]: 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). """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 Unlike the ``*.<public_domain>`` wildcard site, an apex/foreign host isn't
@@ -251,11 +262,7 @@ def _public_site_block(host: str, kind: str, target: str) -> list[str]:
host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's
Cloudflare token can edit the host's zone.""" Cloudflare token can edit the host's zone."""
if kind == "static": if kind == "static":
body = [ body = _static_serve_lines(target, spa, " ")
f" root * {target}",
" try_files {path} /index.html",
" file_server",
]
elif kind == "remote": elif kind == "remote":
body = [ body = [
f" reverse_proxy {target} {{", f" reverse_proxy {target} {{",
@@ -271,11 +278,11 @@ def _public_site_block(host: str, kind: str, target: str) -> list[str]:
return [f"{host} {{", *body, "}", ""] return [f"{host} {{", *body, "}", ""]
# Castle's own control plane: the dashboard frontend and the API it calls. These # 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(
@@ -292,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)
@@ -312,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
@@ -323,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 += ["}", ""]
@@ -334,7 +341,7 @@ 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:
@@ -359,7 +366,7 @@ def generate_caddyfile_from_registry(
host = f"{r.address}.{public_domain}" host = f"{r.address}.{public_domain}"
label = f"{r.name or r.address}_pub" label = f"{r.name or r.address}_pub"
if r.kind == "static": if r.kind == "static":
lines += _host_static_block(label, host, r.target) lines += _host_static_block(label, host, r.target, r.spa)
elif r.kind == "remote": elif r.kind == "remote":
lines += _host_remote_block(label, host, r.target) lines += _host_remote_block(label, host, r.target)
else: else:
@@ -368,7 +375,7 @@ def generate_caddyfile_from_registry(
lines.append("") lines.append("")
for r in custom_pub: for r in custom_pub:
if r.public_host: # always true (custom_pub filter); narrows the type if r.public_host: # always true (custom_pub filter); narrows the type
lines += _public_site_block(r.public_host, r.kind, r.target) 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

@@ -1,6 +1,6 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services. """Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
Castle owns the CNAMEs across every zone the token can see that point at its 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 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 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 work) and deletes any that point at this tunnel but no longer correspond to a
@@ -11,7 +11,7 @@ hand-managed A/CNAME in the same zone is safe.
Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's 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 "Edit zone DNS" template that single permission both lists the accessible zones
and edits records; no separate Zone:Read is needed), stored at 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 `~/.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. caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
""" """
@@ -31,7 +31,7 @@ PUBLIC_DNS_TOKEN = "CLOUDFLARE_PUBLIC_DNS_TOKEN"
def public_dns_token() -> str | None: def public_dns_token() -> str | None:
"""The public-zone DNS token from the active secret backend, or None.""" """The public-zone DNS token from the active secret backend, or None."""
from castle_core.config import read_secret from wildpc_core.config import read_secret
return read_secret(PUBLIC_DNS_TOKEN) or None return read_secret(PUBLIC_DNS_TOKEN) or None
@@ -69,8 +69,8 @@ def reconcile_public_dns(
Each desired host is routed to the accessible zone whose name is its longest 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 suffix (so apex hosts and hosts in different zones are handled), then per zone
castle creates missing CNAMEs (proxied the tunnel; Cloudflare flattens apex wildpc creates missing CNAMEs (proxied the tunnel; Cloudflare flattens apex
CNAMEs) and deletes castle-managed ones (content == `<tunnel_id>.cfargotunnel.com`) CNAMEs) and deletes wildpc-managed ones (content == `<tunnel_id>.cfargotunnel.com`)
no longer desired. Never touches records pointing elsewhere. Scanning every 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 visible zone also cleans up stale CNAMEs after a host moves zones or all public
services are removed. services are removed.

View File

@@ -5,12 +5,12 @@ 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).
@@ -18,7 +18,7 @@ SECRET_ENV_DIR = SECRETS_DIR / "env"
def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str: def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str:
"""The PATH a castle service runs with: resolved toolchain dirs (e.g. a pinned """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 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 definition of a service's runtime PATH — the unit generator writes it into
``Environment=PATH`` and the dependency checker (``relations``) probes tools ``Environment=PATH`` and the dependency checker (``relations``) probes tools
@@ -32,8 +32,8 @@ def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str:
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}"
@@ -132,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
@@ -151,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]
@@ -162,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]
@@ -224,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

View File

@@ -20,8 +20,8 @@ from pathlib import Path
import yaml import yaml
from castle_core.config import SECRETS_DIR from wildpc_core.config import SECRETS_DIR
from castle_core.registry import Deployment, NodeRegistry from wildpc_core.registry import Deployment, NodeRegistry
# Cloudflared tunnel credentials (from `cloudflared tunnel create`) live here as a # Cloudflared tunnel credentials (from `cloudflared tunnel create`) live here as a
# secret, one JSON per tunnel id. The generated config references this path. # secret, one JSON per tunnel id. The generated config references this path.

View File

@@ -1,13 +1,13 @@
"""Git working-copy status and sync for programs whose source is a git repo. """Git working-copy status and sync for programs whose source is a git repo.
Programs that declare a ``repo:`` URL are cloned once (``castle program clone``); Programs that declare a ``repo:`` URL are cloned once (``wildpc program clone``);
this module lets a running castle *see how far behind* a working copy is and pull this module lets a running wildpc *see how far behind* a working copy is and pull
later updates. It is intentionally pull-only it touches files on disk and never later updates. It is intentionally pull-only it touches files on disk and never
builds, applies, or restarts anything. Making the running artifact reflect the new builds, applies, or restarts anything. Making the running artifact reflect the new
code (rebuild a frontend, restart a service) stays an explicit, separate step via code (rebuild a frontend, restart a service) stays an explicit, separate step via
``castle apply`` / ``castle restart``. ``wildpc apply`` / ``wildpc restart``.
Plain ``git`` via subprocess (matching ``castle program clone``); no GitPython. Plain ``git`` via subprocess (matching ``wildpc program clone``); no GitPython.
""" """
from __future__ import annotations from __future__ import annotations

View File

@@ -18,8 +18,8 @@ import sys
import time import time
from pathlib import Path from pathlib import Path
from castle_core.config import CastleConfig from wildpc_core.config import WildpcConfig
from castle_core.generators.systemd import ( from wildpc_core.generators.systemd import (
SYSTEMD_USER_DIR, SYSTEMD_USER_DIR,
generate_timer, generate_timer,
generate_unit_from_deployed, generate_unit_from_deployed,
@@ -28,9 +28,9 @@ from castle_core.generators.systemd import (
unit_env_file, unit_env_file,
unit_name, unit_name,
) )
from castle_core.manifest import CaddyDeployment from wildpc_core.manifest import CaddyDeployment
from castle_core.registry import REGISTRY_PATH, load_registry from wildpc_core.registry import REGISTRY_PATH, load_registry
from castle_core.stacks import ActionResult, run_action from wildpc_core.stacks import ActionResult, run_action
def _systemctl_active(unit: str) -> bool: def _systemctl_active(unit: str) -> bool:
@@ -73,7 +73,7 @@ def _uv_tool_packages() -> set[str]:
def _own_venv_bins() -> set[str]: def _own_venv_bins() -> set[str]:
"""Bin dirs of the *running* interpreter's virtualenv. """Bin dirs of the *running* interpreter's virtualenv.
Excluded from install detection: when the api server (or a dev `castle`) runs Excluded from install detection: when the api server (or a dev `wildpc`) runs
inside a project venv via `uv run`, a tool merely *colocated* there e.g. an inside a project venv via `uv run`, a tool merely *colocated* there e.g. an
editable a dev accidentally `uv pip install`ed while that venv was active must editable a dev accidentally `uv pip install`ed while that venv was active must
not read as "installed on PATH" for the user. We only strip *our own* venv, not not read as "installed on PATH" for the user. We only strip *our own* venv, not
@@ -113,13 +113,13 @@ def tool_installed(name: str) -> bool:
return _on_path(name) return _on_path(name)
def _svc_manager(name: str, kind: str, config: CastleConfig) -> str | None: def _svc_manager(name: str, kind: str, config: WildpcConfig) -> str | None:
"""The manager for a deployment (name, kind), or None if not in config.""" """The manager for a deployment (name, kind), or None if not in config."""
dep = config.deployment(kind, name) dep = config.deployment(kind, name)
return dep.manager if dep is not None else None return dep.manager if dep is not None else None
def _static_built(name: str, config: CastleConfig) -> bool: def _static_built(name: str, config: WildpcConfig) -> bool:
"""Whether a static (caddy) deployment's served dir exists (assets are built).""" """Whether a static (caddy) deployment's served dir exists (assets are built)."""
dep = config.statics.get(name) dep = config.statics.get(name)
if not isinstance(dep, CaddyDeployment): if not isinstance(dep, CaddyDeployment):
@@ -133,7 +133,7 @@ def _static_built(name: str, config: CastleConfig) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def is_active(name: str, kind: str, config: CastleConfig) -> bool: def is_active(name: str, kind: str, config: WildpcConfig) -> bool:
"""Whether a deployment (name, kind) is available in its mode, by manager.""" """Whether a deployment (name, kind) is available in its mode, by manager."""
manager = _svc_manager(name, kind, config) manager = _svc_manager(name, kind, config)
if manager == "systemd": if manager == "systemd":
@@ -157,17 +157,17 @@ def is_active(name: str, kind: str, config: CastleConfig) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def enable_service(name: str, kind: str, config: CastleConfig) -> ActionResult: def enable_service(name: str, kind: str, config: WildpcConfig) -> ActionResult:
"""Generate+install the unit (and timer) from the registry, enable and start it.""" """Generate+install the unit (and timer) from the registry, enable and start it."""
if not REGISTRY_PATH.exists(): if not REGISTRY_PATH.exists():
return ActionResult( return ActionResult(
name, "activate", "error", "No registry. Run 'castle deploy' first." name, "activate", "error", "No registry. Run 'wildpc deploy' first."
) )
registry = load_registry() registry = load_registry()
deployed = registry.get(kind, name) deployed = registry.get(kind, name)
if deployed is None: if deployed is None:
return ActionResult( return ActionResult(
name, "activate", "error", f"'{name}' not in registry; run 'castle deploy'." name, "activate", "error", f"'{name}' not in registry; run 'wildpc deploy'."
) )
if not deployed.managed: if not deployed.managed:
return ActionResult( return ActionResult(
@@ -228,14 +228,14 @@ def disable_service(name: str, kind: str) -> ActionResult:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _program_for(name: str, kind: str, config: CastleConfig): def _program_for(name: str, kind: str, config: WildpcConfig):
"""The program a deployment runs (its `program` ref, defaulting to the name).""" """The program a deployment runs (its `program` ref, defaulting to the name)."""
dep = config.deployment(kind, name) dep = config.deployment(kind, name)
prog = (dep.program if dep else None) or name prog = (dep.program if dep else None) or name
return prog, config.programs.get(prog) return prog, config.programs.get(prog)
async def activate(name: str, kind: str, config: CastleConfig, root: Path) -> ActionResult: async def activate(name: str, kind: str, config: WildpcConfig, root: Path) -> ActionResult:
"""Make a deployment (name, kind) available in its mode, dispatched by manager.""" """Make a deployment (name, kind) available in its mode, dispatched by manager."""
manager = _svc_manager(name, kind, config) manager = _svc_manager(name, kind, config)
@@ -251,9 +251,9 @@ async def activate(name: str, kind: str, config: CastleConfig, root: Path) -> Ac
if manager == "caddy": if manager == "caddy":
# Served by the gateway — reload it so the route is live. Building the # Served by the gateway — reload it so the route is live. Building the
# assets is `castle program build` (the program's concern), not activation. # assets is `wildpc program build` (the program's concern), not activation.
subprocess.run( subprocess.run(
["systemctl", "--user", "reload", unit_name("castle-gateway")], check=False ["systemctl", "--user", "reload", unit_name("wildpc-gateway")], check=False
) )
return ActionResult(name, "activate", "ok", f"{name}: served via gateway") return ActionResult(name, "activate", "ok", f"{name}: served via gateway")
@@ -275,7 +275,7 @@ async def activate(name: str, kind: str, config: CastleConfig, root: Path) -> Ac
return ActionResult(name, "activate", "error", f"'{name}' not found") return ActionResult(name, "activate", "error", f"'{name}' not found")
async def deactivate(name: str, kind: str, config: CastleConfig, root: Path) -> ActionResult: async def deactivate(name: str, kind: str, config: WildpcConfig, root: Path) -> ActionResult:
"""Take a deployment (name, kind) offline in its mode, dispatched by manager.""" """Take a deployment (name, kind) offline in its mode, dispatched by manager."""
manager = _svc_manager(name, kind, config) manager = _svc_manager(name, kind, config)

View File

@@ -1,4 +1,4 @@
"""Castle manifest models — program specs, service specs, job specs.""" """Wild PC manifest models — program specs, service specs, job specs."""
from __future__ import annotations from __future__ import annotations
@@ -110,7 +110,7 @@ class RunCompose(LaunchBase):
"""A multi-container stack supervised as one unit via ``docker compose``. """A multi-container stack supervised as one unit via ``docker compose``.
Unlike ``container`` (a single ``docker run``), compose owns the stack's own Unlike ``container`` (a single ``docker run``), compose owns the stack's own
networking, startup ordering, and per-service health Castle delegates rather networking, startup ordering, and per-service health Wild PC delegates rather
than reinventing orchestration. The unit runs ``compose up`` attached than reinventing orchestration. The unit runs ``compose up`` attached
(``Type=simple``) and tears the stack down via a generated ``ExecStop`` (down). (``Type=simple``) and tears the stack down via a generated ``ExecStop`` (down).
Env/secrets flow through systemd ``Environment=``/``EnvironmentFile=`` (from Env/secrets flow through systemd ``Environment=``/``EnvironmentFile=`` (from
@@ -119,7 +119,7 @@ class RunCompose(LaunchBase):
launcher: Literal["compose"] launcher: Literal["compose"]
file: str = "docker-compose.yml" # resolved relative to the program source file: str = "docker-compose.yml" # resolved relative to the program source
project_name: str | None = None # ``-p``; defaults to ``castle-<name>`` project_name: str | None = None # ``-p``; defaults to ``wildpc-<name>``
LaunchSpec = Annotated[ LaunchSpec = Annotated[
@@ -184,9 +184,9 @@ class HttpExposeSpec(BaseModel):
class TlsMaterial(str, Enum): class TlsMaterial(str, Enum):
"""What cert files castle materializes onto a service from the wildcard cert. """What cert files wildpc materializes onto a service from the wildcard cert.
``off`` the service does its own TLS (or none); castle stays out of it. ``off`` the service does its own TLS (or none); wildpc stays out of it.
``pair`` cert.pem + key.pem (postgres, redis, most daemons). ``pair`` cert.pem + key.pem (postgres, redis, most daemons).
``combined`` one file: key+cert concatenated (mongodb, haproxy, ). ``combined`` one file: key+cert concatenated (mongodb, haproxy, ).
""" """
@@ -197,15 +197,15 @@ class TlsMaterial(str, Enum):
class TlsSpec(BaseModel): class TlsSpec(BaseModel):
"""Castle-managed TLS material for a raw-TCP service, cut from the gateway's """Wild PC-managed TLS material for a raw-TCP service, cut from the gateway's
ACME wildcard cert (valid for ``<name>.<domain>``) and refreshed on renewal. ACME wildcard cert (valid for ``<name>.<domain>``) and refreshed on renewal.
The service consumes the materialized files via the ``${tls_*}`` placeholders. The service consumes the materialized files via the ``${tls_*}`` placeholders.
""" """
material: TlsMaterial = TlsMaterial.OFF material: TlsMaterial = TlsMaterial.OFF
# Optional zero-downtime reload argv (a single command) run after the cert is # Optional zero-downtime reload argv (a single command) run after the cert is
# re-materialized on renewal — e.g. ["systemctl", "--user", "reload", "castle-postgres"]. # re-materialized on renewal — e.g. ["systemctl", "--user", "reload", "wildpc-postgres"].
# Default (None): castle restarts the deployment (fine at a ~60-day cadence). # Default (None): wildpc restarts the deployment (fine at a ~60-day cadence).
reload: list[str] | None = None reload: list[str] | None = None
@@ -214,8 +214,8 @@ class TcpExposeSpec(BaseModel):
with ``reach: internal`` it's reachable at ``<name>.<domain>:<port>`` via the with ``reach: internal`` it's reachable at ``<name>.<domain>:<port>`` via the
wildcard DNS record + the bound port (no Caddy route). Publishing the port on wildcard DNS record + the bound port (no Caddy route). Publishing the port on
the LAN is the deployment's own job (a container's ``run.ports``, or a native the LAN is the deployment's own job (a container's ``run.ports``, or a native
service binding ``0.0.0.0``); castle doesn't rebind it, so there's no bind-host service binding ``0.0.0.0``); wildpc doesn't rebind it, so there's no bind-host
field here to imply otherwise. ``tls`` (optional) has castle drop the wildcard field here to imply otherwise. ``tls`` (optional) has wildpc drop the wildcard
cert onto the service so it presents a trusted cert for ``<name>.<domain>``. cert onto the service so it presents a trusted cert for ``<name>.<domain>``.
""" """
@@ -281,7 +281,7 @@ class CommandsSpec(BaseModel):
class Requirement(BaseModel): class Requirement(BaseModel):
"""A precondition — another **deployment** that must exist for this one to be """A precondition — another **deployment** that must exist for this one to be
*functional* (``ref`` = the target deployment's name). ``bind`` names the env *functional* (``ref`` = the target deployment's name). ``bind`` names the env
var castle projects the target's URL into — env is derived *from* the var wildpc projects the target's URL into — env is derived *from* the
requirement, never scraped back into it. requirement, never scraped back into it.
A deployment declares these in its ``requires`` list; ``kind`` defaults to A deployment declares these in its ``requires`` list; ``kind`` defaults to
@@ -315,7 +315,7 @@ class SessionsSpec(BaseModel):
"""Declarative session-history capability for an agent (optional). """Declarative session-history capability for an agent (optional).
Lets the dashboard show a unified picker of an agent's *own* past sessions Lets the dashboard show a unified picker of an agent's *own* past sessions
without castle knowing anything agent-specific in code: it runs without wildpc knowing anything agent-specific in code: it runs
``list_command`` (which must print a JSON array of session objects), reads ``list_command`` (which must print a JSON array of session objects), reads
three named fields off each object, and launches ``command`` + ``resume`` three named fields off each object, and launches ``command`` + ``resume``
(with ``{id}`` substituted) to reopen one. Field names default to opencode's (with ``{id}`` substituted) to reopen one. Field names default to opencode's
@@ -333,7 +333,7 @@ class SessionsSpec(BaseModel):
class AgentSpec(BaseModel): class AgentSpec(BaseModel):
"""A launchable agent CLI for the dashboard's terminal UX. """A launchable agent CLI for the dashboard's terminal UX.
Castle just runs ``command args`` inside a pty at ``cwd``; it never parses Wild PC just runs ``command args`` inside a pty at ``cwd``; it never parses
the agent's output, so any interactive CLI works. This block only names the the agent's output, so any interactive CLI works. This block only names the
launch live sessions (list/resume/kill) are a runtime concern, not config. launch live sessions (list/resume/kill) are a runtime concern, not config.
""" """
@@ -341,11 +341,11 @@ class AgentSpec(BaseModel):
command: str command: str
args: list[str] = Field(default_factory=list) args: list[str] = Field(default_factory=list)
description: str | None = None description: str | None = None
cwd: str | None = None # defaults to the castle repo root when unset cwd: str | None = None # defaults to the wildpc repo root when unset
env: EnvMap = Field(default_factory=dict) env: EnvMap = Field(default_factory=dict)
# Extra args that open the agent's own session browser / continue its last # Extra args that open the agent's own session browser / continue its last
# conversation (e.g. ["--resume"] or ["--continue"]). Optional and # conversation (e.g. ["--resume"] or ["--continue"]). Optional and
# agent-specific — castle just passes them through. Empty = no such affordance. # agent-specific — wildpc just passes them through. Empty = no such affordance.
resume_args: list[str] = Field(default_factory=list) resume_args: list[str] = Field(default_factory=list)
# Optional: declares how to list + resume the agent's own sessions, so the # Optional: declares how to list + resume the agent's own sessions, so the
# dashboard can render a unified session picker (see SessionsSpec). # dashboard can render a unified session picker (see SessionsSpec).
@@ -364,7 +364,7 @@ class ProgramSpec(BaseModel):
description: str | None = None description: str | None = None
# A program has NO kind of its own — kind is a *deployment* property. A program # A program has NO kind of its own — kind is a *deployment* property. A program
# is a catalog entry that has 0..N deployments, each with its own kind (see # is a catalog entry that has 0..N deployments, each with its own kind (see
# kind_for and CastleConfig.deployments_of). # kind_for and WildpcConfig.deployments_of).
source: str | None = None source: str | None = None
stack: str | None = None stack: str | None = None
@@ -424,7 +424,7 @@ class DeploymentBase(BaseModel):
# is `- ref: <deployment>` (+ optional `bind: ENV_VAR` to project the target's # is `- ref: <deployment>` (+ optional `bind: ENV_VAR` to project the target's
# URL into env). Drives the relationship graph's edges. See docs/relationships.md. # URL into env). Drives the relationship graph's edges. See docs/relationships.md.
requires: list[Requirement] = Field(default_factory=list) requires: list[Requirement] = Field(default_factory=list)
# Declared on/off state. `castle apply` converges reality to this: enabled # Declared on/off state. `wildpc apply` converges reality to this: enabled
# deployments are activated (service started, tool installed, route served), # deployments are activated (service started, tool installed, route served),
# disabled ones are deactivated but kept in the catalog. This is *desired # disabled ones are deactivated but kept in the catalog. This is *desired
# state*, not a runtime toggle — the only way to durably stop something. # state*, not a runtime toggle — the only way to durably stop something.
@@ -513,6 +513,13 @@ class CaddyDeployment(DeploymentBase):
manager: Literal["caddy"] manager: Literal["caddy"]
root: str = "dist" root: str = "dist"
# Serving strategy. `True` (default) → SPA fallback: any unmatched path serves
# the root `index.html` so a client-side router (React/Vite) takes over. `False`
# → content-site serving: the gateway resolves directory indexes (`/posts/` →
# `/posts/index.html`) and 404s genuinely-missing paths — correct for Hugo and
# other multi-page static sites, where the SPA fallback would swallow every
# in-site link back to the homepage.
spa: bool = True
# A static site is inherently served at its subdomain, so `reach` is # A static site is inherently served at its subdomain, so `reach` is
# `internal` or `public` (never `off`). `public` = also project via the tunnel. # `internal` or `public` (never `off`). `public` = also project via the tunnel.
reach: Reach = Reach.INTERNAL reach: Reach = Reach.INTERNAL
@@ -541,7 +548,7 @@ class PathDeployment(DeploymentBase):
manager: Literal["path"] manager: Literal["path"]
# An Anthropic Messages-API tool definition ({name, description, input_schema}) # An Anthropic Messages-API tool definition ({name, description, input_schema})
# for handing this CLI to an agent. Derived from the tool's ``--help`` (see # for handing this CLI to an agent. Derived from the tool's ``--help`` (see
# ``castle_core.tool_schema``) but editable/overridable — a stored draft the # ``wildpc_core.tool_schema``) but editable/overridable — a stored draft the
# completion-context builder reads. None → not yet generated. # completion-context builder reads. None → not yet generated.
tool_schema: dict | None = None tool_schema: dict | None = None

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import yaml import yaml
from castle_core.config import CONTENT_DIR, SPECS_DIR from wildpc_core.config import CONTENT_DIR, SPECS_DIR
REGISTRY_PATH = SPECS_DIR / "registry.yaml" REGISTRY_PATH = SPECS_DIR / "registry.yaml"
STATIC_DIR = CONTENT_DIR # backwards-compat alias STATIC_DIR = CONTENT_DIR # backwards-compat alias
@@ -19,7 +19,7 @@ class NodeConfig:
"""Per-node identity and settings.""" """Per-node identity and settings."""
hostname: str = "" hostname: str = ""
castle_root: str | None = None # repo path, for dev commands wildpc_root: str | None = None # repo path, for dev commands
gateway_port: int = 9000 gateway_port: int = 9000
# None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS; "acme" → Let's # None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS; "acme" → Let's
# Encrypt wildcard (*.gateway_domain) via DNS-01. # Encrypt wildcard (*.gateway_domain) via DNS-01.
@@ -30,7 +30,7 @@ class NodeConfig:
# Cloudflare tunnel: public services publish at <subdomain>.<public_domain>. # Cloudflare tunnel: public services publish at <subdomain>.<public_domain>.
public_domain: str | None = None public_domain: str | None = None
tunnel_id: str | None = None tunnel_id: str | None = None
# Emit the cert_obtained → `castle tls reconcile` hook (needs events-exec plugin). # Emit the cert_obtained → `wildpc tls reconcile` hook (needs events-exec plugin).
cert_hook: bool = False cert_hook: bool = False
# Routable host peers use to reach this node's services (LAN IP/hostname). # Routable host peers use to reach this node's services (LAN IP/hostname).
# Defaults to the hostname; set explicitly when the hostname isn't resolvable # Defaults to the hostname; set explicitly when the hostname isn't resolvable
@@ -38,7 +38,7 @@ class NodeConfig:
address: str | None = None address: str | None = None
# Fleet role: "authority" may write shared config/secrets to the mesh; # Fleet role: "authority" may write shared config/secrets to the mesh;
# "follower" reconciles from it. Static (no election) — the authority is # "follower" reconciles from it. Static (no election) — the authority is
# pinned in castle.yaml. When the authority is down, shared state is # pinned in wildpc.yaml. When the authority is down, shared state is
# read-only and every node keeps serving its own deployments from cache. # read-only and every node keeps serving its own deployments from cache.
role: str = "follower" role: str = "follower"
@@ -92,10 +92,13 @@ class Deployment:
# For `static` runner services: the absolute dir the gateway file_servers. # For `static` runner services: the absolute dir the gateway file_servers.
# Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy). # Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy).
static_root: str | None = None static_root: str | None = None
# For `static` routes: SPA fallback (True, default) vs content-site serving
# (False, Hugo/multi-page). See CaddyDeployment.spa.
spa: bool = True
base_url: str | None = None base_url: str | None = None
schedule: str | None = None schedule: str | None = None
managed: bool = False managed: bool = False
# Declared desired state (from the deployment's `enabled:`). `castle apply` # Declared desired state (from the deployment's `enabled:`). `wildpc apply`
# activates enabled deployments and deactivates disabled ones. Default True. # activates enabled deployments and deactivates disabled ones. Default True.
enabled: bool = True enabled: bool = True
# Deployment `requires` (list of {kind, ref, bind}) — carried so the mesh can # Deployment `requires` (list of {kind, ref, bind}) — carried so the mesh can
@@ -136,14 +139,14 @@ class NodeRegistry:
def load_registry(path: Path | None = None) -> NodeRegistry: def load_registry(path: Path | None = None) -> NodeRegistry:
"""Load the node registry from ~/.castle/registry.yaml.""" """Load the node registry from ~/.wildpc/registry.yaml."""
if path is None: if path is None:
path = REGISTRY_PATH path = REGISTRY_PATH
if not path.exists(): if not path.exists():
raise FileNotFoundError( raise FileNotFoundError(
f"Registry not found: {path}\n" f"Registry not found: {path}\n"
"Run 'castle deploy' to generate it from castle.yaml." "Run 'wildpc deploy' to generate it from wildpc.yaml."
) )
with open(path) as f: with open(path) as f:
@@ -155,7 +158,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
node_data = data.get("node", {}) node_data = data.get("node", {})
node = NodeConfig( node = NodeConfig(
hostname=node_data.get("hostname", ""), hostname=node_data.get("hostname", ""),
castle_root=node_data.get("castle_root"), wildpc_root=node_data.get("wildpc_root"),
gateway_port=node_data.get("gateway_port", 9000), gateway_port=node_data.get("gateway_port", 9000),
gateway_tls=node_data.get("gateway_tls"), gateway_tls=node_data.get("gateway_tls"),
gateway_domain=node_data.get("gateway_domain"), gateway_domain=node_data.get("gateway_domain"),
@@ -192,6 +195,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
public_host=comp_data.get("public_host"), public_host=comp_data.get("public_host"),
tcp_port=comp_data.get("tcp_port"), tcp_port=comp_data.get("tcp_port"),
static_root=comp_data.get("static_root"), static_root=comp_data.get("static_root"),
spa=comp_data.get("spa", True),
base_url=comp_data.get("base_url"), base_url=comp_data.get("base_url"),
schedule=comp_data.get("schedule"), schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False), managed=comp_data.get("managed", False),
@@ -203,7 +207,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
def save_registry(registry: NodeRegistry, path: Path | None = None) -> None: def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
"""Write the node registry to ~/.castle/registry.yaml.""" """Write the node registry to ~/.wildpc/registry.yaml."""
if path is None: if path is None:
path = REGISTRY_PATH path = REGISTRY_PATH
@@ -217,8 +221,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
"deployed": {}, "deployed": {},
} }
if registry.node.castle_root: if registry.node.wildpc_root:
data["node"]["castle_root"] = registry.node.castle_root data["node"]["wildpc_root"] = registry.node.wildpc_root
if registry.node.gateway_tls: if registry.node.gateway_tls:
data["node"]["gateway_tls"] = registry.node.gateway_tls data["node"]["gateway_tls"] = registry.node.gateway_tls
@@ -273,6 +277,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["tcp_port"] = comp.tcp_port entry["tcp_port"] = comp.tcp_port
if comp.static_root: if comp.static_root:
entry["static_root"] = comp.static_root entry["static_root"] = comp.static_root
# Only emit when non-default (content-site) — default-True omission keeps
# existing registries byte-identical and matches the load-side default.
if not comp.spa:
entry["spa"] = comp.spa
if comp.base_url: if comp.base_url:
entry["base_url"] = comp.base_url entry["base_url"] = comp.base_url
if comp.schedule: if comp.schedule:

View File

@@ -23,11 +23,11 @@ from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from castle_core import git from wildpc_core import git
from castle_core.config import USER_TOOL_PATH_DIRS, CastleConfig from wildpc_core.config import USER_TOOL_PATH_DIRS, WildpcConfig
from castle_core.generators.systemd import runtime_path from wildpc_core.generators.systemd import runtime_path
from castle_core.manifest import Requirement, SystemdDeployment from wildpc_core.manifest import Requirement, SystemdDeployment
from castle_core.stacks import ToolRequirement, tools_for from wildpc_core.stacks import ToolRequirement, tools_for
@dataclass @dataclass
@@ -102,7 +102,7 @@ def _slug(name: str, used: set[str]) -> str:
return key return key
def derive_repos(config: CastleConfig) -> dict[str, Repo]: def derive_repos(config: WildpcConfig) -> dict[str, Repo]:
"""Group programs by the git working copy their source lives in.""" """Group programs by the git working copy their source lives in."""
by_top: dict[str, list[str]] = {} by_top: dict[str, list[str]] = {}
for pname, prog in config.programs.items(): for pname, prog in config.programs.items():
@@ -132,7 +132,7 @@ def derive_repos(config: CastleConfig) -> dict[str, Repo]:
return repos return repos
def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]: def requirements_of(config: WildpcConfig, dep_name: str) -> list[Requirement]:
"""The full requirement set for a deployment: its own ``requires`` (deployment """The full requirement set for a deployment: its own ``requires`` (deployment
dependencies), its program's ``system_dependencies`` synthesized as dependencies), its program's ``system_dependencies`` synthesized as
``kind: system`` requirements, and its stack's toolchains synthesized as ``kind: system`` requirements, and its stack's toolchains synthesized as
@@ -159,7 +159,7 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
return out return out
def stack_tools_of(config: CastleConfig, dep_name: str) -> dict[str, ToolRequirement]: def stack_tools_of(config: WildpcConfig, dep_name: str) -> dict[str, ToolRequirement]:
"""command → its :class:`ToolRequirement`, for every stack toolchain the """command → its :class:`ToolRequirement`, for every stack toolchain the
deployment(s) named ``dep_name`` need. The metadata (phase, install hint) behind deployment(s) named ``dep_name`` need. The metadata (phase, install hint) behind
the ``kind: tool`` requirements ``requirements_of`` synthesizes used to check the ``kind: tool`` requirements ``requirements_of`` synthesizes used to check
@@ -210,7 +210,7 @@ def _tool_available(dep: object, tool: ToolRequirement) -> bool:
def _check( def _check(
config: CastleConfig, config: WildpcConfig,
req: Requirement, req: Requirement,
dep: object | None = None, dep: object | None = None,
tool: ToolRequirement | None = None, tool: ToolRequirement | None = None,
@@ -265,7 +265,7 @@ def _endpoints_of(dep: object) -> list[Endpoint]:
def build_model( def build_model(
config: CastleConfig, config: WildpcConfig,
check: bool = True, check: bool = True,
active: set[str] | None = None, active: set[str] | None = None,
freshness: bool = False, freshness: bool = False,

View File

@@ -1,18 +1,18 @@
"""Pluggable secret backends for ``${secret:NAME}`` resolution. """Pluggable secret backends for ``${secret:NAME}`` resolution.
Default is the **file** backend (``~/.castle/secrets/<name>``) identical to the Default is the **file** backend (``~/.wildpc/secrets/<name>``) identical to the
historical behavior, so nothing changes unless a backend is explicitly selected historical behavior, so nothing changes unless a backend is explicitly selected
via ``CASTLE_SECRET_BACKEND``. via ``WILDPC_SECRET_BACKEND``.
The **openbao** backend reads from an OpenBao/Vault KV-v2 mount and falls back to The **openbao** backend reads from an OpenBao/Vault KV-v2 mount and falls back to
the file backend, which is also how it bootstraps: the OpenBao *token* itself is a the file backend, which is also how it bootstraps: the OpenBao *token* itself is a
file secret (it can't live in the vault it unlocks). file secret (it can't live in the vault it unlocks).
Selection (env, so it works in both the CLI and the systemd-run API): Selection (env, so it works in both the CLI and the systemd-run API):
CASTLE_SECRET_BACKEND file | openbao (default: file) WILDPC_SECRET_BACKEND file | openbao (default: file)
CASTLE_OPENBAO_ADDR http://localhost:8200 WILDPC_OPENBAO_ADDR http://localhost:8200
CASTLE_OPENBAO_MOUNT castle (kv-v2 mount path) WILDPC_OPENBAO_MOUNT wildpc (kv-v2 mount path)
CASTLE_OPENBAO_TOKEN_SECRET OPENBAO_TOKEN (file secret holding the token) WILDPC_OPENBAO_TOKEN_SECRET OPENBAO_TOKEN (file secret holding the token)
""" """
from __future__ import annotations from __future__ import annotations
@@ -146,9 +146,9 @@ class OpenBaoBackend:
def build_backend(secrets_dir: Path, settings: dict | None = None) -> SecretBackend: def build_backend(secrets_dir: Path, settings: dict | None = None) -> SecretBackend:
"""Construct the active secret backend. """Construct the active secret backend.
Selection comes from ``settings`` (the ``secrets:`` block of castle.yaml), with Selection comes from ``settings`` (the ``secrets:`` block of wildpc.yaml), with
environment variables overriding so production is configured declaratively in environment variables overriding so production is configured declaratively in
castle.yaml while tests/CI can force a backend via env. Default: file. wildpc.yaml while tests/CI can force a backend via env. Default: file.
The OpenBao **token** is still read from the file backend (the bootstrap root of The OpenBao **token** is still read from the file backend (the bootstrap root of
trust it can't live in the vault it unlocks); everything else comes from the trust it can't live in the vault it unlocks); everything else comes from the
@@ -156,19 +156,19 @@ def build_backend(secrets_dir: Path, settings: dict | None = None) -> SecretBack
""" """
settings = settings or {} settings = settings or {}
file_backend = FileSecretBackend(secrets_dir) file_backend = FileSecretBackend(secrets_dir)
kind = (os.environ.get("CASTLE_SECRET_BACKEND") or settings.get("backend") or "file").lower() kind = (os.environ.get("WILDPC_SECRET_BACKEND") or settings.get("backend") or "file").lower()
if kind == "openbao": if kind == "openbao":
addr = os.environ.get("CASTLE_OPENBAO_ADDR") or settings.get( addr = os.environ.get("WILDPC_OPENBAO_ADDR") or settings.get(
"addr" "addr"
) or "http://localhost:8200" ) or "http://localhost:8200"
mount = os.environ.get("CASTLE_OPENBAO_MOUNT") or settings.get("mount") or "castle" mount = os.environ.get("WILDPC_OPENBAO_MOUNT") or settings.get("mount") or "wildpc"
token_secret = os.environ.get("CASTLE_OPENBAO_TOKEN_SECRET") or settings.get( token_secret = os.environ.get("WILDPC_OPENBAO_TOKEN_SECRET") or settings.get(
"token_secret" "token_secret"
) or "OPENBAO_TOKEN" ) or "OPENBAO_TOKEN"
token = file_backend.read(token_secret) or os.environ.get( token = file_backend.read(token_secret) or os.environ.get(
"CASTLE_OPENBAO_TOKEN", "" "WILDPC_OPENBAO_TOKEN", ""
) )
node_prefix = os.environ.get("CASTLE_OPENBAO_NODE_PREFIX") or settings.get( node_prefix = os.environ.get("WILDPC_OPENBAO_NODE_PREFIX") or settings.get(
"node_prefix" "node_prefix"
) )
return OpenBaoBackend(addr, token, mount, node_prefix) return OpenBaoBackend(addr, token, mount, node_prefix)

View File

@@ -1,4 +1,4 @@
"""Stack dependency status — the derived, per-stack health the `castle stack` """Stack dependency status — the derived, per-stack health the `wildpc stack`
command, the ``GET /stacks`` API, and the dashboard Stacks page all render. command, the ``GET /stacks`` API, and the dashboard Stacks page all render.
A *stack* declares the host toolchains it needs (``stacks.ToolRequirement``); this A *stack* declares the host toolchains it needs (``stacks.ToolRequirement``); this
@@ -15,10 +15,10 @@ import shutil
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
from castle_core.config import CastleConfig from wildpc_core.config import WildpcConfig
from castle_core.generators.systemd import runtime_path from wildpc_core.generators.systemd import runtime_path
from castle_core.relations import _build_path, _tool_available from wildpc_core.relations import _build_path, _tool_available
from castle_core.stacks import ToolRequirement, available_stacks, get_handler, tools_for from wildpc_core.stacks import ToolRequirement, available_stacks, get_handler, tools_for
@dataclass @dataclass
@@ -96,9 +96,9 @@ def _tool_status(
def stack_status( def stack_status(
config: CastleConfig, name: str, *, with_version: bool = True config: WildpcConfig, name: str, *, with_version: bool = True
) -> StackStatus | None: ) -> StackStatus | None:
"""The dependency status of one stack, or None if castle has no such handler.""" """The dependency status of one stack, or None if wildpc has no such handler."""
handler = get_handler(name) handler = get_handler(name)
if handler is None: if handler is None:
return None return None
@@ -125,9 +125,9 @@ def stack_status(
def all_stack_status( def all_stack_status(
config: CastleConfig, *, with_version: bool = True config: WildpcConfig, *, with_version: bool = True
) -> list[StackStatus]: ) -> list[StackStatus]:
"""Dependency status for every stack castle knows — the Stacks catalog.""" """Dependency status for every stack wildpc knows — the Stacks catalog."""
out = [] out = []
for name in available_stacks(): for name in available_stacks():
st = stack_status(config, name, with_version=with_version) st = stack_status(config, name, with_version=with_version)

View File

@@ -9,9 +9,9 @@ import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from castle_core.config import USER_TOOL_PATH_DIRS from wildpc_core.config import USER_TOOL_PATH_DIRS
from castle_core.manifest import ProgramSpec from wildpc_core.manifest import ProgramSpec
from castle_core.toolchains import ToolchainError, resolve_node_bin from wildpc_core.toolchains import ToolchainError, resolve_node_bin
DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"] DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"]
INSTALL_ACTIONS = ["install", "uninstall"] INSTALL_ACTIONS = ["install", "uninstall"]
@@ -45,13 +45,13 @@ class ActionResult:
@dataclass(frozen=True) @dataclass(frozen=True)
class ToolRequirement: class ToolRequirement:
"""A host toolchain a stack needs — the declarative counterpart to the argv each """A host toolchain a stack needs — the declarative counterpart to the argv each
handler hard-codes (``uv sync``, ``pnpm build``, ). Made explicit so castle can handler hard-codes (``uv sync``, ``pnpm build``, ). Made explicit so wildpc can
*check* whether the tool is present (on the box and, for run-phase tools, on the *check* whether the tool is present (on the box and, for run-phase tools, on the
service's own PATH) and, when it isn't, show a copyable fix instead of failing service's own PATH) and, when it isn't, show a copyable fix instead of failing
mid-subprocess with a raw ``command not found``. mid-subprocess with a raw ``command not found``.
- ``phase`` when the tool is needed. ``build`` tools run only at - ``phase`` when the tool is needed. ``build`` tools run only at
``castle apply``/build time (checked against the build env); ``run`` tools must ``wildpc apply``/build time (checked against the build env); ``run`` tools must
also be on the *running service's* PATH (the curated systemd env, which can also be on the *running service's* PATH (the curated systemd env, which can
drift from your shell); ``both`` is checked in both places. drift from your shell); ``both`` is checked in both places.
- ``install_hint`` the exact command a user can copy to install it. - ``install_hint`` the exact command a user can copy to install it.
@@ -70,9 +70,9 @@ def _build_env(node_source: Path | None = None) -> dict[str, str]:
"""Build a subprocess env with user tool dirs on PATH. """Build a subprocess env with user tool dirs on PATH.
``node_source`` is the program's source dir: if it pins a node version (see ``node_source`` is the program's source dir: if it pins a node version (see
:mod:`castle_core.toolchains`), that node's bin dir goes on the front of PATH so :mod:`wildpc_core.toolchains`), that node's bin dir goes on the front of PATH so
the verb uses the program's node instead of whatever ambient node the caller the verb uses the program's node instead of whatever ambient node the caller
happens to have (the CLI inherits your shell's; the castle-api build executor's happens to have (the CLI inherits your shell's; the wildpc-api build executor's
default PATH has none). Raises :class:`ToolchainError` if the pin isn't installed. default PATH has none). Raises :class:`ToolchainError` if the pin isn't installed.
""" """
env = os.environ.copy() env = os.environ.copy()
@@ -111,7 +111,7 @@ async def _run(
def _vite_base(name: str) -> str: def _vite_base(name: str) -> str:
"""The base path a castle-served static frontend builds against. """The base path a wildpc-served static frontend builds against.
Every frontend now serves at the **root of its own subdomain** Every frontend now serves at the **root of its own subdomain**
(`<name>.<gateway.domain>`), so the base is always '/'. Exposed to the build (`<name>.<gateway.domain>`), so the base is always '/'. Exposed to the build
@@ -130,7 +130,7 @@ class StackHandler:
"""Base class — subclasses implement each lifecycle action.""" """Base class — subclasses implement each lifecycle action."""
# Whether this stack owns *persistent external state* (a database schema, a # Whether this stack owns *persistent external state* (a database schema, a
# bucket, …) that outlives a code delete. Drives whether `castle delete` # bucket, …) that outlives a code delete. Drives whether `wildpc delete`
# surfaces a data remnant / honors `--purge-data`. Overridden to True by the # surfaces a data remnant / honors `--purge-data`. Overridden to True by the
# stacks whose `teardown` actually destroys something. # stacks whose `teardown` actually destroys something.
owns_data: bool = False owns_data: bool = False
@@ -143,9 +143,9 @@ class StackHandler:
# lint/type-check/test also drops `check` implicitly. # lint/type-check/test also drops `check` implicitly.
provides: set[str] = _STACK_VERBS provides: set[str] = _STACK_VERBS
# The host toolchains this stack needs, declared so castle can check them and # The host toolchains this stack needs, declared so wildpc can check them and
# hint a fix. Empty by default (a stackless / declared-command program depends on # hint a fix. Empty by default (a stackless / declared-command program depends on
# nothing castle can name); each real handler overrides it. See `tools_for`. # nothing wildpc can name); each real handler overrides it. See `tools_for`.
tools: tuple[ToolRequirement, ...] = () tools: tuple[ToolRequirement, ...] = ()
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -194,7 +194,7 @@ class StackHandler:
delete. Distinct from `uninstall` (which only takes a program offline). delete. Distinct from `uninstall` (which only takes a program offline).
Default: nothing to tear down. Only stacks that set ``owns_data`` and own Default: nothing to tear down. Only stacks that set ``owns_data`` and own
durable state override this; `castle delete --purge-data` invokes it. durable state override this; `wildpc delete --purge-data` invokes it.
""" """
return ActionResult( return ActionResult(
program=name, program=name,
@@ -535,7 +535,7 @@ def _substrate_db_url() -> str | None:
explicit = os.environ.get("SUPABASE_DB_URL") explicit = os.environ.get("SUPABASE_DB_URL")
if explicit: if explicit:
return explicit return explicit
from castle_core.config import read_secret from wildpc_core.config import read_secret
pw = read_secret("SUPABASE_POSTGRES_PASSWORD") pw = read_secret("SUPABASE_POSTGRES_PASSWORD")
if pw: if pw:
@@ -558,9 +558,9 @@ def app_schema(name: str) -> str:
# The privilege grant that makes an app schema reachable through PostgREST — the # The privilege grant that makes an app schema reachable through PostgREST — the
# canonical Supabase "expose a custom schema" snippet. Idempotent; run before # canonical Supabase "expose a custom schema" snippet. Idempotent; run before
# migrations so `alter default privileges` also covers the tables they create. # migrations so `alter default privileges` also covers the tables they create.
# Exposure ALSO requires the schema in the substrate's PGRST_DB_SCHEMAS — castle # Exposure ALSO requires the schema in the substrate's PGRST_DB_SCHEMAS — wildpc
# derives that from the registered supabase apps (`${supabase_app_schemas}`), so a # derives that from the registered supabase apps (`${supabase_app_schemas}`), so a
# newly-added app needs a `castle deploy` + substrate restart to become routable. # newly-added app needs a `wildpc deploy` + substrate restart to become routable.
def _schema_setup_sql(schema: str) -> str: def _schema_setup_sql(schema: str) -> str:
roles = "anon, authenticated, service_role" roles = "anon, authenticated, service_role"
return ( return (
@@ -714,7 +714,7 @@ class SupabaseHandler(StackHandler):
"""Drop the app's schema and everything in it (tables, its own """Drop the app's schema and everything in it (tables, its own
schema_migrations, functions) in one statement total and knowable schema_migrations, functions) in one statement total and knowable
because the app owns exactly one schema. The substrate's PGRST_DB_SCHEMAS because the app owns exactly one schema. The substrate's PGRST_DB_SCHEMAS
drops the (now-absent) schema on the next `castle deploy` + restart. drops the (now-absent) schema on the next `wildpc deploy` + restart.
""" """
schema = app_schema(name) schema = app_schema(name)
psql = shutil.which("psql") psql = shutil.which("psql")
@@ -745,7 +745,7 @@ class SupabaseHandler(StackHandler):
name, name,
"teardown", "teardown",
"ok", "ok",
f"Dropped schema '{schema}' (all tables + rows). Run `castle deploy` " f"Dropped schema '{schema}' (all tables + rows). Run `wildpc deploy` "
"and restart the substrate to prune it from PGRST_DB_SCHEMAS.", "and restart the substrate to prune it from PGRST_DB_SCHEMAS.",
) )
@@ -806,7 +806,7 @@ def get_handler(stack: str | None) -> StackHandler | None:
def available_stacks() -> list[str]: def available_stacks() -> list[str]:
"""The stack names castle has handlers for — the single source of truth for the """The stack names wildpc has handlers for — the single source of truth for the
CLI ``--stack`` choices, the ``GET /stacks`` endpoint, and the dashboard select. CLI ``--stack`` choices, the ``GET /stacks`` endpoint, and the dashboard select.
""" """
return sorted(HANDLERS) return sorted(HANDLERS)
@@ -815,7 +815,7 @@ def available_stacks() -> list[str]:
def tools_for(stack: str | None) -> tuple[ToolRequirement, ...]: def tools_for(stack: str | None) -> tuple[ToolRequirement, ...]:
"""The host toolchains a stack declares it needs (empty for an unknown/absent """The host toolchains a stack declares it needs (empty for an unknown/absent
stack). The single source of truth for the dependency checks in `relations`, stack). The single source of truth for the dependency checks in `relations`,
`castle stack`, `castle doctor`, and the dashboard Stacks page.""" `wildpc stack`, `wildpc doctor`, and the dashboard Stacks page."""
handler = get_handler(stack) handler = get_handler(stack)
return handler.tools if handler is not None else () return handler.tools if handler is not None else ()

View File

@@ -1,8 +1,8 @@
"""Castle-managed TLS material for raw-TCP services. """Wild PC-managed TLS material for raw-TCP services.
Cuts the gateway's ACME wildcard cert (valid for ``<name>.<domain>``) onto a Cuts the gateway's ACME wildcard cert (valid for ``<name>.<domain>``) onto a
service so it presents a *trusted* cert on its raw port, and refreshes it on service so it presents a *trusted* cert on its raw port, and refreshes it on
renewal. Protocol-agnostic: castle only copies files (in the requested format) renewal. Protocol-agnostic: wildpc only copies files (in the requested format)
and signals the service each deployment declares the format (``pair`` / and signals the service each deployment declares the format (``pair`` /
``combined``) and how it consumes the files (via the ``${tls_*}`` placeholders). ``combined``) and how it consumes the files (via the ``${tls_*}`` placeholders).
@@ -10,7 +10,7 @@ Two entry points:
- ``materialize_all`` write/refresh the cert files, no reload (used by ``apply``, - ``materialize_all`` write/refresh the cert files, no reload (used by ``apply``,
which (re)starts the service itself). which (re)starts the service itself).
- ``reconcile_tls`` materialize *and* reload the services whose cert changed - ``reconcile_tls`` materialize *and* reload the services whose cert changed
(used by ``castle tls reconcile`` and the Caddy ``cert_obtained`` hook). (used by ``wildpc tls reconcile`` and the Caddy ``cert_obtained`` hook).
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,8 +21,8 @@ import subprocess
import time import time
from pathlib import Path from pathlib import Path
from castle_core.config import CastleConfig from wildpc_core.config import WildpcConfig
from castle_core.manifest import SystemdDeployment, TlsMaterial from wildpc_core.manifest import SystemdDeployment, TlsMaterial
_KEY_MODE_FILES = {"key.pem", "combined.pem"} # secret → 0600; certs → 0644 _KEY_MODE_FILES = {"key.pem", "combined.pem"} # secret → 0600; certs → 0644
@@ -37,7 +37,7 @@ def wildcard_cert(domain: str) -> tuple[Path, Path] | None:
"""``(crt, key)`` for ``*.<domain>`` from Caddy's store, or None. """``(crt, key)`` for ``*.<domain>`` from Caddy's store, or None.
Caddy stores it at ``certificates/<acme-dir>/wildcard_.<domain>/``. Prefer a Caddy stores it at ``certificates/<acme-dir>/wildcard_.<domain>/``. Prefer a
production cert over staging (``CASTLE_ACME_STAGING=1`` yields a staging dir). production cert over staging (``WILDPC_ACME_STAGING=1`` yields a staging dir).
The ``.crt`` is the full chain (leaf + intermediates). The ``.crt`` is the full chain (leaf + intermediates).
""" """
store = _caddy_data_dir() / "certificates" store = _caddy_data_dir() / "certificates"
@@ -102,7 +102,7 @@ def _wanted_files(
return files return files
def materialize_tls(config: CastleConfig, name: str, dep: object) -> bool: def materialize_tls(config: WildpcConfig, name: str, dep: object) -> bool:
"""Write ``dep``'s cert files from the wildcard, in its declared format. """Write ``dep``'s cert files from the wildcard, in its declared format.
Idempotent: returns ``False`` (no write) when the on-disk copy already matches Idempotent: returns ``False`` (no write) when the on-disk copy already matches
@@ -144,7 +144,7 @@ def materialize_tls(config: CastleConfig, name: str, dep: object) -> bool:
def materialize_all( def materialize_all(
config: CastleConfig, config: WildpcConfig,
messages: list[str] | None = None, messages: list[str] | None = None,
only: list[str] | None = None, only: list[str] | None = None,
) -> list[str]: ) -> list[str]:
@@ -152,10 +152,10 @@ def materialize_all(
which starts/restarts the services itself. which starts/restarts the services itself.
``only`` scopes materialization to the given deployment names (what a scoped ``only`` scopes materialization to the given deployment names (what a scoped
``castle apply <name>`` is converging). Left None every deployment. Scoping ``wildpc apply <name>`` is converging). Left None every deployment. Scoping
keeps a scoped apply from rewriting an *unrelated* service's cert on disk keeps a scoped apply from rewriting an *unrelated* service's cert on disk
without also reloading it (which would leave the file diverged from the running without also reloading it (which would leave the file diverged from the running
process until the next ``castle tls reconcile``).""" process until the next ``wildpc tls reconcile``)."""
msgs = messages if messages is not None else [] msgs = messages if messages is not None else []
scope = set(only) if only is not None else None scope = set(only) if only is not None else None
for _kind, name, dep in config.all_deployments(): for _kind, name, dep in config.all_deployments():
@@ -169,14 +169,14 @@ def materialize_all(
def wait_for_wildcard( def wait_for_wildcard(
config: CastleConfig, config: WildpcConfig,
names: list[str], names: list[str],
messages: list[str] | None = None, messages: list[str] | None = None,
timeout: float = 120.0, timeout: float = 120.0,
interval: float = 3.0, interval: float = 3.0,
) -> list[str]: ) -> list[str]:
"""Block until the ACME wildcard cert exists, when an in-scope deployment needs """Block until the ACME wildcard cert exists, when an in-scope deployment needs
castle-materialized TLS but the cert isn't issued yet. wildpc-materialized TLS but the cert isn't issued yet.
On a fresh node the gateway reload during ``apply`` only *kicks off* DNS-01 On a fresh node the gateway reload during ``apply`` only *kicks off* DNS-01
issuance of ``*.<domain>`` (seconds to a couple minutes); materializing right issuance of ``*.<domain>`` (seconds to a couple minutes); materializing right
@@ -184,7 +184,7 @@ def wait_for_wildcard(
and with ``gateway.cert_hook`` disabled (the default) nothing would later and with ``gateway.cert_hook`` disabled (the default) nothing would later
reconcile it. Waiting here lets ``apply`` bring the service up with its cert in reconcile it. Waiting here lets ``apply`` bring the service up with its cert in
place on first deploy. Bounded: on timeout it warns and returns so ``apply`` place on first deploy. Bounded: on timeout it warns and returns so ``apply``
still proceeds (rerun ``castle tls reconcile`` once the cert lands).""" still proceeds (rerun ``wildpc tls reconcile`` once the cert lands)."""
msgs = messages if messages is not None else [] msgs = messages if messages is not None else []
needs = [ needs = [
n n
@@ -205,7 +205,7 @@ def wait_for_wildcard(
return msgs return msgs
msgs.append( msgs.append(
f"tls: wildcard *.{domain} not ready after {int(timeout)}s — " f"tls: wildcard *.{domain} not ready after {int(timeout)}s — "
f"{', '.join(needs)} may start without a cert; rerun `castle tls reconcile` " f"{', '.join(needs)} may start without a cert; rerun `wildpc tls reconcile` "
"once it is issued" "once it is issued"
) )
return msgs return msgs
@@ -218,14 +218,14 @@ def _reload(name: str, tls: object, msgs: list[str]) -> None:
msgs.append(f"tls: reloaded {name} (reload command)") msgs.append(f"tls: reloaded {name} (reload command)")
else: else:
subprocess.run( subprocess.run(
["systemctl", "--user", "restart", f"castle-{name}.service"], check=False ["systemctl", "--user", "restart", f"wildpc-{name}.service"], check=False
) )
msgs.append(f"tls: restarted {name} to pick up rotated cert") msgs.append(f"tls: restarted {name} to pick up rotated cert")
def reconcile_tls(config: CastleConfig, messages: list[str] | None = None) -> list[str]: def reconcile_tls(config: WildpcConfig, messages: list[str] | None = None) -> list[str]:
"""Materialize certs and reload the services whose cert changed. Idempotent — """Materialize certs and reload the services whose cert changed. Idempotent —
a no-op when nothing rotated. Invoked by ``castle tls reconcile`` and the Caddy a no-op when nothing rotated. Invoked by ``wildpc tls reconcile`` and the Caddy
``cert_obtained`` hook. Logs its own outcome (the hook's ``exec`` swallows it).""" ``cert_obtained`` hook. Logs its own outcome (the hook's ``exec`` swallows it)."""
msgs = messages if messages is not None else [] msgs = messages if messages is not None else []
for _kind, name, dep in config.all_deployments(): for _kind, name, dep in config.all_deployments():

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