Merged
dan-claude-bot
merged 3 commits from 2026-07-13 16:54:16 +00:00
fix/clone-auth-instances-capture into main
3 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 3eee70fa77 |
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>
|
|||
| e457261436 |
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> |
|||
| 79834369b1 |
fix: authenticate clones via gh / token, never fall into git's prompt (#13)
resolveCheckout shelled out to a bare `git clone` and relied entirely on the ambient credential helper. On a workstation with none configured, git falls through to its interactive username/password prompt — which GitHub no longer accepts — and the resulting error talks about *the repository* rather than about cast's missing credentials. Being logged into `gh` does not help: `gh auth login` alone does not wire git's helper (that is `gh auth setup-git`, a separate act most people never run). Not routable around for prod: resolveCheckout refuses --path with --env prod, so the clone is the only path and its auth is mandatory. cast now resolves credentials itself, in order: `gh` borrowed as a per-invocation credential helper (no mutation of the user's global git config), then GITHUB_TOKEN / GH_TOKEN, then the ambient helper. The token is never embedded in the clone URL or in http.extraheader — both leak it into `ps`, and the latter persists it into the clone's git config. The helper reads it from the environment at run time, so what lands in argv is the literal text `$CAST_GIT_TOKEN`, never its value. GIT_TERMINAL_PROMPT=0 on every path: whichever credential was used, git may never fall through to a prompt it cannot satisfy — it can only hang, or hide the real fault. When there were no credentials at all, cast now says so, and names the fix. Note that the empty `credential.helper=` reset clears URL-scoped helpers (`credential.https://github.com.helper`, what `gh auth setup-git` writes) as well as generic ones — verified against a live private clone, along with all three acceptance criteria. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |