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 { spawn } from "node:child_process";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
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 { createServer } from "node:http";
|
|
|
|
|
import type { AddressInfo } from "node:net";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { tmp } from "./helpers/tmp.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
|
|
|
|
|
|
|
|
// Spawned ASYNCHRONOUSLY, and that is load-bearing: the stub Coolify below
|
|
|
|
|
// runs in THIS process, so a blocking execFileSync would hold the event loop
|
|
|
|
|
// and the stub could never answer the CLI it just launched — the two would
|
|
|
|
|
// deadlock until the test timed out.
|
|
|
|
|
function runCli(args: string[]): Promise<{ code: number; output: string }> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const child = spawn("node", ["dist/cli.js", ...args], {
|
|
|
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
|
|
|
});
|
|
|
|
|
let output = "";
|
|
|
|
|
child.stdout.on("data", (d) => {
|
|
|
|
|
output += String(d);
|
|
|
|
|
});
|
|
|
|
|
child.stderr.on("data", (d) => {
|
|
|
|
|
output += String(d);
|
|
|
|
|
});
|
|
|
|
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A Coolify that answers the two calls these paths make, and RECORDS what it
|
|
|
|
|
// was asked. The recording is the point: "which instance did cast actually
|
|
|
|
|
// talk to" is the question #14 exists to make answerable, so the tests below
|
|
|
|
|
// answer it from the wire rather than from cast's own console output.
|
|
|
|
|
type Stub = { url: string; hits: string[]; close: () => Promise<void> };
|
|
|
|
|
const stubs: Stub[] = [];
|
|
|
|
|
|
|
|
|
|
async function stubCoolify(): Promise<Stub> {
|
|
|
|
|
const hits: string[] = [];
|
|
|
|
|
const server = createServer((req, res) => {
|
|
|
|
|
hits.push(req.url ?? "");
|
|
|
|
|
const body =
|
|
|
|
|
req.url === "/api/v1/teams/current"
|
|
|
|
|
? JSON.stringify({ id: 0, name: "Root Team" })
|
|
|
|
|
: "[]";
|
|
|
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
|
|
|
res.end(body);
|
|
|
|
|
});
|
|
|
|
|
await new Promise<void>((r) => {
|
|
|
|
|
server.listen(0, "127.0.0.1", r);
|
|
|
|
|
});
|
|
|
|
|
const stub: Stub = {
|
|
|
|
|
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
|
|
|
|
hits,
|
|
|
|
|
close: () =>
|
|
|
|
|
new Promise<void>((r) => {
|
|
|
|
|
server.close(() => r());
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
stubs.push(stub);
|
|
|
|
|
return stub;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await Promise.all(stubs.splice(0).map((s) => s.close()));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const coolifyEnv = (url: string, readOnly = false) =>
|
|
|
|
|
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n${
|
|
|
|
|
readOnly ? "COOLIFY_READ_ONLY=true\n" : ""
|
|
|
|
|
}`;
|
|
|
|
|
|
|
|
|
|
// A state dir: the default instance, plus any named ones, plus bindings.
|
|
|
|
|
function stateWith(opts: {
|
|
|
|
|
default?: string;
|
|
|
|
|
named?: Record<string, string>;
|
|
|
|
|
boundInstance?: string;
|
|
|
|
|
}): string {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const dir = tmp("cast-cli-");
|
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 (opts.default) writeFileSync(join(dir, ".coolify.env"), opts.default);
|
|
|
|
|
if (opts.named) {
|
|
|
|
|
mkdirSync(join(dir, ".coolify"));
|
|
|
|
|
for (const [name, body] of Object.entries(opts.named)) {
|
|
|
|
|
writeFileSync(join(dir, ".coolify", `${name}.env`), body);
|
|
|
|
|
}
|
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
|
|
|
writeFileSync(
|
|
|
|
|
join(dir, "environments.yaml"),
|
|
|
|
|
[
|
|
|
|
|
"environments:",
|
|
|
|
|
" prod:",
|
|
|
|
|
" server: prod-box",
|
|
|
|
|
" team: { id: 0, name: Root Team }",
|
|
|
|
|
...(opts.boundInstance ? [` instance: ${opts.boundInstance}`] : []),
|
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
|
|
|
" projects:",
|
|
|
|
|
" heavy-duty/incubator:",
|
|
|
|
|
" smoke_target: core",
|
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
|
|
|
"github_apps:",
|
|
|
|
|
" incubator: hdb-coolify",
|
|
|
|
|
"",
|
|
|
|
|
].join("\n"),
|
|
|
|
|
);
|
|
|
|
|
return dir;
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("infra cli", () => {
|
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
|
|
|
it("refuses apply --path with --env prod, exit non-zero", async () => {
|
|
|
|
|
const r = await runCli([
|
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
|
|
|
"apply",
|
|
|
|
|
"acme/widget",
|
|
|
|
|
"--env",
|
|
|
|
|
"prod",
|
|
|
|
|
"--path",
|
|
|
|
|
"/tmp/x",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/--path.*prod/);
|
|
|
|
|
});
|
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
|
|
|
it("prints usage on unknown command", async () => {
|
|
|
|
|
const r = await runCli(["frobnicate"]);
|
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
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/usage: cast (apply|diff)/i);
|
|
|
|
|
});
|
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
|
|
|
// Both of these reach a live Coolify and mutate — `server add` registers a
|
|
|
|
|
// server into the token's team (permanently: a server belongs to exactly one
|
|
|
|
|
// team), and `smoke` writes env vars onto a live app. Neither may run without
|
|
|
|
|
// an environment to assert the token's team against.
|
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
|
|
|
it("refuses server add without --env, exit non-zero", async () => {
|
|
|
|
|
const r = await runCli([
|
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
|
|
|
"server",
|
|
|
|
|
"add",
|
|
|
|
|
"prod-box",
|
|
|
|
|
"--ip",
|
|
|
|
|
"10.0.0.1",
|
|
|
|
|
"--key",
|
|
|
|
|
"/tmp/k",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/--env/);
|
|
|
|
|
});
|
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
|
|
|
it("refuses smoke without --env, exit non-zero", async () => {
|
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 r = await runCli(["smoke", "heavy-duty/incubator"]);
|
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
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/--env/);
|
|
|
|
|
});
|
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 repo is the PROJECT, and the project is half of the only scope in which
|
|
|
|
|
// `smoke_target: core` names anything (#29). `cast smoke --env prod` used to
|
|
|
|
|
// run — resolving the name against every application on the instance — which
|
|
|
|
|
// is precisely the invocation that could write prod's `core` while smoking
|
|
|
|
|
// staging. It is now a usage error, before any state is even read.
|
|
|
|
|
it("refuses smoke without the <org>/<repo> positional, exit non-zero", async () => {
|
|
|
|
|
const r = await runCli(["smoke", "--env", "prod"]);
|
|
|
|
|
expect(r.code).toBe(2);
|
|
|
|
|
expect(r.output).toMatch(/cast smoke\s+<org>\/<repo>/);
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
describe("--instance (multiple Coolify instances)", () => {
|
|
|
|
|
// The acceptance criterion from #14, end to end: a command against a named
|
|
|
|
|
// instance reaches THAT Coolify, with no edit to .coolify.env — which still
|
|
|
|
|
// sits there, untouched, pointing somewhere else entirely.
|
|
|
|
|
it("talks to the named instance, leaving .coolify.env untouched", async () => {
|
|
|
|
|
const [main, legacy] = [await stubCoolify(), await stubCoolify()];
|
|
|
|
|
const dir = stateWith({
|
|
|
|
|
default: coolifyEnv(main.url),
|
|
|
|
|
named: { legacy: coolifyEnv(legacy.url) },
|
|
|
|
|
});
|
|
|
|
|
const r = await runCli(["team", "--state", dir, "--instance", "legacy"]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(r.output).toContain(`instance legacy → ${legacy.url}`);
|
|
|
|
|
// The wire is the witness, not the log line.
|
|
|
|
|
expect(legacy.hits).toContain("/api/v1/teams/current");
|
|
|
|
|
expect(main.hits).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses .coolify.env when no instance is named — unchanged behavior", async () => {
|
|
|
|
|
const [main, legacy] = [await stubCoolify(), await stubCoolify()];
|
|
|
|
|
const dir = stateWith({
|
|
|
|
|
default: coolifyEnv(main.url),
|
|
|
|
|
named: { legacy: coolifyEnv(legacy.url) },
|
|
|
|
|
});
|
|
|
|
|
const r = await runCli(["team", "--state", dir]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(r.output).toContain(`instance default → ${main.url}`);
|
|
|
|
|
expect(main.hits).toContain("/api/v1/teams/current");
|
|
|
|
|
expect(legacy.hits).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// environments.yaml binds the instance, so --env prod selects the right
|
|
|
|
|
// control plane with no flag and no file edit at all.
|
|
|
|
|
it("honors an environment's instance binding with no flag", async () => {
|
|
|
|
|
const [main, prodCp] = [await stubCoolify(), await stubCoolify()];
|
|
|
|
|
const dir = stateWith({
|
|
|
|
|
default: coolifyEnv(main.url),
|
|
|
|
|
named: { "prod-cp": coolifyEnv(prodCp.url) },
|
|
|
|
|
boundInstance: "prod-cp",
|
|
|
|
|
});
|
|
|
|
|
const r = await runCli(["team", "--state", dir, "--env", "prod"]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(r.output).toContain(`instance prod-cp → ${prodCp.url}`);
|
|
|
|
|
expect(prodCp.hits).toContain("/api/v1/teams/current");
|
|
|
|
|
expect(main.hits).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("lets an explicit --instance beat the environment's binding", async () => {
|
|
|
|
|
const [prodCp, legacy] = [await stubCoolify(), await stubCoolify()];
|
|
|
|
|
const dir = stateWith({
|
|
|
|
|
named: {
|
|
|
|
|
"prod-cp": coolifyEnv(prodCp.url),
|
|
|
|
|
legacy: coolifyEnv(legacy.url),
|
|
|
|
|
},
|
|
|
|
|
boundInstance: "prod-cp",
|
|
|
|
|
});
|
|
|
|
|
const r = await runCli([
|
|
|
|
|
"team",
|
|
|
|
|
"--state",
|
|
|
|
|
dir,
|
|
|
|
|
"--env",
|
|
|
|
|
"prod",
|
|
|
|
|
"--instance",
|
|
|
|
|
"legacy",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(legacy.hits).toContain("/api/v1/teams/current");
|
|
|
|
|
expect(prodCp.hits).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses an unknown instance, names the known ones, and touches nothing", async () => {
|
|
|
|
|
const main = await stubCoolify();
|
|
|
|
|
const dir = stateWith({
|
|
|
|
|
default: coolifyEnv(main.url),
|
|
|
|
|
named: { legacy: coolifyEnv(main.url) },
|
|
|
|
|
});
|
|
|
|
|
const r = await runCli(["team", "--state", dir, "--instance", "typo"]);
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/no Coolify instance named "typo"/);
|
|
|
|
|
expect(r.output).toMatch(/configured:\s+legacy/);
|
|
|
|
|
// Refuse, don't guess: it must not have fallen back to the default.
|
|
|
|
|
expect(main.hits).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The nice-to-have that turns "I pointed the wrong token at the wrong box"
|
|
|
|
|
// into an exit code: the refusal lands BEFORE the first call, so a read-only
|
|
|
|
|
// instance never even gets asked.
|
|
|
|
|
it("refuses a writing verb against a read-only instance, before any call", async () => {
|
|
|
|
|
const legacy = await stubCoolify();
|
|
|
|
|
const dir = stateWith({
|
|
|
|
|
named: { legacy: coolifyEnv(legacy.url, true) },
|
|
|
|
|
});
|
|
|
|
|
const r = await runCli([
|
|
|
|
|
"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
|
|
|
"heavy-duty/incubator",
|
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
|
|
|
"--state",
|
|
|
|
|
dir,
|
|
|
|
|
"--env",
|
|
|
|
|
"prod",
|
|
|
|
|
"--instance",
|
|
|
|
|
"legacy",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/refusing to smoke.*read-only/s);
|
|
|
|
|
expect(legacy.hits).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still allows a read-only instance to be read", async () => {
|
|
|
|
|
const legacy = await stubCoolify();
|
|
|
|
|
const dir = stateWith({ named: { legacy: coolifyEnv(legacy.url, true) } });
|
|
|
|
|
const r = await runCli(["team", "--state", dir, "--instance", "legacy"]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/read-only/);
|
|
|
|
|
expect(legacy.hits).toContain("/api/v1/teams/current");
|
|
|
|
|
});
|
|
|
|
|
});
|