diff --git a/src/bindings.ts b/src/bindings.ts index cc7c5e1..c66d301 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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 + // (/.coolify/.env). Optional: with no binding and no + // --instance, cast reads /.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 diff --git a/src/cli.ts b/src/cli.ts index e516aaa..b261da9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 / --env [--path ] [--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 + the Coolify to talk to: /.coolify/.env, instead + of /.coolify.env. Bind one per environment in + environments.yaml (\`instance: \`) and --env selects it + with no flag; an explicit --instance still wins. An instance + may declare COOLIFY_READ_ONLY=true, and then no command that + writes will run against it. --project the Coolify project to act on, when it is not named after the repo (the default). A project built by hand in the UI is called @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // "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; diff --git a/src/config.ts b/src/config.ts index d4f13cd..5cd5215 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 /.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 /.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 { const vars: Record = {}; 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 /.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: /.coolify/.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)" : "" + }`; } diff --git a/test/cli.test.ts b/test/cli.test.ts index 1e8fb76..d15b20a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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 }; +const stubs: Stub[] = []; + +async function stubCoolify(): Promise { + 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((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((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; + 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"); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..20ee672 --- /dev/null +++ b/test/config.test.ts @@ -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 = {}, + 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/.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/); + }); +});