#!/usr/bin/env node import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { createInterface } from "node:readline/promises"; 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 { type LiveEnvs, absentResources, classify, renderAbsentResources, renderCapturePlan, } from "./capture.js"; import { type CoolifyInstance, DEFAULT_INSTANCE, assertWritable, formatInstance, loadInstance, } from "./config.js"; import { CoolifyClient, HttpError } from "./coolify.js"; import { type Live, type ResourceKind, computeDiff, renderDiff, } from "./diff.js"; import { type DraftProject, assertEmptyTarget, draftResourcesFrom, emitDraft, planDraft, renderAmbiguousEnvironments, renderDraftPlan, renderNoRecipient, renderRepoWithDraft, } from "./draft.js"; import { assertEnvVarPolicy } from "./envtemplate.js"; import { type ProjectOutcome, fleetConflict, fleetExitCode, renderEmptyRegistry, renderFleetApply, renderFleetConflict, renderFleetDiff, renderProjectHeading, } from "./fleet.js"; import { type LiveResource, type SweepEnvironment, type SweepProject, reconcile, renderInventory, renderSweep, } from "./inventory.js"; import { PATH_IN_PROD_REFUSAL, desiredFromManifest, manifestResources, refusesPathInProd, requiredSecrets, resolveCheckout, } from "./resolve.js"; import { decryptSecrets, encryptSecrets, keyFileFor, secretsFileFor, } from "./secrets.js"; import { serverAdd } from "./server.js"; 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 cast inventory --env --emit-draft [--recipient age1…] [--no-secrets] cast server add --ip --key --env [--user root] [--port 22] cast smoke / --env [--project ] [--environment ] cast team [--env ] --state the state checkout holding environments.yaml, secrets/ and .coolify.env (default: $CAST_STATE, else the cwd) --env the environment to act on. Every command that reaches a live Coolify takes one, because every one of them first asserts the token belongs to that environment's declared team. \`cast team\` alone (no --env) reports the token's team without needing a binding — use it to fill environments.yaml. --instance the Coolify to talk to: /.coolify/.env, instead of /.coolify.env. Bind one per environment in environments.yaml (\`instance: \`) and --env selects it with no flag; an explicit --instance still wins. An instance may declare COOLIFY_READ_ONLY=true, and then no command that writes will run against it. --project the Coolify project to act on, when it is not named after the repo (the default). A project built by hand in the UI is called whatever someone typed; \`diff\` refuses rather than reporting an absent project as an empty one, and this is how you point it at the real name. --environment the Coolify environment to act on, when it is not named after --env (the default). Same problem as --project, one level down: a box built by hand has whatever Coolify defaulted to, which is \`production\`, not \`prod\`. This changes ONLY the name on the wire — --env still selects the manifest block, the environments.yaml binding, the age key and the store path. --resource = the same problem, one level further down: a hand-built box names resources for a human reading a UI ("Incubator Stack v2"), a manifest names them for a diff (\`core\`). Repeatable. Read-side only (\`diff\`, \`capture\`, \`inventory\`) — \`apply\` creates under the manifest's names and refuses this flag. --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, for a manifest that has not declared generated_secrets yet. Repeatable. --override supply NAME yourself instead of copying the source's value. The VALUE is read from \$CAST_CAPTURE_, never from the command line — argv is visible in \`ps\`. Repeatable. --force overwrite an existing store (refused by default). inventory --emit-draft (write down what a box has, as a PROPOSAL): --emit-draft emit what the sweep saw as a draft of cast's own inputs — a manifest per project, env templates, an environments.yaml carrying the \`projects:\` registry, an age store per project, and UNCAPTURED.md. Into a NEW directory, always: a draft is a proposal, reviewed by a human and landed as a PR, and \`apply\` never reads one. SWEEP MODE ONLY — with a repo there is already a manifest, and a manifest regenerated from a live box would overwrite a reviewed spec with that box's accumulated cruft. --recipient age1… the age recipient the draft's stores are encrypted to. Defaults to the environment's \`age_recipient\` binding. --no-secrets emit no stores. Required when no recipient is available: cast will not silently drop the values it read off the box. --environment a TIEBREAK, not a filter: which environment to draft for a project that has resources in more than one (cast refuses to pick). A project with only one populated environment is drafted from it either way — filtering the instance by an environment name would drop whole projects out of a blueprint that claims to describe the box.`; // cast is stateless: every instance-scoped input is read from the state // directory it is pointed at, never from a location the tool itself knows. function stateDirFrom(flag: string | undefined): string { return flag ?? process.env.CAST_STATE ?? "."; } // Resolve which Coolify to talk to, announce it, and open a client on it. // // Precedence: --instance > the environment's `instance:` binding > the // default .coolify.env. Every command that reaches a live Coolify goes through // here, so every one of them SAYS which Coolify it is about to touch, right // next to the team assert. The connection target used to be implicit in // .coolify.env's current contents — retargeting meant hand-editing a live // credential file and putting it back afterwards, and the failure mode of // getting it wrong is running `apply` against production. function openCoolify( stateDir: string, flag: string | undefined, binding?: { instance?: string }, ): { instance: CoolifyInstance; client: CoolifyClient } { const instance = loadInstance(stateDir, flag ?? binding?.instance); console.log(formatInstance(instance)); return { instance, client: new CoolifyClient(instance.baseUrl, instance.token), }; } // Live Coolify objects use their own field vocabulary; computeDiff compares // by the DESIRED vocabulary, so each live resource must be projected onto it // or every run reports spurious drift (breaks idempotency, criterion 2). // Source keys per reference/coolify-openapi-4.1.2.json, cross-checked // against the coollabsio/coolify v4.1.2 controller/model source where the // vendored doc is silent or wrong (see task-8-report.md for the full list). const DATABASE_TYPE_ALIASES: Record = { "standalone-postgresql": "postgresql", "standalone-redis": "redis", }; export function databaseVersionFromImage(image: unknown): string | undefined { if (typeof image !== "string") return undefined; const tag = image.split(":")[1]; const m = tag?.match(/^(\d+(?:\.\d+)*)/); return m?.[1]; } // Coolify's GET application model exposes `docker_compose_domains` as a // nullable string (reference/coolify-openapi-4.1.2.json ~line 12689), not // the structured array the create/update request bodies accept (~line 353) — // the live value is the same array-of-{name,domain} shape, JSON-encoded. // Parses defensively: anything that isn't a JSON-encoded array of well-formed // {name, domain} entries collapses to `undefined` rather than throwing, so a // live instance that turns out not to expose this (unverified until Task 8 // step 6) degrades to "field omitted", not a crash. export function parseDockerComposeDomains( raw: unknown, ): Record | undefined { if (typeof raw !== "string" || raw.length === 0) return undefined; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return undefined; } if (!Array.isArray(parsed)) return undefined; const map: Record = {}; for (const entry of parsed) { const name = (entry as { name?: unknown } | null)?.name; const domain = (entry as { domain?: unknown } | null)?.domain; if (typeof name === "string" && typeof domain === "string") { map[name] = domain.split(",").filter(Boolean); } } return map; } export function projectLiveFields( kind: ResourceKind, raw: Record, ): Record { if (kind === "application") { const composeDomains = parseDockerComposeDomains( raw.docker_compose_domains, ); return { git_repository: raw.git_repository, git_branch: raw.git_branch, build_pack: raw.build_pack, base_directory: raw.base_directory, ...(raw.publish_directory ? { publish_directory: raw.publish_directory } : {}), ...(raw.ports_exposes ? { port: Number(raw.ports_exposes) } : {}), ...(raw.health_check_path ? { healthcheck: raw.health_check_path } : {}), domains: String(raw.fqdn ?? "") .split(",") .filter(Boolean), ...(raw.docker_compose_location ? { docker_compose_location: raw.docker_compose_location } : {}), ...(composeDomains ? { docker_compose_domains: composeDomains } : {}), }; } if (kind === "database") { // GET /projects/{uuid}/{env} returns raw Postgresql/Redis Eloquent // models (see fetchLive) — the vendored OpenAPI documents no schema for // these at all ("Content is very complex. Will be implemented later."). // `database_type` is a model accessor (app/Models/StandalonePostgresql.php // / StandaloneRedis.php @ v4.1.2) returning "standalone-postgresql" / // "standalone-redis"; normalized here to the manifest's plain // "postgresql"/"redis" vocabulary. There is no `version` field on the // wire — we best-effort recover it from the leading digits of the // `image` tag, mirroring the convention Coolify's own "New Resource" // wizard writes on create (see defaultDatabaseImage below). const rawType = String(raw.database_type ?? raw.type ?? ""); const type = DATABASE_TYPE_ALIASES[rawType] ?? rawType; const version = databaseVersionFromImage(raw.image); return { type, ...(version ? { version } : {}) }; } return { type: raw.type ?? raw.service_type, // Coolify's live `Service` model carries no flat `fqdn` — hostnames // live per-container on service.applications[].fqdn // (app/Models/Service.php @ v4.1.2), which this environment-list call // doesn't eager-load. We deliberately don't fabricate a `domains` value // here; see serviceApiFields below for the matching create/update-side // limitation. }; } // The live side of a diff/apply is either "here are the resources" or "the // thing I was told to look at does not exist" — and those two must NOT collapse // into the same value. // // They used to: both returned []. That is right for `apply` (a first apply // legitimately creates the project and its environment) and quietly wrong for // `diff`, because computeDiff(desired, []) means "every desired resource is // missing" — rendered as a confident full-create plan. So a diff pointed at a // project name that does not exist reports a CLEAN-LOOKING plan that verified // nothing at all. Same shape of lie as the wrong-team token in team.ts: an // unverifiable read that answers "absent" and invites a create. // // Keeping the distinction in the type is what lets each caller take its own // (opposite, and both correct) position on absence. export type LiveLookup = | { found: true; live: Live[] } | { found: false; missing: "project"; project: string; available: string[]; } | { found: false; missing: "environment"; project: string; environment: string; }; export async function fetchLive( client: CoolifyClient, projectName: string, envName: string, ): Promise { const projects = (await client.get("/projects")) as Array<{ uuid: string; name: string; }>; const project = projects.find((p) => p.name === projectName); if (!project) { return { found: false, missing: "project", project: projectName, available: projects.map((p) => p.name), }; } // GET /projects/{uuid}/{environment_name_or_uuid} eager-loads exactly // these relations (app/Http/Controllers/Api/ProjectController.php // @environment_details, coollabsio/coolify v4.1.2): applications, // postgresqls, redis, mongodbs, mysqls, mariadbs, services. The vendored // OpenAPI's `Environment` schema response omits all of them (published // doc gap — the brief's `env.databases` shape does not exist on the // wire). We only map postgresql/redis: the two database types // manifest.ts's DatabaseSpecSchema supports. const env = (await client .get(`/projects/${project.uuid}/${envName}`) .catch((err) => { // Missing environment (first apply into a project without it yet) is // a 404 and means "no live resources"; anything else (401, 5xx, // network) must surface, not be silently treated as an empty diff — // that would cause createResource to attempt duplicate resources. if (err instanceof HttpError && err.status === 404) { return null; } throw err; })) as { applications?: Array>; postgresqls?: Array>; redis?: Array>; services?: Array>; } | null; if (!env) { return { found: false, missing: "environment", project: projectName, environment: envName, }; } const map = ( kind: ResourceKind, items: Array> = [], ): Live[] => items.map((i) => ({ kind, name: String(i.name), uuid: String(i.uuid), fields: projectLiveFields(kind, i), env: undefined, // populated per-resource below only in full mode by caller // The one thing Coolify will tell us about placement. `destination_id` is // a plain column on all three resource tables and none of the three // controllers' removeSensitiveData() hides it (v4.1.2), so it survives // into this response — whereas the destination's UUID never appears in // any response at all, because environment_details does not eager-load // the `destination` relation and no endpoint exposes it. See Placement. destinationId: typeof i.destination_id === "number" ? i.destination_id : undefined, })); return { found: true, live: [ ...map("application", env.applications), ...map("database", env.postgresqls), ...map("database", env.redis), ...map("service", env.services), ], }; } // Why `diff` refuses instead of reporting an empty live side: see LiveLookup. // The message has one job — make it impossible to read "absent" as "empty" — // so it names what was looked for, where the name came from, and what actually // exists next to it. export function renderAbsentTarget( lookup: Extract, ctx: { orgRepo: string; overridden: boolean; envOverridden?: boolean; verb?: string; }, ): string { // `capture` takes the same position as `diff`, and for the same reason: it // is only ever a claim about something that already exists. Against an // absent target it would read back zero live values and call every required // secret "missing" — an alarming-but-meaningless report about the wrong box. const verb = ctx.verb ?? "diff"; const origin = ctx.overridden ? "--project" : `derived from the repo slug ${ctx.orgRepo}`; const envOrigin = ctx.envOverridden ? "--environment" : "derived from --env"; const head = lookup.missing === "project" ? [ `refusing to ${verb}: no project named "${lookup.project}" exists in this team`, "", ` looked for: project "${lookup.project}" (${origin})`, ` exists here: ${lookup.available.join(", ") || "(no projects at all)"}`, ] : [ `refusing to ${verb}: project "${lookup.project}" has no environment "${lookup.environment}"`, "", ` looked for: environment "${lookup.environment}" in project "${lookup.project}" (${envOrigin})`, " note: a project built by hand in the Coolify UI may well use a", " different name for the same tier — Coolify's own default is", " `production`, not `prod`.", ]; return [ ...head, "", "An absent target reads back exactly like an empty one, so continuing would diff", 'it as "nothing exists — create everything": a clean-looking report that verified', `nothing. \`apply\` may create a target; \`${verb}\` may only ever describe one that is`, "already there.", "", lookup.missing === "project" ? "Pass --project if this instance names it differently." : // NOT "rename your environment to match the box". The box does not get to // name our environments: --env selects the manifest block, the binding, the // age key and the store path, and a hand-built box being evicted next week // must not decide any of them. --environment is the coordinate for reading // it, and it changes nothing on our side of the line. "Pass --environment if this instance names it differently.", ].join("\n"); } // The same disposition as renderAbsentTarget, one level deeper, and for the one // verb that WRITES: the project and the environment are both there, and hold no // application of the name `smoke` was told to write to. // // Until #29, `smoke` never got here. It resolved its target against // GET /applications — every application the token can see, across every project // and every environment of the instance — and wrote to the first name match. So // `smoke_target: core` did not name an application; it named whichever `core` // Coolify happened to list first, and one instance carrying prod and staging is // enough for that to be prod's. The canary vars land on an app nobody named, and // on the failure path they stay there. // // This message therefore does NOT offer to look elsewhere, and the code behind it // does not either. An application in another project is not the same application // seen from a different angle — it is a different application, and this verb // writes. The only thing worth saying is: here is where I looked, here is what is // actually in there, and here is which coordinate to correct. export function renderAbsentSmokeTarget( target: string, live: Array<{ kind: ResourceKind; name: string }>, ctx: { orgRepo: string; env: string; project: string; environment: string }, ): string { const apps = live.filter((l) => l.kind === "application").map((l) => l.name); // A service or a database of that name is not a near-miss to be accommodating // about — smoke POSTs to /applications//envs, so being pointed at one // would 404 on an endpoint that does not exist for that kind, and the operator // would spend the afternoon on an HTTP status instead of on the name. const sameName = live.find((l) => l.name === target); const wrongKind = sameName && sameName.kind !== "application" ? [ "", ` but note: "${target}" DOES exist here — as a ${sameName.kind}, not an`, " application. `smoke` writes to an application's /envs endpoint;", ` a ${sameName.kind} of the same name is a different resource behind a`, " different endpoint, not this one seen sideways.", ] : []; return [ `refusing to smoke: project "${ctx.project}" / environment "${ctx.environment}" holds no application named "${target}"`, "", ` looked for: application "${target}"`, ` (environments.${ctx.env}.projects["${ctx.orgRepo}"].smoke_target)`, ` in: project "${ctx.project}", environment "${ctx.environment}"`, ` exists here: ${apps.join(", ") || "(no applications at all)"}`, ...wrongKind, "", "cast will not go looking for that name anywhere else on this instance. A bare", "application name is unique only INSIDE a project and an environment, so the first", `\`${target}\` the API lists may belong to another project — or to prod, while you are`, "smoking staging (#29). `smoke` POSTs two canary env vars to the application it", "resolves, and deletes them again; on the failure path it leaves them behind. An", "app it was not pointed at is not a fallback.", "", "Name the application as it exists here, or pass --project / --environment if this", "instance names the project or the environment differently.", ].join("\n"); } // The third name a hand-built box does not share with you: the RESOURCE. // // `--project` and `--environment` are coordinates for finding the target; // `--resource` is the coordinate for finding the things inside it. A box built // by hand names its resources for humans reading a UI ("Incubator Stack v2"), // while a manifest names them for machines reading a diff (`core`). Neither is // wrong, and neither gets to overwrite the other — so the mapping is stated at // the call site and applied at the boundary. // // It is deliberately NOT a manifest field: a manifest that recorded its own // legacy names would carry a dead box's vocabulary forever, which is the exact // failure #17 exists to prevent. This is an argument to a one-off read. export function parseResourceAliases( pairs: string[], declared: string[], ): Record { const alias: Record = {}; for (const pair of pairs) { const eq = pair.indexOf("="); if (eq <= 0 || eq === pair.length - 1) { throw new Error( `--resource expects =, got "${pair}"`, ); } const from = pair.slice(0, eq).trim(); const to = pair.slice(eq + 1).trim(); // A typo here would be silent and expensive: the alias would map nothing, // the manifest's real resource would still be looked up under its own name, // and the run would refuse (or capture) with no hint that the flag missed. if (!declared.includes(from)) { throw new Error( [ `--resource ${from}=${to}: the manifest declares no resource named "${from}"`, "", ` declares: ${declared.join(", ") || "(nothing)"}`, "", "The left side is the MANIFEST's name; the right side is what this box", "calls the same thing.", ].join("\n"), ); } alias[from] = to; } return alias; } // Rename live resources to the manifest's vocabulary, once, at the boundary. // Everything downstream — computeDiff, classify, reconcile — then matches by // name as it always has, and none of them needs to know a box was involved. export function aliasLive( live: T[], alias: Record, ): Array { const toManifestName = new Map( Object.entries(alias).map(([manifest, box]) => [box, manifest]), ); return live.map((l) => { const manifestName = toManifestName.get(l.name); return manifestName ? { ...l, name: manifestName, sourceName: l.name } : l; }); } // A live resource's env vars, by key. `real_value` is the decrypted one and // needs a token with read:sensitive; `value` is what a lesser token sees. // // A 404 (a resource we just listed no longer having an envs endpoint — not // expected in practice, but consistent with treating "gone" as "no env vars") // collapses to {}; anything else (401, 5xx, network) must surface. Swallowing // it would make a live resource's env look EMPTY, which turns every one of its // vars into a spurious create in a diff, and into a spurious "missing" in a // capture. async function fetchEnv( client: CoolifyClient, l: Pick, ): Promise> { const base = l.kind === "database" ? "databases" : `${l.kind}s`; const envs = (await client.get(`/${base}/${l.uuid}/envs`).catch((err) => { if (err instanceof HttpError && err.status === 404) return []; throw err; })) as Array<{ key: string; real_value?: string; value: string }>; return Object.fromEntries(envs.map((e) => [e.key, e.real_value ?? e.value])); } // The value for an --override, read from the ENVIRONMENT rather than argv. // // A secret passed as a command-line argument is visible in `ps` to every // process on the box, and lands in shell history — the same class of leak the // clone-auth fix (#13) exists to avoid. So --override names the secret and the // environment carries it. function readOverrides(names: string[]): Record { const out: Record = {}; for (const name of names) { const varName = `CAST_CAPTURE_${name}`; const value = process.env[varName]; if (value === undefined) { throw new Error( [ `--override ${name}: no value supplied.`, "", `cast reads an override's value from ${varName}, never from the command`, "line — an argv value is visible in `ps` to every process on this box.", "", ` ${varName}=… cast capture …`, ].join("\n"), ); } out[name] = value; } return out; } // Typed confirmation, and deliberately NOT a --yes flag. // // This verb writes an environment's secret store, once, off a box nobody is // going to rebuild. The entire reason it exists is that the hand-run version // was easy to get subtly wrong — so the last gate is a human who has read the // provenance column typing the environment's own name. Nothing shorter counts: // not "y", not a flag. Automating it means deliberately echoing the // environment name into cast, which is an explicit act rather than an absent // one. // // EOF (a closed or empty stdin) resolves to `null` and aborts. Without that // race, a `< /dev/null` run would hang forever on a question nobody can answer. async function confirmCapture(envName: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise((resolve) => { rl.question( `\ntype the environment name to write this store (${envName}): `, ).then(resolve, () => resolve(null)); rl.once("close", () => resolve(null)); }); rl.close(); return answer?.trim() === envName; } // 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") { console.log(USAGE); return 0; } if (command === "apply" || command === "diff") { const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { env: { type: "string" }, path: { type: "string" }, state: { type: "string" }, project: { type: "string" }, environment: { type: "string" }, resource: { type: "string", multiple: true }, 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; // --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 // than creating. That is a different operation, nobody has asked for it, and // guessing at it would silently create a duplicate beside the very resource // the operator was pointing at. if (command === "apply" && (values.resource?.length ?? 0) > 0) { console.error( [ "refusing to apply: --resource is a read-side coordinate", "", "It exists so `diff`, `capture` and `inventory` can READ a box whose", "resources are named differently. `apply` creates resources under the", "manifest's own names, so an alias here would have to mean 'adopt the", "existing one instead' — which is not what this flag does.", ].join("\n"), ); return 2; } const stateDir = stateDirFrom(values.state); 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; } // 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, values.instance, binding, ); if (command === "apply") assertWritable(instance, "apply"); // Fail-closed, before the first live read — not merely before the first // write. A wrong-team token makes fetchLive come back empty (the API // resolves what it cannot see to null), so an unasserted `diff` would // cheerfully report "everything is absent" and an unasserted `apply` // would then create all of it in the wrong team. The read is already // the lie; gate it, not just the write. // // 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 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 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; } } console.log( command === "diff" ? renderFleetDiff(envName, targets, outcomes) : renderFleetApply(envName, targets, outcomes), ); return fleetExitCode(command, targets, outcomes); } if (command === "capture") { const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { env: { type: "string" }, state: { type: "string" }, path: { type: "string" }, project: { type: "string" }, environment: { type: "string" }, resource: { type: "string", multiple: true }, instance: { type: "string" }, generated: { type: "string", multiple: true }, override: { type: "string", multiple: true }, force: { type: "boolean", default: false }, }, }); const orgRepo = positionals[0]; const envName = values.env; if (!orgRepo || !envName) { console.error(USAGE); return 2; } const stateDir = stateDirFrom(values.state); const repoShort = orgRepo.split("/")[1]; const projectName = values.project ?? repoShort; // Their name for the environment, on the wire. The store below stays keyed // by OUR name (--env) — capture is the verb most likely to be pointed at a // hand-built box, and the store it writes must not inherit that box's // vocabulary. const coolifyEnv = values.environment ?? envName; const store = secretsFileFor(stateDir, repoShort, envName); // Never overwrite a store by accident. `apply` never deletes; the verb // that WRITES the store gets the same disposition, because the thing it // would destroy is the only copy of values that may not exist anywhere // else any more. if (existsSync(store) && !values.force) { console.error( [ `refusing to capture: ${store} already exists`, "", "That store may hold the only copy of values the source box no longer has.", "Pass --force to overwrite it deliberately, or move it aside first.", ].join("\n"), ); return 2; } const bindings = loadBindings(join(stateDir, "environments.yaml")); const binding = bindings.environments[envName]; if (!binding) { console.error(`environment ${envName} not in environments.yaml`); return 2; } const recipient = binding.age_recipient; if (!recipient) { console.error( [ `environment ${envName} has no age_recipient in environments.yaml`, "", "capture encrypts the store TO that recipient (the public half of the", "environment's age key — safe to commit next to the bindings). Add it:", "", " environments:", ` ${envName}:`, " age_recipient: age1…", ].join("\n"), ); return 2; } // Same rule as apply (resolveCheckout enforces it): prod always reads the // default branch. A feature-branch manifest must not be able to decide // which names land in the prod store. const checkout = resolveCheckout(orgRepo, { env: envName, path: values.path, }); const { required, generated } = requiredSecrets(checkout, envName); const overrides = readOverrides(values.override ?? []); const { client } = openCoolify(stateDir, values.instance, binding); // capture READS Coolify and writes only to the local store, so it is // allowed against a read-only instance — inspecting a legacy box is // precisely what such an instance is for. It still takes the team assert: // a wrong-team token reads back nothing, and "nothing" here would render // as "every secret is missing" against a box that is fine. const team = await assertTeam(client, binding.team, envName); console.log(`team ${formatTeam(team)} ✓`); const lookup = await fetchLive(client, projectName, coolifyEnv); if (!lookup.found) { console.error( renderAbsentTarget(lookup, { orgRepo, overridden: values.project !== undefined, envOverridden: values.environment !== undefined, verb: "capture", }), ); return 2; } const aliases = parseResourceAliases( values.resource ?? [], manifestResources(checkout, envName).map((r) => r.name), ); const aliased = aliasLive(lookup.live, aliases); // Databases hold no manifest-templated env of their own — their URL is what // the APPS reference, and that name is generated, not captured. const envBearing = aliased.filter((l) => l.kind !== "database"); // Before reading a single env: does every resource the manifest requires a // secret FROM actually exist here? An absent resource reads back exactly // like one with no env vars set — every name it declares reports MISSING — // and the suggested remedy for MISSING (--override) would then have the // operator hand-carry values that are sitting right there under a different // name, burying the real finding. Same lie as the absent project, one level // deeper. See absentResources. const absent = absentResources( required, envBearing.map((l) => l.name), ); if (absent.length > 0) { console.error( renderAbsentResources(absent, lookup.live, { project: projectName, environment: coolifyEnv, }), ); return 2; } const liveEnvs: LiveEnvs = {}; for (const l of envBearing) { liveEnvs[l.name] = await fetchEnv(client, l); } const classification = classify( required, [...generated, ...(values.generated ?? [])], liveEnvs, overrides, ); console.log( renderCapturePlan(classification, { orgRepo, env: envName, instance: values.instance ?? binding.instance ?? "default", store, recipient, }), ); // Refuse, don't write a wrong store. Both of these are stop conditions, // and the plan above has already named every offending entry. if ( classification.missing.length > 0 || classification.conflicts.length > 0 ) return 2; if (!(await confirmCapture(envName))) { console.error("aborted — nothing written"); return 2; } encryptSecrets( recipient, store, Object.fromEntries(classification.plan.map((d) => [d.ref, d.value])), ); console.log( `wrote ${store} — ${classification.plan.length} name(s), encrypted to ${recipient}`, ); return 0; } if (command === "inventory") { const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { env: { type: "string" }, state: { type: "string" }, path: { type: "string" }, project: { type: "string" }, environment: { type: "string" }, resource: { type: "string", multiple: true }, instance: { type: "string" }, "emit-draft": { type: "string" }, recipient: { type: "string" }, "no-secrets": { type: "boolean", default: false }, }, }); const orgRepo = positionals[0]; const envName = values.env; if (!envName) { console.error(USAGE); return 2; } const draftDir = values["emit-draft"]; // A draft is emitted from the SWEEP, and only from the sweep. With a repo, // inventory is reconciling against a manifest that already exists — which is // exactly the case where a draft must not be written: for a declared project // the manifest IS the truth, and one regenerated from a live box would let // that box's accumulated cruft overwrite the reviewed spec. Adoption is // one-way, so the two flags cannot be combined at all. if (draftDir && orgRepo) { console.error(renderRepoWithDraft(orgRepo, draftDir)); return 2; } const stateDir = stateDirFrom(values.state); const sweepBindings = loadBindings(join(stateDir, "environments.yaml")); const sweepBinding = sweepBindings.environments[envName]; if (!sweepBinding) { console.error(`environment ${envName} not in environments.yaml`); return 2; } // NO REPO → SWEEP. There is nothing to reconcile against, so don't pretend // to: just show what is on the box. This is the pass that has to come first // when the box is one you did not build, and requiring coordinates for it // made inventory a discovery verb that needed you to have already // discovered. if (!orgRepo) { // Both refusals BEFORE the first live call. A draft that is going to be // refused should be refused before an operator watches a whole instance be // swept for it — and, more to the point, before cast reads every env var on // a box it is then not going to write down. const recipient = values.recipient ?? sweepBinding.age_recipient; if (draftDir) { try { assertEmptyTarget(draftDir); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); return 2; } // No recipient, no store — and cast will not make that decision quietly. // Silently skipping the secrets would emit a draft that LOOKS complete: a // manifest, templates full of ${REF}s, and nothing anywhere holding a // single value. You would find out when `apply` refused, having already // deleted the box the values were on. if (!recipient && !values["no-secrets"]) { console.error(renderNoRecipient(envName)); return 2; } } const { instance, client } = openCoolify( stateDir, values.instance, sweepBinding, ); // The team assert matters MORE here than anywhere: Coolify scopes what a // token can see to its team, so a wrong-team token sweeps an instance and // truthfully reports that it is empty. const team = await assertTeam(client, sweepBinding.team, envName); console.log(`team ${formatTeam(team)} ✓`); const live = await client.projects(); const projects: SweepProject[] = []; for (const p of live) { const environments: SweepEnvironment[] = []; for (const name of await client.environments(p.uuid)) { const found = await fetchLive(client, p.name, name); environments.push({ name, resources: found.found ? found.live.map((l) => ({ kind: l.kind, name: l.name })) : [], }); } projects.push({ name: p.name, environments }); } console.log( renderSweep(projects, { instance: instance.name, baseUrl: instance.baseUrl, }), ); if (!draftDir) return 0; // --- The draft (#27) --- // // The sweep above is a DOCUMENT. This is the same reading, written into the // shape of cast's own inputs — and it is still a proposal, not desired // state. See draft.ts for the boundary that lets this verb exist at all. // // A project with resources in TWO environments cannot be drafted without // picking one, and cast does not pick: a blueprint of half a box, silently // chosen, is the failure mode this whole issue is about. --environment says // which. // // --environment is a TIEBREAK here, not a filter. A project with resources // in exactly one environment has no tie to break, and is drafted from it // whatever the flag says — filtering the instance by an environment NAME // would drop the projects that most need drafting (the third-party sites, // each sitting in its own Coolify-default `production`) out of a blueprint // that claims to describe the box. const populatedIn = (p: SweepProject) => p.environments.filter((e) => e.resources.length > 0); const pick = (p: SweepProject) => { const populated = populatedIn(p); return populated.length === 1 ? populated[0] : populated.find((e) => e.name === values.environment); }; const ambiguous = projects.filter( (p) => populatedIn(p).length > 1 && !pick(p), ); if (ambiguous.length > 0) { console.error( renderAmbiguousEnvironments( ambiguous.map((p) => ({ name: p.name, environments: populatedIn(p).map( (e) => `${e.name} (${e.resources.length})`, ), })), envName, ), ); return 2; } const draftProjects: DraftProject[] = []; for (const p of live) { const swept = projects.find((s) => s.name === p.name); const populated = swept ? populatedIn(swept) : []; const chosen = swept ? pick(swept) : undefined; const others = populated .filter((e) => e.name !== chosen?.name) .map((e) => ({ name: e.name, resources: e.resources.length })); if (!chosen) { // Not drafted — and SAID, in UNCAPTURED.md, rather than left out of a // blueprint that a reader would take for the whole box. draftProjects.push({ name: p.name, coolifyEnv: "(none)", resources: [], unreadable: [], otherEnvironments: others, skipReason: "every environment on it is empty", }); continue; } // The RAW environment document, not fetchLive's projection: the uncaptured // pass's whole job is to notice fields cast has no home for, and it cannot // notice what a projection has already thrown away. const raw = (await client.get( `/projects/${p.uuid}/${chosen.name}`, )) as Record | null; const { resources, unreadable } = draftResourcesFrom(raw ?? {}); for (const r of resources) { // Databases hold no manifest-templated env of their own — their URL is // what the APPS reference, and that name is generated, not captured. if (r.kind === "database") continue; r.env = await fetchEnv(client, { kind: r.kind, uuid: r.uuid }); } draftProjects.push({ name: p.name, coolifyEnv: chosen.name, resources, unreadable, otherEnvironments: others, }); } // Which GitHub App clones a repo is NOT a property of any resource — no // field Coolify returns about an application says so. What the instance can // answer is which Apps exist; with exactly one, there is no other it could // be. Best-effort: an instance that will not list them still gets a draft, // with a REVIEW marker where the binding goes. const githubApps = (await client.get("/github-apps").catch(() => [])) as | Array<{ name?: unknown }> | undefined; const draftCtx = { env: envName, instance: instance.name, baseUrl: instance.baseUrl, team, server: sweepBinding.server, githubApps: (Array.isArray(githubApps) ? githubApps : []) .map((a) => a?.name) .filter((n): n is string => typeof n === "string"), recipient, generatedAt: new Date().toISOString(), }; const storeRecipient = values["no-secrets"] ? undefined : recipient; const plan = planDraft(draftProjects, draftCtx); const written = emitDraft(draftDir, plan, { recipient: storeRecipient }); console.log( renderDraftPlan(plan, draftCtx, { dir: draftDir, recipient: storeRecipient, written, }), ); return 0; } const repoShort = orgRepo.split("/")[1]; const projectName = values.project ?? repoShort; const coolifyEnv = values.environment ?? envName; const bindings = loadBindings(join(stateDir, "environments.yaml")); const binding = bindings.environments[envName]; if (!binding) { console.error(`environment ${envName} not in environments.yaml`); return 2; } // Deliberately NOT resolveCheckout's prod ban, no secrets, no age key, no // age_recipient: inventory runs BEFORE any store exists — that is the whole // point of it — and it reads nothing it could leak. A read token is enough. const checkout = resolveCheckout(orgRepo, { env: envName, path: values.path, }); const manifest = manifestResources(checkout, envName); const { client } = openCoolify(stateDir, values.instance, binding); // Read-only instances are exactly what this verb is for. It still takes the // team assert: a wrong-team token reads back nothing, and "nothing" would // render here as "the box is empty" — the same lie, dressed as a report. const team = await assertTeam(client, binding.team, envName); console.log(`team ${formatTeam(team)} ✓`); const lookup = await fetchLive(client, projectName, coolifyEnv); if (!lookup.found) { console.error( renderAbsentTarget(lookup, { orgRepo, overridden: values.project !== undefined, envOverridden: values.environment !== undefined, verb: "inventory", }), ); return 2; } // With --resource, inventory stops reporting "these five are missing / these // five are unknown" and starts reporting what you actually want to know: // for each PAIR, which env keys differ. The report keeps the box's own name // beside ours, because losing it would make the document unusable against // the UI it describes. const inventoryAliases = parseResourceAliases( values.resource ?? [], manifest.map((r) => r.name), ); const live: LiveResource[] = []; for (const l of aliasLive(lookup.live, inventoryAliases)) { // Keys, never values — see renderInventory. Databases carry no env of // their own worth reconciling (their URL is what the apps reference). const envKeys = l.kind === "database" ? [] : Object.keys(await fetchEnv(client, l)); live.push({ kind: l.kind, name: l.name, envKeys, ...(l.sourceName ? { sourceName: l.sourceName } : {}), }); } console.log( renderInventory(reconcile(manifest, live), { orgRepo, env: envName, instance: values.instance ?? binding.instance ?? DEFAULT_INSTANCE, project: projectName, environment: coolifyEnv, }), ); // Always 0: this is a report, not a gate. `diff` is the gate. return 0; } if (command === "server" && rest[0] === "add") { const { values, positionals } = parseArgs({ args: rest.slice(1), allowPositionals: true, options: { ip: { type: "string" }, key: { type: "string" }, env: { type: "string" }, user: { type: "string" }, port: { type: "string" }, state: { type: "string" }, instance: { type: "string" }, }, }); // --env is required: a server is registered under the token's team and // belongs to exactly one team forever (Coolify has no pivot and no // is_system_wide escape hatch for servers). Registering it under the // wrong team is not a mistake you fix with a PATCH — you delete and // re-add. So it takes the same assert as every other command, against // the team of the environment the server is being registered to serve. if (!positionals[0] || !values.ip || !values.key || !values.env) { console.error(USAGE); return 2; } const stateDir = stateDirFrom(values.state); const binding = loadBindings(join(stateDir, "environments.yaml")) .environments[values.env]; if (!binding) { console.error(`environment ${values.env} not in environments.yaml`); return 2; } const { instance, client } = openCoolify( stateDir, values.instance, binding, ); assertWritable(instance, "server add"); const team = await assertTeam(client, binding.team, values.env); console.log(`team ${formatTeam(team)} ✓`); await serverAdd(client, { name: positionals[0], ip: values.ip, keyFile: values.key, user: values.user, port: values.port ? Number(values.port) : undefined, }); return 0; } if (command === "smoke") { const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { state: { type: "string" }, env: { type: "string" }, project: { type: "string" }, environment: { type: "string" }, instance: { type: "string" }, }, }); // REQUIRED, like every other verb's — because the repo IS the project, and // the project is half of the only scope in which the target's name means // anything (#29). `cast smoke --env prod` with no repo used to work by // reading the state-file-scoped `smoke_target`, which named an application // from a key that could not say which project or which environment it was // in; that key is gone (see BindingsSchema), and so is the invocation. const orgRepo = positionals[0]; const envName = values.env; if (!orgRepo || !envName) { console.error(USAGE); return 2; } const stateDir = stateDirFrom(values.state); const repoShort = orgRepo.split("/")[1]; // The same two read-side coordinates diff/capture/inventory take, for the // same two reasons: a project built by hand in the UI is called whatever // someone typed, and an environment built by hand is called whatever Coolify // defaulted to (`production`, not `prod`). --env still selects the manifest // block, the environments.yaml binding and the team to assert; --project and // --environment change ONLY the names cast looks the target up under. const projectName = values.project ?? repoShort; const coolifyEnv = values.environment ?? envName; const bindings = loadBindings(join(stateDir, "environments.yaml")); const binding = bindings.environments[envName]; if (!binding) { console.error(`environment ${envName} not in environments.yaml`); return 2; } const { instance, client } = openCoolify( stateDir, values.instance, binding, ); // smoke writes: it POSTs two env vars onto the live smoke_target app and // deletes them again. That is a mutation, so it takes both gates — the // read-only instance refusal and the team assert — before the first call. // Without the assert, a wrong-team token that happened to own an app of the // same name would have that app written to instead. assertWritable(instance, "smoke"); const team = await assertTeam(client, binding.team, envName); console.log(`team ${formatTeam(team)} ✓`); const target = smokeTargetFor(bindings, envName, orgRepo); if (!target) { console.error( [ `no smoke_target for ${orgRepo} in ${envName}`, "", ` looked for: environments.${envName}.projects["${orgRepo}"].smoke_target`, ` (a bare "${repoShort}" key resolves too)`, "", "`smoke` writes two canary env vars to one application and deletes them", "again — it has to be told which one, under the project that owns it:", "", " environments:", ` ${envName}:`, " projects:", ` ${orgRepo}:`, " smoke_target: ", ].join("\n"), ); return 2; } // The fix for #29, and the whole of it: the target is resolved in the project // and the environment it was DECLARED under — the same lookup every read-side // verb makes — instead of by name against GET /applications, which is every // application on the instance and answers with whichever one it lists first. const lookup = await fetchLive(client, projectName, coolifyEnv); if (!lookup.found) { console.error( renderAbsentTarget(lookup, { orgRepo, overridden: values.project !== undefined, envOverridden: values.environment !== undefined, verb: "smoke", }), ); return 2; } // Applications only. fetchLive returns every kind in the environment, and a // service or database called `core` is not a smoke target — it is a 404 on an // endpoint that does not exist for it (see renderAbsentSmokeTarget). const app = lookup.live.find( (l) => l.kind === "application" && l.name === target, ); if (!app) { console.error( renderAbsentSmokeTarget(target, lookup.live, { orgRepo, env: envName, project: projectName, environment: coolifyEnv, }), ); return 2; } await smoke(client, app.uuid); return 0; } if (command === "team") { const { values } = parseArgs({ args: rest, allowPositionals: true, options: { state: { type: "string" }, env: { type: "string" }, instance: { type: "string" }, }, }); const stateDir = stateDirFrom(values.state); // Bindings first, but only when --env was given: an environment's // `instance:` binding is what selects the Coolify to ask. Without --env // there is no binding to read (and deliberately so — see below), so the // flag or the default file decides. const binding = values.env ? loadBindings(join(stateDir, "environments.yaml")).environments[ values.env ] : undefined; if (values.env && !binding) { console.error(`environment ${values.env} not in environments.yaml`); return 2; } const { client } = openCoolify(stateDir, values.instance, binding); // Read-only, and the one command that deliberately does NOT require a // team binding: it is how you discover the values to write into // environments.yaml in the first place. Asserting here would be circular. // With --env it also checks the binding, which makes it the dry run for // "will apply refuse?" — ask the question without touching anything. const actual = await client.currentTeam(); console.log(`token's team: ${formatTeam(actual)}`); if (!values.env || !binding) return 0; await assertTeam(client, binding.team, values.env); console.log(`matches the team ${values.env} expects ✓`); return 0; } console.error(USAGE); return 2; } async function resolveOrCreateProject( client: CoolifyClient, name: string, ): Promise { try { return await client.projectUuid(name); } catch (err) { // projectUuid's resolver-miss (CoolifyClient.resolve) throws this exact // message with no `status` — that's the only case we treat as "create // it"; a 401/5xx/network failure must surface, not fall through to a // duplicate-create attempt. if ( err instanceof Error && err.message === `not found in Coolify: project ${name}` ) { const p = (await client.post("/projects", { name })) as { uuid: string }; return p.uuid; } throw err; } } // --- Desired-vocabulary -> Coolify wire-vocabulary field mapping --- // // `fields` (from Desired/Change) speaks the internal vocabulary used for // diffing (see resolve.ts / diff.ts): `port`, `healthcheck`, `domains` // (array), `type`, `version`. Coolify's actual create/update payloads use // different field names and shapes for some of these (verified against // reference/coolify-openapi-4.1.2.json requestBody schemas for // /applications/private-github-app, PATCH /applications/{uuid}, // /databases/postgresql, /databases/redis, PATCH /databases/{uuid}, // POST/PATCH /services) — spreading `fields` straight into the request body // (as an earlier draft of this executor did) would silently drop // healthcheck/domains updates and leak unrecognized type/version keys into // database creates. These helpers do the translation once, shared by // createResource and updateFields. export function applicationApiFields( fields: Record, ): Record { const { port, healthcheck, domains, docker_compose_domains, ...rest } = fields; return { ...rest, // ports_exposes wants a string; healthcheck -> health_check_path; // domains wants a comma-separated string, not an array. ...(port !== undefined ? { ports_exposes: String(port) } : {}), ...(healthcheck !== undefined ? { health_check_path: healthcheck } : {}), ...(domains !== undefined ? { domains: Array.isArray(domains) ? domains.join(",") : domains } : {}), // docker_compose_domains speaks the internal map vocabulary // (service -> string[]); the wire shape is an array of // {name, domain} where domain is that array comma-joined (verified // against the /applications/private-github-app + PATCH /applications // request schemas, ~line 353 of the vendored OpenAPI). ...(docker_compose_domains !== undefined ? { docker_compose_domains: Object.entries( docker_compose_domains as Record, ).map(([name, urls]) => ({ name, domain: urls.join(",") })), } : {}), }; } export function defaultDatabaseImage(type: string, version: string): string { // Verified against coollabsio/coolify v4.1.2 source // (resources/views/livewire/project/new/select.blade.php + // app/Livewire/Project/New/Select.php): the "New Resource" wizard's // PostgreSQL version picker calls setPostgresqlType('postgres:{v}-alpine') // for each offered version. Redis has no version picker in that wizard — // this half of the mapping extrapolates the same Docker Hub tag // convention and is UNVERIFIED against a live instance (see task-8-report.md). const repo = type === "postgresql" ? "postgres" : "redis"; return `${repo}:${version}-alpine`; } export function databaseApiFields( fields: Record, ): Record { // /databases/postgresql and /databases/redis accept no `type` param (the // endpoint path already encodes it) and no `version` param at all — only // `image`, a literal Docker image string. const { type, version, ...rest } = fields; return { ...rest, ...(typeof version === "string" ? { image: defaultDatabaseImage(String(type), version) } : {}), }; } export function serviceApiFields( fields: Record, ): Record { // /services accepts `urls`, a structured per-container list // ({name, url}[]), not the flat `domains` string list the manifest // speaks. manifest.ts's ServiceSpecSchema has no notion of per-container // name, so we can't build a correct `urls` payload from `domains` alone — // dropped rather than sent malformed. Known limitation: service hostnames // need manual Coolify UI configuration (see README, Task 10). const { domains: _domains, ...rest } = fields; return rest; } export function buildExecutor( client: CoolifyClient, ctx: { projectName: string; envName: string; serverUuid: string; githubAppUuid: string; // The Docker network to create resources on. A raw UUID from // environments.yaml for the same reason s3DestinationUuid is one: Coolify // 4.1.2 has no destinations API, so there is no name for cast to resolve. // // Create-time ONLY, and every kind gets it (Coolify's three controllers run // identical destination logic). Undefined means "the server's only // destination", which is what Coolify picks anyway — and, until a server // hosts two projects, is the right answer. destinationUuid?: string; s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names backupSchedules: Record; }, ): Executor { // Coolify resolves this identically for applications, databases and services // (ApplicationsController ~1003, DatabasesController ~1700, // ServicesController ~378 @ v4.1.2): // // 0 destinations -> 400, whatever we send // >1 and no destination_uuid -> 400 "Server has multiple destinations and // you do not set destination_uuid" // >1 and a foreign uuid -> 422 "does not belong to the specified server" // exactly 1 -> $destinations->first(), and anything we // send here is IGNORED, not validated // // So this field is what makes cast able to create resources on a server that // has more than one destination AT ALL — without it, apply simply 400s there, // which is the state of things before this change. On a single-destination // server it is inert (and so, note, a WRONG uuid is silently accepted there — // nothing on either side can catch that; see renderDiff's placement note). const destination = ctx.destinationUuid ? { destination_uuid: ctx.destinationUuid } : {}; return { async createResource(change) { // Field payloads assembled from change.fieldDiffs (desired values): const fields = Object.fromEntries( change.fieldDiffs.map((f) => [f.field, f.desired]), ); const projectUuid = await resolveOrCreateProject(client, ctx.projectName); if (change.kind === "application") { const res = (await client.post("/applications/private-github-app", { project_uuid: projectUuid, environment_name: ctx.envName, server_uuid: ctx.serverUuid, ...destination, github_app_uuid: ctx.githubAppUuid, name: change.name, instant_deploy: false, ...applicationApiFields(fields), // A compose stack must reach the managed Postgres/Redis resources // (the box-B lesson, DEPLOY.md §0/§3) — Coolify only wires that up // when this flag is set on create. ...(fields.build_pack === "dockercompose" ? { connect_to_docker_network: true } : {}), })) as { uuid: string }; return res.uuid; } if (change.kind === "database") { const type = String(fields.type); const res = (await client.post( `/databases/${type === "postgresql" ? "postgresql" : "redis"}`, { project_uuid: projectUuid, environment_name: ctx.envName, server_uuid: ctx.serverUuid, ...destination, name: change.name, ...databaseApiFields(fields), }, )) as { uuid: string }; const schedule = ctx.backupSchedules[change.name]; if (schedule) { if (!ctx.s3DestinationUuid) { throw new Error( `database ${change.name} declares a backup schedule but environments.yaml has no s3_destination UUID for this environment`, ); } await client.post(`/databases/${res.uuid}/backups`, { frequency: schedule.frequency, database_backup_retention_amount_locally: schedule.retention, save_s3: true, s3_storage_uuid: ctx.s3DestinationUuid, }); } return res.uuid; } const res = (await client.post("/services", { project_uuid: projectUuid, environment_name: ctx.envName, server_uuid: ctx.serverUuid, ...destination, name: change.name, ...serviceApiFields(fields), })) as { uuid: string }; return res.uuid; }, async updateFields(uuid, kind, fields) { const base = kind === "application" ? "applications" : kind === "database" ? "databases" : "services"; const apiFields = kind === "application" ? applicationApiFields(fields) : kind === "service" ? serviceApiFields(fields) : databaseApiFields(fields); await client.patch(`/${base}/${uuid}`, apiFields); }, async syncEnv(uuid, kind, env) { // Bulk env update is an UPSERT of listed keys, not a full replace — // verified against app/Http/Controllers/Api/{Applications,Databases, // Services}Controller.php@create_bulk_envs (coollabsio/coolify // v4.1.2): each item is found-by-key-and-updated or created; no // deletion of unlisted keys occurs (audit event is literally named // "*.env_bulk_upserted"). Safe under the iron rule that apply never // deletes — no need to fall back to per-key create-or-update calls. const base = kind === "application" ? "applications" : kind === "database" ? "databases" : "services"; await client.patch(`/${base}/${uuid}/envs/bulk`, { data: Object.entries(env.vars).map(([key, v]) => ({ key, value: v.value, is_buildtime: false, is_preview: false, })), }); }, async redeploy(uuid, kind) { if (kind === "service") await client.restart(uuid); else await client.deploy(uuid); }, }; } // Guard the entrypoint so `test/wire.test.ts` can import the pure // translation helpers above without executing the CLI (parseArgs against // vitest's argv, process.exit mid-test-run, etc). Only runs main() when // this file is the process entrypoint (`node dist/cli.js ...`), not when // imported as a module. if (import.meta.url === `file://${process.argv[1]}`) { main().then( (code) => process.exit(code), (err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); }, ); }