CaddySpec had only `enable` left (path_prefix/host removed; extra_snippets was
unused), so `proxy: { caddy: {} }` was three levels of nesting for a checkbox —
and it invited drift (supabase silently reverted to proxy.caddy.host). Replace it
with a plain `ServiceSpec.proxy: bool`.
- Model: remove ProxySpec/CaddySpec; `proxy: bool = False`. Readers simplified to
`bool(svc.proxy)` (generator, castle-api summaries, CLI info).
- CLI create / dashboard editor + create form: write `proxy: true` / a checkbox.
- Configs migrated to `proxy: true` (and supabase's stale proxy.caddy.host block
removed).
- Docs (registry, dns-and-tls, design, stack guides) + tests/fixtures updated;
also fixed lingering path-model staleness in the stack guides.
Each route address is a subdomain label now, so render it as a link to its full
https://<subdomain>.<domain> URL (via subdomainUrl), opening in a new tab. Falls
back to plain text on a bare host (off mode) where no subdomain URL exists.
The HTTP health poller marked anything >=300 as down. With subdomain routing the
gateway's :<port> health path returns 302 (redirect to the dashboard subdomain),
so castle-gateway showed "down" despite being healthy. Accept <400: a 2xx or 3xx
means the server answered; only 4xx/5xx (or a connection error) is down.
The dashboard is now served at castle.<domain> (was castle-app.<domain>). Since
the subdomain is the program name, this is just a program rename plus the one
special-case constant.
- Generator: _DASHBOARD = "castle" (the :9000 redirect target and off-mode root).
- Program file renamed (runtime): programs/castle-app.yaml -> programs/castle.yaml
(source unchanged: repo:app).
- Dashboard UI: ProgramDetail shows a frontend's subdomain (via subdomainUrl),
dropping the old castle-app-at-root special-case; comment refs updated.
- Docs: registry.md/design.md/react-vite.md updated to the subdomain model and the
`castle` name (react-vite.md also corrected — frontends serve at their subdomain
root with VITE_BASE=/, and builds are manual via `castle program build`, not
deploy).
Follow the backend to the subdomain-only model:
- Service editor + create form: replace the Proxy path / Proxy host text fields
with a single "Expose" checkbox (writes proxy: { caddy: {} } or removes proxy).
- ServiceSummary/DeploymentSummary type: proxy_path/proxy_host → subdomain.
- ServiceCard / ServiceDetail: show the subdomain, linking to
<subdomain>.<domain> via a new subdomainUrl() helper (domain derived from the
dashboard's own host; null on a bare host / off mode).
Collapse the two proxy shapes (path_prefix + host) to a single checkbox: a
service is either port-only, or exposed at <service-name>.<gateway.domain>. Path
routes and their prefix-stripping foot-guns are gone; the subdomain is always the
service name (rename the service to change it).
- Schema: CaddySpec is just `enable` (presence = expose). service_proxy_targets
returns (expose, port, base_url); Deployment carries `subdomain` (was
proxy_path/proxy_host). Registry/deploy/mesh/CLI updated in lockstep.
- Generator: compute_routes emits one host route per exposed service/frontend
(address = name). acme mode = one *.<domain> site with a reverse_proxy matcher
per service + a file_server matcher per frontend; the :<port> site redirects to
the dashboard. off mode = a HTTP control plane on :<port> (dashboard at / +
/api → castle-api); other services are port-only.
- Dashboard/API: castle-app and castle-api are ordinary subdomains. The dashboard
derives the API base at runtime (castle-api.<domain> on a subdomain, else /api)
and calls it cross-origin — castle-api already allows CORS *. Frontends build
with VITE_BASE=/ (served at their subdomain root).
- CLI: `service create` drops --path/--host; --no-proxy makes it port-only.
- Docs/tests reworked for the model; docs use generic placeholders only (no
personal host/domain/IP details).
acme mode fully replaced the internal-CA path (which required installing a private
CA on every device — the exact pain, esp. on Android, that acme avoids). Remove
`internal` entirely and simplify how services declare host routes.
- Generator (caddyfile.py): drop the `tls internal` branch — modes are now
`off` | `acme`. In acme mode the published subdomain is the **first DNS label**
of `proxy.caddy.host` (a bare `claw`, or a legacy `claw.civil.lan`, both →
`claw.<domain>`), so services stay domain-agnostic and the declared value is
authoritative again (no more silent service-name override). Shared
`_host_matcher_block` reused by off-mode and the acme wildcard site.
- castle-api: delete `GET /gateway/ca.crt`, `_gateway_ca_pem`, `_ca_fingerprint`
and the now-unused imports; drop `ca_fingerprint` from `GatewayInfo` (keep
`tls`).
- Dashboard: remove the CA-cert download button + unused imports; drop
`ca_fingerprint` from the `GatewayInfo` type.
- Tests: replace TestCaddyfileTlsInternal with an off-mode class (keeps the
runner-agnostic host-route coverage); acme tests assert first-label derivation
incl. label-wins-over-service-name; drop the castle-api CA-endpoint test.
- Docs: registry.md + dns-and-tls.md — two-mode tables (off|acme), remove the
internal sections/CA-download, document the bare-label host convention; note a
domain-less node stays on `off`.
A conceptual companion to registry.md's field-level gateway reference. Covers the
two questions (resolve / trust) an internal-only LAN service must answer:
- DNS: gateway-as-ingress, path-prefix vs host routes, LAN name resolution,
wildcard records, split-horizon (internal names, no public A records), and the
private-TLD vs public-subdomain zone styles with the worked router/wild-central
topology.
- TLS: the three gateway.tls modes (off / internal / acme) with when-to-use, the
internal-CA distribution pain vs the acme DNS-01 "no client setup" win, the
secure-context (WebCrypto) rationale, the put-a-service-on-HTTPS recipe,
operational prerequisites (443/80 bind, plugin caddy, DNS token, staging), and a
decision table.
Linked from CLAUDE.md's docs list.
internal-CA HTTPS forces every device to trust a private root, which breaks down
on some clients (Android browsers). acme mode instead obtains a real Let's Encrypt
wildcard cert (*.<domain>) via a DNS-01 challenge, so browsers trust it with zero
CA install — while services stay internal-only (no inbound exposure, no public A
records; only a transient _acme-challenge TXT touches the public zone, and LAN DNS
resolves the names to a private IP).
- Config: GatewayConfig/NodeConfig gain domain, acme_email, acme_dns_provider
(plumbed through load/save + registry + deploy, omitted-when-empty for
round-trip stability). tls stays the discriminator: off | internal | acme.
- Caddyfile generator: acme branch emits a global {email, acme_dns <provider>
{env.TOKEN}} block + one *.<domain> wildcard site with host matchers derived
from the service name (<name>.<domain>); path/static stay on :<port>. Shared
_host_matcher_block helper (reused by off-mode). CASTLE_ACME_STAGING=1 toggles
the LE staging CA; acme-without-domain falls back to HTTP with a warning.
- deploy(): _acme_preflight warns (never writes) when domain, the gateway-service
token env, or the CLOUDFLARE_API_TOKEN secret is missing.
- install.sh: opt-in --with-dns-plugin[=provider] builds a DNS-plugin Caddy via
xcaddy (pinned) to /usr/local/bin/caddy, which the gateway picks up on deploy.
- Tests: TestCaddyfileTlsAcme (global block, wildcard site, derived host matcher,
path routes on :port, staging toggle, no-domain fallback). Docs: gateway.tls
modes table + full acme/DNS-01 section (setup, wild-central LAN DNS, staging).
Adds "a stack whose default is a substrate": a shared self-hosted Supabase
backend plus a `--stack supabase` that scaffolds per-app projects (migrations +
edge functions + static UI) which deploy against it. Apps own their code and
stay repo-durable; only their rows/blobs live on the shared substrate.
- compose runner: new RunCompose supervises a multi-container stack as one
systemd unit (ExecStart=`compose up`, generated ExecStop=`compose down`);
secrets via EnvironmentFile. Reusable beyond Supabase. Deployment.stop_cmd
carries the teardown command through the registry.
- supabase stack: CLI --stack choice, STACK_DEFAULTS→frontend with
build.outputs=[public], _scaffold_supabase() (migrations/RLS/functions/
static UI/app manifest), and SupabaseHandler with a forward-only idempotent
migration runner (plan_migrations + psql build) and deno dev-verbs.
- docs: docs/stacks/supabase.md, registered in CLAUDE.md; compose runner
documented in registry.md.
- tests: compose run/stop cmd, systemd ExecStop, compose host-route TLS,
migration planner, supabase verb resolution, create --stack supabase.
When the gateway serves internal-CA HTTPS (gateway.tls=internal), other devices
must trust Caddy's root CA to accept *.lan HTTPS. Surface that from the dashboard
instead of hand-copying root.crt around.
- API: GET /gateway/ca.crt returns the public root cert (PEM) as a download,
sourced from Caddy's admin API (/pki/ca/local — matches the running gateway)
with an on-disk root.crt fallback. 404 unless tls=internal. GatewayInfo gains
`tls` and `ca_fingerprint` (SHA-256, for out-of-band verification). The CA
private key is never exposed — only the public root.
- UI: a "CA cert" button in the Gateway panel, shown only when tls=internal,
linking to the download with the fingerprint in its tooltip.
- Tests for the tls=off defaults and the 404-without-internal-tls path; docs note
the button + endpoint.
The gateway was HTTP-only by design, but a plain-HTTP origin is not a browser
"secure context", so apps that need WebCrypto (device identity, E2E crypto)
can't run when proxied — only localhost or HTTPS qualifies.
Add an opt-in `gateway.tls: internal`. When set, each host route
(proxy.caddy.host) is emitted as its own `<host> { tls internal; reverse_proxy }`
site, served over HTTPS by Caddy's local CA (Caddy binds :443 and redirects :80).
Path-prefix/static routes stay on the HTTP :<port> site — give a service a host
route to put it on HTTPS. Default (unset/off) keeps the existing HTTP-only
Caddyfile (single :<port> block, auto_https off, host matchers inline).
Plumbing mirrors gateway_port: GatewayConfig.tls (castle.yaml load/save) →
NodeConfig.gateway_tls (registry load/save, set in deploy) → the Caddyfile
generator. Tests cover both modes; docs/registry.md documents the option plus
the two operational requirements (binding 443/80 via the unprivileged-port
sysctl, and trusting Caddy's root CA on clients) and the host-route-vs-prefix
guidance that motivates it.
`_require_repo()` now returns the validated repo root (Path) instead of
None, so callers get a non-None path and pyright stops flagging `root /
spec.source` and the `CastleConfig(root=root)` construction. Drops the
redundant `get_castle_root()` re-fetch after each guard and hoists the
`pathlib.Path` import to module scope.
Editing a service's routing (port, path_prefix, host) and then hitting
"apply" or "gateway reload" — rather than a full deploy — left the running
Caddyfile reflecting the old routing. The Caddyfile was generated from the
derived registry.yaml snapshot, which only `deploy()` rebuilds from
castle.yaml; apply/reload regenerated from that stale snapshot.
Make castle.yaml the single source of truth for local routes:
- caddyfile.py: new shared `service_proxy_targets()` derives a service's
(proxy_path, proxy_host, port, base_url) from its spec. `compute_routes`
now builds local routes from `config.services` when castle.yaml is
loadable, falling back to `registry.deployed` only when it isn't. Since
deploy/apply/reload all funnel through `compute_routes`, every regeneration
path now tracks the spec. Mesh/remote and static-frontend routes unchanged.
- deploy.py: `_build_deployed_service` uses the same shared deriver, so the
written registry and the computed routes can't diverge.
- config_editor.py: `/config/apply` now runs a full `deploy()` (rebuilding
units + Caddyfile and reloading the gateway) before restarting services, so
changed unit ExecStarts actually take effect. Scheduled jobs are no longer
restarted on apply. Drops the now-unused direct caddy-reload path.
- Regression tests: a stale registry's old port/path loses to castle.yaml;
registry fallback still works when config is unavailable.
- Introduced `_write_castle_config` function to scatter nested config into separate YAML files for programs, services, and jobs.
- Updated `castle_root` fixture to utilize the new config writing method.
- Added tests for configuration aggregation and scattering in `test_health.py`.
- Removed legacy unit handling from `manifest.py` and refactored related code in `config.py`.
- Updated documentation to reflect new configuration directory structure.
- Removed obsolete unit tests related to unit expansion.
New lines auto-scroll the view to the bottom; scrolling up to read now
pauses that (an amber 'paused' marker shows), and scrolling back to the
bottom resumes — the explicit Pause/Play button and the scroll position stay
in sync via a single at-bottom check. While paused, logs still stream in
below without moving the view. Jump-to-latest is instant rather than smooth
so mid-animation positions never register as a false scroll-up.
A python-runner service ran from a tool venv (uv tool install --editable),
separate from the project venv that uv sync manages. The editable link kept
source live, but dependencies were resolved independently in each venv, so a
new dependency landed in the project venv and never reached the running
service — leaving no clean rollout for it.
Deploy now emits ExecStart=uv run --project <source> --no-dev <program>,
running the service in place from its own project venv. uv run syncs the env
to the lockfile before launching, so a single restart picks up both code and
dependency changes; the ExecStart is deterministic from source and never goes
stale. The tool-venv install is gated to the command runner only; uv tool
install is reserved for tool-behavior programs, where PATH is the point. A
python service with no resolvable source falls back to a PATH lookup.
pnpm 10+ runs a deps-status check before `pnpm <script>` that wants to
purge and reinstall node_modules, aborting in a non-TTY build with
ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY. It is a false positive for a
build verb (which builds, not installs), so ReactViteHandler now prepends
--config.verify-deps-before-run=false to every pnpm call, building against
the existing modules. CI=true is kept as a general non-interactive guard.
The LogViewer header now has a maximize toggle (and a clear button) — maximize
expands it to a full-screen overlay (Esc to restore), so you can read service/
job logs without the 24rem cap. Consolidated the per-page 'Logs' Clear button
into the viewer header (both service and job detail pages).
Generated systemd units hardcoded a thin PATH (~/.local/bin + system dirs)
while dev-verb builds used a separate _EXTRA_PATH_DIRS list, so a service
could be built and run with different tools on PATH. Hoist a single
USER_TOOL_PATH_DIRS in config.py, consumed by both stacks._build_env
(build) and the systemd unit generator (runtime).
Also prefer pnpm's modern $PNPM_HOME/bin shim over the bare $PNPM_HOME dir
(older installs leave a stale version wrapper there) and add
/usr/local/go/bin so Go-based builds/services resolve their toolchain.
Editing a service/job only persisted to castle.yaml; the unit isn't
regenerated and the process isn't restarted (the Restart button just runs
'systemctl restart' on the *existing* unit), so a change like a new port
appeared saved but never took effect.
The config panel now, after saving a service/job, shows an amber 'not live
yet' banner with an Apply button that runs POST /deploy {name} (regenerate
unit + Caddyfile) then, for services, POST /services/{name}/restart — so the
edit actually goes live. Programs are unaffected (no deploy/restart needed).
App builds clean.
The check composite returned ActionResult with no output on success, so the
dashboard's 'Check All' (and the CLI) showed nothing when checks passed — only
a failing sub-verb ever produced output. It now accumulates each sub-verb's
output with a ✓/✗ marker (lint / type-check / test), on both success and
failure, so the output panel always shows what ran.
Also fixes lan-info's type-check: socket.getaddrinfo sockaddr is loosely typed,
so str(infos[0][4][0]) makes the address an unambiguous str.
core 94 green; ruff clean; verified POST /programs/lan-info/check returns output.
Native <select> option popups fell back to the OS default (light) background
while inheriting the dark theme's light text — light-on-light, unreadable.
Pin select/option to the card background + foreground text in index.css.
- Add a 'hint' to the shared Field/TextField; annotate the config forms with
short, accurate descriptions of each field:
- program: source (working copy castle builds from), behavior (tool/daemon/
frontend), stack (optional toolchain template), repo/ref (for clone),
system deps (listed for reference, NOT auto-installed), commands.
- service: port (listened-on, health-checked, proxied; map via ${port}),
health path, proxy path vs proxy host, runs.
- job: schedule (cron → systemd timer), runs.
- ProgramFields: the Commands section is now editable (build/test/lint/
type-check/run), so a program with no stack can declare its own dev verbs
by hand — a declared command overrides the stack default. Verified the
commands/build shape round-trips through the config API.
App builds clean.
handle_path /foo/* matches /foo/ but not the bare /foo, so the no-trailing-
slash form fell through to the root catch-all (castle-app) — which then 404'd
because its router has no such route. Hitting http://gateway:9000/power-graph-app
served the dashboard's 404 instead of Power Graph; same for every proxied
service accessed without the slash.
The generator now emits 'redir /foo /foo/' for each path-prefix route (static,
proxy, remote), so the no-slash form canonicalizes to the slash form that
handle_path matches. Caddy's bare path matcher is exact, so /foo/bar is
unaffected. Host routes and the root '/' are unaffected.
Verified: /power-graph-app → 302 → Power Graph; caddy validate clean; core 94 /
api 52 green.
Sort routes by address in GET /gateway and 'castle gateway status' (so the
dashboard + CLI tables read alphabetically). The Caddyfile keeps its
precedence-sensitive order — only the display is sorted.
The gateway does two things — reverse-proxy services and serve static
frontends — but the dashboard/API route table only ever showed the proxy
routes, so 'serving a frontend' and 'proxying a service' looked like unrelated
features and castle-app/power-graph-app were invisible in the route view.
Now there's one concept: a route maps an address (path '/foo' or host 'foo.lan')
to a target of one kind — static (a built dist served by file_server), proxy
(a local service port), or remote (a service on another node).
- core: caddyfile.py gains compute_routes() — the single source of truth for
the route list; generate_caddyfile_from_registry renders it (output byte-for-
byte identical, verified against the live Caddyfile). Adds a GatewayRoute
dataclass.
- api: GatewayRoute model → {address, kind, target, name, node}; GET /gateway
builds from compute_routes (incl. config for static frontends + mesh for
remote), so the table matches what Caddy actually does.
- app: Gateway panel shows Address · Kind · Target for every route (static
frontends + host routes now appear); program detail shows 'Reachable at
/foo/ · served (static)' for static frontends.
- cli: 'castle gateway status' prints the full route table.
- docs: registry.md/design.md/CLAUDE.md describe routes as one concept with
three target kinds.
core 94 / cli 24 / api 52 green; ruff + app build clean; Caddyfile unchanged.
A service/job's env is now exactly its defaults.env — castle injects no hidden
convention vars. Values support ${port}/${data_dir}/${name} placeholders
(resolved at deploy, alongside ${secret:…}), so a program's own env var names
map to castle's computed values without hardcoding.
Why: the auto-injected <PREFIX>_PORT/<PREFIX>_DATA_DIR were a guess at the
program's env names — right for castle-scaffolded services, dead weight for
adopted ones (lakehouse carried two dead vars; notification-bridge/backup jobs
too). They also weren't visible in the config editor (computed at deploy), which
was the source of the 'four env vars but the UI shows none' mystery.
- core: resolve_env_vars gains a context (${port}/${data_dir}/${name});
deploy builds env from defaults.env only — no <PREFIX>_* injection, no
port_env. Removed the port_env field and the dead _env_prefix helper.
- cli: 'service/job create' gains repeatable --env KEY=VALUE (replaces
--port-env); 'program create' scaffolds <PREFIX>_PORT/_DATA_DIR: ${…} for new
daemons.
- app: removed the 'Port env' field; the Environment editor (defaults.env) is
the single place, with a placeholder hint.
- live migration: central-context/castle-api/power-graph/protonmail mapped their
real vars explicitly; lakehouse → just LAKEHOUSED_DAEMON_PORT: ${port}, data
stays in ~/.lakehoused. Verified all services healthy on their ports, dead
vars gone, zero failed units.
- docs: registry.md/design.md/stack guides + findings updated to the explicit
model.
core 94 / cli 24 / api 52 green; ruff + app build clean.
Sweep of accumulated drift unrelated to the CLI reorg:
- Source paths: ~/.castle/code/<name> → /data/repos/<name> (REPOS_DIR), and
source: code/X → absolute /data/repos/X. registry.md source-resolution table
and 'how programs get in' section rewritten for the /data/repos layout.
- Old field name component: → program: in all YAML examples.
- Old term 'component' → 'program' throughout design.md and the stack guides.
- Frontend serving: corrected the copy-to-artifacts/content model to serve-in-
place from the repo (<source>/<dist>), matching Phase 2b; dropped the dead
_copy_app_static reference and the 'runtime never references the source tree'
claim (frontends are the deliberate exception).
- Stale endpoints/commands: /components → /deployments; castle create/add →
castle program create/add in the create.py/add.py docstrings.
CLAUDE.md was already current. cli 24 green; ruff clean.
Names collide across resource types (a program and a service can share a name),
so the CLI no longer resolves bare names. Operations live under the resource
they act on; platform lifecycle and the cross-resource overview stay top-level.
castle program list|info|create|add|clone|delete|run|install|uninstall|
build|test|lint|format|type-check|check
castle service list|info|create|delete|deploy|enable|disable|start|stop|restart|logs
castle job <same verbs> (create takes --schedule)
castle gateway start|stop|reload|status
castle list | status | deploy [name] # cross-cutting
castle start | stop | restart # whole platform (systemd verbs)
Key changes:
- info/delete/list/run are resource-scoped (no more cross-section name match).
- delete only removes the named resource's entry; program delete still blocks
when a deployment references it.
- per-resource start/stop/restart (systemctl on the unit/timer); top-level
start/stop/restart act on the whole platform.
- expose → 'service create' (more general: --program ref, --runner); gained
'job create' (the CLI never had one).
- dropped up/down — bringing things online is the honest 'deploy && start'.
Docs (CLAUDE.md, registry.md, stack guides) and user-facing messages updated.
core 94 / cli 24 / api 52 green; ruff clean. Verified live: program/service/job
list+info scope correctly, service create+scoped-delete round-trips.
The Stage 2 editor split dropped the old confirm() — Remove program/service/job
deleted immediately on click. Restored confirmation on all three (FormFooter
window.confirm with a per-type message).
Removing a program that still has services/jobs referencing it would orphan
their program: ref. Now blocked:
- API: DELETE /config/programs/{name} → 409 'Programs with active jobs or
services cannot be removed. Delete these first: …' (authoritative).
- App: ProgramFields shows the blocked reason instead of a Remove button when
the program has deployments.
- CLI: castle delete refuses a program with referencing deployments not named
the same (which would survive the delete dangling).
Verified live: DELETE program lakehouse → 409. api 52 / cli 24 green; build clean.
CreateDeploymentForm builds a service or job in castle.yaml (PUT /config/...),
then deploys it (POST /deploy {name} → unit + Caddyfile + reload) and, for a
service, starts it (POST /services/{name}/start), then navigates to the new
detail page.
Reachable two ways:
- Program page Deployments section: 'Create service' / 'Create job' buttons,
prefilled from the program (name, program ref, run target, runner).
- Dashboard: standalone '+ Add service' / '+ Add job' (deployments can run
anything, not just castle programs).
Service form covers port / port_env / health / proxy path / proxy host; job
form covers schedule. Name validated against existing services/jobs.
Verified: the configs the form builds validate (PUT 200) and round-trip clean.
App builds; api 52 / core 94 green from prior stages.
- ProgramActions: install/uninstall (activate) shown only for tools and
no-service frontends. Daemons activate via a service/job, so the program
page no longer offers a misleading Install/Uninstall (which secretly
enabled/disabled the service).
- Program page: new Deployments section listing the services/jobs that run
the program, as links (a program → 0-N services/jobs).
- Service page: 'Runs' row from the run_target summary field (the old code
dug into manifest.run, which was empty pre-Stage-1), a Host row for
proxy_host, and a convenience link to the referenced program.
- lifecycle.activate on a daemon with no service now errors toward
'castle expose' instead of silently installing its binary to PATH.
core 94 green; ruff + app build clean.
The single service-shaped DeploymentFields was reused for all three sections,
so the program page bled service fields (port/proxy/health) and couldn't edit
program config, and the job page couldn't edit its schedule. Replaced it with:
- ProgramFields — description, source, stack, behavior, version, repo/ref,
system deps (+ read-only commands). Edits the program catalog entry.
- ServiceFields — run target, port, port_env, health, proxy path + host,
env/secrets. Prefills correctly now (Stage 1) and round-trips through PUT.
- JobFields — schedule, run target, env/secrets.
ConfigPanel selects the form by section. Shared Field/TextField/useEnvSecrets/
FormFooter helpers in detail/fields.tsx. Retired DeploymentFields. run editing
only touches python/command specs (container/node/remote run blocks preserved).
Verified: GET service manifest → PUT back validates 200. App type-checks/builds.
The config editor read service/job detail 'manifest' as the castle.yaml spec
but the endpoint returned the flat *runtime registry* shape for deployed
entries — so service fields (port/health/proxy) never prefilled and a save
would 422/corrupt. Detail 'manifest' is now always the editable castle.yaml
ServiceSpec/JobSpec when the entry is declared there; the flat registry shape
is a display-only fallback for entries that exist only at runtime.
Also:
- ServiceSummary/JobSummary gain run_target (what it runs) + program ref;
ServiceSummary gains proxy_host.
- ProgramSummary gains services[]/jobs[] — the deployments referencing a
program (a program → 0-N services/jobs), for linking + create prefill.
- App types updated to match; deleted orphaned DeploymentEditor/AddDeployment
(leftovers from the removed ConfigEditor).
Verified live: service manifest nested (expose.http.internal.port, proxy.caddy
.host prefill), run_target=lakehoused, program lakehouse → services:[lakehouse].
api 52 green; ruff + app build clean.
Driven by adopting lakehouse (an existing daemon that bundles its own SPA) —
see docs/findings-lakehouse.md.
#3 deploy reloads the gateway. castle deploy regenerated the Caddyfile but
left the running Caddy on the old config, so new proxy routes were silently
dead. Deploy now reloads the gateway when it's running.
#1 castle expose <program>. Turns an adopted program into a service
(run/port/health/proxy/systemd) in one command — the missing
daemon-to-service step. Flags: --port --health --path --run --port-env
--host --no-proxy.
#2 port_env mapping. A service can declare expose.http.internal.port_env so
castle sets the env var the program actually reads (e.g. lakehoused reads
LAKEHOUSED_DAEMON_PORT, not castle's convention LAKEHOUSE_PORT). Castle now
genuinely drives an adopted daemon's bind port.
#4a auto-base for react-vite. The build passes VITE_BASE = the gateway serve
prefix (/<name>/, or / for castle-app); vite.config reads it. A castle-built
frontend now works at its subpath with no hand-tuned base. castle-app's
vite.config updated as the reference.
#4b host-based routing. proxy.caddy.host routes a whole hostname to the backend
root via a host matcher inside the :9000 site, so a root-based SPA (base="/")
serves unchanged — the fix for proxying an app castle can't rebuild. Caddyfile
now emits 'auto_https off' (HTTP-only gateway on a non-standard port).
Nit: activate skips the editable reinstall when the tool is already on PATH.
Tests: core 94, cli 24, api 52; ruff + app build clean. Verified live: lakehouse
runs under systemd, API at /lakehouse, full UI (SPA boots) via host routing.
The Programs table's install/uninstall icon keyed off `installed`
(shutil.which(name)) — a PATH lookup that only makes sense for python CLI
tools. Static frontends have no PATH binary so they were always
`installed=false` → a green 'Install' icon even while actively served;
container daemons were `installed=null` → same. And for tools the value
reflected the castle-api service's PATH, which isn't guaranteed to include
~/.local/bin across reboots, so they could all flip to 'not installed'.
Switch ProgramActions/ProgramTable/ProgramDetail to the uniform `active`
state (Phase 2's lifecycle.is_active: PATH-independent ~/.local/bin check for
tools, systemctl for services/jobs, served-assets check for static
frontends). Now every program reflects real state: served frontends show
Uninstall, container daemons (no install verb) show no icon.
App builds clean.
Imported nowhere and absent from the router — an unreachable bulk-editor
superseded by the typed detail pages (ServiceDetail/ProgramDetail/
ScheduledDetail), which edit via ConfigPanel. It also called endpoints that
no longer exist (GET /components, PUT/DELETE /config/components/{name}; the
API uses /deployments and typed /config/{programs,services,jobs}/{name}).
App builds clean.
The dashboard widgets were named after the old 'component' concept. Renamed
each to what it actually renders:
- ComponentCard → ServiceCard (ServiceSummary, /services/, service actions)
- ComponentTable → ProgramTable (ProgramSummary[], the program catalog)
- ComponentEditor→ DeploymentEditor (DeploymentDetail, the unified type)
- ComponentFields→ DeploymentFields (AnyDetail)
- AddComponent → AddDeployment
Also purged the domain term 'component' from the rest of the app: the
useComponent hook → useDeployment, ConfigPanel + the three detail pages'
`component` prop/var → `deployment`, and the gateway/node table headers
(Component → Program / Deployment). Props renamed to match (component →
service / program / deployment). The React UI directory app/src/components/
keeps its conventional name.
App type-checks and builds clean.
#1 Positional dest unified to `name` across info + dev verbs (was `project`).
#2 install/uninstall help now states the activate semantics (tool→PATH,
service/job→systemd, frontend→served) rather than 'to PATH'.
#4 `service status` (a plural op) moved to `services status`; the singular
`service` group is enable/disable only.
#5 `list --behavior` now filters the program *catalog* by its real behavior
field. behavior (what a program is) and service/job (how it's deployed) are
separate axes: `list` shows a Programs catalog plus Services/Jobs deployment
views; a behavior filter scopes the catalog only.
#6 `list` uses lifecycle.is_active uniformly for the status dot (tool on PATH /
service running / job timer / static frontend served) instead of registry
presence.
#7 New `castle restart <name>` — restart a service (unit) or job (timer).
#8 New `castle status` — unified services + jobs + programs activation view.
#9 New `castle format [name]` dev verb (ruff format / pnpm format); wired into
stacks DEV_ACTIONS + handlers.
#10 New `castle up` — deploy + start everything in one shot.
Docs: CLAUDE.md command reference refreshed. Tests updated to the two-axis
list model (+ a daemon-behavior program fixture). core 92 · cli 24 · api 52
green; ruff clean.
The Caddyfile generator now emits static-frontend routes that root directly at
<source>/<build.outputs[0]> (e.g. /data/repos/castle/app/dist) instead of a copy
under ~/.castle/artifacts/content. Removes _copy_app_static and the central
content-dir staging entirely; rebuilds are live without a copy step.
- caddyfile.py: manifest-driven static routes (castle-app at /, others at /<name>)
- ReactViteHandler.install just builds in place (no copy); uninstall is a no-op
- is_active(static frontend) = repo dist exists
- test isolation updated for the generator's config use
Dashboard + power-graph-app verified serving from repo dist; 168 tests pass.
Convergent deploy (prefix-based prune): castle deploy now removes orphaned
castle-* systemd units/timers no longer in the registry. Full deploy only;
partial (--name) deploys preserve siblings. Fixes the orphaned-timer / stale-
path drift class.
Container runner: derive --name from the service name (castle-<name>) instead
of the image name; skip the <PREFIX>_DATA_DIR env injection for containers
(avoids colliding with image env namespaces like NEO4J_*).
Unified active lifecycle (core/lifecycle.py): install/uninstall keep their
names but become activate/deactivate, dispatching by behavior — tool→PATH,
daemon/self-serving-frontend/job→systemd, static frontend→served. is_active
reports a uniform state (PATH-independent for tools). The CLI install/uninstall
and service enable/disable route through the extracted core.
API + dashboard: surface a uniform `active` state on programs alongside
`installed`; ProgramDetail shows active/inactive.
Tests: test_deploy_prune (6), test_lifecycle (5). 168 tests pass; lint clean.