cast/src/cli.ts
claude-hdb 68bf3f1876 docs: point smoke's instance-wide lookup at #29
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:42:23 +00:00

1409 lines
58 KiB
JavaScript

#!/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,
projectBindingFor,
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 { assertEnvVarPolicy } from "./envtemplate.js";
import { type LiveResource, reconcile, renderInventory } from "./inventory.js";
import {
desiredFromManifest,
manifestResources,
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 <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full] [--project <name>] [--environment <name>]
cast capture <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--generated <NAME>] [--override <NAME>] [--force]
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke [<org>/<repo>] --env <env>
cast team [--env <env>]
--state <dir> the state checkout holding environments.yaml, secrets/ and
.coolify.env (default: $CAST_STATE, else the cwd)
--env <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 <name>
the Coolify to talk to: <state>/.coolify/<name>.env, instead
of <state>/.coolify.env. Bind one per environment in
environments.yaml (\`instance: <name>\`) 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 <name>
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 <name>
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 <manifest-name>=<live-name>
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.
capture (adopt a hand-built instance into the age secret store):
--generated <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
for a manifest that has not declared generated_secrets yet.
Repeatable.
--override <NAME> supply NAME yourself instead of copying the source's value.
The VALUE is read from \$CAST_CAPTURE_<NAME>, 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<string, string> = {
"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<string, string[]> | 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<string, string[]> = {};
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<string, unknown>,
): Record<string, unknown> {
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<LiveLookup> {
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<Record<string, unknown>>;
postgresqls?: Array<Record<string, unknown>>;
redis?: Array<Record<string, unknown>>;
services?: Array<Record<string, unknown>>;
} | null;
if (!env) {
return {
found: false,
missing: "environment",
project: projectName,
environment: envName,
};
}
const map = (
kind: ResourceKind,
items: Array<Record<string, unknown>> = [],
): 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<LiveLookup, { found: false }>,
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 <name> 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 <name> if this instance names it 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<string, string> {
const alias: Record<string, string> = {};
for (const pair of pairs) {
const eq = pair.indexOf("=");
if (eq <= 0 || eq === pair.length - 1) {
throw new Error(
`--resource expects <manifest-name>=<live-name>, 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<T extends { name: string }>(
live: T[],
alias: Record<string, string>,
): Array<T & { sourceName?: string }> {
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: Live,
): Promise<Record<string, string>> {
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<string, string> {
const out: Record<string, string> = {};
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<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string | null>((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<number> {
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 },
},
});
const orgRepo = positionals[0];
const envName = values.env;
if (!orgRepo || !envName) {
console.error(USAGE);
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 repoShort = orgRepo.split("/")[1];
// The Coolify project name and the secrets-file key are different things
// that happen to default to the same string. Only the former is a name
// some other system chose: a project built by hand in the UI is called
// whatever someone typed. --project overrides that one, and nothing else —
// secrets stay keyed by the repo (a state-repo convention we own).
const projectName = values.project ?? repoShort;
// Exactly the same split, one level down. `--env` is OUR name for the
// environment: it selects the manifest block, the environments.yaml
// binding, the age key, the store path, the team to assert. `--environment`
// is THEIR name for it on the wire, and nothing else. Collapsing the two
// (as cast did until now) means a box built by hand in someone's UI gets to
// name our environment — and since apply creates the environment from this
// value, a legacy box's accident would be inherited by the new one forever.
const coolifyEnv = values.environment ?? envName;
const checkout = resolveCheckout(orgRepo, {
env: envName,
path: values.path,
});
const secrets = decryptSecrets(
secretsFileFor(stateDir, repoShort, envName),
keyFileFor(envName),
);
let { desired, resolvedEnvs, backupSchedules } = desiredFromManifest(
checkout,
envName,
secrets,
);
const bindings = loadBindings(join(stateDir, "environments.yaml"));
const binding = bindings.environments[envName];
if (!binding) {
console.error(`environment ${envName} not in environments.yaml`);
return 2;
}
assertEnvVarPolicy(envName, resolvedEnvs, binding.forbidden_var_patterns);
// Keyed by the REPO, not by --project: --project is the name Coolify's own
// UI happens to use for this project, and cast's state is keyed by the name
// WE own (same split as the secrets file — see the --project note above).
const projectBinding = projectBindingFor(bindings, envName, orgRepo);
if (values["hostname-overlay"]) {
desired = applyHostnameOverlay(
desired,
parseYaml(readFileSync(values["hostname-overlay"], "utf8")),
);
}
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, coolifyEnv);
// apply and diff take opposite (and both correct) positions on absence:
// apply is *allowed* to be the thing that brings a project into existence,
// so [] is a legitimate starting point. diff is only ever a claim about
// something that already exists — for it, absence is not an empty diff, it
// is the absence of anything to diff against, and reporting a full-create
// plan would launder that into a pass. See LiveLookup.
if (!lookup.found && command === "diff") {
console.error(
renderAbsentTarget(lookup, {
orgRepo,
overridden: values.project !== undefined,
envOverridden: values.environment !== undefined,
}),
);
return 2;
}
const aliases = parseResourceAliases(
values.resource ?? [],
desired.map((d) => d.name),
);
// Without this, a diff against a box that names things differently reports
// every manifest resource as "to create" and every live one as unknown —
// the D-237 lie by another route: a confident full-create plan that verified
// nothing, against a box that has all of it under other names.
const live = lookup.found ? aliasLive(lookup.live, aliases) : [];
if (mode === "full") {
for (const l of live) {
l.env = await fetchEnv(client, l);
}
}
const report = computeDiff(desired, live, mode, {
declaredDestination: projectBinding?.destination_uuid,
});
console.log(renderDiff(report));
if (command === "diff") return report.clean ? 0 : 1;
const serverUuid = await client.serverUuid(binding.server);
const githubAppUuid = await client.githubAppUuid(
githubAppNameFor(bindings, orgRepo),
);
const exec = buildExecutor(client, {
projectName,
// The name the environment gets ON COOLIFY when apply creates it — so an
// apply that adopts an existing hand-named environment writes into that
// one, rather than creating a second environment beside it.
envName: coolifyEnv,
serverUuid,
githubAppUuid,
destinationUuid: projectBinding?.destination_uuid,
s3DestinationUuid: binding.s3_destination,
backupSchedules,
});
const { mutated } = await applyPlan(report, desired, exec);
console.log(
mutated.length === 0
? "no-op (clean)"
: `applied + redeployed: ${mutated.join(", ")}`,
);
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" },
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" },
},
});
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 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" },
instance: { type: "string" },
},
});
// Optional, unlike every other verb's — and only because the target used to
// be state-file-scoped, so `cast smoke --env prod` with no repo at all is
// what the runbook says today. Without it, only the deprecated key can
// answer; with it, the project-scoped one can.
const smokeRepo = positionals[0];
// 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)}`);
const resolved = smokeTargetFor(bindings, values.env, smokeRepo);
if (!resolved) {
const lookedFor = smokeRepo
? [
` looked for: environments.${values.env}.projects["${smokeRepo}"].smoke_target`,
" then the deprecated state-file-scoped smoke_target",
]
: [
" looked for: the deprecated state-file-scoped smoke_target — and only",
" that one, because no <org>/<repo> was given and so no",
" project's binding could be consulted",
];
console.error(
[
`no smoke_target for ${values.env}`,
"",
...lookedFor,
"",
"`smoke` writes two canary env vars to one application and deletes them",
"again — it has to be told which one. Name it under the project it belongs",
"to, and pass that repo:",
"",
" environments:",
` ${values.env}:`,
" projects:",
` ${smokeRepo ?? "<org>/<repo>"}:`,
" smoke_target: <the application's name>",
].join("\n"),
);
return 2;
}
if (resolved.source === "deprecated") {
console.warn(
`warning: smoke_target read from the deprecated state-file-scoped key. It names ONE project's application from a key that cannot tell two projects — or even prod from staging — apart. Move it to environments.${values.env}.projects.<org>/<repo>.smoke_target and pass the repo to \`cast smoke\`.`,
);
}
// Resolved against the instance-wide application list, not the project's:
// that is what it did before this change and it is not this change's job to
// alter which app gets written to. It does mean the name is not actually a
// coordinate — one project's `core` and another's, or prod's and staging's
// on the same instance, are a coin flip. See #29; fixing it needs the
// read-side coordinates (--project/--environment) smoke does not yet have.
const apps = (await client.get("/applications")) as Array<{
uuid: string;
name: string;
}>;
const target = apps.find((a) => a.name === resolved.target);
if (!target) {
console.error(`smoke_target ${resolved.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<string> {
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<string, unknown>,
): Record<string, unknown> {
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<string, string[]>,
).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<string, unknown>,
): Record<string, unknown> {
// /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<string, unknown>,
): Record<string, unknown> {
// /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<string, { frequency: string; retention: number }>;
},
): 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);
},
);
}