fix clone auth (#13); named Coolify instances (#14); cast capture (#15) #16

Merged
dan-claude-bot merged 3 commits from fix/clone-auth-instances-capture into main 2026-07-13 16:54:16 +00:00
dan-claude-bot commented 2026-07-13 16:52:18 +00:00 (Migrated from github.com)

Closes #13, closes #14, closes #15. Three commits, in that order — each stands alone, and the later two lean on the earlier ones (capture clones a private manifest, and takes --instance).

#13 — clones authenticate, and never prompt

resolveCheckout shelled out to a bare git clone and relied entirely on the ambient credential helper. With none configured, git falls through to its interactive username/password prompt — which GitHub no longer accepts — and the error talks about the repository rather than about cast's missing credentials. Being logged into gh doesn't help: gh auth login alone does not wire git's helper (that's gh auth setup-git, a separate act most people never run). Not routable around for prod, where --path is refused and the clone is the only path.

cast now resolves credentials itself: gh borrowed as a per-invocation helper (no mutation of your global git config), then GITHUB_TOKEN/GH_TOKEN, then the ambient helper. GIT_TERMINAL_PROMPT=0 on every path. The token is never in the clone URL or http.extraheader — the helper reads it from the environment at run time, so what lands in argv is the literal text $CAST_GIT_TOKEN.

Verified against a live private clone, not just unit tests — this box turned out to be the repro condition (gh logged in, no generic helper):

  • gh logged in → private repo clones ✓
  • GITHUB_TOKEN only, no gh → clones ✓
  • no credentials at all → fails in 353ms with terminal prompts disabled and an actionable message ✓

One thing worth knowing, since it's load-bearing and I checked it empirically rather than assuming: the empty credential.helper= reset clears URL-scoped helpers (credential.https://github.com.helper — what gh auth setup-git writes), not just generic ones. So cast's chosen credential is genuinely the one used.

#14 — pick the Coolify by name, don't edit .coolify.env

The connection target was implicit in a file's current contents. Retargeting meant hand-editing a live credential file and putting it back — and the failure mode of getting that wrong is running apply against production.

  • <state>/.coolify/<name>.env + --instance <name> on every verb that reaches Coolify.
  • environments.yaml can bind one per environment (instance: prod-cp), so --env selects the right control plane with no flag and no file edit. An explicit --instance still wins.
  • An unknown --instance refuses, naming the ones that exist — same spirit as #12/D-237. Falling back to the default is how a diff meant for a legacy box gets run against prod.
  • COOLIFY_READ_ONLY=true on an instance makes apply/smoke/server add refuse it before their first call, even though the token would permit the writes. The guard is the declaration, not the token's scope.
  • Every command that reaches a Coolify now says which one, next to the team assert.

With no flag 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 console output.

#15cast capture

The adoption path cast never had. Reads the required secret names from the manifest's own env templates, reads live values off the instance, and forces every name into a disposition:

captured found live, value taken
generated manifest's generated_secrets → literal pending-coolify-generated, never the live value
overridden $CAST_CAPTURE_<NAME>
missing / conflict refuses

The mapping is deliberately not mechanical — that's the design. A DATABASE_URL copied off the source points at the source box's Postgres: confidently wrong, entirely plausible, and the target's real URL doesn't exist until Coolify creates the resource. generated_secrets is a manifest property rather than a flag you have to 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 still reads like a guard, and the likeliest cause is a typo whose real name then gets captured instead of placeheld.

Hygiene, each covered by a test asserting on real values: the plan prints names and provenance, never values; an override's value comes from the environment, never argv (ps); plaintext is piped to age on stdin (never a temp file, stdout, or shell history); an existing store isn't overwritten without --force. It inherits diff's absent-target refusal, the team assert, and the --path/--env prod ban. 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.

Two things for the reviewer

  1. #15 said "deliberately not urgent — file now, build when it isn't load-bearing." This does not change the prod-migration plan: Task 7's secrets capture is still the attended manual pass unless you decide otherwise. Using capture for it would put new code on the migration's critical path, which is the trade the issue argued against.
  2. capture can't be used on incubator prod until .infra/manifest.yaml declares generated_secrets (DATABASE_URL_PROD, REDIS_URL_PROD, UMAMI_DATABASE_URL) and environments.yaml gains age_recipient. Both live in other repos, so they're not in this PR. Without them capture refuses (no recipient) rather than doing anything wrong — but it's worth knowing it isn't drop-in yet.

Checks

npm run check (biome), npm run build (tsc), npm test151 passing, 16 files (was 118/14). Installer scripts pass bash -n.

🤖 Generated with Claude Code

Closes #13, closes #14, closes #15. Three commits, in that order — each stands alone, and the later two lean on the earlier ones (`capture` clones a private manifest, and takes `--instance`). ## #13 — clones authenticate, and never prompt `resolveCheckout` shelled out to a bare `git clone` and relied entirely on the ambient credential helper. With none configured, git falls through to its interactive username/password prompt — which GitHub no longer accepts — and the error talks about *the repository* rather than about cast's missing credentials. Being logged into `gh` doesn't help: `gh auth login` alone does not wire git's helper (that's `gh auth setup-git`, a separate act most people never run). Not routable around for prod, where `--path` is refused and the clone is the only path. cast now resolves credentials itself: `gh` borrowed as a **per-invocation** helper (no mutation of your global git config), then `GITHUB_TOKEN`/`GH_TOKEN`, then the ambient helper. `GIT_TERMINAL_PROMPT=0` on every path. The token is never in the clone URL or `http.extraheader` — the helper reads it from the environment at run time, so what lands in argv is the literal text `$CAST_GIT_TOKEN`. **Verified against a live private clone**, not just unit tests — this box turned out to be the repro condition (gh logged in, no *generic* helper): - gh logged in → private repo clones ✓ - `GITHUB_TOKEN` only, no gh → clones ✓ - no credentials at all → fails in **353ms** with `terminal prompts disabled` and an actionable message ✓ One thing worth knowing, since it's load-bearing and I checked it empirically rather than assuming: the empty `credential.helper=` reset clears **URL-scoped** helpers (`credential.https://github.com.helper` — what `gh auth setup-git` writes), not just generic ones. So cast's chosen credential is genuinely the one used. ## #14 — pick the Coolify by name, don't edit `.coolify.env` The connection target was implicit in a file's current contents. Retargeting meant hand-editing a live credential file and putting it back — and the failure mode of getting that wrong is running `apply` against production. - `<state>/.coolify/<name>.env` + `--instance <name>` on every verb that reaches Coolify. - `environments.yaml` can bind one per environment (`instance: prod-cp`), so `--env` selects the right control plane with **no flag and no file edit**. An explicit `--instance` still wins. - **An unknown `--instance` refuses**, naming the ones that exist — same spirit as #12/D-237. Falling back to the default is how a diff meant for a legacy box gets run against prod. - **`COOLIFY_READ_ONLY=true`** on an instance makes `apply`/`smoke`/`server add` refuse it *before their first call*, even though the token would permit the writes. The guard is the declaration, not the token's scope. - Every command that reaches a Coolify now **says which one**, next to the team assert. **With no flag 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 console output. ## #15 — `cast capture` The adoption path cast never had. Reads the required secret **names** from the manifest's own env templates, reads live values off the instance, and forces every name into a disposition: | | | | --- | --- | | **captured** | found live, value taken | | **generated** | manifest's `generated_secrets` → literal `pending-coolify-generated`, never the live value | | **overridden** | `$CAST_CAPTURE_<NAME>` | | **missing** / **conflict** | **refuses** | The mapping is deliberately not mechanical — that's the design. A `DATABASE_URL` copied off the source points at the *source box's* Postgres: confidently wrong, entirely plausible, and the target's real URL doesn't exist until Coolify creates the resource. `generated_secrets` is a **manifest** property rather than a flag you have to 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 still reads like a guard, and the likeliest cause is a typo whose real name then gets *captured* instead of placeheld. Hygiene, each covered by a test asserting on real values: the plan prints **names and provenance, never values**; an override's value comes from the environment, never argv (`ps`); plaintext is piped to `age` on stdin (never a temp file, stdout, or shell history); an existing store isn't overwritten without `--force`. It inherits diff's absent-target refusal, the team assert, and the `--path`/`--env prod` ban. 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. ## Two things for the reviewer 1. **#15 said "deliberately not urgent — file now, build when it isn't load-bearing."** This does **not** change the prod-migration plan: Task 7's secrets capture is still the attended manual pass unless you decide otherwise. Using `capture` for it would put new code on the migration's critical path, which is the trade the issue argued against. 2. **`capture` can't be used on `incubator` prod until `.infra/manifest.yaml` declares `generated_secrets`** (`DATABASE_URL_PROD`, `REDIS_URL_PROD`, `UMAMI_DATABASE_URL`) and `environments.yaml` gains `age_recipient`. Both live in other repos, so they're not in this PR. Without them capture refuses (no recipient) rather than doing anything wrong — but it's worth knowing it isn't drop-in yet. ## Checks `npm run check` (biome), `npm run build` (tsc), `npm test` — **151 passing, 16 files** (was 118/14). Installer scripts pass `bash -n`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#16
No description provided.