#!/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 { githubAppNameFor, loadBindings } from "./bindings.js"; import { type LiveEnvs, classify, renderCapturePlan } from "./capture.js"; import { type CoolifyInstance, assertWritable, formatInstance, loadInstance, } from "./config.js"; import { CoolifyClient, HttpError } from "./coolify.js"; import { type Live, type ResourceKind, computeDiff, renderDiff, } from "./diff.js"; import { assertEnvVarPolicy } from "./envtemplate.js"; import { desiredFromManifest, 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 ] [--hostname-overlay ] cast diff / --env [--full] [--project ] cast capture / --env [--path ] [--project ] [--generated ] [--override ] [--force] cast server add --ip --key --env [--user root] [--port 22] cast smoke --env 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. 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).`; // 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 })); 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; 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 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}"`, " note: cast names environments after --env, so 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`).", ]; 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." : "Re-run with --env naming the environment as it exists here.", ].join("\n"); } // 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: Live, ): 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; } 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" }, instance: { type: "string" }, "hostname-overlay": { type: "string" }, full: { 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]; // 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; 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 binding = bindings.environments[envName]; if (!binding) { console.error(`environment ${envName} not in environments.yaml`); return 2; } assertEnvVarPolicy(envName, resolvedEnvs, binding.forbidden_var_patterns); if (values["hostname-overlay"]) { desired = applyHostnameOverlay( desired, parseYaml(readFileSync(values["hostname-overlay"], "utf8")), ); } 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. 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, envName); // 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, }), ); return 2; } const live = lookup.found ? lookup.live : []; if (mode === "full") { for (const l of live) { l.env = await fetchEnv(client, l); } } const report = computeDiff(desired, live, mode); 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, envName, serverUuid, githubAppUuid, s3DestinationUuid: binding.s3_destination, backupSchedules, }); const { mutated } = await applyPlan(report, desired, exec); console.log( mutated.length === 0 ? "no-op (clean)" : `applied + redeployed: ${mutated.join(", ")}`, ); return 0; } if (command === "capture") { const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { env: { type: "string" }, state: { type: "string" }, path: { type: "string" }, project: { type: "string" }, 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; 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, envName); if (!lookup.found) { console.error( renderAbsentTarget(lookup, { orgRepo, overridden: values.project !== undefined, verb: "capture", }), ); return 2; } const liveEnvs: LiveEnvs = {}; for (const l of lookup.live) { // Databases hold no manifest-templated env of their own — their URL is // what the APPS reference, and that name is generated, not captured. if (l.kind === "database") continue; 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 === "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 } = parseArgs({ args: rest, allowPositionals: true, options: { state: { type: "string" }, env: { type: "string" }, instance: { type: "string" }, }, }); // smoke writes: it POSTs two env vars onto the live smoke_target app and // deletes them again. That is a mutation, so it takes the assert like any // other. Without it, a wrong-team token that happened to own an app of // the same name would have that app written to instead. if (!values.env) { console.error(USAGE); return 2; } const stateDir = stateDirFrom(values.state); const bindings = loadBindings(join(stateDir, "environments.yaml")); const binding = bindings.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, "smoke"); const team = await assertTeam(client, binding.team, values.env); console.log(`team ${formatTeam(team)} ✓`); if (!bindings.smoke_target) { console.error( "environments.yaml: smoke_target (app name) required for smoke", ); return 2; } // resolve app uuid by name across the project list const apps = (await client.get("/applications")) as Array<{ uuid: string; name: string; }>; const target = apps.find((a) => a.name === bindings.smoke_target); if (!target) { console.error(`smoke_target ${bindings.smoke_target} not found`); return 2; } await smoke(client, target.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; s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names backupSchedules: Record; }, ): Executor { 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, 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, 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, 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); }, ); }