Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
600 lines
23 KiB
JavaScript
600 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { parseArgs } from "node:util";
|
|
import { parse as parseYaml } from "yaml";
|
|
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
|
|
import { loadBindings } from "./bindings.js";
|
|
import { loadCoolifyEnv } 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, resolveCheckout } from "./resolve.js";
|
|
import { decryptSecrets, keyFileFor, secretsFileFor } from "./secrets.js";
|
|
import { serverAdd } from "./server.js";
|
|
import { smoke } from "./smoke.js";
|
|
|
|
const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
|
|
cast diff <org>/<repo> --env <env> [--full]
|
|
cast server add <name> --ip <ip> --key <file> [--user root] [--port 22]
|
|
cast smoke
|
|
|
|
--state <dir> the state checkout holding environments.yaml, secrets/ and
|
|
.coolify.env (default: $CAST_STATE, else the cwd)`;
|
|
|
|
// 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 ?? ".";
|
|
}
|
|
|
|
// 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.
|
|
};
|
|
}
|
|
|
|
async function fetchLive(
|
|
client: CoolifyClient,
|
|
projectName: string,
|
|
envName: string,
|
|
): Promise<Live[]> {
|
|
// List resources inside <projectName>/<envName>; tolerate a missing
|
|
// project (first apply creates it) or a missing environment (first apply
|
|
// into a project that doesn't have this environment yet) by returning [].
|
|
const projects = (await client.get("/projects")) as Array<{
|
|
uuid: string;
|
|
name: string;
|
|
}>;
|
|
const project = projects.find((p) => p.name === projectName);
|
|
if (!project) return [];
|
|
// 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 [];
|
|
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
|
|
}));
|
|
return [
|
|
...map("application", env.applications),
|
|
...map("database", env.postgresqls),
|
|
...map("database", env.redis),
|
|
...map("service", env.services),
|
|
];
|
|
}
|
|
|
|
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" },
|
|
"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];
|
|
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 { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
|
const client = new CoolifyClient(baseUrl, token);
|
|
const mode = command === "apply" || values.full ? "full" : "structural";
|
|
const live = await fetchLive(client, repoShort, envName);
|
|
if (mode === "full") {
|
|
for (const l of live) {
|
|
const envs = (await client
|
|
.get(
|
|
`/${l.kind === "database" ? "databases" : `${l.kind}s`}/${l.uuid}/envs`,
|
|
)
|
|
.catch((err) => {
|
|
// Same policy as fetchLive's environment fetch: 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 here would
|
|
// make a live resource's env look empty and turn every one of
|
|
// its vars into a spurious create in the diff.
|
|
if (err instanceof HttpError && err.status === 404) return [];
|
|
throw err;
|
|
})) as Array<{
|
|
key: string;
|
|
real_value?: string;
|
|
value: string;
|
|
}>;
|
|
l.env = Object.fromEntries(
|
|
envs.map((e) => [e.key, e.real_value ?? e.value]),
|
|
);
|
|
}
|
|
}
|
|
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(
|
|
bindings.github_apps[repoShort],
|
|
);
|
|
const exec = buildExecutor(client, {
|
|
projectName: repoShort,
|
|
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 === "server" && rest[0] === "add") {
|
|
const { values, positionals } = parseArgs({
|
|
args: rest.slice(1),
|
|
allowPositionals: true,
|
|
options: {
|
|
ip: { type: "string" },
|
|
key: { type: "string" },
|
|
user: { type: "string" },
|
|
port: { type: "string" },
|
|
state: { type: "string" },
|
|
},
|
|
});
|
|
if (!positionals[0] || !values.ip || !values.key) {
|
|
console.error(USAGE);
|
|
return 2;
|
|
}
|
|
const { baseUrl, token } = loadCoolifyEnv(
|
|
join(stateDirFrom(values.state), ".coolify.env"),
|
|
);
|
|
await serverAdd(new CoolifyClient(baseUrl, token), {
|
|
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" } },
|
|
});
|
|
const stateDir = stateDirFrom(values.state);
|
|
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
|
|
const client = new CoolifyClient(baseUrl, token);
|
|
const bindings = loadBindings(join(stateDir, "environments.yaml"));
|
|
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;
|
|
}
|
|
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;
|
|
s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names
|
|
backupSchedules: Record<string, { frequency: string; retention: number }>;
|
|
},
|
|
): 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);
|
|
},
|
|
);
|
|
}
|