feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
# ! / u s r / b i n / e n v n o d e
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
import { existsSync , readFileSync } from "node:fs" ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
import { join } from "node:path" ;
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
import { createInterface } from "node:readline/promises" ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
import { parseArgs } from "node:util" ;
import { parse as parseYaml } from "yaml" ;
import { type Executor , applyHostnameOverlay , applyPlan } from "./apply.js" ;
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
import {
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
type Bindings ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
githubAppNameFor ,
loadBindings ,
projectBindingFor ,
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
projectsIn ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
smokeTargetFor ,
} from "./bindings.js" ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
import {
type LiveEnvs ,
absentResources ,
classify ,
renderAbsentResources ,
renderCapturePlan ,
} from "./capture.js" ;
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
import {
type CoolifyInstance ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
DEFAULT_INSTANCE ,
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
assertWritable ,
formatInstance ,
loadInstance ,
} from "./config.js" ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
import { CoolifyClient , HttpError } from "./coolify.js" ;
import {
type Live ,
type ResourceKind ,
computeDiff ,
renderDiff ,
} from "./diff.js" ;
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
import {
type DraftProject ,
assertEmptyTarget ,
draftResourcesFrom ,
emitDraft ,
planDraft ,
renderAmbiguousEnvironments ,
renderDraftPlan ,
renderNoRecipient ,
renderRepoWithDraft ,
} from "./draft.js" ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
import { assertEnvVarPolicy } from "./envtemplate.js" ;
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
import {
type ProjectOutcome ,
fleetConflict ,
fleetExitCode ,
renderEmptyRegistry ,
renderFleetApply ,
renderFleetConflict ,
renderFleetDiff ,
renderProjectHeading ,
} from "./fleet.js" ;
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.
cast inventory --env prod --instance box-b # no repo → sweep
Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.
Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:
on the box, NOT in the manifest
(none)
5 difference(s) between the manifest and this box.
Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.
The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.
Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.
The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.
npm run check + build clean; 173 tests passing (was 169).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
import {
type LiveResource ,
type SweepEnvironment ,
type SweepProject ,
reconcile ,
renderInventory ,
renderSweep ,
} from "./inventory.js" ;
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
import {
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
PATH_IN_PROD_REFUSAL ,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
desiredFromManifest ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
manifestResources ,
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
refusesPathInProd ,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
requiredSecrets ,
resolveCheckout ,
} from "./resolve.js" ;
import {
decryptSecrets ,
encryptSecrets ,
keyFileFor ,
secretsFileFor ,
} from "./secrets.js" ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
import { serverAdd } from "./server.js" ;
import { smoke } from "./smoke.js" ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
import { assertTeam , formatTeam } from "./team.js" ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
const USAGE = ` usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--hostname-overlay <file>]
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
cast apply -- env < env > -- all # no repo : EVERY registered project
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
cast diff < org > / < repo > -- env < env > [ -- full ] [ -- project < name > ] [ -- environment < name > ]
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
cast diff -- env < env > -- all [ -- full ] # no repo : EVERY registered project
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
cast capture < org > / < repo > -- env < env > [ -- path < dir > ] [ -- project < name > ] [ -- environment < name > ] [ -- generated < NAME > ] [ -- override < NAME > ] [ -- force ]
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.
cast inventory --env prod --instance box-b # no repo → sweep
Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.
Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:
on the box, NOT in the manifest
(none)
5 difference(s) between the manifest and this box.
Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.
The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.
Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.
The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.
npm run check + build clean; 173 tests passing (was 169).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
cast inventory < org > / < repo > -- env < env > [ -- path < dir > ] [ -- project < name > ] [ -- environment < name > ] [ -- resource < m > = < l > ]
cast inventory -- env < env > [ -- instance < name > ] # no repo : SWEEP the whole instance
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
cast inventory -- env < env > -- emit - draft < dir > [ -- recipient age1 … ] [ -- no - secrets ]
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
cast server add < name > -- ip < ip > -- key < file > -- env < env > [ -- user root ] [ -- port 22 ]
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
cast smoke < org > / < repo > -- env < env > [ -- project < name > ] [ -- environment < name > ]
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
cast team [ -- env < env > ]
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
-- state < dir > the state checkout holding environments . yaml , secrets / and
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
. coolify . env ( default : $CAST_STATE , else the cwd )
-- env < env > the environment to act on . Every command that reaches a live
Coolify takes one , because every one of them first asserts
the token belongs to that environment ' s declared team .
\ ` cast team \` alone (no --env) reports the token's team
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
without needing a binding — use it to fill environments . yaml .
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
-- instance < name >
the Coolify to talk to : < state > / . coolify / < name > . env , instead
of < state > / . coolify . env . Bind one per environment in
environments . yaml ( \ ` instance: <name> \` ) and --env selects it
with no flag ; an explicit -- instance still wins . An instance
may declare COOLIFY_READ_ONLY = true , and then no command that
writes will run against it .
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
-- project < name >
the Coolify project to act on , when it is not named after the
repo ( the default ) . A project built by hand in the UI is called
whatever someone typed ; \ ` diff \` refuses rather than reporting an
absent project as an empty one , and this is how you point it at
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
the real name .
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
-- environment < name >
the Coolify environment to act on , when it is not named after
-- env ( the default ) . Same problem as -- project , one level down :
a box built by hand has whatever Coolify defaulted to , which is
\ ` production \` , not \` prod \` . This changes ONLY the name on the
wire — -- env still selects the manifest block , the
environments . yaml binding , the age key and the store path .
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
-- resource < manifest - name > = < live - name >
the same problem , one level further down : a hand - built box
names resources for a human reading a UI ( "Incubator Stack v2" ) ,
a manifest names them for a diff ( \ ` core \` ). Repeatable. Read-side
only ( \ ` diff \` , \` capture \` , \` inventory \` ) — \` apply \` creates under the
manifest ' s names and refuses this flag .
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
-- all ( \ ` apply \` / \` diff \` ) act on EVERY project the \` projects: \` registry
lists for this environment , instead of one named repo — the loop
the operator used to write from memory , and the project they
forgot is the one that drifted . Reports per project and fails
CLOSED on the aggregate : a registered project cast cannot reach
is an ERROR , never a skip , because a skipped project reads
exactly like a clean one . \ ` diff --all \` runs every project to
completion and exits 2 if any could not be read ( which outranks
drift ' s 1 — an unread project is not a diff result ) ; \ ` apply --all \`
stops at the first failure and says what it did and did not
touch . An empty ( or absent ) registry refuses . Mutually exclusive
with the repo positional and with every single - project
coordinate : -- path , -- project , -- environment , -- resource ,
-- hostname - overlay .
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
capture ( adopt a hand - built instance into the age secret store ) :
-- generated < NAME > force NAME to the \ ` pending-coolify-generated \` placeholder,
for a manifest that has not declared generated_secrets yet .
Repeatable .
-- override < NAME > supply NAME yourself instead of copying the source ' s value .
The VALUE is read from \ $CAST_CAPTURE_ < NAME > , never from the
command line — argv is visible in \ ` ps \` . Repeatable.
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
-- force overwrite an existing store ( refused by default ) .
inventory -- emit - draft ( write down what a box has , as a PROPOSAL ) :
-- emit - draft < dir > emit what the sweep saw as a draft of cast ' s own inputs — a
manifest per project , env templates , an environments . yaml
carrying the \ ` projects: \` registry, an age store per project,
and UNCAPTURED . md . Into a NEW directory , always : a draft is a
proposal , reviewed by a human and landed as a PR , and \ ` apply \`
never reads one . SWEEP MODE ONLY — with a repo there is already
a manifest , and a manifest regenerated from a live box would
overwrite a reviewed spec with that box ' s accumulated cruft .
-- recipient age1 … the age recipient the draft ' s stores are encrypted to . Defaults
to the environment ' s \ ` age_recipient \` binding.
-- no - secrets emit no stores . Required when no recipient is available : cast
will not silently drop the values it read off the box .
-- environment < name > a TIEBREAK , not a filter : which environment to draft for a
project that has resources in more than one ( cast refuses to
pick ) . A project with only one populated environment is drafted
from it either way — filtering the instance by an environment
name would drop whole projects out of a blueprint that claims to
describe the box . ` ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
// cast is stateless: every instance-scoped input is read from the state
// directory it is pointed at, never from a location the tool itself knows.
function stateDirFrom ( flag : string | undefined ) : string {
return flag ? ? process . env . CAST_STATE ? ? "." ;
}
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
// Resolve which Coolify to talk to, announce it, and open a client on it.
//
// Precedence: --instance > the environment's `instance:` binding > the
// default .coolify.env. Every command that reaches a live Coolify goes through
// here, so every one of them SAYS which Coolify it is about to touch, right
// next to the team assert. The connection target used to be implicit in
// .coolify.env's current contents — retargeting meant hand-editing a live
// credential file and putting it back afterwards, and the failure mode of
// getting it wrong is running `apply` against production.
function openCoolify (
stateDir : string ,
flag : string | undefined ,
binding ? : { instance? : string } ,
) : { instance : CoolifyInstance ; client : CoolifyClient } {
const instance = loadInstance ( stateDir , flag ? ? binding ? . instance ) ;
console . log ( formatInstance ( instance ) ) ;
return {
instance ,
client : new CoolifyClient ( instance . baseUrl , instance . token ) ,
} ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
// Live Coolify objects use their own field vocabulary; computeDiff compares
// by the DESIRED vocabulary, so each live resource must be projected onto it
// or every run reports spurious drift (breaks idempotency, criterion 2).
// Source keys per reference/coolify-openapi-4.1.2.json, cross-checked
// against the coollabsio/coolify v4.1.2 controller/model source where the
// vendored doc is silent or wrong (see task-8-report.md for the full list).
const DATABASE_TYPE_ALIASES : Record < string , string > = {
"standalone-postgresql" : "postgresql" ,
"standalone-redis" : "redis" ,
} ;
export function databaseVersionFromImage ( image : unknown ) : string | undefined {
if ( typeof image !== "string" ) return undefined ;
const tag = image . split ( ":" ) [ 1 ] ;
const m = tag ? . match ( /^(\d+(?:\.\d+)*)/ ) ;
return m ? . [ 1 ] ;
}
// Coolify's GET application model exposes `docker_compose_domains` as a
// nullable string (reference/coolify-openapi-4.1.2.json ~line 12689), not
// the structured array the create/update request bodies accept (~line 353) —
// the live value is the same array-of-{name,domain} shape, JSON-encoded.
// Parses defensively: anything that isn't a JSON-encoded array of well-formed
// {name, domain} entries collapses to `undefined` rather than throwing, so a
// live instance that turns out not to expose this (unverified until Task 8
// step 6) degrades to "field omitted", not a crash.
export function parseDockerComposeDomains (
raw : unknown ,
) : Record < string , string [ ] > | undefined {
if ( typeof raw !== "string" || raw . length === 0 ) return undefined ;
let parsed : unknown ;
try {
parsed = JSON . parse ( raw ) ;
} catch {
return undefined ;
}
if ( ! Array . isArray ( parsed ) ) return undefined ;
const map : Record < string , string [ ] > = { } ;
for ( const entry of parsed ) {
const name = ( entry as { name? : unknown } | null ) ? . name ;
const domain = ( entry as { domain? : unknown } | null ) ? . domain ;
if ( typeof name === "string" && typeof domain === "string" ) {
map [ name ] = domain . split ( "," ) . filter ( Boolean ) ;
}
}
return map ;
}
export function projectLiveFields (
kind : ResourceKind ,
raw : Record < string , unknown > ,
) : Record < string , unknown > {
if ( kind === "application" ) {
const composeDomains = parseDockerComposeDomains (
raw . docker_compose_domains ,
) ;
return {
git_repository : raw.git_repository ,
git_branch : raw.git_branch ,
build_pack : raw.build_pack ,
base_directory : raw.base_directory ,
. . . ( raw . publish_directory
? { publish_directory : raw.publish_directory }
: { } ) ,
. . . ( raw . ports_exposes ? { port : Number ( raw . ports_exposes ) } : { } ) ,
. . . ( raw . health_check_path ? { healthcheck : raw.health_check_path } : { } ) ,
domains : String ( raw . fqdn ? ? "" )
. split ( "," )
. filter ( Boolean ) ,
. . . ( raw . docker_compose_location
? { docker_compose_location : raw.docker_compose_location }
: { } ) ,
. . . ( composeDomains ? { docker_compose_domains : composeDomains } : { } ) ,
} ;
}
if ( kind === "database" ) {
// GET /projects/{uuid}/{env} returns raw Postgresql/Redis Eloquent
// models (see fetchLive) — the vendored OpenAPI documents no schema for
// these at all ("Content is very complex. Will be implemented later.").
// `database_type` is a model accessor (app/Models/StandalonePostgresql.php
// / StandaloneRedis.php @ v4.1.2) returning "standalone-postgresql" /
// "standalone-redis"; normalized here to the manifest's plain
// "postgresql"/"redis" vocabulary. There is no `version` field on the
// wire — we best-effort recover it from the leading digits of the
// `image` tag, mirroring the convention Coolify's own "New Resource"
// wizard writes on create (see defaultDatabaseImage below).
const rawType = String ( raw . database_type ? ? raw . type ? ? "" ) ;
const type = DATABASE_TYPE_ALIASES [ rawType ] ? ? rawType ;
const version = databaseVersionFromImage ( raw . image ) ;
return { type , . . . ( version ? { version } : { } ) } ;
}
return {
type : raw . type ? ? raw . service_type ,
// Coolify's live `Service` model carries no flat `fqdn` — hostnames
// live per-container on service.applications[].fqdn
// (app/Models/Service.php @ v4.1.2), which this environment-list call
// doesn't eager-load. We deliberately don't fabricate a `domains` value
// here; see serviceApiFields below for the matching create/update-side
// limitation.
} ;
}
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
// The live side of a diff/apply is either "here are the resources" or "the
// thing I was told to look at does not exist" — and those two must NOT collapse
// into the same value.
//
// They used to: both returned []. That is right for `apply` (a first apply
// legitimately creates the project and its environment) and quietly wrong for
// `diff`, because computeDiff(desired, []) means "every desired resource is
// missing" — rendered as a confident full-create plan. So a diff pointed at a
// project name that does not exist reports a CLEAN-LOOKING plan that verified
// nothing at all. Same shape of lie as the wrong-team token in team.ts: an
// unverifiable read that answers "absent" and invites a create.
//
// Keeping the distinction in the type is what lets each caller take its own
// (opposite, and both correct) position on absence.
export type LiveLookup =
| { found : true ; live : Live [ ] }
| {
found : false ;
missing : "project" ;
project : string ;
available : string [ ] ;
}
| {
found : false ;
missing : "environment" ;
project : string ;
environment : string ;
} ;
export async function fetchLive (
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
client : CoolifyClient ,
projectName : string ,
envName : string ,
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
) : Promise < LiveLookup > {
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const projects = ( await client . get ( "/projects" ) ) as Array < {
uuid : string ;
name : string ;
} > ;
const project = projects . find ( ( p ) = > p . name === projectName ) ;
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
if ( ! project ) {
return {
found : false ,
missing : "project" ,
project : projectName ,
available : projects.map ( ( p ) = > p . name ) ,
} ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
// GET /projects/{uuid}/{environment_name_or_uuid} eager-loads exactly
// these relations (app/Http/Controllers/Api/ProjectController.php
// @environment_details, coollabsio/coolify v4.1.2): applications,
// postgresqls, redis, mongodbs, mysqls, mariadbs, services. The vendored
// OpenAPI's `Environment` schema response omits all of them (published
// doc gap — the brief's `env.databases` shape does not exist on the
// wire). We only map postgresql/redis: the two database types
// manifest.ts's DatabaseSpecSchema supports.
const env = ( await client
. get ( ` /projects/ ${ project . uuid } / ${ envName } ` )
. catch ( ( err ) = > {
// Missing environment (first apply into a project without it yet) is
// a 404 and means "no live resources"; anything else (401, 5xx,
// network) must surface, not be silently treated as an empty diff —
// that would cause createResource to attempt duplicate resources.
if ( err instanceof HttpError && err . status === 404 ) {
return null ;
}
throw err ;
} ) ) as {
applications? : Array < Record < string , unknown > > ;
postgresqls? : Array < Record < string , unknown > > ;
redis? : Array < Record < string , unknown > > ;
services? : Array < Record < string , unknown > > ;
} | null ;
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
if ( ! env ) {
return {
found : false ,
missing : "environment" ,
project : projectName ,
environment : envName ,
} ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const map = (
kind : ResourceKind ,
items : Array < Record < string , unknown > > = [ ] ,
) : Live [ ] = >
items . map ( ( i ) = > ( {
kind ,
name : String ( i . name ) ,
uuid : String ( i . uuid ) ,
fields : projectLiveFields ( kind , i ) ,
env : undefined , // populated per-resource below only in full mode by caller
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
// The one thing Coolify will tell us about placement. `destination_id` is
// a plain column on all three resource tables and none of the three
// controllers' removeSensitiveData() hides it (v4.1.2), so it survives
// into this response — whereas the destination's UUID never appears in
// any response at all, because environment_details does not eager-load
// the `destination` relation and no endpoint exposes it. See Placement.
destinationId :
typeof i . destination_id === "number" ? i.destination_id : undefined ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
} ) ) ;
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
return {
found : true ,
live : [
. . . map ( "application" , env . applications ) ,
. . . map ( "database" , env . postgresqls ) ,
. . . map ( "database" , env . redis ) ,
. . . map ( "service" , env . services ) ,
] ,
} ;
}
// Why `diff` refuses instead of reporting an empty live side: see LiveLookup.
// The message has one job — make it impossible to read "absent" as "empty" —
// so it names what was looked for, where the name came from, and what actually
// exists next to it.
export function renderAbsentTarget (
lookup : Extract < LiveLookup , { found : false } > ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
ctx : {
orgRepo : string ;
overridden : boolean ;
envOverridden? : boolean ;
verb? : string ;
} ,
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
) : string {
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
// `capture` takes the same position as `diff`, and for the same reason: it
// is only ever a claim about something that already exists. Against an
// absent target it would read back zero live values and call every required
// secret "missing" — an alarming-but-meaningless report about the wrong box.
const verb = ctx . verb ? ? "diff" ;
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
const origin = ctx . overridden
? "--project"
: ` derived from the repo slug ${ ctx . orgRepo } ` ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
const envOrigin = ctx . envOverridden ? "--environment" : "derived from --env" ;
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
const head =
lookup . missing === "project"
? [
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
` refusing to ${ verb } : no project named " ${ lookup . project } " exists in this team ` ,
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
"" ,
` looked for: project " ${ lookup . project } " ( ${ origin } ) ` ,
` exists here: ${ lookup . available . join ( ", " ) || "(no projects at all)" } ` ,
]
: [
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
` refusing to ${ verb } : project " ${ lookup . project } " has no environment " ${ lookup . environment } " ` ,
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
"" ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
` looked for: environment " ${ lookup . environment } " in project " ${ lookup . project } " ( ${ envOrigin } ) ` ,
" note: a project built by hand in the Coolify UI may well use a" ,
" different name for the same tier — Coolify's own default is" ,
" `production`, not `prod`." ,
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
] ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
return [
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
. . . head ,
"" ,
"An absent target reads back exactly like an empty one, so continuing would diff" ,
'it as "nothing exists — create everything": a clean-looking report that verified' ,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
` nothing. \` apply \` may create a target; \` ${ verb } \` may only ever describe one that is ` ,
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
"already there." ,
"" ,
lookup . missing === "project"
? "Pass --project <name> if this instance names it differently."
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
: // NOT "rename your environment to match the box". The box does not get to
// name our environments: --env selects the manifest block, the binding, the
// age key and the store path, and a hand-built box being evicted next week
// must not decide any of them. --environment is the coordinate for reading
// it, and it changes nothing on our side of the line.
"Pass --environment <name> if this instance names it differently." ,
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
] . join ( "\n" ) ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
}
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
// The same disposition as renderAbsentTarget, one level deeper, and for the one
// verb that WRITES: the project and the environment are both there, and hold no
// application of the name `smoke` was told to write to.
//
// Until #29, `smoke` never got here. It resolved its target against
// GET /applications — every application the token can see, across every project
// and every environment of the instance — and wrote to the first name match. So
// `smoke_target: core` did not name an application; it named whichever `core`
// Coolify happened to list first, and one instance carrying prod and staging is
// enough for that to be prod's. The canary vars land on an app nobody named, and
// on the failure path they stay there.
//
// This message therefore does NOT offer to look elsewhere, and the code behind it
// does not either. An application in another project is not the same application
// seen from a different angle — it is a different application, and this verb
// writes. The only thing worth saying is: here is where I looked, here is what is
// actually in there, and here is which coordinate to correct.
export function renderAbsentSmokeTarget (
target : string ,
live : Array < { kind : ResourceKind ; name : string } > ,
ctx : { orgRepo : string ; env : string ; project : string ; environment : string } ,
) : string {
const apps = live . filter ( ( l ) = > l . kind === "application" ) . map ( ( l ) = > l . name ) ;
// A service or a database of that name is not a near-miss to be accommodating
// about — smoke POSTs to /applications/<uuid>/envs, so being pointed at one
// would 404 on an endpoint that does not exist for that kind, and the operator
// would spend the afternoon on an HTTP status instead of on the name.
const sameName = live . find ( ( l ) = > l . name === target ) ;
const wrongKind =
sameName && sameName . kind !== "application"
? [
"" ,
` but note: " ${ target } " DOES exist here — as a ${ sameName . kind } , not an ` ,
" application. `smoke` writes to an application's /envs endpoint;" ,
` a ${ sameName . kind } of the same name is a different resource behind a ` ,
" different endpoint, not this one seen sideways." ,
]
: [ ] ;
return [
` refusing to smoke: project " ${ ctx . project } " / environment " ${ ctx . environment } " holds no application named " ${ target } " ` ,
"" ,
` looked for: application " ${ target } " ` ,
` (environments. ${ ctx . env } .projects[" ${ ctx . orgRepo } "].smoke_target) ` ,
` in: project " ${ ctx . project } ", environment " ${ ctx . environment } " ` ,
` exists here: ${ apps . join ( ", " ) || "(no applications at all)" } ` ,
. . . wrongKind ,
"" ,
"cast will not go looking for that name anywhere else on this instance. A bare" ,
"application name is unique only INSIDE a project and an environment, so the first" ,
` \` ${ target } \` the API lists may belong to another project — or to prod, while you are ` ,
"smoking staging (#29). `smoke` POSTs two canary env vars to the application it" ,
"resolves, and deletes them again; on the failure path it leaves them behind. An" ,
"app it was not pointed at is not a fallback." ,
"" ,
"Name the application as it exists here, or pass --project / --environment if this" ,
"instance names the project or the environment differently." ,
] . join ( "\n" ) ;
}
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
// The third name a hand-built box does not share with you: the RESOURCE.
//
// `--project` and `--environment` are coordinates for finding the target;
// `--resource` is the coordinate for finding the things inside it. A box built
// by hand names its resources for humans reading a UI ("Incubator Stack v2"),
// while a manifest names them for machines reading a diff (`core`). Neither is
// wrong, and neither gets to overwrite the other — so the mapping is stated at
// the call site and applied at the boundary.
//
// It is deliberately NOT a manifest field: a manifest that recorded its own
// legacy names would carry a dead box's vocabulary forever, which is the exact
// failure #17 exists to prevent. This is an argument to a one-off read.
export function parseResourceAliases (
pairs : string [ ] ,
declared : string [ ] ,
) : Record < string , string > {
const alias : Record < string , string > = { } ;
for ( const pair of pairs ) {
const eq = pair . indexOf ( "=" ) ;
if ( eq <= 0 || eq === pair . length - 1 ) {
throw new Error (
` --resource expects <manifest-name>=<live-name>, got " ${ pair } " ` ,
) ;
}
const from = pair . slice ( 0 , eq ) . trim ( ) ;
const to = pair . slice ( eq + 1 ) . trim ( ) ;
// A typo here would be silent and expensive: the alias would map nothing,
// the manifest's real resource would still be looked up under its own name,
// and the run would refuse (or capture) with no hint that the flag missed.
if ( ! declared . includes ( from ) ) {
throw new Error (
[
` --resource ${ from } = ${ to } : the manifest declares no resource named " ${ from } " ` ,
"" ,
` declares: ${ declared . join ( ", " ) || "(nothing)" } ` ,
"" ,
"The left side is the MANIFEST's name; the right side is what this box" ,
"calls the same thing." ,
] . join ( "\n" ) ,
) ;
}
alias [ from ] = to ;
}
return alias ;
}
// Rename live resources to the manifest's vocabulary, once, at the boundary.
// Everything downstream — computeDiff, classify, reconcile — then matches by
// name as it always has, and none of them needs to know a box was involved.
export function aliasLive < T extends { name : string } > (
live : T [ ] ,
alias : Record < string , string > ,
) : Array < T & { sourceName ? : string } > {
const toManifestName = new Map (
Object . entries ( alias ) . map ( ( [ manifest , box ] ) = > [ box , manifest ] ) ,
) ;
return live . map ( ( l ) = > {
const manifestName = toManifestName . get ( l . name ) ;
return manifestName ? { . . . l , name : manifestName , sourceName : l.name } : l ;
} ) ;
}
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
// A live resource's env vars, by key. `real_value` is the decrypted one and
// needs a token with read:sensitive; `value` is what a lesser token sees.
//
// A 404 (a resource we just listed no longer having an envs endpoint — not
// expected in practice, but consistent with treating "gone" as "no env vars")
// collapses to {}; anything else (401, 5xx, network) must surface. Swallowing
// it would make a live resource's env look EMPTY, which turns every one of its
// vars into a spurious create in a diff, and into a spurious "missing" in a
// capture.
async function fetchEnv (
client : CoolifyClient ,
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
l : Pick < Live , " kind " | " uuid " > ,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
) : Promise < Record < string , string > > {
const base = l . kind === "database" ? "databases" : ` ${ l . kind } s ` ;
const envs = ( await client . get ( ` / ${ base } / ${ l . uuid } /envs ` ) . catch ( ( err ) = > {
if ( err instanceof HttpError && err . status === 404 ) return [ ] ;
throw err ;
} ) ) as Array < { key : string ; real_value? : string ; value : string } > ;
return Object . fromEntries ( envs . map ( ( e ) = > [ e . key , e . real_value ? ? e . value ] ) ) ;
}
// The value for an --override, read from the ENVIRONMENT rather than argv.
//
// A secret passed as a command-line argument is visible in `ps` to every
// process on the box, and lands in shell history — the same class of leak the
// clone-auth fix (#13) exists to avoid. So --override names the secret and the
// environment carries it.
function readOverrides ( names : string [ ] ) : Record < string , string > {
const out : Record < string , string > = { } ;
for ( const name of names ) {
const varName = ` CAST_CAPTURE_ ${ name } ` ;
const value = process . env [ varName ] ;
if ( value === undefined ) {
throw new Error (
[
` --override ${ name } : no value supplied. ` ,
"" ,
` cast reads an override's value from ${ varName } , never from the command ` ,
"line — an argv value is visible in `ps` to every process on this box." ,
"" ,
` ${ varName } =… cast capture … ` ,
] . join ( "\n" ) ,
) ;
}
out [ name ] = value ;
}
return out ;
}
// Typed confirmation, and deliberately NOT a --yes flag.
//
// This verb writes an environment's secret store, once, off a box nobody is
// going to rebuild. The entire reason it exists is that the hand-run version
// was easy to get subtly wrong — so the last gate is a human who has read the
// provenance column typing the environment's own name. Nothing shorter counts:
// not "y", not a flag. Automating it means deliberately echoing the
// environment name into cast, which is an explicit act rather than an absent
// one.
//
// EOF (a closed or empty stdin) resolves to `null` and aborts. Without that
// race, a `< /dev/null` run would hang forever on a question nobody can answer.
async function confirmCapture ( envName : string ) : Promise < boolean > {
const rl = createInterface ( { input : process.stdin , output : process.stdout } ) ;
const answer = await new Promise < string | null > ( ( resolve ) = > {
rl . question (
` \ ntype the environment name to write this store ( ${ envName } ): ` ,
) . then ( resolve , ( ) = > resolve ( null ) ) ;
rl . once ( "close" , ( ) = > resolve ( null ) ) ;
} ) ;
rl . close ( ) ;
return answer ? . trim ( ) === envName ;
}
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
// Everything ONE project run needs that is the same for every project in a
// fleet run: the instance it talks to (one client, one asserted team), the
// bindings, the environment. The single-project coordinates are here too and are
// always undefined under --all — the refusal above is what guarantees that, and
// it is why this one function can serve both paths without a branch inside it.
type ProjectRunContext = {
command : "apply" | "diff" ;
stateDir : string ;
envName : string ;
bindings : Bindings ;
binding : Bindings [ "environments" ] [ string ] ;
client : CoolifyClient ;
mode : "structural" | "full" ;
path? : string ;
projectOverride? : string ;
environmentOverride? : string ;
resources : string [ ] ;
hostnameOverlay? : string ;
} ;
// What one project's run came to. `absent` is the one failure this function
// RETURNS rather than throws, because it is the one the caller has always
// handled itself (renderAbsentTarget, exit 2) — everything else throws, and the
// fleet loop turns a throw into an unreachable project.
type ProjectResult =
| { status : "clean" }
| { status : "drift" }
| { status : "applied" ; mutated : string [ ] }
| { status : "absent" ; message : string } ;
// ONE project, end to end: checkout → secrets → desired → bindings → live →
// diff → (apply). There is exactly one implementation of what a project run IS,
// and both `cast diff <repo>` and `cast diff --all` call it — a second, parallel
// fleet path would be a second thing to keep true, and the two would drift the
// first time either was touched. That drift is the whole subject of this tool.
async function runProject (
ctx : ProjectRunContext ,
orgRepo : string ,
) : Promise < ProjectResult > {
const repoShort = orgRepo . split ( "/" ) [ 1 ] ;
// The Coolify project name and the secrets-file key are different things
// that happen to default to the same string. Only the former is a name
// some other system chose: a project built by hand in the UI is called
// whatever someone typed. --project overrides that one, and nothing else —
// secrets stay keyed by the repo (a state-repo convention we own).
const projectName = ctx . projectOverride ? ? repoShort ;
// Exactly the same split, one level down. `--env` is OUR name for the
// environment: it selects the manifest block, the environments.yaml
// binding, the age key, the store path, the team to assert. `--environment`
// is THEIR name for it on the wire, and nothing else. Collapsing the two
// (as cast did until now) means a box built by hand in someone's UI gets to
// name our environment — and since apply creates the environment from this
// value, a legacy box's accident would be inherited by the new one forever.
const coolifyEnv = ctx . environmentOverride ? ? ctx . envName ;
const checkout = resolveCheckout ( orgRepo , {
env : ctx.envName ,
path : ctx.path ,
} ) ;
const store = secretsFileFor ( ctx . stateDir , repoShort , ctx . envName ) ;
// Named, rather than left to `age` to fail on. A registered project whose
// store was never written is a project a fleet run cannot read — and under
// --all the headline of this message is what the summary carries, so it has to
// say which project and which file rather than "Command failed: age -d".
if ( ! existsSync ( store ) ) {
throw new Error (
[
` no secret store for ${ orgRepo } in ${ ctx . envName } ` ,
"" ,
` looked for: ${ store } ` ,
"" ,
"The manifest's ${…} refs are resolved from that store, so there is nothing to" ,
"diff or apply without it. `cast capture` writes one from a live box." ,
] . join ( "\n" ) ,
) ;
}
const secrets = decryptSecrets ( store , keyFileFor ( ctx . envName ) ) ;
let { desired , resolvedEnvs , backupSchedules } = desiredFromManifest (
checkout ,
ctx . envName ,
secrets ,
) ;
assertEnvVarPolicy (
ctx . envName ,
resolvedEnvs ,
ctx . binding . forbidden_var_patterns ,
) ;
// Keyed by the REPO, not by --project: --project is the name Coolify's own
// UI happens to use for this project, and cast's state is keyed by the name
// WE own (same split as the secrets file — see the --project note above).
const projectBinding = projectBindingFor ( ctx . bindings , ctx . envName , orgRepo ) ;
if ( ctx . hostnameOverlay ) {
desired = applyHostnameOverlay (
desired ,
parseYaml ( readFileSync ( ctx . hostnameOverlay , "utf8" ) ) ,
) ;
}
const lookup = await fetchLive ( ctx . client , projectName , coolifyEnv ) ;
// apply and diff take opposite (and both correct) positions on absence:
// apply is *allowed* to be the thing that brings a project into existence,
// so [] is a legitimate starting point. diff is only ever a claim about
// something that already exists — for it, absence is not an empty diff, it
// is the absence of anything to diff against, and reporting a full-create
// plan would launder that into a pass. See LiveLookup.
//
// That split holds under --all unchanged: a fleet diff counts an absent
// project as UNREACHABLE (it read nothing, so it may claim nothing), while a
// fleet apply creates it, exactly as a single apply would.
if ( ! lookup . found && ctx . command === "diff" ) {
return {
status : "absent" ,
message : renderAbsentTarget ( lookup , {
orgRepo ,
overridden : ctx.projectOverride !== undefined ,
envOverridden : ctx.environmentOverride !== undefined ,
} ) ,
} ;
}
const aliases = parseResourceAliases (
ctx . resources ,
desired . map ( ( d ) = > d . name ) ,
) ;
// Without this, a diff against a box that names things differently reports
// every manifest resource as "to create" and every live one as unknown —
// the D-237 lie by another route: a confident full-create plan that verified
// nothing, against a box that has all of it under other names.
const live = lookup . found ? aliasLive ( lookup . live , aliases ) : [ ] ;
if ( ctx . mode === "full" ) {
for ( const l of live ) {
l . env = await fetchEnv ( ctx . client , l ) ;
}
}
const report = computeDiff ( desired , live , ctx . mode , {
declaredDestination : projectBinding?.destination_uuid ,
} ) ;
console . log ( renderDiff ( report ) ) ;
if ( ctx . command === "diff" )
return report . clean ? { status : "clean" } : { status : "drift" } ;
const serverUuid = await ctx . client . serverUuid ( ctx . binding . server ) ;
const githubAppUuid = await ctx . client . githubAppUuid (
githubAppNameFor ( ctx . bindings , orgRepo ) ,
) ;
const exec = buildExecutor ( ctx . client , {
projectName ,
// The name the environment gets ON COOLIFY when apply creates it — so an
// apply that adopts an existing hand-named environment writes into that
// one, rather than creating a second environment beside it.
envName : coolifyEnv ,
serverUuid ,
githubAppUuid ,
destinationUuid : projectBinding?.destination_uuid ,
s3DestinationUuid : ctx.binding.s3_destination ,
backupSchedules ,
} ) ;
const { mutated } = await applyPlan ( report , desired , exec ) ;
console . log (
mutated . length === 0
? "no-op (clean)"
: ` applied + redeployed: ${ mutated . join ( ", " ) } ` ,
) ;
return { status : "applied" , mutated } ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
async function main ( ) : Promise < number > {
const [ command , . . . rest ] = process . argv . slice ( 2 ) ;
if ( command === "-h" || command === "--help" || command === "help" ) {
console . log ( USAGE ) ;
return 0 ;
}
if ( command === "apply" || command === "diff" ) {
const { values , positionals } = parseArgs ( {
args : rest ,
allowPositionals : true ,
options : {
env : { type : "string" } ,
path : { type : "string" } ,
state : { type : "string" } ,
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
project : { type : "string" } ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
environment : { type : "string" } ,
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
resource : { type : "string" , multiple : true } ,
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
instance : { type : "string" } ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
"hostname-overlay" : { type : "string" } ,
full : { type : "boolean" , default : false } ,
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
all : { type : "boolean" , default : false } ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
} ,
} ) ;
const orgRepo = positionals [ 0 ] ;
const envName = values . env ;
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
// --all IS the target, so it replaces the positional rather than joining it.
if ( ! envName || ( ! orgRepo && ! values . all ) ) {
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
console . error ( USAGE ) ;
return 2 ;
}
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
// Before anything is read: --all names a fleet, and every coordinate below
// names ONE project's checkout, ONE project's Coolify name, ONE box's
// resource names. See SINGLE_PROJECT_COORDINATES — each refusal says which
// flag it was and what applying it fleet-wide would actually do.
if ( values . all ) {
const conflict = fleetConflict ( {
"<org>/<repo>" : orgRepo ,
"--path" : values . path ,
"--project" : values . project ,
"--environment" : values . environment ,
"--resource" : values . resource ,
"--hostname-overlay" : values [ "hostname-overlay" ] ,
} ) ;
if ( conflict ) {
console . error ( renderFleetConflict ( command , conflict ) ) ;
return 2 ;
}
}
// A checkout cannot decide what prod runs. Refused here, before a state file
// is opened, and enforced again where it is actually honored (resolveCheckout)
// — same rule, same string, no second spelling of it.
if ( refusesPathInProd ( { env : envName , path : values.path } ) ) {
console . error ( PATH_IN_PROD_REFUSAL ) ;
return 2 ;
}
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
// Up front, before a clone or a decrypt or a single call: `apply` creates
// resources under the MANIFEST's names, so an alias there would have to mean
// "adopt the existing resource called X instead" — updating in place rather
// than creating. That is a different operation, nobody has asked for it, and
// guessing at it would silently create a duplicate beside the very resource
// the operator was pointing at.
if ( command === "apply" && ( values . resource ? . length ? ? 0 ) > 0 ) {
console . error (
[
"refusing to apply: --resource is a read-side coordinate" ,
"" ,
"It exists so `diff`, `capture` and `inventory` can READ a box whose" ,
"resources are named differently. `apply` creates resources under the" ,
"manifest's own names, so an alias here would have to mean 'adopt the" ,
"existing one instead' — which is not what this flag does." ,
] . join ( "\n" ) ,
) ;
return 2 ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const stateDir = stateDirFrom ( values . state ) ;
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
const bindingsPath = join ( stateDir , "environments.yaml" ) ;
const bindings = loadBindings ( bindingsPath ) ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const binding = bindings . environments [ envName ] ;
if ( ! binding ) {
console . error ( ` environment ${ envName } not in environments.yaml ` ) ;
return 2 ;
}
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
// The fleet, or the one repo that was named. `projectsIn` is the ONLY place
// "every project" comes from: a registry that does not list a project is a
// registry that has never heard of it, and cast does not go looking for one
// behind the operator's back.
const targets = values . all ? projectsIn ( bindings , envName ) : [ orgRepo ] ;
if ( values . all && targets . length === 0 ) {
console . error (
renderEmptyRegistry ( command , envName , bindings , bindingsPath ) ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
) ;
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
return 2 ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
}
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
const { instance , client } = openCoolify (
stateDir ,
values . instance ,
binding ,
) ;
if ( command === "apply" ) assertWritable ( instance , "apply" ) ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
// Fail-closed, before the first live read — not merely before the first
// write. A wrong-team token makes fetchLive come back empty (the API
// resolves what it cannot see to null), so an unasserted `diff` would
// cheerfully report "everything is absent" and an unasserted `apply`
// would then create all of it in the wrong team. The read is already
// the lie; gate it, not just the write.
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
//
// Hoisted OUT of runProject deliberately, and it changes nothing about when
// it lands: the instance is one instance and the team is one team for the
// whole run, so asserting once here is asserting strictly before the FIRST
// project's first read. Re-asserting per project would be the same call with
// the same answer, N times.
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
const team = await assertTeam ( client , binding . team , envName ) ;
console . log ( ` team ${ formatTeam ( team ) } ✓ ` ) ;
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
const ctx : ProjectRunContext = {
command ,
stateDir ,
envName ,
bindings ,
binding ,
client ,
mode : command === "apply" || values . full ? "full" : "structural" ,
path : values.path ,
projectOverride : values.project ,
environmentOverride : values.environment ,
resources : values.resource ? ? [ ] ,
hostnameOverlay : values [ "hostname-overlay" ] ,
} ;
if ( ! values . all ) {
const result = await runProject ( ctx , targets [ 0 ] ) ;
if ( result . status === "absent" ) {
console . error ( result . message ) ;
return 2 ;
}
if ( command === "diff" ) return result . status === "clean" ? 0 : 1 ;
return 0 ;
fix: diff refuses an absent target instead of reporting it as empty (#11, #6)
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
}
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
const outcomes : ProjectOutcome [ ] = [ ] ;
for ( const [ i , repo ] of targets . entries ( ) ) {
console . log ( renderProjectHeading ( repo , i + 1 , targets . length ) ) ;
try {
const result = await runProject ( ctx , repo ) ;
if ( result . status === "absent" ) {
console . error ( result . message ) ;
outcomes . push ( {
repo ,
status : "unreachable" ,
message : result.message ,
} ) ;
} else if ( result . status === "applied" ) {
outcomes . push ( { repo , status : "applied" , mutated : result.mutated } ) ;
} else {
outcomes . push ( { repo , status : result.status } ) ;
}
} catch ( err ) {
// Every way a project can fail to answer — a clone that will not clone,
// a manifest with no block for this environment, a store that will not
// decrypt, a 500 from Coolify — arrives here, and NONE of them is a skip.
// The single-project path lets these throw to main's handler; a fleet run
// cannot, or the first bad project would take the rest of the report with
// it (`diff`) or leave it un-summarized (`apply`).
const message = err instanceof Error ? err.message : String ( err ) ;
console . error ( message ) ;
outcomes . push ( { repo , status : "unreachable" , message } ) ;
}
// `diff --all` runs every project to completion: stopping early hides the
// drift in the projects it never reached, and a partial read is exactly the
// report this flag exists to make impossible. `apply --all` does the
// opposite and stops — continuing to MUTATE a fleet after an unexplained
// failure is not a thing cast gets to do. The two dispositions differ
// because a read that continues costs nothing and a write that continues
// costs everything.
if (
command === "apply" &&
outcomes [ outcomes . length - 1 ] . status === "unreachable"
) {
break ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
}
}
console . log (
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
command === "diff"
? renderFleetDiff ( envName , targets , outcomes )
: renderFleetApply ( envName , targets , outcomes ) ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
) ;
feat: --all — every project in an environment, and a report that says so (#26)
Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.
The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.
Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:
diff --all 0 every registered project was READ, and every one is clean
1 every one was read, and at least one has drift
2 a project could not be read — outranking drift, because an
unreadable project is not a diff result but the absence of one
apply --all 0 every registered project applied; non-zero otherwise
`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).
Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.
Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
return fleetExitCode ( command , targets , outcomes ) ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
}
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
if ( command === "capture" ) {
const { values , positionals } = parseArgs ( {
args : rest ,
allowPositionals : true ,
options : {
env : { type : "string" } ,
state : { type : "string" } ,
path : { type : "string" } ,
project : { type : "string" } ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
environment : { type : "string" } ,
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
resource : { type : "string" , multiple : true } ,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
instance : { type : "string" } ,
generated : { type : "string" , multiple : true } ,
override : { type : "string" , multiple : true } ,
force : { type : "boolean" , default : false } ,
} ,
} ) ;
const orgRepo = positionals [ 0 ] ;
const envName = values . env ;
if ( ! orgRepo || ! envName ) {
console . error ( USAGE ) ;
return 2 ;
}
const stateDir = stateDirFrom ( values . state ) ;
const repoShort = orgRepo . split ( "/" ) [ 1 ] ;
const projectName = values . project ? ? repoShort ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
// Their name for the environment, on the wire. The store below stays keyed
// by OUR name (--env) — capture is the verb most likely to be pointed at a
// hand-built box, and the store it writes must not inherit that box's
// vocabulary.
const coolifyEnv = values . environment ? ? envName ;
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
const store = secretsFileFor ( stateDir , repoShort , envName ) ;
// Never overwrite a store by accident. `apply` never deletes; the verb
// that WRITES the store gets the same disposition, because the thing it
// would destroy is the only copy of values that may not exist anywhere
// else any more.
if ( existsSync ( store ) && ! values . force ) {
console . error (
[
` refusing to capture: ${ store } already exists ` ,
"" ,
"That store may hold the only copy of values the source box no longer has." ,
"Pass --force to overwrite it deliberately, or move it aside first." ,
] . join ( "\n" ) ,
) ;
return 2 ;
}
const bindings = loadBindings ( join ( stateDir , "environments.yaml" ) ) ;
const binding = bindings . environments [ envName ] ;
if ( ! binding ) {
console . error ( ` environment ${ envName } not in environments.yaml ` ) ;
return 2 ;
}
const recipient = binding . age_recipient ;
if ( ! recipient ) {
console . error (
[
` environment ${ envName } has no age_recipient in environments.yaml ` ,
"" ,
"capture encrypts the store TO that recipient (the public half of the" ,
"environment's age key — safe to commit next to the bindings). Add it:" ,
"" ,
" environments:" ,
` ${ envName } : ` ,
" age_recipient: age1…" ,
] . join ( "\n" ) ,
) ;
return 2 ;
}
// Same rule as apply (resolveCheckout enforces it): prod always reads the
// default branch. A feature-branch manifest must not be able to decide
// which names land in the prod store.
const checkout = resolveCheckout ( orgRepo , {
env : envName ,
path : values.path ,
} ) ;
const { required , generated } = requiredSecrets ( checkout , envName ) ;
const overrides = readOverrides ( values . override ? ? [ ] ) ;
const { client } = openCoolify ( stateDir , values . instance , binding ) ;
// capture READS Coolify and writes only to the local store, so it is
// allowed against a read-only instance — inspecting a legacy box is
// precisely what such an instance is for. It still takes the team assert:
// a wrong-team token reads back nothing, and "nothing" here would render
// as "every secret is missing" against a box that is fine.
const team = await assertTeam ( client , binding . team , envName ) ;
console . log ( ` team ${ formatTeam ( team ) } ✓ ` ) ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
const lookup = await fetchLive ( client , projectName , coolifyEnv ) ;
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
if ( ! lookup . found ) {
console . error (
renderAbsentTarget ( lookup , {
orgRepo ,
overridden : values.project !== undefined ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
envOverridden : values.environment !== undefined ,
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
verb : "capture" ,
} ) ,
) ;
return 2 ;
}
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
const aliases = parseResourceAliases (
values . resource ? ? [ ] ,
manifestResources ( checkout , envName ) . map ( ( r ) = > r . name ) ,
) ;
const aliased = aliasLive ( lookup . live , aliases ) ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
// Databases hold no manifest-templated env of their own — their URL is what
// the APPS reference, and that name is generated, not captured.
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
const envBearing = aliased . filter ( ( l ) = > l . kind !== "database" ) ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
// Before reading a single env: does every resource the manifest requires a
// secret FROM actually exist here? An absent resource reads back exactly
// like one with no env vars set — every name it declares reports MISSING —
// and the suggested remedy for MISSING (--override) would then have the
// operator hand-carry values that are sitting right there under a different
// name, burying the real finding. Same lie as the absent project, one level
// deeper. See absentResources.
const absent = absentResources (
required ,
envBearing . map ( ( l ) = > l . name ) ,
) ;
if ( absent . length > 0 ) {
console . error (
renderAbsentResources ( absent , lookup . live , {
project : projectName ,
environment : coolifyEnv ,
} ) ,
) ;
return 2 ;
}
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
const liveEnvs : LiveEnvs = { } ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
for ( const l of envBearing ) {
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
liveEnvs [ l . name ] = await fetchEnv ( client , l ) ;
}
const classification = classify (
required ,
[ . . . generated , . . . ( values . generated ? ? [ ] ) ] ,
liveEnvs ,
overrides ,
) ;
console . log (
renderCapturePlan ( classification , {
orgRepo ,
env : envName ,
instance : values.instance ? ? binding . instance ? ? "default" ,
store ,
recipient ,
} ) ,
) ;
// Refuse, don't write a wrong store. Both of these are stop conditions,
// and the plan above has already named every offending entry.
if (
classification . missing . length > 0 ||
classification . conflicts . length > 0
)
return 2 ;
if ( ! ( await confirmCapture ( envName ) ) ) {
console . error ( "aborted — nothing written" ) ;
return 2 ;
}
encryptSecrets (
recipient ,
store ,
Object . fromEntries ( classification . plan . map ( ( d ) = > [ d . ref , d . value ] ) ) ,
) ;
console . log (
` wrote ${ store } — ${ classification . plan . length } name(s), encrypted to ${ recipient } ` ,
) ;
return 0 ;
}
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
if ( command === "inventory" ) {
const { values , positionals } = parseArgs ( {
args : rest ,
allowPositionals : true ,
options : {
env : { type : "string" } ,
state : { type : "string" } ,
path : { type : "string" } ,
project : { type : "string" } ,
environment : { type : "string" } ,
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
resource : { type : "string" , multiple : true } ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
instance : { type : "string" } ,
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
"emit-draft" : { type : "string" } ,
recipient : { type : "string" } ,
"no-secrets" : { type : "boolean" , default : false } ,
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
} ,
} ) ;
const orgRepo = positionals [ 0 ] ;
const envName = values . env ;
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.
cast inventory --env prod --instance box-b # no repo → sweep
Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.
Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:
on the box, NOT in the manifest
(none)
5 difference(s) between the manifest and this box.
Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.
The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.
Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.
The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.
npm run check + build clean; 173 tests passing (was 169).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
if ( ! envName ) {
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
console . error ( USAGE ) ;
return 2 ;
}
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
const draftDir = values [ "emit-draft" ] ;
// A draft is emitted from the SWEEP, and only from the sweep. With a repo,
// inventory is reconciling against a manifest that already exists — which is
// exactly the case where a draft must not be written: for a declared project
// the manifest IS the truth, and one regenerated from a live box would let
// that box's accumulated cruft overwrite the reviewed spec. Adoption is
// one-way, so the two flags cannot be combined at all.
if ( draftDir && orgRepo ) {
console . error ( renderRepoWithDraft ( orgRepo , draftDir ) ) ;
return 2 ;
}
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
const stateDir = stateDirFrom ( values . state ) ;
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.
cast inventory --env prod --instance box-b # no repo → sweep
Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.
Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:
on the box, NOT in the manifest
(none)
5 difference(s) between the manifest and this box.
Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.
The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.
Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.
The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.
npm run check + build clean; 173 tests passing (was 169).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
const sweepBindings = loadBindings ( join ( stateDir , "environments.yaml" ) ) ;
const sweepBinding = sweepBindings . environments [ envName ] ;
if ( ! sweepBinding ) {
console . error ( ` environment ${ envName } not in environments.yaml ` ) ;
return 2 ;
}
// NO REPO → SWEEP. There is nothing to reconcile against, so don't pretend
// to: just show what is on the box. This is the pass that has to come first
// when the box is one you did not build, and requiring coordinates for it
// made inventory a discovery verb that needed you to have already
// discovered.
if ( ! orgRepo ) {
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
// Both refusals BEFORE the first live call. A draft that is going to be
// refused should be refused before an operator watches a whole instance be
// swept for it — and, more to the point, before cast reads every env var on
// a box it is then not going to write down.
const recipient = values . recipient ? ? sweepBinding . age_recipient ;
if ( draftDir ) {
try {
assertEmptyTarget ( draftDir ) ;
} catch ( err ) {
console . error ( err instanceof Error ? err.message : String ( err ) ) ;
return 2 ;
}
// No recipient, no store — and cast will not make that decision quietly.
// Silently skipping the secrets would emit a draft that LOOKS complete: a
// manifest, templates full of ${REF}s, and nothing anywhere holding a
// single value. You would find out when `apply` refused, having already
// deleted the box the values were on.
if ( ! recipient && ! values [ "no-secrets" ] ) {
console . error ( renderNoRecipient ( envName ) ) ;
return 2 ;
}
}
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.
cast inventory --env prod --instance box-b # no repo → sweep
Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.
Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:
on the box, NOT in the manifest
(none)
5 difference(s) between the manifest and this box.
Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.
The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.
Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.
The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.
npm run check + build clean; 173 tests passing (was 169).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
const { instance , client } = openCoolify (
stateDir ,
values . instance ,
sweepBinding ,
) ;
// The team assert matters MORE here than anywhere: Coolify scopes what a
// token can see to its team, so a wrong-team token sweeps an instance and
// truthfully reports that it is empty.
const team = await assertTeam ( client , sweepBinding . team , envName ) ;
console . log ( ` team ${ formatTeam ( team ) } ✓ ` ) ;
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
const live = await client . projects ( ) ;
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.
cast inventory --env prod --instance box-b # no repo → sweep
Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.
Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:
on the box, NOT in the manifest
(none)
5 difference(s) between the manifest and this box.
Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.
The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.
Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.
The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.
npm run check + build clean; 173 tests passing (was 169).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
const projects : SweepProject [ ] = [ ] ;
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
for ( const p of live ) {
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.
cast inventory --env prod --instance box-b # no repo → sweep
Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.
Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:
on the box, NOT in the manifest
(none)
5 difference(s) between the manifest and this box.
Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.
The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.
Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.
The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.
npm run check + build clean; 173 tests passing (was 169).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
const environments : SweepEnvironment [ ] = [ ] ;
for ( const name of await client . environments ( p . uuid ) ) {
const found = await fetchLive ( client , p . name , name ) ;
environments . push ( {
name ,
resources : found.found
? found . live . map ( ( l ) = > ( { kind : l.kind , name : l.name } ) )
: [ ] ,
} ) ;
}
projects . push ( { name : p.name , environments } ) ;
}
console . log (
renderSweep ( projects , {
instance : instance.name ,
baseUrl : instance.baseUrl ,
} ) ,
) ;
feat: emit a draft of what a box holds — a proposal, never desired state (#27)
`cast inventory` could already see a whole instance (#22). It can now write
down what it sees, in the shape of cast's own inputs:
cast inventory --env prod --instance box-b --emit-draft ./draft
draft/
environments.yaml # bindings as far as they can be read — with the projects: registry (#25)
incubator/.infra/manifest.yaml # one per project
incubator/.infra/env/*.env.template
la-familia/.infra/manifest.yaml # …including the client sites nobody ever declared
secrets/<project>.<env>.env.age # encrypted to a recipient you name
UNCAPTURED.md # ← the important file
Two uses: bootstrapping a project that has no manifest (the third-party sites
on the box being drained were never declared, and never will be unless
something writes the first draft), and a point-in-time blueprint.
A DRAFT IS A PROPOSAL. It is never desired state, and `apply` never reads it:
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL, and the boundary is enforced, not
merely documented. It never emits into a repo that already has a manifest —
for a declared project the manifest IS the truth, and one regenerated from a
live box would let that box's accumulated cruft overwrite a reviewed spec, in
the one direction nobody reviews. Adoption is one-way. So: a non-empty target
refuses, a manifest at the path it would write refuses, and --emit-draft with
a repo positional refuses (that is the reconcile path, and it is exactly the
case where a draft must not be written).
Two things would make a draft actively dangerous, and both are the point:
1. COPIED PROVIDER-GENERATED VALUES. A DATABASE_URL read off the source points
at the SOURCE box's Postgres; rebuild elsewhere and the new box comes up
WORKING, reading and writing the old box's database, and you find out the
day the old box is deleted. So the draft applies capture's discipline: a
provider-generated name is placeheld with the same GENERATED_PLACEHOLDER
literal, its live value is written into no artifact, and the emitted
manifest declares it under generated_secrets: so a later capture placeholds
it again with no flag to remember. The rule is by NAME — Coolify's SERVICE_*
magic vars, and any name carrying a datastore word and a connection word —
and it errs wide, because over-matching a real secret is loud and
recoverable while under-matching a generated one is silent and is not.
Every other var becomes a ${REF} with its value in the age store, never a
literal in a committed file: cast cannot know which of a box's vars are
secret, and a live key written as a literal is a key in a git repo.
2. SILENT LOSSES. UNCAPTURED.md is a first-class output, emitted on every run:
per resource, every live setting cast saw and could not express —
destinations (#21), service hostnames, Basic Auth/Traefik labels, backup
schedules, database kinds cast does not model, env names a template cannot
hold — plus what no API in 4.1.2 will tell it, and the table of what a
blueprint still cannot restore (the GitHub App private key and the S3 keys:
re-create by hand). A blueprint that omits these without saying so is worse
than no blueprint, because in a disaster you would trust it and rebuild a
different box.
Secrets are encrypted to a recipient you NAME (--recipient, or the
environment's age_recipient binding). With neither, cast refuses rather than
quietly emitting a draft that looks complete and holds not one value;
--no-secrets says so deliberately. A project with resources in two populated
environments is a tie cast will not break — it refuses, and --environment says
which, as a tiebreak rather than a filter (filtering by name would drop the
client sites, each alone in Coolify's default `production`, out of a blueprint
that claims to describe the box).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:32:25 +00:00
if ( ! draftDir ) return 0 ;
// --- The draft (#27) ---
//
// The sweep above is a DOCUMENT. This is the same reading, written into the
// shape of cast's own inputs — and it is still a proposal, not desired
// state. See draft.ts for the boundary that lets this verb exist at all.
//
// A project with resources in TWO environments cannot be drafted without
// picking one, and cast does not pick: a blueprint of half a box, silently
// chosen, is the failure mode this whole issue is about. --environment says
// which.
//
// --environment is a TIEBREAK here, not a filter. A project with resources
// in exactly one environment has no tie to break, and is drafted from it
// whatever the flag says — filtering the instance by an environment NAME
// would drop the projects that most need drafting (the third-party sites,
// each sitting in its own Coolify-default `production`) out of a blueprint
// that claims to describe the box.
const populatedIn = ( p : SweepProject ) = >
p . environments . filter ( ( e ) = > e . resources . length > 0 ) ;
const pick = ( p : SweepProject ) = > {
const populated = populatedIn ( p ) ;
return populated . length === 1
? populated [ 0 ]
: populated . find ( ( e ) = > e . name === values . environment ) ;
} ;
const ambiguous = projects . filter (
( p ) = > populatedIn ( p ) . length > 1 && ! pick ( p ) ,
) ;
if ( ambiguous . length > 0 ) {
console . error (
renderAmbiguousEnvironments (
ambiguous . map ( ( p ) = > ( {
name : p.name ,
environments : populatedIn ( p ) . map (
( e ) = > ` ${ e . name } ( ${ e . resources . length } ) ` ,
) ,
} ) ) ,
envName ,
) ,
) ;
return 2 ;
}
const draftProjects : DraftProject [ ] = [ ] ;
for ( const p of live ) {
const swept = projects . find ( ( s ) = > s . name === p . name ) ;
const populated = swept ? populatedIn ( swept ) : [ ] ;
const chosen = swept ? pick ( swept ) : undefined ;
const others = populated
. filter ( ( e ) = > e . name !== chosen ? . name )
. map ( ( e ) = > ( { name : e.name , resources : e.resources.length } ) ) ;
if ( ! chosen ) {
// Not drafted — and SAID, in UNCAPTURED.md, rather than left out of a
// blueprint that a reader would take for the whole box.
draftProjects . push ( {
name : p.name ,
coolifyEnv : "(none)" ,
resources : [ ] ,
unreadable : [ ] ,
otherEnvironments : others ,
skipReason : "every environment on it is empty" ,
} ) ;
continue ;
}
// The RAW environment document, not fetchLive's projection: the uncaptured
// pass's whole job is to notice fields cast has no home for, and it cannot
// notice what a projection has already thrown away.
const raw = ( await client . get (
` /projects/ ${ p . uuid } / ${ chosen . name } ` ,
) ) as Record < string , unknown > | null ;
const { resources , unreadable } = draftResourcesFrom ( raw ? ? { } ) ;
for ( const r of resources ) {
// Databases hold no manifest-templated env of their own — their URL is
// what the APPS reference, and that name is generated, not captured.
if ( r . kind === "database" ) continue ;
r . env = await fetchEnv ( client , { kind : r.kind , uuid : r.uuid } ) ;
}
draftProjects . push ( {
name : p.name ,
coolifyEnv : chosen.name ,
resources ,
unreadable ,
otherEnvironments : others ,
} ) ;
}
// Which GitHub App clones a repo is NOT a property of any resource — no
// field Coolify returns about an application says so. What the instance can
// answer is which Apps exist; with exactly one, there is no other it could
// be. Best-effort: an instance that will not list them still gets a draft,
// with a REVIEW marker where the binding goes.
const githubApps = ( await client . get ( "/github-apps" ) . catch ( ( ) = > [ ] ) ) as
| Array < { name? : unknown } >
| undefined ;
const draftCtx = {
env : envName ,
instance : instance.name ,
baseUrl : instance.baseUrl ,
team ,
server : sweepBinding.server ,
githubApps : ( Array . isArray ( githubApps ) ? githubApps : [ ] )
. map ( ( a ) = > a ? . name )
. filter ( ( n ) : n is string = > typeof n === "string" ) ,
recipient ,
generatedAt : new Date ( ) . toISOString ( ) ,
} ;
const storeRecipient = values [ "no-secrets" ] ? undefined : recipient ;
const plan = planDraft ( draftProjects , draftCtx ) ;
const written = emitDraft ( draftDir , plan , { recipient : storeRecipient } ) ;
console . log (
renderDraftPlan ( plan , draftCtx , {
dir : draftDir ,
recipient : storeRecipient ,
written ,
} ) ,
) ;
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.
cast inventory --env prod --instance box-b # no repo → sweep
Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.
Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:
on the box, NOT in the manifest
(none)
5 difference(s) between the manifest and this box.
Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.
The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.
Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.
The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.
npm run check + build clean; 173 tests passing (was 169).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
return 0 ;
}
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
const repoShort = orgRepo . split ( "/" ) [ 1 ] ;
const projectName = values . project ? ? repoShort ;
const coolifyEnv = values . environment ? ? envName ;
const bindings = loadBindings ( join ( stateDir , "environments.yaml" ) ) ;
const binding = bindings . environments [ envName ] ;
if ( ! binding ) {
console . error ( ` environment ${ envName } not in environments.yaml ` ) ;
return 2 ;
}
// Deliberately NOT resolveCheckout's prod ban, no secrets, no age key, no
// age_recipient: inventory runs BEFORE any store exists — that is the whole
// point of it — and it reads nothing it could leak. A read token is enough.
const checkout = resolveCheckout ( orgRepo , {
env : envName ,
path : values.path ,
} ) ;
const manifest = manifestResources ( checkout , envName ) ;
const { client } = openCoolify ( stateDir , values . instance , binding ) ;
// Read-only instances are exactly what this verb is for. It still takes the
// team assert: a wrong-team token reads back nothing, and "nothing" would
// render here as "the box is empty" — the same lie, dressed as a report.
const team = await assertTeam ( client , binding . team , envName ) ;
console . log ( ` team ${ formatTeam ( team ) } ✓ ` ) ;
const lookup = await fetchLive ( client , projectName , coolifyEnv ) ;
if ( ! lookup . found ) {
console . error (
renderAbsentTarget ( lookup , {
orgRepo ,
overridden : values.project !== undefined ,
envOverridden : values.environment !== undefined ,
verb : "inventory" ,
} ) ,
) ;
return 2 ;
}
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
// With --resource, inventory stops reporting "these five are missing / these
// five are unknown" and starts reporting what you actually want to know:
// for each PAIR, which env keys differ. The report keeps the box's own name
// beside ours, because losing it would make the document unusable against
// the UI it describes.
const inventoryAliases = parseResourceAliases (
values . resource ? ? [ ] ,
manifest . map ( ( r ) = > r . name ) ,
) ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
const live : LiveResource [ ] = [ ] ;
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
for ( const l of aliasLive ( lookup . live , inventoryAliases ) ) {
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
// Keys, never values — see renderInventory. Databases carry no env of
// their own worth reconciling (their URL is what the apps reference).
const envKeys =
l . kind === "database" ? [ ] : Object . keys ( await fetchEnv ( client , l ) ) ;
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
live . push ( {
kind : l.kind ,
name : l.name ,
envKeys ,
. . . ( l . sourceName ? { sourceName : l.sourceName } : { } ) ,
} ) ;
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
}
console . log (
renderInventory ( reconcile ( manifest , live ) , {
orgRepo ,
env : envName ,
instance : values.instance ? ? binding . instance ? ? DEFAULT_INSTANCE ,
project : projectName ,
environment : coolifyEnv ,
} ) ,
) ;
// Always 0: this is a report, not a gate. `diff` is the gate.
return 0 ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
if ( command === "server" && rest [ 0 ] === "add" ) {
const { values , positionals } = parseArgs ( {
args : rest.slice ( 1 ) ,
allowPositionals : true ,
options : {
ip : { type : "string" } ,
key : { type : "string" } ,
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
env : { type : "string" } ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
user : { type : "string" } ,
port : { type : "string" } ,
state : { type : "string" } ,
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
instance : { type : "string" } ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
} ,
} ) ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
// --env is required: a server is registered under the token's team and
// belongs to exactly one team forever (Coolify has no pivot and no
// is_system_wide escape hatch for servers). Registering it under the
// wrong team is not a mistake you fix with a PATCH — you delete and
// re-add. So it takes the same assert as every other command, against
// the team of the environment the server is being registered to serve.
if ( ! positionals [ 0 ] || ! values . ip || ! values . key || ! values . env ) {
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
console . error ( USAGE ) ;
return 2 ;
}
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
const stateDir = stateDirFrom ( values . state ) ;
const binding = loadBindings ( join ( stateDir , "environments.yaml" ) )
. environments [ values . env ] ;
if ( ! binding ) {
console . error ( ` environment ${ values . env } not in environments.yaml ` ) ;
return 2 ;
}
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
const { instance , client } = openCoolify (
stateDir ,
values . instance ,
binding ,
) ;
assertWritable ( instance , "server add" ) ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
const team = await assertTeam ( client , binding . team , values . env ) ;
console . log ( ` team ${ formatTeam ( team ) } ✓ ` ) ;
await serverAdd ( client , {
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
name : positionals [ 0 ] ,
ip : values.ip ,
keyFile : values.key ,
user : values.user ,
port : values.port ? Number ( values . port ) : undefined ,
} ) ;
return 0 ;
}
if ( command === "smoke" ) {
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
const { values , positionals } = parseArgs ( {
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
args : rest ,
allowPositionals : true ,
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
options : {
state : { type : "string" } ,
env : { type : "string" } ,
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
project : { type : "string" } ,
environment : { type : "string" } ,
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
instance : { type : "string" } ,
} ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
} ) ;
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
// REQUIRED, like every other verb's — because the repo IS the project, and
// the project is half of the only scope in which the target's name means
// anything (#29). `cast smoke --env prod` with no repo used to work by
// reading the state-file-scoped `smoke_target`, which named an application
// from a key that could not say which project or which environment it was
// in; that key is gone (see BindingsSchema), and so is the invocation.
const orgRepo = positionals [ 0 ] ;
const envName = values . env ;
if ( ! orgRepo || ! envName ) {
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
console . error ( USAGE ) ;
return 2 ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const stateDir = stateDirFrom ( values . state ) ;
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
const repoShort = orgRepo . split ( "/" ) [ 1 ] ;
// The same two read-side coordinates diff/capture/inventory take, for the
// same two reasons: a project built by hand in the UI is called whatever
// someone typed, and an environment built by hand is called whatever Coolify
// defaulted to (`production`, not `prod`). --env still selects the manifest
// block, the environments.yaml binding and the team to assert; --project and
// --environment change ONLY the names cast looks the target up under.
const projectName = values . project ? ? repoShort ;
const coolifyEnv = values . environment ? ? envName ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const bindings = loadBindings ( join ( stateDir , "environments.yaml" ) ) ;
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
const binding = bindings . environments [ envName ] ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
if ( ! binding ) {
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
console . error ( ` environment ${ envName } not in environments.yaml ` ) ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
return 2 ;
}
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
const { instance , client } = openCoolify (
stateDir ,
values . instance ,
binding ,
) ;
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
// smoke writes: it POSTs two env vars onto the live smoke_target app and
// deletes them again. That is a mutation, so it takes both gates — the
// read-only instance refusal and the team assert — before the first call.
// Without the assert, a wrong-team token that happened to own an app of the
// same name would have that app written to instead.
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
assertWritable ( instance , "smoke" ) ;
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
const team = await assertTeam ( client , binding . team , envName ) ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
console . log ( ` team ${ formatTeam ( team ) } ✓ ` ) ;
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
const target = smokeTargetFor ( bindings , envName , orgRepo ) ;
if ( ! target ) {
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
console . error (
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
[
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
` no smoke_target for ${ orgRepo } in ${ envName } ` ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
"" ,
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
` looked for: environments. ${ envName } .projects[" ${ orgRepo } "].smoke_target ` ,
` (a bare " ${ repoShort } " key resolves too) ` ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
"" ,
"`smoke` writes two canary env vars to one application and deletes them" ,
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
"again — it has to be told which one, under the project that owns it:" ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
"" ,
" environments:" ,
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
` ${ envName } : ` ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
" projects:" ,
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
` ${ orgRepo } : ` ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
" smoke_target: <the application's name>" ,
] . join ( "\n" ) ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
) ;
return 2 ;
}
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
// The fix for #29, and the whole of it: the target is resolved in the project
// and the environment it was DECLARED under — the same lookup every read-side
// verb makes — instead of by name against GET /applications, which is every
// application on the instance and answers with whichever one it lists first.
const lookup = await fetchLive ( client , projectName , coolifyEnv ) ;
if ( ! lookup . found ) {
console . error (
renderAbsentTarget ( lookup , {
orgRepo ,
overridden : values.project !== undefined ,
envOverridden : values.environment !== undefined ,
verb : "smoke" ,
} ) ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
) ;
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
return 2 ;
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
}
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
// Applications only. fetchLive returns every kind in the environment, and a
// service or database called `core` is not a smoke target — it is a 404 on an
// endpoint that does not exist for it (see renderAbsentSmokeTarget).
const app = lookup . live . find (
( l ) = > l . kind === "application" && l . name === target ,
) ;
if ( ! app ) {
console . error (
renderAbsentSmokeTarget ( target , lookup . live , {
orgRepo ,
env : envName ,
project : projectName ,
environment : coolifyEnv ,
} ) ,
) ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
return 2 ;
}
fix: smoke resolves its target inside the project it was declared under (#29)
`smoke` found the application it WRITES to by name against GET /applications —
every app the token can see, across every project and every environment on the
instance — and took the first name match. So `smoke_target: core` did not name
an application; it named whichever `core` Coolify happened to list first. One
instance carrying prod and staging is enough for `cast smoke --env staging` to
POST its canary vars onto prod's `core`, and on the failure path leave them
there.
It now resolves the target through fetchLive(project, environment) — the same
lookup every read-side verb makes — and takes the coordinates that lookup needs:
--project and --environment, with diff/capture/inventory's semantics and
defaults. An application that is not in that project + environment is not an
empty result, it is the absence of anything to write to, so smoke refuses:
naming what it looked for, where the name came from, and what is actually there
(including when the name belongs to a service or a database, which would 404 on
the /envs endpoint smoke writes to).
The <org>/<repo> positional is now REQUIRED, and the deprecated state-file-scoped
`smoke_target` is dropped: it named an app from a key with no project to scope
to, so it could not be fixed, only carried. It is still declared in the schema —
refused with a migration message rather than a strict-mode "unrecognized key",
because loadBindings runs for every verb and an unmigrated state file must not
take `diff` and `apply` down with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:06:29 +00:00
await smoke ( client , app . uuid ) ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
return 0 ;
}
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
if ( command === "team" ) {
const { values } = parseArgs ( {
args : rest ,
allowPositionals : true ,
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
options : {
state : { type : "string" } ,
env : { type : "string" } ,
instance : { type : "string" } ,
} ,
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
} ) ;
const stateDir = stateDirFrom ( values . state ) ;
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
// Bindings first, but only when --env was given: an environment's
// `instance:` binding is what selects the Coolify to ask. Without --env
// there is no binding to read (and deliberately so — see below), so the
// flag or the default file decides.
const binding = values . env
? loadBindings ( join ( stateDir , "environments.yaml" ) ) . environments [
values . env
]
: undefined ;
if ( values . env && ! binding ) {
console . error ( ` environment ${ values . env } not in environments.yaml ` ) ;
return 2 ;
}
const { client } = openCoolify ( stateDir , values . instance , binding ) ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
// Read-only, and the one command that deliberately does NOT require a
// team binding: it is how you discover the values to write into
// environments.yaml in the first place. Asserting here would be circular.
// With --env it also checks the binding, which makes it the dry run for
// "will apply refuse?" — ask the question without touching anything.
const actual = await client . currentTeam ( ) ;
console . log ( ` token's team: ${ formatTeam ( actual ) } ` ) ;
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
if ( ! values . env || ! binding ) return 0 ;
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
await assertTeam ( client , binding . team , values . env ) ;
console . log ( ` matches the team ${ values . env } expects ✓ ` ) ;
return 0 ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
console . error ( USAGE ) ;
return 2 ;
}
async function resolveOrCreateProject (
client : CoolifyClient ,
name : string ,
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
) : Promise < { uuid : string ; created : boolean } > {
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
try {
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
return { uuid : await client . projectUuid ( name ) , created : false } ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
} catch ( err ) {
// projectUuid's resolver-miss (CoolifyClient.resolve) throws this exact
// message with no `status` — that's the only case we treat as "create
// it"; a 401/5xx/network failure must surface, not fall through to a
// duplicate-create attempt.
if (
err instanceof Error &&
err . message === ` not found in Coolify: project ${ name } `
) {
const p = ( await client . post ( "/projects" , { name } ) ) as { uuid : string } ;
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
return { uuid : p.uuid , created : true } ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
}
throw err ;
}
}
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
// The environment every resource create names in `environment_name` has to
// EXIST before the create, and cast is the only thing that can be relied on to
// make it so: POST /projects gives a new project Coolify's OWN default
// environment ("production"), not ours, so the first apply against a project
// cast itself created 404s on the first resource — "Environment not found" —
// with the project left behind, created and empty (#38).
//
// It went unseen for as long as it did because every environment cast had met
// until then was built by hand in a UI and adopted, so it already existed under
// whatever name someone typed — which is the same history that put `--environment`
// in the tool. The genuinely-from-nothing apply is the one path nobody had run.
//
// Idempotent by construction, so it is safe on EVERY apply and not just the
// first: absent -> create, present -> nothing. Reading before writing is also
// what keeps this change from being able to break an apply that works TODAY —
// an environment that already exists (every environment cast has ever touched)
// takes the read and stops, and the create route is never called at all. The
// 409 is the same answer as "present" (Coolify's create-environment 409s on a
// duplicate name), reached when something else wins the race between our read
// and our write.
//
// Coolify's own default environment is left exactly where it is: cast does not
// remove things (see renderDiff — an orphan is reported and NOT repaired,
// "removal is a manual runbook act"), and an empty `production` beside the
// environment everything lives in is the mildest possible case of that. It is
// reported for the same reason an orphan is: so the operator knows, and decides.
async function ensureEnvironment (
client : CoolifyClient ,
projectUuid : string ,
projectName : string ,
envName : string ,
projectWasCreated : boolean ,
) : Promise < void > {
// The read that decides. On a project cast just created, it is also the list
// of environments Coolify gave it by itself — which is what `strays` reports.
const existing = await client . environments ( projectUuid ) ;
if ( ! existing . includes ( envName ) ) {
try {
await client . post ( ` /projects/ ${ projectUuid } /environments ` , {
name : envName ,
} ) ;
} catch ( err ) {
if ( ! ( err instanceof HttpError ) || err . status !== 409 ) throw err ;
}
}
const strays = projectWasCreated ? existing . filter ( ( e ) = > e !== envName ) : [ ] ;
if ( strays . length > 0 ) {
console . log (
` note: new project ${ projectName } carries Coolify's default environment(s): ${ strays . join ( ", " ) } — empty, unused, and cast never removes (delete by hand if unwanted) ` ,
) ;
}
}
// Project + environment, reconciled once per run and then remembered — the pair
// a resource create has to name before it can name anything else.
function projectEnvironmentResolver (
client : CoolifyClient ,
projectName : string ,
envName : string ,
) : ( ) = > Promise < string > {
let once : Promise < string > | undefined ;
return ( ) = > {
once ? ? = ( async ( ) = > {
const { uuid , created } = await resolveOrCreateProject (
client ,
projectName ,
) ;
await ensureEnvironment ( client , uuid , projectName , envName , created ) ;
return uuid ;
} ) ( ) ;
return once ;
} ;
}
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
// --- Desired-vocabulary -> Coolify wire-vocabulary field mapping ---
//
// `fields` (from Desired/Change) speaks the internal vocabulary used for
// diffing (see resolve.ts / diff.ts): `port`, `healthcheck`, `domains`
// (array), `type`, `version`. Coolify's actual create/update payloads use
// different field names and shapes for some of these (verified against
// reference/coolify-openapi-4.1.2.json requestBody schemas for
// /applications/private-github-app, PATCH /applications/{uuid},
// /databases/postgresql, /databases/redis, PATCH /databases/{uuid},
// POST/PATCH /services) — spreading `fields` straight into the request body
// (as an earlier draft of this executor did) would silently drop
// healthcheck/domains updates and leak unrecognized type/version keys into
// database creates. These helpers do the translation once, shared by
// createResource and updateFields.
export function applicationApiFields (
fields : Record < string , unknown > ,
) : Record < string , unknown > {
const { port , healthcheck , domains , docker_compose_domains , . . . rest } =
fields ;
return {
. . . rest ,
// ports_exposes wants a string; healthcheck -> health_check_path;
// domains wants a comma-separated string, not an array.
. . . ( port !== undefined ? { ports_exposes : String ( port ) } : { } ) ,
. . . ( healthcheck !== undefined ? { health_check_path : healthcheck } : { } ) ,
. . . ( domains !== undefined
? { domains : Array.isArray ( domains ) ? domains . join ( "," ) : domains }
: { } ) ,
// docker_compose_domains speaks the internal map vocabulary
// (service -> string[]); the wire shape is an array of
// {name, domain} where domain is that array comma-joined (verified
// against the /applications/private-github-app + PATCH /applications
// request schemas, ~line 353 of the vendored OpenAPI).
. . . ( docker_compose_domains !== undefined
? {
docker_compose_domains : Object.entries (
docker_compose_domains as Record < string , string [ ] > ,
) . map ( ( [ name , urls ] ) = > ( { name , domain : urls.join ( "," ) } ) ) ,
}
: { } ) ,
} ;
}
export function defaultDatabaseImage ( type : string , version : string ) : string {
// Verified against coollabsio/coolify v4.1.2 source
// (resources/views/livewire/project/new/select.blade.php +
// app/Livewire/Project/New/Select.php): the "New Resource" wizard's
// PostgreSQL version picker calls setPostgresqlType('postgres:{v}-alpine')
// for each offered version. Redis has no version picker in that wizard —
// this half of the mapping extrapolates the same Docker Hub tag
// convention and is UNVERIFIED against a live instance (see task-8-report.md).
const repo = type === "postgresql" ? "postgres" : "redis" ;
return ` ${ repo } : ${ version } -alpine ` ;
}
export function databaseApiFields (
fields : Record < string , unknown > ,
) : Record < string , unknown > {
// /databases/postgresql and /databases/redis accept no `type` param (the
// endpoint path already encodes it) and no `version` param at all — only
// `image`, a literal Docker image string.
const { type , version , . . . rest } = fields ;
return {
. . . rest ,
. . . ( typeof version === "string"
? { image : defaultDatabaseImage ( String ( type ) , version ) }
: { } ) ,
} ;
}
export function serviceApiFields (
fields : Record < string , unknown > ,
) : Record < string , unknown > {
// /services accepts `urls`, a structured per-container list
// ({name, url}[]), not the flat `domains` string list the manifest
// speaks. manifest.ts's ServiceSpecSchema has no notion of per-container
// name, so we can't build a correct `urls` payload from `domains` alone —
// dropped rather than sent malformed. Known limitation: service hostnames
// need manual Coolify UI configuration (see README, Task 10).
const { domains : _domains , . . . rest } = fields ;
return rest ;
}
export function buildExecutor (
client : CoolifyClient ,
ctx : {
projectName : string ;
envName : string ;
serverUuid : string ;
githubAppUuid : string ;
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
// The Docker network to create resources on. A raw UUID from
// environments.yaml for the same reason s3DestinationUuid is one: Coolify
// 4.1.2 has no destinations API, so there is no name for cast to resolve.
//
// Create-time ONLY, and every kind gets it (Coolify's three controllers run
// identical destination logic). Undefined means "the server's only
// destination", which is what Coolify picks anyway — and, until a server
// hosts two projects, is the right answer.
destinationUuid? : string ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
s3DestinationUuid? : string ; // raw UUID from environments.yaml — no storage API exists to resolve names
backupSchedules : Record < string , { frequency : string ; retention : number } > ;
} ,
) : Executor {
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
// Coolify resolves this identically for applications, databases and services
// (ApplicationsController ~1003, DatabasesController ~1700,
// ServicesController ~378 @ v4.1.2):
//
// 0 destinations -> 400, whatever we send
// >1 and no destination_uuid -> 400 "Server has multiple destinations and
// you do not set destination_uuid"
// >1 and a foreign uuid -> 422 "does not belong to the specified server"
// exactly 1 -> $destinations->first(), and anything we
// send here is IGNORED, not validated
//
// So this field is what makes cast able to create resources on a server that
// has more than one destination AT ALL — without it, apply simply 400s there,
// which is the state of things before this change. On a single-destination
// server it is inert (and so, note, a WRONG uuid is silently accepted there —
// nothing on either side can catch that; see renderDiff's placement note).
const destination = ctx . destinationUuid
? { destination_uuid : ctx.destinationUuid }
: { } ;
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
// Lazy, so a run with nothing to create touches neither route, and memoized,
// so a run with five creates reconciles the project and its environment once
// rather than five times.
const projectEnv = projectEnvironmentResolver (
client ,
ctx . projectName ,
ctx . envName ,
) ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
return {
async createResource ( change ) {
// Field payloads assembled from change.fieldDiffs (desired values):
const fields = Object . fromEntries (
change . fieldDiffs . map ( ( f ) = > [ f . field , f . desired ] ) ,
) ;
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
const projectUuid = await projectEnv ( ) ;
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
if ( change . kind === "application" ) {
const res = ( await client . post ( "/applications/private-github-app" , {
project_uuid : projectUuid ,
environment_name : ctx.envName ,
server_uuid : ctx.serverUuid ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
. . . destination ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
github_app_uuid : ctx.githubAppUuid ,
name : change.name ,
instant_deploy : false ,
. . . applicationApiFields ( fields ) ,
// A compose stack must reach the managed Postgres/Redis resources
// (the box-B lesson, DEPLOY.md §0/§3) — Coolify only wires that up
// when this flag is set on create.
. . . ( fields . build_pack === "dockercompose"
? { connect_to_docker_network : true }
: { } ) ,
} ) ) as { uuid : string } ;
return res . uuid ;
}
if ( change . kind === "database" ) {
const type = String ( fields . type ) ;
const res = ( await client . post (
` /databases/ ${ type === "postgresql" ? "postgresql" : "redis" } ` ,
{
project_uuid : projectUuid ,
environment_name : ctx.envName ,
server_uuid : ctx.serverUuid ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
. . . destination ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
name : change.name ,
. . . databaseApiFields ( fields ) ,
} ,
) ) as { uuid : string } ;
const schedule = ctx . backupSchedules [ change . name ] ;
if ( schedule ) {
if ( ! ctx . s3DestinationUuid ) {
throw new Error (
` database ${ change . name } declares a backup schedule but environments.yaml has no s3_destination UUID for this environment ` ,
) ;
}
await client . post ( ` /databases/ ${ res . uuid } /backups ` , {
frequency : schedule.frequency ,
database_backup_retention_amount_locally : schedule.retention ,
save_s3 : true ,
s3_storage_uuid : ctx.s3DestinationUuid ,
} ) ;
}
return res . uuid ;
}
const res = ( await client . post ( "/services" , {
project_uuid : projectUuid ,
environment_name : ctx.envName ,
server_uuid : ctx.serverUuid ,
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
. . . destination ,
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
name : change.name ,
. . . serviceApiFields ( fields ) ,
} ) ) as { uuid : string } ;
return res . uuid ;
} ,
async updateFields ( uuid , kind , fields ) {
const base =
kind === "application"
? "applications"
: kind === "database"
? "databases"
: "services" ;
const apiFields =
kind === "application"
? applicationApiFields ( fields )
: kind === "service"
? serviceApiFields ( fields )
: databaseApiFields ( fields ) ;
await client . patch ( ` / ${ base } / ${ uuid } ` , apiFields ) ;
} ,
async syncEnv ( uuid , kind , env ) {
// Bulk env update is an UPSERT of listed keys, not a full replace —
// verified against app/Http/Controllers/Api/{Applications,Databases,
// Services}Controller.php@create_bulk_envs (coollabsio/coolify
// v4.1.2): each item is found-by-key-and-updated or created; no
// deletion of unlisted keys occurs (audit event is literally named
// "*.env_bulk_upserted"). Safe under the iron rule that apply never
// deletes — no need to fall back to per-key create-or-update calls.
const base =
kind === "application"
? "applications"
: kind === "database"
? "databases"
: "services" ;
await client . patch ( ` / ${ base } / ${ uuid } /envs/bulk ` , {
data : Object.entries ( env . vars ) . map ( ( [ key , v ] ) = > ( {
key ,
value : v.value ,
is_buildtime : false ,
is_preview : false ,
} ) ) ,
} ) ;
} ,
async redeploy ( uuid , kind ) {
if ( kind === "service" ) await client . restart ( uuid ) ;
else await client . deploy ( uuid ) ;
} ,
} ;
}
// Guard the entrypoint so `test/wire.test.ts` can import the pure
// translation helpers above without executing the CLI (parseArgs against
// vitest's argv, process.exit mid-test-run, etc). Only runs main() when
// this file is the process entrypoint (`node dist/cli.js ...`), not when
// imported as a module.
if ( import . meta . url === ` file:// ${ process . argv [ 1 ] } ` ) {
main ( ) . then (
( code ) = > process . exit ( code ) ,
( err ) = > {
console . error ( err instanceof Error ? err.message : String ( err ) ) ;
process . exit ( 1 ) ;
} ,
) ;
}