diff --git a/README.md b/README.md
index 6441a00..a183cf5 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,9 @@ Pass it with `--state
`, or set `CAST_STATE`. Defaults to the cwd.
```sh
cast apply / --env [--path ] [--hostname-overlay ]
+cast apply --env --all # no repo: EVERY registered project
cast diff / --env [--full]
+cast diff --env --all [--full] # no repo: EVERY registered project
cast capture / --env [--generated ] [--override ]
cast inventory / --env
cast server add --ip --key --env [--user root] [--port 22]
@@ -79,6 +81,9 @@ cast team [--env ]
default branch).
- **`diff`** — reports drift, manifest → Coolify. Structural by default; `--full`
also compares env vars. Exits non-zero when dirty, so CI can gate on it.
+- **`--all`** — on `apply`/`diff`, act on **every project the registry lists for
+ this environment** instead of one named repo. See *The whole environment at
+ once* below.
- **`inventory`** — what is actually *on* a box. **With no repo it sweeps the
instance** (every project, every environment, every resource — no manifest
involved); with a repo it reconciles, showing resources and env var **keys**
@@ -402,11 +407,77 @@ a clean one.** Silence is the one report that must never be ambiguous.
What it unlocks, neither of which was possible without a list to iterate:
- **fleet operations** — `cast diff --all` / `apply --all` over every project in
- an environment.
+ an environment (below).
- **rebuild-from-state** — "restore this Coolify from the state repo" cannot even
be *attempted* without knowing what was on it. The registry is the difference
between a documented recovery and an archaeology exercise.
+## The whole environment at once: `--all`
+
+Every other cast verb is 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.** `--all` iterates the registry instead:
+
+```sh
+cast diff --env prod --all # every project registered for prod
+cast apply --env prod --all
+```
+
+It runs the **same per-project path** the single-repo form runs (there is one
+implementation of what a project run *is*), reports each project under its own
+heading, and then prints an aggregate that leads with **coverage**:
+
+```
+fleet diff — prod
+
+ registered: 3 (heavy-duty/incubator, acme/client-site, acme/landing)
+ read: 2 of 3
+ clean: 1 heavy-duty/incubator
+ drift: 1 acme/landing
+ UNREACHABLE: 1 acme/client-site
+ acme/client-site: refusing to diff: no project named "client-site" …
+```
+
+**A registered project cast cannot reach is an ERROR, not a skip** — the clone
+failing, no manifest block for this environment, a missing or undecryptable
+store, an absent Coolify project or environment, any HTTP error. A skipped
+project reads exactly like a clean one, which would make the one report you get
+most often (silence) the one you cannot trust. So the fleet **fails closed**: a
+clean fleet diff means *every* project was read, not that the ones cast happened
+to look at were fine.
+
+| | |
+|---|---|
+| `diff --all` exit 0 | every registered project was **read**, and every one is clean |
+| `diff --all` exit 1 | every one was read, and at least one has drift |
+| `diff --all` exit 2 | a project could not be read — **outranks drift**, because an unread project is not a diff result, it is the absence of one |
+| `apply --all` exit 0 | every registered project applied |
+| `apply --all` non-zero | anything else |
+
+The two verbs take opposite dispositions on failure, and both are deliberate:
+
+- **`diff --all` runs every project to completion.** Stopping early would hide the
+ drift in the projects it never reached — a partial read is exactly the report
+ this flag exists to make impossible.
+- **`apply --all` stops at the first failure**, and says which projects it
+ applied and which it did not touch. Continuing to *mutate* a fleet after an
+ unexplained failure is not a thing cast gets to do. `apply` is idempotent, so
+ re-running after a fix is a no-op over the ones that already applied.
+
+**An empty or absent registry refuses** (exit 2). `--all` over a state file with
+no `projects:` block does *not* print "0 projects, clean" and exit 0 — an empty
+fleet reading as a clean fleet is the whole failure this feature is against. The
+refusal names what it looked for and prints the YAML to write.
+
+**`--all` is mutually exclusive** with the repo positional and with every
+single-project coordinate — `--path`, `--project`, `--environment`, `--resource`,
+`--hostname-overlay`. Each of those names ONE project's checkout, ONE project's
+Coolify name, ONE box's resource names; fleet-wide they are meaningless at best
+and dangerous at worst (`--project X` applied to every project in the registry
+would point them all at the same Coolify project — a false report on `diff`, and
+on `apply` every manifest in the fleet written into one project). The refusal
+names the offending flag.
+
## Two projects, one box: destinations
A **destination** is the Docker network a resource is created on. A server has a
diff --git a/docs/semantics.md b/docs/semantics.md
index 668b284..aa81ec1 100644
--- a/docs/semantics.md
+++ b/docs/semantics.md
@@ -199,10 +199,90 @@ ambiguous:
present**, so pre-registry state files keep loading.
The registry is what makes two things possible, neither of which can be attempted
-without a list to iterate: **fleet operations** (`--all`), and
+without a list to iterate: **fleet operations** (`--all`, below), and
**rebuild-from-state** — restoring a Coolify from the state repo, which is
otherwise an assumption, since you cannot restore what you cannot enumerate.
+## Fleet runs (`--all`)
+
+`cast diff --env --all` and `cast apply --env --all` act on **every
+project the registry lists for that environment**, in place of the `/`
+positional. The projects are visited in the registry's sorted order
+(`projectsIn`) — a fleet report a human reads top to bottom, and CI diffs, must
+not reshuffle because someone appended a project.
+
+**One implementation.** `--all` loops the *same* per-project path the single-repo
+form runs (checkout → secrets → desired → bindings → team-asserted client → live
+read → diff → optionally apply). There is deliberately no second, parallel fleet
+code path: two implementations of "what a project run is" would drift, and drift
+is the thing this tool exists to catch.
+
+**The instance and the team are asserted once, before the first project's first
+read.** One `--env` means one instance and one team for the whole run, so the
+gate lands where it always did — strictly before the first live read, which is
+already the lie a wrong-team token tells (see *Team scoping*).
+
+### Fails closed on the aggregate
+
+**A registered project cast cannot reach is an ERROR, never a skip.** "Cannot
+reach" is every way a project can fail to answer: the clone failing, the manifest
+carrying no block for this environment, the secret store being absent or
+undecryptable, the Coolify project or environment being absent
+(`LiveLookup.found === false`), and any HTTP error. They collapse into one
+outcome because only one thing about them matters downstream — **this project was
+not read** — and a silently skipped project reads exactly like a clean one. That
+is the failure of #12/#18/#22 at fleet scale, and it would make *silence*, the
+most common report there is, the least trustworthy one.
+
+So the aggregate reports **coverage** (registered / read / clean / drifted /
+unreachable), and the exit code ranks an unread project above a drifted one:
+
+| verb | exit | meaning |
+|---|---|---|
+| `diff --all` | `0` | every registered project was **read**, and every one is clean |
+| `diff --all` | `1` | every one was read, and at least one has drift |
+| `diff --all` | `2` | a project could not be read. **Outranks drift**: an unreadable project is not a diff result, it is the absence of one |
+| `apply --all` | `0` | every registered project applied |
+| `apply --all` | `≠0` | anything else |
+
+`fleetExitCode` defaults to `2` on any coverage shape it does not recognize — an
+exit code is the only part of the report CI reads, so an unrecognized shape must
+fail rather than pass.
+
+### Opposite dispositions on failure, both deliberate
+
+- **`diff --all` runs every project to completion.** A read that stops early hides
+ the drift in the projects it never reached; a read that continues costs nothing.
+- **`apply --all` stops at the first failure**, and reports which projects were
+ applied and which were **not touched**. A write that continues costs everything:
+ the next project's apply would be a guess about whether the last one broke
+ something it depends on. `apply` is idempotent, so re-running after the fix is a
+ no-op over the projects that already applied.
+
+`apply` keeps its usual position on an absent Coolify project — it *creates* it,
+exactly as a single-project `apply` does (see *the read side*, and `LiveLookup`).
+Only `diff` treats absence as unreachable, because `diff` may only ever describe a
+target that already exists.
+
+### Two refusals
+
+- **An empty or absent registry refuses** (exit 2). `projectsIn` answers `[]` both
+ for a state file with no `projects:` block and for one whose registry names
+ nothing in this environment; to a fleet run they are the same thing — *nothing
+ to iterate* — and "0 projects, clean" is precisely the sentence this feature
+ exists to make impossible. The refusal names what was looked for, distinguishes
+ an unmigrated state file from a registry pointed elsewhere, and prints the YAML
+ to write.
+- **`--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 — none is true of the project beside it.
+ Fleet-wide they are meaningless at best and dangerous at worst: `--project X`
+ across a registry points every project at the same Coolify project, which on
+ `diff` is a false report and on `apply` is every manifest in the fleet written
+ into one project. The refusal names the offending flag and says what applying it
+ fleet-wide would have done.
+
## Placement (destinations)
A **destination** is the Docker network a resource is created on. It is declared
diff --git a/src/bindings.ts b/src/bindings.ts
index c0ff16b..66be606 100644
--- a/src/bindings.ts
+++ b/src/bindings.ts
@@ -368,9 +368,14 @@ export function smokeTargetFor(
// registry is a set; this returns it as one.
//
// `[]` when there is no registry, which is every state file written before this
-// block existed. That is the honest answer for "which projects are registered
-// here" when nothing is registered anywhere — and it makes a fleet verb over an
-// unmigrated state file a clean no-op rather than a crash.
+// block existed. That is the honest answer to "which projects are registered
+// here" when nothing is registered anywhere — and it is emphatically NOT a
+// licence to do nothing with it: the fleet verbs REFUSE on an empty list (#26,
+// renderEmptyRegistry). An earlier draft of this comment guessed that `[]` would
+// make `--all` over an unmigrated state file "a clean no-op rather than a crash",
+// which is precisely backwards — "0 projects, clean" is a clean fleet's report
+// printed over a fleet nobody looked at. There is no honest no-op here; there is
+// only a refusal that says what it looked for.
export function projectsIn(bindings: Bindings, envName: string): string[] {
const registry = bindings.projects;
if (!registry) return [];
diff --git a/src/cli.ts b/src/cli.ts
index 99c97bd..6d8ad57 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -6,9 +6,11 @@ import { parseArgs } from "node:util";
import { parse as parseYaml } from "yaml";
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
import {
+ type Bindings,
githubAppNameFor,
loadBindings,
projectBindingFor,
+ projectsIn,
smokeTargetFor,
} from "./bindings.js";
import {
@@ -33,6 +35,16 @@ import {
renderDiff,
} from "./diff.js";
import { assertEnvVarPolicy } from "./envtemplate.js";
+import {
+ type ProjectOutcome,
+ fleetConflict,
+ fleetExitCode,
+ renderEmptyRegistry,
+ renderFleetApply,
+ renderFleetConflict,
+ renderFleetDiff,
+ renderProjectHeading,
+} from "./fleet.js";
import {
type LiveResource,
type SweepEnvironment,
@@ -42,8 +54,10 @@ import {
renderSweep,
} from "./inventory.js";
import {
+ PATH_IN_PROD_REFUSAL,
desiredFromManifest,
manifestResources,
+ refusesPathInProd,
requiredSecrets,
resolveCheckout,
} from "./resolve.js";
@@ -58,7 +72,9 @@ import { smoke } from "./smoke.js";
import { assertTeam, formatTeam } from "./team.js";
const USAGE = `usage: cast apply / --env [--path ] [--project ] [--environment ] [--hostname-overlay ]
+ cast apply --env --all # no repo: EVERY registered project
cast diff / --env [--full] [--project ] [--environment ]
+ cast diff --env --all [--full] # no repo: EVERY registered project
cast capture / --env [--path ] [--project ] [--environment ] [--generated ] [--override ] [--force]
cast inventory / --env [--path ] [--project ] [--environment ] [--resource =]
cast inventory --env [--instance ] # no repo: SWEEP the whole instance
@@ -99,6 +115,20 @@ const USAGE = `usage: cast apply / --env [--path ] [--
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.
+ --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.
capture (adopt a hand-built instance into the age secret store):
--generated force NAME to the \`pending-coolify-generated\` placeholder,
@@ -541,6 +571,168 @@ async function confirmCapture(envName: string): Promise {
return answer?.trim() === envName;
}
+// 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 ` 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 {
+ 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 };
+}
+
async function main(): Promise {
const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") {
@@ -561,14 +753,41 @@ async function main(): Promise {
instance: { type: "string" },
"hostname-overlay": { type: "string" },
full: { type: "boolean", default: false },
+ all: { type: "boolean", default: false },
},
});
const orgRepo = positionals[0];
const envName = values.env;
- if (!orgRepo || !envName) {
+ // --all IS the target, so it replaces the positional rather than joining it.
+ if (!envName || (!orgRepo && !values.all)) {
console.error(USAGE);
return 2;
}
+ // 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({
+ "/": 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;
+ }
// 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
@@ -589,50 +808,23 @@ async function main(): Promise {
return 2;
}
const stateDir = stateDirFrom(values.state);
- 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 = values.project ?? 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 = values.environment ?? envName;
- const checkout = resolveCheckout(orgRepo, {
- env: envName,
- path: values.path,
- });
- const secrets = decryptSecrets(
- secretsFileFor(stateDir, repoShort, envName),
- keyFileFor(envName),
- );
- let { desired, resolvedEnvs, backupSchedules } = desiredFromManifest(
- checkout,
- envName,
- secrets,
- );
- const bindings = loadBindings(join(stateDir, "environments.yaml"));
+ const bindingsPath = join(stateDir, "environments.yaml");
+ const bindings = loadBindings(bindingsPath);
const binding = bindings.environments[envName];
if (!binding) {
console.error(`environment ${envName} not in environments.yaml`);
return 2;
}
- assertEnvVarPolicy(envName, resolvedEnvs, 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(bindings, envName, orgRepo);
- if (values["hostname-overlay"]) {
- desired = applyHostnameOverlay(
- desired,
- parseYaml(readFileSync(values["hostname-overlay"], "utf8")),
+ // 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),
);
+ return 2;
}
const { instance, client } = openCoolify(
stateDir,
@@ -646,68 +838,85 @@ async function main(): Promise {
// 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.
+ //
+ // 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.
const team = await assertTeam(client, binding.team, envName);
console.log(`team ${formatTeam(team)} ✓`);
- const mode = command === "apply" || values.full ? "full" : "structural";
- const lookup = await fetchLive(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.
- if (!lookup.found && command === "diff") {
- console.error(
- renderAbsentTarget(lookup, {
- orgRepo,
- overridden: values.project !== undefined,
- envOverridden: values.environment !== undefined,
- }),
- );
- return 2;
+ 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;
}
- const aliases = parseResourceAliases(
- values.resource ?? [],
- 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 (mode === "full") {
- for (const l of live) {
- l.env = await fetchEnv(client, l);
+ 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;
}
}
- const report = computeDiff(desired, live, mode, {
- declaredDestination: projectBinding?.destination_uuid,
- });
- console.log(renderDiff(report));
- if (command === "diff") return report.clean ? 0 : 1;
- const serverUuid = await client.serverUuid(binding.server);
- const githubAppUuid = await client.githubAppUuid(
- githubAppNameFor(bindings, orgRepo),
- );
- const exec = buildExecutor(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: binding.s3_destination,
- backupSchedules,
- });
- const { mutated } = await applyPlan(report, desired, exec);
console.log(
- mutated.length === 0
- ? "no-op (clean)"
- : `applied + redeployed: ${mutated.join(", ")}`,
+ command === "diff"
+ ? renderFleetDiff(envName, targets, outcomes)
+ : renderFleetApply(envName, targets, outcomes),
);
- return 0;
+ return fleetExitCode(command, targets, outcomes);
}
if (command === "capture") {
const { values, positionals } = parseArgs({
diff --git a/src/fleet.ts b/src/fleet.ts
new file mode 100644
index 0000000..0020cf9
--- /dev/null
+++ b/src/fleet.ts
@@ -0,0 +1,320 @@
+import type { Bindings } from "./bindings.js";
+
+// A fleet run is N project runs and ONE verdict — and the verdict is the part
+// that has to be right. Every other command in cast answers a question about a
+// project the operator named; `--all` answers a question about a set nobody
+// enumerated by hand, which means the report's COVERAGE is as load-bearing as
+// its content. "Nothing to report" and "I looked at nothing" must never render
+// the same, so a project is only ever one of these four things, and the one
+// that does not exist is "skipped".
+export type ProjectOutcome =
+ | { repo: string; status: "clean" }
+ | { repo: string; status: "drift" }
+ | { repo: string; status: "applied"; mutated: string[] }
+ // Everything that stopped cast from producing a result for a project it was
+ // told exists: the clone failing, no manifest block for this environment, an
+ // absent or undecryptable secret store, an absent Coolify project or
+ // environment, any HTTP error. They collapse into one status deliberately —
+ // downstream, the only thing that matters about all of them is that this
+ // project WAS NOT READ, and every one of them is fatal to the fleet.
+ | { repo: string; status: "unreachable"; message: string };
+
+// The headline of an unreachable project, for the summary block. The full
+// message (multi-line, and normally very much worth reading) is printed in the
+// project's own section as the run reaches it; repeating it whole at the bottom
+// would bury the aggregate under the detail it is meant to summarize.
+function headline(message: string): string {
+ return message.split("\n")[0];
+}
+
+export function renderProjectHeading(
+ repo: string,
+ index: number,
+ total: number,
+): string {
+ const label = `[${index}/${total}] ${repo}`;
+ return `\n── ${label} ${"─".repeat(Math.max(3, 74 - label.length))}`;
+}
+
+function coverage(registered: string[], outcomes: ProjectOutcome[]) {
+ const of = (status: ProjectOutcome["status"]) =>
+ outcomes.filter((o) => o.status === status).map((o) => o.repo);
+ const visited = new Set(outcomes.map((o) => o.repo));
+ return {
+ clean: of("clean"),
+ drift: of("drift"),
+ applied: of("applied"),
+ unreachable: outcomes.filter(
+ (o): o is Extract =>
+ o.status === "unreachable",
+ ),
+ // Registered, and never even attempted. Only `apply` can produce these (it
+ // stops at the first failure); for `diff` this is always empty, because a
+ // read that stops early hides the drift in the projects it never reached.
+ notReached: registered.filter((r) => !visited.has(r)),
+ };
+}
+
+const list = (repos: string[]): string => repos.join(", ") || "—";
+
+// The aggregate a human reads to decide whether to trust the run — so it leads
+// with COUNTS OF PROJECTS, not counts of changes. A drifted project and an
+// unreadable one are both "not clean", but only one of them is a diff result:
+// the other is a hole in the report, and a report with a hole in it is not a
+// clean fleet however green the projects around the hole were.
+export function renderFleetDiff(
+ envName: string,
+ registered: string[],
+ outcomes: ProjectOutcome[],
+): string {
+ const { clean, drift, unreachable, notReached } = coverage(
+ registered,
+ outcomes,
+ );
+ const read = clean.length + drift.length;
+ const lines = [
+ "",
+ `fleet diff — ${envName}`,
+ "",
+ ` registered: ${registered.length} (${list(registered)})`,
+ ` read: ${read} of ${registered.length}`,
+ ` clean: ${clean.length} ${list(clean)}`,
+ ` drift: ${drift.length} ${list(drift)}`,
+ ` UNREACHABLE: ${unreachable.length} ${list(unreachable.map((u) => u.repo))}`,
+ ];
+ for (const u of unreachable) {
+ lines.push(` ${u.repo}: ${headline(u.message)}`);
+ }
+ if (notReached.length > 0) {
+ // Cannot happen for `diff` (it runs every project to completion), and said
+ // out loud anyway: if it ever did, the fleet's coverage would be a lie and
+ // the exit code below would be a 2. Silence here would hide the one thing
+ // this report is for.
+ lines.push(` NOT REACHED: ${notReached.length} ${list(notReached)}`);
+ }
+ lines.push("");
+ if (unreachable.length > 0 || notReached.length > 0) {
+ lines.push(
+ `${unreachable.length + notReached.length} of ${registered.length} registered project(s) could not be read.`,
+ "",
+ "A fleet report is worth exactly its coverage. An unread project is not a clean",
+ "one — it is a project cast has nothing to say about, and a silently skipped",
+ "project reads exactly like a clean one (#12/#18/#22, at fleet scale). So this",
+ "run is a FAILURE, not a diff result: what it read stands, but the state of this",
+ "environment as a whole is UNKNOWN until every registered project answers.",
+ );
+ } else if (drift.length > 0) {
+ lines.push(
+ `all ${registered.length} registered project(s) were read; ${drift.length} ${
+ drift.length === 1 ? "has" : "have"
+ } drift.`,
+ );
+ } else {
+ lines.push(
+ `all ${registered.length} registered project(s) were read, and every one is clean.`,
+ );
+ }
+ return lines.join("\n");
+}
+
+export function renderFleetApply(
+ envName: string,
+ registered: string[],
+ outcomes: ProjectOutcome[],
+): string {
+ const { applied, unreachable, notReached } = coverage(registered, outcomes);
+ const changed = outcomes.filter(
+ (o): o is Extract =>
+ o.status === "applied" && o.mutated.length > 0,
+ );
+ const lines = [
+ "",
+ `fleet apply — ${envName}`,
+ "",
+ ` registered: ${registered.length} (${list(registered)})`,
+ ` applied: ${applied.length} of ${registered.length} ${list(applied)}`,
+ ];
+ for (const c of changed) {
+ lines.push(` ${c.repo}: ${c.mutated.join(", ")}`);
+ }
+ if (unreachable.length > 0) {
+ lines.push(
+ ` FAILED: ${list(unreachable.map((u) => u.repo))}`,
+ ...unreachable.map((u) => ` ${headline(u.message)}`),
+ );
+ }
+ if (notReached.length > 0) {
+ lines.push(` not reached: ${notReached.length} ${list(notReached)}`);
+ }
+ lines.push("");
+ if (unreachable.length === 0 && notReached.length === 0) {
+ lines.push(
+ `all ${registered.length} registered project(s) applied.`,
+ ...(changed.length === 0
+ ? ["nothing changed — the fleet already matched its manifests."]
+ : []),
+ );
+ return lines.join("\n");
+ }
+ lines.push(
+ "STOPPED at the first failure, and deliberately: continuing to mutate a fleet after",
+ "an unexplained failure is not a thing cast gets to do — the next project's apply",
+ "would be a guess about whether the last one broke something it depends on.",
+ "",
+ notReached.length > 0
+ ? `The ${notReached.length} project(s) listed as "not reached" were NOT touched. Resolve the failure`
+ : "Resolve the failure",
+ "and re-run: `apply` is idempotent, so re-running over the projects that already",
+ "applied is a no-op.",
+ );
+ return lines.join("\n");
+}
+
+// The whole point of the flag, expressed as a number.
+//
+// diff: 0 every registered project was READ, and every one is clean
+// 1 every registered project was read, and at least one has drift
+// 2 a registered project could not be read — which OUTRANKS drift,
+// because it is not a diff result at all: it is the absence of one.
+// apply: 0 every registered project applied
+// 2 anything else
+//
+// The default is 2, not 0: a coverage gap this function does not recognize must
+// fail, not pass. An exit code is the only part of this report that CI reads.
+export function fleetExitCode(
+ verb: "apply" | "diff",
+ registered: string[],
+ outcomes: ProjectOutcome[],
+): number {
+ const { clean, drift, applied, unreachable, notReached } = coverage(
+ registered,
+ outcomes,
+ );
+ if (unreachable.length > 0 || notReached.length > 0) return 2;
+ if (verb === "apply") return applied.length === registered.length ? 0 : 2;
+ if (clean.length + drift.length !== registered.length) return 2;
+ return drift.length > 0 ? 1 : 0;
+}
+
+// An empty registry REFUSES. It does not report "0 projects, clean".
+//
+// `projectsIn` answers `[]` for a state file with no `projects:` block, and for
+// one whose registry names no project in this environment. Both are the same
+// thing to a fleet run: NOTHING TO ITERATE — and a fleet run over nothing prints
+// exactly what a fleet run over a clean fleet prints. That equivalence is the
+// entire failure this feature exists to prevent, so it is a refusal, and the
+// refusal says what was looked for and what to write instead.
+export function renderEmptyRegistry(
+ verb: "apply" | "diff",
+ envName: string,
+ bindings: Bindings,
+ bindingsPath: string,
+): string {
+ const registry = bindings.projects;
+ const elsewhere = Object.entries(registry ?? {}).map(
+ ([slug, project]) => `${slug} (${project.environments.join(", ")})`,
+ );
+ const found = !registry
+ ? [
+ " found: no `projects:` block at all — this state file predates the",
+ " registry, so it lists no projects anywhere",
+ ]
+ : elsewhere.length > 0
+ ? [
+ " found: a registry, but nothing registered for this environment:",
+ ...elsewhere.map((e) => ` ${e}`),
+ ]
+ : [" found: a `projects:` block with no projects in it"];
+ return [
+ `refusing to ${verb} --all: no projects are registered for "${envName}"`,
+ "",
+ ` looked for: projects./.environments containing "${envName}"`,
+ ` in: ${bindingsPath}`,
+ ...found,
+ "",
+ "--all iterates the registry — the list of which projects exist. An EMPTY list is",
+ "not an empty fleet; it is an unanswered question. A run over no projects prints",
+ 'exactly what a run over a clean fleet prints, and "0 projects, clean" is the one',
+ "sentence this flag exists to make impossible.",
+ "",
+ "Register what deploys here (or name the one repo you mean, without --all):",
+ "",
+ " projects:",
+ " /:",
+ ` environments: [${envName}]`,
+ ].join("\n");
+}
+
+// The coordinates that name ONE project, each with the reason it cannot mean
+// anything across a fleet. Every one of them is a name someone else chose for a
+// single project — one checkout, one Coolify project, one box's resource names —
+// and none of them is true of the project next to it. Fleet-wide they are
+// meaningless at best and, on `apply`, actively dangerous.
+//
+// Insertion order is the reporting order: the positional first, because passing
+// a repo AND --all is the most likely way in here.
+const SINGLE_PROJECT_COORDINATES: Record = {
+ "/": [
+ "A repo positional names the ONE project to act on. --all says: every project the",
+ "registry lists for this environment. Passing both asks cast to guess which of the",
+ "two you meant — and the wrong guess is either a fleet-wide act you did not ask",
+ "for, or a fleet you thought you covered and did not.",
+ ],
+ "--path": [
+ "--path is ONE project's local checkout. Fleet-wide it would resolve every project",
+ "to the same working tree, and cast would diff one repo's manifest against every",
+ "other project on the box — reporting each of them as an unrecognizable mess of",
+ "creates and orphans.",
+ ],
+ "--project": [
+ "--project is the name ONE project has on Coolify (the coordinate for a project",
+ "somebody built by hand in the UI). Fleet-wide it would point every project in the",
+ "registry at that SAME Coolify project: on `diff` that is a false report, and on",
+ "`apply` it is every manifest in the fleet written into one project.",
+ ],
+ "--environment": [
+ "--environment is the name ONE project's environment has on the wire. The projects",
+ "in a fleet need not share it — a box that calls one of them `production` says",
+ "nothing whatsoever about the next one — so fleet-wide it is a guess applied to",
+ "projects that never agreed to it.",
+ ],
+ "--resource": [
+ "--resource maps ONE project's manifest names onto the names some box gave the same",
+ "resources. Its left-hand side is validated against that project's manifest, so",
+ "across a fleet it is either an error or, worse, an alias that happens to match a",
+ "different project's resource and quietly diffs the wrong thing.",
+ ],
+ "--hostname-overlay": [
+ "--hostname-overlay names applications in ONE project's manifest (an unknown name is",
+ "an error — that is the guard that makes it safe). It cannot be true of a second",
+ "project's manifest, and a fleet cutover is not one file's job.",
+ ],
+};
+
+// Which single-project coordinate was passed alongside --all, if any. `undefined`
+// means the invocation is a legitimate fleet run.
+export function fleetConflict(
+ given: Record,
+): string | undefined {
+ for (const flag of Object.keys(SINGLE_PROJECT_COORDINATES)) {
+ const value = given[flag];
+ if (value === undefined) continue;
+ if (Array.isArray(value) && value.length === 0) continue;
+ return flag;
+ }
+ return undefined;
+}
+
+export function renderFleetConflict(
+ verb: "apply" | "diff",
+ flag: string,
+): string {
+ return [
+ `refusing to ${verb}: --all cannot be combined with ${flag}`,
+ "",
+ ...(SINGLE_PROJECT_COORDINATES[flag] ?? []),
+ "",
+ `Act on one project (name its /, and keep ${flag}), or act on the whole`,
+ "environment (--all, and drop it). Not both.",
+ ].join("\n");
+}
diff --git a/src/resolve.ts b/src/resolve.ts
index 4ad6c51..43bb888 100644
--- a/src/resolve.ts
+++ b/src/resolve.ts
@@ -132,17 +132,32 @@ export function cloneFailureMessage(
].join("\n");
}
+// Holds for every verb that reads a manifest (apply, diff, capture, inventory):
+// a feature-branch checkout must not be able to decide what prod runs, nor which
+// secret names land in prod's store.
+//
+// The rule is a value, not only a throw inside resolveCheckout, because the CLI
+// refuses this combination UP FRONT — before it opens a state file, a store or a
+// Coolify. A flag pairing that can never be honored must not need the rest of the
+// invocation to be well-formed in order to be caught (it used to be caught late,
+// and only by accident of resolveCheckout running before the bindings load). One
+// rule, one string, two call sites — never two spellings of the same refusal.
+export const PATH_IN_PROD_REFUSAL =
+ "refuses --path with --env prod: prod always reads the default branch";
+
+export function refusesPathInProd(opts: {
+ env: string;
+ path?: string;
+}): boolean {
+ return opts.path !== undefined && opts.env === "prod";
+}
+
export function resolveCheckout(
orgRepo: string,
opts: { env: string; path?: string },
): string {
- if (opts.path && opts.env === "prod") {
- // Holds for every verb that reads a manifest (apply, and now capture): a
- // feature-branch checkout must not be able to decide what prod runs, nor
- // which secret names land in prod's store.
- throw new Error(
- "refuses --path with --env prod: prod always reads the default branch",
- );
+ if (refusesPathInProd(opts)) {
+ throw new Error(PATH_IN_PROD_REFUSAL);
}
if (opts.path) return opts.path;
const dir = mkdtempSync(join(tmpdir(), "infra-checkout-"));
diff --git a/test/fleet-cli.test.ts b/test/fleet-cli.test.ts
new file mode 100644
index 0000000..01ea316
--- /dev/null
+++ b/test/fleet-cli.test.ts
@@ -0,0 +1,427 @@
+import { execFileSync, spawn } from "node:child_process";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeAll, describe, expect, it } from "vitest";
+
+// `cast diff --all` / `cast apply --all` (#26), end to end against a stub
+// Coolify carrying three projects.
+//
+// The failure this whole feature is about is a report that reads the same
+// whether cast looked at everything or at nothing — so every test below is
+// really an assertion about COVERAGE: what the run says it read, versus what the
+// registry says exists. A skipped project reads exactly like a clean one, and
+// the exit code is where that lie would land.
+//
+// --all forbids --path (it is ONE project's checkout), so these runs take the
+// real clone path. `insteadOf` points github.com at local git repos: the clone
+// is genuine, the network is not.
+
+const REPOS = ["alpha", "beta", "gamma"] as const;
+const SLUGS = REPOS.map((r) => `heavy-duty/${r}`);
+
+let recipient: string;
+let keyFile: string;
+
+beforeAll(() => {
+ const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
+ keyFile = join(dir, "age.key");
+ execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
+ recipient = execFileSync("age-keygen", ["-y", keyFile], {
+ encoding: "utf8",
+ }).trim();
+});
+
+// What the box does when cast asks about a project:
+// clean — exactly what the manifest declares
+// drift — the same app on the wrong branch
+// absent — no such project on this Coolify (LiveLookup.found === false)
+// error — the environment read 500s (any HTTP error at all)
+type Behavior = "clean" | "drift" | "absent" | "error";
+
+type Stub = { url: string; hits: string[]; close: () => Promise };
+const stubs: Stub[] = [];
+
+async function stubCoolify(
+ behavior: Partial> = {},
+): Promise {
+ const of = (repo: string): Behavior => behavior[repo] ?? "clean";
+ const hits: string[] = [];
+ const server = createServer((req, res) => {
+ const path = (req.url ?? "").replace("/api/v1", "");
+ hits.push(path);
+ const json = (body: unknown) => {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify(body));
+ };
+ if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
+ if (path === "/servers") return json([{ uuid: "s1", name: "fleet-box" }]);
+ if (path === "/github-apps")
+ return json([{ uuid: "g1", name: "hdb-coolify" }]);
+ if (path === "/projects") {
+ return json(
+ REPOS.filter((r) => of(r) !== "absent").map((r) => ({
+ uuid: `p-${r}`,
+ name: r,
+ })),
+ );
+ }
+ for (const repo of REPOS) {
+ if (path === `/projects/p-${repo}/staging`) {
+ if (of(repo) === "error") {
+ res.writeHead(500);
+ return res.end("boom");
+ }
+ return json({
+ applications: [
+ {
+ name: "core",
+ uuid: `a-${repo}`,
+ git_repository: `heavy-duty/${repo}`,
+ git_branch:
+ of(repo) === "drift" ? "someones-feature-branch" : "main",
+ build_pack: "nixpacks",
+ base_directory: "/",
+ fqdn: `http://${repo}.example.com`,
+ destination_id: 1,
+ },
+ ],
+ });
+ }
+ if (path === `/applications/a-${repo}/envs`) return json([]);
+ }
+ res.writeHead(404);
+ res.end("{}");
+ });
+ await new Promise((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((r) => {
+ server.close(() => r());
+ }),
+ };
+ stubs.push(stub);
+ return stub;
+}
+
+afterEach(async () => {
+ await Promise.all(stubs.splice(0).map((s) => s.close()));
+});
+
+const manifest = (repo: string) => `project: ${repo}
+environments:
+ staging:
+ applications:
+ core:
+ source: { repo: heavy-duty/${repo}, branch: main }
+ build: { pack: nixpacks, base_directory: / }
+ domains: ["http://${repo}.example.com"]
+`;
+
+// A state dir + three clonable product repos. `registry` is the knob: which
+// slugs the `projects:` block registers for staging — undefined writes no
+// `projects:` block at all (a state file from before the registry existed).
+function fixture(
+ url: string,
+ opts: { registry?: string[]; registryEnv?: string } = {},
+) {
+ const root = mkdtempSync(join(tmpdir(), "cast-fleet-"));
+ for (const repo of REPOS) {
+ const dir = join(root, "repos", "heavy-duty", `${repo}.git`);
+ mkdirSync(join(dir, ".infra"), { recursive: true });
+ writeFileSync(join(dir, ".infra", "manifest.yaml"), manifest(repo));
+ const git = (...args: string[]) =>
+ execFileSync("git", args, { cwd: dir, stdio: "pipe" });
+ git("init", "-q");
+ git("add", "-A");
+ git(
+ "-c",
+ "user.email=cast@example.com",
+ "-c",
+ "user.name=cast",
+ "commit",
+ "-qm",
+ "manifest",
+ );
+ }
+ const state = join(root, "state");
+ mkdirSync(join(state, "secrets"), { recursive: true });
+ writeFileSync(
+ join(state, ".coolify.env"),
+ `COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
+ );
+ for (const repo of REPOS) {
+ // No template refs a secret, but the store still has to exist and open —
+ // a project whose store is missing is a project cast cannot read, which is
+ // a fleet ERROR, not a fleet skip (asserted below).
+ execFileSync("age", ["-r", recipient, "-o", `${repo}.staging.env.age`], {
+ input: "\n",
+ cwd: join(state, "secrets"),
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ }
+ const registry = opts.registry ?? SLUGS;
+ writeFileSync(
+ join(state, "environments.yaml"),
+ [
+ "environments:",
+ " staging:",
+ " server: fleet-box",
+ " team: { id: 0, name: Root Team }",
+ // A second environment, so "registered, but not HERE" is a state this
+ // fixture can express — it is the difference between a fleet nobody has
+ // registered and a fleet registered somewhere else.
+ " prod:",
+ " server: prod-box",
+ " team: { id: 0, name: Root Team }",
+ "github_apps:",
+ ...SLUGS.map((s) => ` ${s}: hdb-coolify`),
+ ...(registry.length > 0
+ ? [
+ "projects:",
+ ...registry.flatMap((slug) => [
+ ` ${slug}:`,
+ ` environments: [${opts.registryEnv ?? "staging"}]`,
+ ]),
+ ]
+ : []),
+ "",
+ ].join("\n"),
+ );
+ return { root, state };
+}
+
+function run(
+ verb: "apply" | "diff",
+ args: string[],
+ f?: { root: string },
+): Promise<{ code: number; output: string }> {
+ return new Promise((resolve) => {
+ const child = spawn("node", ["dist/cli.js", verb, ...args], {
+ stdio: ["ignore", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ CAST_AGE_KEY_FILE_STAGING: keyFile,
+ // The clone is real; only its origin is local. cast builds the URL as
+ // https://github.com//.git and git rewrites it here.
+ ...(f
+ ? {
+ GIT_CONFIG_COUNT: "1",
+ GIT_CONFIG_KEY_0: `url.${join(f.root, "repos")}/.insteadOf`,
+ GIT_CONFIG_VALUE_0: "https://github.com/",
+ }
+ : {}),
+ },
+ });
+ 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 }));
+ });
+}
+
+const fleet = (f: { state: string }) => [
+ "--env",
+ "staging",
+ "--state",
+ f.state,
+ "--all",
+];
+
+describe("cast diff --all (#26)", () => {
+ it("iterates the registry, reports each project, and exits 0 on a clean fleet", async () => {
+ const f = fixture((await stubCoolify()).url);
+ const r = await run("diff", fleet(f), f);
+ expect(r.code).toBe(0);
+ for (const slug of SLUGS) expect(r.output).toContain(slug);
+ expect(r.output).toContain("[1/3] heavy-duty/alpha");
+ expect(r.output).toContain("[3/3] heavy-duty/gamma");
+ // Coverage, said out loud. "clean" alone would be exactly the sentence a
+ // fleet run over nothing at all prints.
+ expect(r.output).toContain("registered: 3");
+ expect(r.output).toContain("read: 3 of 3");
+ expect(r.output).toContain(
+ "all 3 registered project(s) were read, and every one is clean.",
+ );
+ });
+
+ it("exits 1 on drift, naming the drifted project and still reporting the rest", async () => {
+ const f = fixture((await stubCoolify({ beta: "drift" })).url);
+ const r = await run("diff", fleet(f), f);
+ expect(r.code).toBe(1);
+ expect(r.output).toContain("someones-feature-branch");
+ expect(r.output).toContain("read: 3 of 3");
+ expect(r.output).toContain("drift: 1 heavy-duty/beta");
+ expect(r.output).toContain("clean: 2");
+ });
+
+ // The issue, in one test: a project cast could not reach is an ERROR, not a
+ // skip — and it does not stop the read either, because stopping would hide
+ // the drift in the projects never reached.
+ it("fails the fleet on an unreachable project, and still reads the ones after it", async () => {
+ const f = fixture(
+ (await stubCoolify({ beta: "absent", gamma: "drift" })).url,
+ );
+ const r = await run("diff", fleet(f), f);
+ // 2, not 1: an unreadable project is not a diff result, so it outranks the
+ // drift that WAS found.
+ expect(r.code).toBe(2);
+ expect(r.output).toContain("[3/3] heavy-duty/gamma");
+ expect(r.output).toContain("someones-feature-branch");
+ expect(r.output).toContain("read: 2 of 3");
+ expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
+ expect(r.output).toContain("FAILURE, not a diff result");
+ expect(r.output).not.toContain("every one is clean");
+ });
+
+ it("treats an HTTP error as unreachable, never as an empty project", async () => {
+ const f = fixture((await stubCoolify({ beta: "error" })).url);
+ const r = await run("diff", fleet(f), f);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
+ expect(r.output).toContain("500");
+ });
+
+ it("treats a missing secret store as unreachable, naming the file", async () => {
+ const f = fixture((await stubCoolify()).url);
+ execFileSync("rm", [join(f.state, "secrets", "beta.staging.env.age")]);
+ const r = await run("diff", fleet(f), f);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain(
+ "no secret store for heavy-duty/beta in staging",
+ );
+ expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
+ });
+});
+
+describe("cast diff --all — an empty fleet is not a clean fleet", () => {
+ it("refuses a state file with no registry at all", async () => {
+ const f = fixture((await stubCoolify()).url, { registry: [] });
+ const r = await run("diff", fleet(f), f);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain(
+ 'refusing to diff --all: no projects are registered for "staging"',
+ );
+ expect(r.output).toContain("no `projects:` block at all");
+ expect(r.output).toContain("environments: [staging]");
+ // The thing it must NOT have done — the ONLY reason this refusal exists.
+ // (The refusal quotes the phrase "0 projects, clean" to name the lie, so
+ // the assertion is on the verdict line a real run would have printed.)
+ expect(r.output).not.toContain("read:");
+ expect(r.output).not.toContain("were read, and every one is clean");
+ });
+
+ // The registry is not empty — it just has nothing to say about THIS
+ // environment. Same refusal, because it is the same silence: there is nothing
+ // to iterate, and a run over nothing must never print what a clean run prints.
+ it("refuses a registry that registers every project somewhere else", async () => {
+ const f = fixture((await stubCoolify()).url, { registryEnv: "prod" });
+ const r = await run("diff", fleet(f), f);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain(
+ "a registry, but nothing registered for this environment",
+ );
+ expect(r.output).toContain("heavy-duty/alpha (prod)");
+ });
+});
+
+describe("cast diff/apply --all — mutually exclusive coordinates", () => {
+ const cases: Array<[string, string[]]> = [
+ ["/", ["heavy-duty/alpha"]],
+ ["--path", ["--path", "/tmp/somewhere"]],
+ ["--project", ["--project", "Incubator"]],
+ ["--environment", ["--environment", "production"]],
+ ["--resource", ["--resource", "core=Stack v2"]],
+ ["--hostname-overlay", ["--hostname-overlay", "/tmp/overlay.yaml"]],
+ ];
+
+ it.each(cases)("refuses --all with %s", async (flag, args) => {
+ // No state dir and no Coolify: the refusal lands before cast opens either.
+ const r = await run("diff", ["--env", "staging", "--all", ...args]);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain(
+ `refusing to diff: --all cannot be combined with ${flag}`,
+ );
+ });
+
+ it("refuses on apply too, and says so as apply", async () => {
+ const r = await run("apply", [
+ "--env",
+ "staging",
+ "--all",
+ "--project",
+ "Incubator",
+ ]);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain(
+ "refusing to apply: --all cannot be combined with --project",
+ );
+ });
+});
+
+// The prod ban is older than --all (a feature-branch checkout must not decide
+// what prod runs), but --all is what moved it: hoisting loadBindings for the
+// registry put it AFTER the point where resolveCheckout used to catch this, so
+// the CLI now refuses up front and resolveCheckout still throws behind it. Two
+// call sites, one rule — and the up-front one is a code path of its own, so it
+// gets a test of its own rather than riding on resolve.test.ts's.
+describe("--path with --env prod is refused before anything is opened", () => {
+ it.each(["diff", "apply"] as const)(
+ "refuses on %s with no state dir, no store and no Coolify",
+ async (verb) => {
+ const r = await run(verb, [
+ "heavy-duty/alpha",
+ "--env",
+ "prod",
+ "--path",
+ "/tmp/somewhere",
+ ]);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain(
+ "refuses --path with --env prod: prod always reads the default branch",
+ );
+ },
+ );
+});
+
+describe("cast apply --all (#26)", () => {
+ it("applies every registered project and says what it did", async () => {
+ const f = fixture((await stubCoolify()).url);
+ const r = await run("apply", fleet(f), f);
+ expect(r.code).toBe(0);
+ expect(r.output).toContain("applied: 3 of 3");
+ expect(r.output).toContain("all 3 registered project(s) applied.");
+ expect(r.output).toContain(
+ "nothing changed — the fleet already matched its manifests.",
+ );
+ });
+
+ // The one disposition `apply` does not share with `diff`: it STOPS. Half a
+ // fleet mutated after an unexplained failure is not a fleet cast keeps writing
+ // to — and the report has to say which half.
+ it("stops at the first failure, and names what it did and did not touch", async () => {
+ const stub = await stubCoolify({ beta: "error" });
+ const f = fixture(stub.url);
+ const r = await run("apply", fleet(f), f);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain("STOPPED at the first failure");
+ expect(r.output).toContain("applied: 1 of 3 heavy-duty/alpha");
+ expect(r.output).toContain("FAILED: heavy-duty/beta");
+ expect(r.output).toContain("not reached: 1 heavy-duty/gamma");
+ expect(r.output).toContain('"not reached" were NOT touched');
+ // It did not merely SAY it stopped: gamma was never run…
+ expect(r.output).not.toContain("[3/3] heavy-duty/gamma");
+ // …and the box was never asked about it.
+ expect(stub.hits).not.toContain("/projects/p-gamma/staging");
+ });
+});
diff --git a/test/fleet.test.ts b/test/fleet.test.ts
new file mode 100644
index 0000000..8f3252c
--- /dev/null
+++ b/test/fleet.test.ts
@@ -0,0 +1,214 @@
+import { describe, expect, it } from "vitest";
+import { loadBindings } from "../src/bindings.js";
+import {
+ type ProjectOutcome,
+ fleetConflict,
+ fleetExitCode,
+ renderEmptyRegistry,
+ renderFleetApply,
+ renderFleetDiff,
+} from "../src/fleet.js";
+
+const REGISTERED = ["heavy-duty/alpha", "heavy-duty/beta", "heavy-duty/gamma"];
+
+const clean = (repo: string): ProjectOutcome => ({ repo, status: "clean" });
+const drift = (repo: string): ProjectOutcome => ({ repo, status: "drift" });
+const applied = (repo: string, mutated: string[] = []): ProjectOutcome => ({
+ repo,
+ status: "applied",
+ mutated,
+});
+const unreachable = (repo: string, message: string): ProjectOutcome => ({
+ repo,
+ status: "unreachable",
+ message,
+});
+
+// The contract, as a table. Everything else in this feature is prose a human
+// reads; this is the part CI reads, and the only part that can gate a pipeline.
+describe("fleetExitCode", () => {
+ it("diff: 0 only when every registered project was READ and every one is clean", () => {
+ expect(fleetExitCode("diff", REGISTERED, REGISTERED.map(clean))).toBe(0);
+ });
+
+ it("diff: 1 when all were read and at least one drifted", () => {
+ expect(
+ fleetExitCode("diff", REGISTERED, [
+ clean("heavy-duty/alpha"),
+ drift("heavy-duty/beta"),
+ clean("heavy-duty/gamma"),
+ ]),
+ ).toBe(1);
+ });
+
+ it("diff: 2 when a project could not be read — outranking drift", () => {
+ // The whole point of the ranking: an unreadable project is not a diff
+ // result, it is the ABSENCE of one. A run that reported 1 here would be
+ // saying "the fleet has drift", when what it actually has is a hole.
+ expect(
+ fleetExitCode("diff", REGISTERED, [
+ clean("heavy-duty/alpha"),
+ unreachable("heavy-duty/beta", "refusing to diff: no project named…"),
+ drift("heavy-duty/gamma"),
+ ]),
+ ).toBe(2);
+ });
+
+ it("diff: 2 when the outcomes do not cover the registry at all", () => {
+ // Fails closed on a coverage gap it does not otherwise recognize. A clean
+ // outcome for two of three projects is not a clean fleet — and the default
+ // an unrecognized shape falls into must never be 0.
+ expect(
+ fleetExitCode("diff", REGISTERED, [
+ clean("heavy-duty/alpha"),
+ clean("heavy-duty/beta"),
+ ]),
+ ).toBe(2);
+ });
+
+ it("apply: 0 only when every registered project applied", () => {
+ expect(
+ fleetExitCode(
+ "apply",
+ REGISTERED,
+ REGISTERED.map((r) => applied(r)),
+ ),
+ ).toBe(0);
+ expect(
+ fleetExitCode("apply", REGISTERED, [
+ applied("heavy-duty/alpha", ["core"]),
+ unreachable("heavy-duty/beta", "GET /projects/p2/staging → 500: boom"),
+ ]),
+ ).toBe(2);
+ });
+});
+
+describe("renderFleetDiff", () => {
+ it("states coverage, not just findings — a clean fleet says how many it read", () => {
+ const out = renderFleetDiff("prod", REGISTERED, REGISTERED.map(clean));
+ expect(out).toContain("registered: 3");
+ expect(out).toContain("read: 3 of 3");
+ expect(out).toContain(
+ "all 3 registered project(s) were read, and every one is clean.",
+ );
+ });
+
+ it("never lets an unreachable project read like a clean one", () => {
+ const out = renderFleetDiff("prod", REGISTERED, [
+ clean("heavy-duty/alpha"),
+ unreachable(
+ "heavy-duty/beta",
+ 'refusing to diff: no project named "beta" exists in this team\n\n looked for: …',
+ ),
+ clean("heavy-duty/gamma"),
+ ]);
+ expect(out).toContain("read: 2 of 3");
+ expect(out).toContain("UNREACHABLE: 1 heavy-duty/beta");
+ // The headline of the refusal travels with the summary; the body of it was
+ // printed in that project's own section.
+ expect(out).toContain('no project named "beta" exists in this team');
+ expect(out).not.toContain("looked for:");
+ expect(out).toContain("FAILURE, not a diff result");
+ expect(out).not.toContain("every one is clean");
+ });
+});
+
+describe("renderFleetApply", () => {
+ it("says what it applied when it applied everything", () => {
+ const out = renderFleetApply("prod", REGISTERED, [
+ applied("heavy-duty/alpha", ["core"]),
+ applied("heavy-duty/beta"),
+ applied("heavy-duty/gamma"),
+ ]);
+ expect(out).toContain("applied: 3 of 3");
+ expect(out).toContain("heavy-duty/alpha: core");
+ expect(out).toContain("all 3 registered project(s) applied.");
+ });
+
+ it("says what it did AND what it did not touch when it stopped", () => {
+ const out = renderFleetApply("prod", REGISTERED, [
+ applied("heavy-duty/alpha", ["core"]),
+ unreachable("heavy-duty/beta", "GET /projects/p2/staging → 500: boom"),
+ ]);
+ expect(out).toContain("applied: 1 of 3 heavy-duty/alpha");
+ expect(out).toContain("FAILED: heavy-duty/beta");
+ expect(out).toContain("not reached: 1 heavy-duty/gamma");
+ // The sentence an operator has to be able to trust before re-running.
+ expect(out).toContain('"not reached" were NOT touched');
+ expect(out).toContain("STOPPED at the first failure");
+ });
+});
+
+describe("renderEmptyRegistry", () => {
+ const bindings = (extra: string[]) =>
+ loadBindings("(inline)", {
+ overrideText: [
+ "environments:",
+ " prod:",
+ " server: prod-box",
+ " team: { id: 0, name: Root Team }",
+ " staging:",
+ " server: staging-box",
+ " team: { id: 0, name: Root Team }",
+ "github_apps:",
+ " heavy-duty/alpha: hdb-coolify",
+ ...extra,
+ "",
+ ].join("\n"),
+ });
+
+ it("names the environment, the file, and the YAML to write", () => {
+ const out = renderEmptyRegistry(
+ "diff",
+ "prod",
+ bindings([]),
+ "/state/environments.yaml",
+ );
+ expect(out).toContain(
+ 'refusing to diff --all: no projects are registered for "prod"',
+ );
+ expect(out).toContain("/state/environments.yaml");
+ expect(out).toContain("no `projects:` block at all");
+ expect(out).toContain("environments: [prod]");
+ // The sentence the refusal exists for.
+ expect(out).toContain('"0 projects, clean"');
+ });
+
+ it("distinguishes an unmigrated state file from one registered elsewhere", () => {
+ const out = renderEmptyRegistry(
+ "apply",
+ "prod",
+ bindings([
+ "projects:",
+ " heavy-duty/alpha:",
+ " environments: [staging]",
+ ]),
+ "/state/environments.yaml",
+ );
+ expect(out).toContain(
+ "a registry, but nothing registered for this environment",
+ );
+ expect(out).toContain("heavy-duty/alpha (staging)");
+ expect(out).not.toContain("no `projects:` block at all");
+ });
+});
+
+describe("fleetConflict", () => {
+ it("names the offending single-project coordinate", () => {
+ expect(fleetConflict({ "--project": "Incubator" })).toBe("--project");
+ expect(fleetConflict({ "--resource": ["core=Stack v2"] })).toBe(
+ "--resource",
+ );
+ expect(fleetConflict({ "/": "heavy-duty/alpha" })).toBe(
+ "/",
+ );
+ });
+
+ it("passes a legitimate fleet invocation", () => {
+ // parseArgs hands back `[]` for an unused repeatable flag, not `undefined`.
+ expect(
+ fleetConflict({ "--resource": [], "--path": undefined }),
+ ).toBeUndefined();
+ expect(fleetConflict({})).toBeUndefined();
+ });
+});