fix clone auth (#13); named Coolify instances (#14); cast capture (#15) #16

Merged
dan-claude-bot merged 3 commits from fix/clone-auth-instances-capture into main 2026-07-13 16:54:16 +00:00
5 changed files with 563 additions and 43 deletions
Showing only changes of commit e457261436 - Show all commits

View file

@ -38,6 +38,15 @@ const BindingsSchema = z
// it per environment keeps each one's expectation explicit and
// survives a future split into per-environment tokens.
team: TeamSchema,
// The named Coolify instance this environment lives on
// (<state>/.coolify/<name>.env). Optional: with no binding and no
// --instance, cast reads <state>/.coolify.env exactly as it always
// has. Binding it here is what lets `--env prod` select the right
// control plane with no flag and no file edit — the connection
// target stops being implicit in a file's current contents.
// An explicit --instance still wins, so a one-off read against a
// legacy box needs no change to this file either.
instance: z.string().optional(),
s3_destination: z.string().optional(),
// Var-name patterns this environment refuses outright (see
// assertEnvVarPolicy). Operator-owned guard: prod typically bans

View file

@ -5,7 +5,12 @@ 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 { loadCoolifyEnv } from "./config.js";
import {
type CoolifyInstance,
assertWritable,
formatInstance,
loadInstance,
} from "./config.js";
import { CoolifyClient, HttpError } from "./coolify.js";
import {
type Live,
@ -33,6 +38,13 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--proj
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
@ -46,6 +58,28 @@ 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).
@ -306,6 +340,7 @@ async function main(): Promise<number> {
path: { type: "string" },
state: { type: "string" },
project: { type: "string" },
instance: { type: "string" },
"hostname-overlay": { type: "string" },
full: { type: "boolean", default: false },
},
@ -350,8 +385,12 @@ async function main(): Promise<number> {
parseYaml(readFileSync(values["hostname-overlay"], "utf8")),
);
}
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
const client = new CoolifyClient(baseUrl, token);
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
@ -438,6 +477,7 @@ async function main(): Promise<number> {
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
@ -457,8 +497,12 @@ async function main(): Promise<number> {
console.error(`environment ${values.env} not in environments.yaml`);
return 2;
}
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
const client = new CoolifyClient(baseUrl, token);
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, {
@ -474,7 +518,11 @@ async function main(): Promise<number> {
const { values } = parseArgs({
args: rest,
allowPositionals: true,
options: { state: { type: "string" }, env: { type: "string" } },
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
@ -485,14 +533,18 @@ async function main(): Promise<number> {
return 2;
}
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"));
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) {
@ -518,11 +570,27 @@ async function main(): Promise<number> {
const { values } = parseArgs({
args: rest,
allowPositionals: true,
options: { state: { type: "string" }, env: { type: "string" } },
options: {
state: { type: "string" },
env: { type: "string" },
instance: { type: "string" },
},
});
const stateDir = stateDirFrom(values.state);
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
const client = new CoolifyClient(baseUrl, token);
// 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.
@ -530,13 +598,7 @@ async function main(): Promise<number> {
// "will apply refuse?" — ask the question without touching anything.
const actual = await client.currentTeam();
console.log(`token's team: ${formatTeam(actual)}`);
if (!values.env) return 0;
const binding = loadBindings(join(stateDir, "environments.yaml"))
.environments[values.env];
if (!binding) {
console.error(`environment ${values.env} not in environments.yaml`);
return 2;
}
if (!values.env || !binding) return 0;
await assertTeam(client, binding.team, values.env);
console.log(`matches the team ${values.env} expects ✓`);
return 0;

View file

@ -1,11 +1,28 @@
import { readFileSync } from "node:fs";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
// Coolify's base URL + API token, read from <state>/.coolify.env — the one
// file in the state repo that is never committed (it is a live credential).
export function loadCoolifyEnv(path: string): {
// Which Coolify cast is about to talk to. The single most consequential input
// to any command — so it is a value that gets resolved, named, and printed,
// not an implicit property of whatever `.coolify.env` happens to contain right
// now.
export type CoolifyInstance = {
// "default" for <state>/.coolify.env, else the --instance name.
name: string;
baseUrl: string;
token: string;
} {
// Declared by the instance, not inferred from the token: an instance
// configured for inspection must not be writable even if its token would
// permit the writes. See assertWritable.
readOnly: boolean;
// The file it came from, so every message can name it.
file: string;
};
export const DEFAULT_INSTANCE = "default";
const DEFAULT_FILE = ".coolify.env";
const INSTANCE_DIR = ".coolify";
function parseEnvFile(path: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const raw of readFileSync(path, "utf8").split("\n")) {
const line = raw.trim();
@ -14,6 +31,17 @@ export function loadCoolifyEnv(path: string): {
if (eq === -1) continue;
vars[line.slice(0, eq)] = line.slice(eq + 1).replace(/^"|"$/g, "");
}
return vars;
}
// Coolify's base URL + API token, read from <state>/.coolify.env — the one
// file in the state repo that is never committed (it is a live credential).
export function loadCoolifyEnv(path: string): {
baseUrl: string;
token: string;
readOnly: boolean;
} {
const vars = parseEnvFile(path);
const baseUrl = vars.COOLIFY_BASE_URL;
const token = vars.COOLIFY_ACCESS_TOKEN;
if (!baseUrl || !token) {
@ -21,5 +49,82 @@ export function loadCoolifyEnv(path: string): {
`${path}: COOLIFY_BASE_URL and COOLIFY_ACCESS_TOKEN are required`,
);
}
return { baseUrl, token };
return { baseUrl, token, readOnly: vars.COOLIFY_READ_ONLY === "true" };
}
// The named instances configured in a state dir: <state>/.coolify/<name>.env.
export function knownInstances(stateDir: string): string[] {
const dir = join(stateDir, INSTANCE_DIR);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith(".env"))
.map((f) => f.slice(0, -".env".length))
.sort();
}
export function instanceFile(stateDir: string, name?: string): string {
return name === undefined
? join(stateDir, DEFAULT_FILE)
: join(stateDir, INSTANCE_DIR, `${name}.env`);
}
// Refuse, don't guess — the same position `diff` takes on an absent target
// (#12). An unknown instance name is not a reason to fall back to the default
// one: "the instance I asked for isn't there, so I used a different one" is
// how a diff meant for a legacy box gets run against production.
export function loadInstance(stateDir: string, name?: string): CoolifyInstance {
const file = instanceFile(stateDir, name);
if (name !== undefined && !existsSync(file)) {
const known = knownInstances(stateDir);
throw new Error(
[
`no Coolify instance named "${name}"`,
"",
` looked for: ${file}`,
` configured: ${known.join(", ") || "(none)"}`,
"",
"A named instance is an env file holding COOLIFY_BASE_URL +",
"COOLIFY_ACCESS_TOKEN (and optionally COOLIFY_READ_ONLY=true). Create the",
"file above, or pass one of the names that exist. With no --instance, cast",
`reads ${join(stateDir, DEFAULT_FILE)}.`,
].join("\n"),
);
}
const { baseUrl, token, readOnly } = loadCoolifyEnv(file);
return {
name: name ?? DEFAULT_INSTANCE,
baseUrl,
token,
readOnly,
file,
};
}
// A read-only instance is one the operator declared for inspection. The token
// it holds may well be able to write — that is exactly the point: this turns
// "I pointed the wrong token at the wrong box" from a live incident into an
// exit code, before the first mutating call rather than after it.
export function assertWritable(instance: CoolifyInstance, verb: string): void {
if (!instance.readOnly) return;
throw new Error(
[
`refusing to ${verb}: Coolify instance "${instance.name}" is read-only`,
"",
` declared by: COOLIFY_READ_ONLY=true in ${instance.file}`,
` base url: ${instance.baseUrl}`,
"",
`\`${verb}\` writes. An instance configured for inspection cannot be written`,
"to, even if its token would permit it. Run `cast diff` or `cast team`",
"against this instance, or pass an --instance that is not read-only.",
].join("\n"),
);
}
// What cast prints before it touches a Coolify, on every command that reaches
// one. The instance is the input most likely to be wrong and least likely to
// be noticed — so it gets said out loud, next to the team assert.
export function formatInstance(instance: CoolifyInstance): string {
return `instance ${instance.name}${instance.baseUrl}${
instance.readOnly ? " (read-only)" : ""
}`;
}

View file

@ -1,22 +1,107 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
function runCli(args: string[]): { code: number; output: string } {
try {
const output = execFileSync("node", ["dist/cli.js", ...args], {
encoding: "utf8",
stdio: "pipe",
// Spawned ASYNCHRONOUSLY, and that is load-bearing: the stub Coolify below
// runs in THIS process, so a blocking execFileSync would hold the event loop
// and the stub could never answer the CLI it just launched — the two would
// deadlock until the test timed out.
function runCli(args: string[]): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
return { code: 0, output };
} catch (e) {
const err = e as { status: number; stderr: string; stdout: string };
return { code: err.status, output: `${err.stdout}${err.stderr}` };
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
// A Coolify that answers the two calls these paths make, and RECORDS what it
// was asked. The recording is the point: "which instance did cast actually
// talk to" is the question #14 exists to make answerable, so the tests below
// answer it from the wire rather than from cast's own console output.
type Stub = { url: string; hits: string[]; close: () => Promise<void> };
const stubs: Stub[] = [];
async function stubCoolify(): Promise<Stub> {
const hits: string[] = [];
const server = createServer((req, res) => {
hits.push(req.url ?? "");
const body =
req.url === "/api/v1/teams/current"
? JSON.stringify({ id: 0, name: "Root Team" })
: "[]";
res.writeHead(200, { "content-type": "application/json" });
res.end(body);
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
const coolifyEnv = (url: string, readOnly = false) =>
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n${
readOnly ? "COOLIFY_READ_ONLY=true\n" : ""
}`;
// A state dir: the default instance, plus any named ones, plus bindings.
function stateWith(opts: {
default?: string;
named?: Record<string, string>;
boundInstance?: string;
}): string {
const dir = mkdtempSync(join(tmpdir(), "cast-cli-"));
if (opts.default) writeFileSync(join(dir, ".coolify.env"), opts.default);
if (opts.named) {
mkdirSync(join(dir, ".coolify"));
for (const [name, body] of Object.entries(opts.named)) {
writeFileSync(join(dir, ".coolify", `${name}.env`), body);
}
}
writeFileSync(
join(dir, "environments.yaml"),
[
"environments:",
" prod:",
" server: prod-box",
" team: { id: 0, name: Root Team }",
...(opts.boundInstance ? [` instance: ${opts.boundInstance}`] : []),
"github_apps:",
" incubator: hdb-coolify",
"smoke_target: core",
"",
].join("\n"),
);
return dir;
}
describe("infra cli", () => {
it("refuses apply --path with --env prod, exit non-zero", () => {
const r = runCli([
it("refuses apply --path with --env prod, exit non-zero", async () => {
const r = await runCli([
"apply",
"acme/widget",
"--env",
@ -27,8 +112,8 @@ describe("infra cli", () => {
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/--path.*prod/);
});
it("prints usage on unknown command", () => {
const r = runCli(["frobnicate"]);
it("prints usage on unknown command", async () => {
const r = await runCli(["frobnicate"]);
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/usage: cast (apply|diff)/i);
});
@ -36,8 +121,8 @@ describe("infra cli", () => {
// server into the token's team (permanently: a server belongs to exactly one
// team), and `smoke` writes env vars onto a live app. Neither may run without
// an environment to assert the token's team against.
it("refuses server add without --env, exit non-zero", () => {
const r = runCli([
it("refuses server add without --env, exit non-zero", async () => {
const r = await runCli([
"server",
"add",
"prod-box",
@ -49,9 +134,125 @@ describe("infra cli", () => {
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/--env/);
});
it("refuses smoke without --env, exit non-zero", () => {
const r = runCli(["smoke"]);
it("refuses smoke without --env, exit non-zero", async () => {
const r = await runCli(["smoke"]);
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/--env/);
});
});
describe("--instance (multiple Coolify instances)", () => {
// The acceptance criterion from #14, end to end: a command against a named
// instance reaches THAT Coolify, with no edit to .coolify.env — which still
// sits there, untouched, pointing somewhere else entirely.
it("talks to the named instance, leaving .coolify.env untouched", async () => {
const [main, legacy] = [await stubCoolify(), await stubCoolify()];
const dir = stateWith({
default: coolifyEnv(main.url),
named: { legacy: coolifyEnv(legacy.url) },
});
const r = await runCli(["team", "--state", dir, "--instance", "legacy"]);
expect(r.code).toBe(0);
expect(r.output).toContain(`instance legacy → ${legacy.url}`);
// The wire is the witness, not the log line.
expect(legacy.hits).toContain("/api/v1/teams/current");
expect(main.hits).toEqual([]);
});
it("uses .coolify.env when no instance is named — unchanged behavior", async () => {
const [main, legacy] = [await stubCoolify(), await stubCoolify()];
const dir = stateWith({
default: coolifyEnv(main.url),
named: { legacy: coolifyEnv(legacy.url) },
});
const r = await runCli(["team", "--state", dir]);
expect(r.code).toBe(0);
expect(r.output).toContain(`instance default → ${main.url}`);
expect(main.hits).toContain("/api/v1/teams/current");
expect(legacy.hits).toEqual([]);
});
// environments.yaml binds the instance, so --env prod selects the right
// control plane with no flag and no file edit at all.
it("honors an environment's instance binding with no flag", async () => {
const [main, prodCp] = [await stubCoolify(), await stubCoolify()];
const dir = stateWith({
default: coolifyEnv(main.url),
named: { "prod-cp": coolifyEnv(prodCp.url) },
boundInstance: "prod-cp",
});
const r = await runCli(["team", "--state", dir, "--env", "prod"]);
expect(r.code).toBe(0);
expect(r.output).toContain(`instance prod-cp → ${prodCp.url}`);
expect(prodCp.hits).toContain("/api/v1/teams/current");
expect(main.hits).toEqual([]);
});
it("lets an explicit --instance beat the environment's binding", async () => {
const [prodCp, legacy] = [await stubCoolify(), await stubCoolify()];
const dir = stateWith({
named: {
"prod-cp": coolifyEnv(prodCp.url),
legacy: coolifyEnv(legacy.url),
},
boundInstance: "prod-cp",
});
const r = await runCli([
"team",
"--state",
dir,
"--env",
"prod",
"--instance",
"legacy",
]);
expect(r.code).toBe(0);
expect(legacy.hits).toContain("/api/v1/teams/current");
expect(prodCp.hits).toEqual([]);
});
it("refuses an unknown instance, names the known ones, and touches nothing", async () => {
const main = await stubCoolify();
const dir = stateWith({
default: coolifyEnv(main.url),
named: { legacy: coolifyEnv(main.url) },
});
const r = await runCli(["team", "--state", dir, "--instance", "typo"]);
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/no Coolify instance named "typo"/);
expect(r.output).toMatch(/configured:\s+legacy/);
// Refuse, don't guess: it must not have fallen back to the default.
expect(main.hits).toEqual([]);
});
// The nice-to-have that turns "I pointed the wrong token at the wrong box"
// into an exit code: the refusal lands BEFORE the first call, so a read-only
// instance never even gets asked.
it("refuses a writing verb against a read-only instance, before any call", async () => {
const legacy = await stubCoolify();
const dir = stateWith({
named: { legacy: coolifyEnv(legacy.url, true) },
});
const r = await runCli([
"smoke",
"--state",
dir,
"--env",
"prod",
"--instance",
"legacy",
]);
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/refusing to smoke.*read-only/s);
expect(legacy.hits).toEqual([]);
});
it("still allows a read-only instance to be read", async () => {
const legacy = await stubCoolify();
const dir = stateWith({ named: { legacy: coolifyEnv(legacy.url, true) } });
const r = await runCli(["team", "--state", dir, "--instance", "legacy"]);
expect(r.code).toBe(0);
expect(r.output).toMatch(/read-only/);
expect(legacy.hits).toContain("/api/v1/teams/current");
});
});

143
test/config.test.ts Normal file
View file

@ -0,0 +1,143 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
assertWritable,
formatInstance,
knownInstances,
loadInstance,
} from "../src/config.js";
// A state dir with a default .coolify.env and any number of named instances.
function stateDir(
named: Record<string, string> = {},
defaultEnv?: string,
): string {
const dir = mkdtempSync(join(tmpdir(), "cast-state-"));
if (defaultEnv !== undefined) {
writeFileSync(join(dir, ".coolify.env"), defaultEnv);
}
if (Object.keys(named).length > 0) {
mkdirSync(join(dir, ".coolify"));
for (const [name, body] of Object.entries(named)) {
writeFileSync(join(dir, ".coolify", `${name}.env`), body);
}
}
return dir;
}
const OK =
'COOLIFY_BASE_URL="https://cp.example.com"\nCOOLIFY_ACCESS_TOKEN="t"\n';
describe("loadInstance", () => {
// The whole point of #14 is that adding this must change nothing for anyone
// who does not use it.
it("reads .coolify.env when no instance is named — unchanged behavior", () => {
const dir = stateDir({}, OK);
const inst = loadInstance(dir);
expect(inst).toMatchObject({
name: "default",
baseUrl: "https://cp.example.com",
token: "t",
readOnly: false,
file: join(dir, ".coolify.env"),
});
});
it("reads .coolify/<name>.env for a named instance", () => {
const dir = stateDir(
{
legacy:
'COOLIFY_BASE_URL="https://old.example.com"\nCOOLIFY_ACCESS_TOKEN="lt"\n',
},
OK,
);
expect(loadInstance(dir, "legacy")).toMatchObject({
name: "legacy",
baseUrl: "https://old.example.com",
token: "lt",
});
});
// Refuse, don't guess (#12's position on an absent target, applied to the
// connection target). Falling back to the default instance here is how a
// diff meant for a legacy box gets run against production.
it("refuses an unknown instance and names the ones that exist", () => {
const dir = stateDir({ "prod-cp": OK, "staging-cp": OK }, OK);
expect(() => loadInstance(dir, "legacy")).toThrow(
/no Coolify instance named "legacy"/,
);
expect(() => loadInstance(dir, "legacy")).toThrow(/prod-cp, staging-cp/);
});
it("says so plainly when no named instances exist at all", () => {
const dir = stateDir({}, OK);
expect(() => loadInstance(dir, "legacy")).toThrow(/\(none\)/);
});
it("never silently falls back to the default instance", () => {
const dir = stateDir({}, OK);
expect(() => loadInstance(dir, "legacy")).toThrow();
});
it("reads COOLIFY_READ_ONLY off an instance", () => {
const dir = stateDir({ legacy: `${OK}COOLIFY_READ_ONLY=true\n` });
expect(loadInstance(dir, "legacy").readOnly).toBe(true);
});
it("still requires base url and token", () => {
const dir = stateDir({ broken: 'COOLIFY_BASE_URL="https://x"\n' });
expect(() => loadInstance(dir, "broken")).toThrow(
/COOLIFY_BASE_URL and COOLIFY_ACCESS_TOKEN are required/,
);
});
});
describe("knownInstances", () => {
it("lists named instances, sorted, and is empty when there are none", () => {
expect(knownInstances(stateDir({ b: OK, a: OK }, OK))).toEqual(["a", "b"]);
expect(knownInstances(stateDir({}, OK))).toEqual([]);
});
});
describe("assertWritable", () => {
const inst = (readOnly: boolean) => ({
name: "legacy",
baseUrl: "https://old.example.com",
token: "t",
readOnly,
file: "/s/.coolify/legacy.env",
});
// "I pointed the wrong token at the wrong box" becomes an exit code rather
// than a live incident — and it holds even when the TOKEN would permit the
// write. That is the point: the declaration is the guard, not the scope.
it("refuses a write against a read-only instance, naming the declaration", () => {
expect(() => assertWritable(inst(true), "apply")).toThrow(
/refusing to apply.*read-only/s,
);
expect(() => assertWritable(inst(true), "apply")).toThrow(
/COOLIFY_READ_ONLY=true in \/s\/\.coolify\/legacy\.env/,
);
});
it("allows writes against a normal instance", () => {
expect(() => assertWritable(inst(false), "apply")).not.toThrow();
});
});
describe("formatInstance", () => {
it("names the instance and its base url, and flags read-only", () => {
const base = {
name: "legacy",
baseUrl: "https://old.example.com",
token: "t",
file: "f",
};
expect(formatInstance({ ...base, readOnly: false })).toBe(
"instance legacy → https://old.example.com",
);
expect(formatInstance({ ...base, readOnly: true })).toMatch(/read-only/);
});
});