From 10a161ee07836371e44e8cb10028452753a74670 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 19:07:17 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20inventory=20sweeps=20the=20instance?= =?UTF-8?q?=20=E2=80=94=20a=20discovery=20verb=20that=20needed=20you=20to?= =?UTF-8?q?=20have=20discovered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inventory` (#19/#20) reconciled a manifest against one project and one environment THAT YOU NAME. But the premise of the verb is that you are looking at a box you did not build — so you do not know those coordinates yet. It was a discovery tool that required you to have already discovered, and the operator went straight back to hand-curling /projects to find out where anything lived. cast inventory --env prod --instance box-b # no repo → sweep Every project, every environment, every resource the token can see. No manifest, no store, no age key, no recipient. With a repo it reconciles exactly as before. Worse than the missing sweep was how the targeted path FAILED. Pointed at a project's `production` environment — auto-created by Coolify, and empty — it reported: on the box, NOT in the manifest (none) 5 difference(s) between the manifest and this box. Every word true; the overall impression ("the box has nothing, the manifest has five things") exactly the D-237 lie cast refuses everywhere else. The resources were alive and serving production the whole time, in an environment named `staging` that nobody had ever swapped. An environment with ZERO resources is far more often the wrong coordinate than an empty one, so it now says so, and names the sweep. The sweep asserts the team first, and that matters more here than anywhere: Coolify scopes what a token can see to its team, so a wrong-team token would sweep an instance and truthfully report that it is empty. Environment enumeration takes two roads — GET /projects/{uuid}/environments, falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has been wrong before, and this is the one path where failing to enumerate is worse than being slow. The stub in the new suite is shaped like the box this came from: three projects (two of them unrelated third-party client sites nobody knew were there), an empty auto-created `production`, and the real system in `staging`. npm run check + build clean; 173 tests passing (was 169). Co-Authored-By: Claude Opus 4.8 --- README.md | 46 +++++++-- src/cli.ts | 58 ++++++++++- src/coolify.ts | 38 ++++++++ src/inventory.ts | 103 ++++++++++++++++++++ test/sweep-cli.test.ts | 212 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 447 insertions(+), 10 deletions(-) create mode 100644 test/sweep-cli.test.ts diff --git a/README.md b/README.md index e84ce29..610ea3c 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,13 @@ cast team [--env ] default branch). - **`diff`** — reports drift, manifest → Coolify. Structural by default; `--full` also compares env vars. Exits non-zero when dirty, so CI can gate on it. -- **`inventory`** — what is actually *on* a box, and how it lines up with the - manifest: resources and env var **keys** (never values), sorted into - on-both / manifest-only / box-only. Needs no store, no age key, and no - recipient — it runs *before* adoption, which is the point of it. A document, - read by a person; nothing here is consumed by `apply`. See *Adopting a - hand-built instance*. +- **`inventory`** — what is actually *on* a box. **With no repo it sweeps the + instance** (every project, every environment, every resource — no manifest + involved); with a repo it reconciles, showing resources and env var **keys** + (never values) sorted into on-both / manifest-only / box-only. Needs no store, + no age key, and no recipient — it runs *before* adoption, which is the point of + it. A document, read by a person; nothing here is consumed by `apply`. See + *Adopting a hand-built instance*. - **`capture`** — the adoption path: reads a hand-built instance's live env and writes the environment's age store from it. See *Adopting a hand-built instance* below. @@ -173,9 +174,40 @@ clicked *New Resource* that afternoon. `inventory` shows you both sides at once, so those differences arrive together, as a document — instead of one at a time, as refusals from a verb that is already halfway through a migration. +**First, sweep it — you cannot aim at coordinates you do not have yet:** + +```sh +cast inventory --env prod --instance legacy +``` + +``` +sweep — instance legacy (https://coolify.example.com) + + Incubator + production (empty) + staging 2 applications, 2 databases, 1 service + application Incubator Stack v2 + application Incubator Landing + database Incubator Database v2 + … + La Familia Site + production 1 application + application lafamilia-web +``` + +Note what that costs you to *not* have: Coolify auto-creates a `production` +environment in every project, so the obvious guess is empty and the live system +is somewhere else entirely — under a name someone typed, in a project you may +not have known was there. An environment with **zero** resources is far more +often the wrong coordinate than an empty one, and `inventory` says so rather than +quietly reporting that the manifest has five things the box lacks. + +**Then reconcile**, against a target you now know exists: + ```sh cast inventory heavy-duty/incubator --env prod --instance legacy \ - --project Incubator --environment production + --project Incubator --environment staging \ + --resource core="Incubator Stack v2" ``` It never reads a value, needs no store and no key, and its output is **not** diff --git a/src/cli.ts b/src/cli.ts index ea06016..4526243 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,14 @@ import { renderDiff, } from "./diff.js"; import { assertEnvVarPolicy } from "./envtemplate.js"; -import { type LiveResource, reconcile, renderInventory } from "./inventory.js"; +import { + type LiveResource, + type SweepEnvironment, + type SweepProject, + reconcile, + renderInventory, + renderSweep, +} from "./inventory.js"; import { desiredFromManifest, manifestResources, @@ -48,7 +55,8 @@ import { assertTeam, formatTeam } from "./team.js"; const USAGE = `usage: cast apply / --env [--path ] [--project ] [--environment ] [--hostname-overlay ] cast diff / --env [--full] [--project ] [--environment ] cast capture / --env [--path ] [--project ] [--environment ] [--generated ] [--override ] [--force] - cast inventory / --env [--path ] [--project ] [--environment ] + cast inventory / --env [--path ] [--project ] [--environment ] [--resource =] + cast inventory --env [--instance ] # no repo: SWEEP the whole instance cast server add --ip --key --env [--user root] [--port 22] cast smoke --env cast team [--env ] @@ -863,11 +871,55 @@ async function main(): Promise { }); const orgRepo = positionals[0]; const envName = values.env; - if (!orgRepo || !envName) { + if (!envName) { console.error(USAGE); return 2; } const stateDir = stateDirFrom(values.state); + const sweepBindings = loadBindings(join(stateDir, "environments.yaml")); + const sweepBinding = sweepBindings.environments[envName]; + if (!sweepBinding) { + console.error(`environment ${envName} not in environments.yaml`); + return 2; + } + // NO REPO → SWEEP. There is nothing to reconcile against, so don't pretend + // to: just show what is on the box. This is the pass that has to come first + // when the box is one you did not build, and requiring coordinates for it + // made inventory a discovery verb that needed you to have already + // discovered. + if (!orgRepo) { + const { instance, client } = openCoolify( + stateDir, + values.instance, + sweepBinding, + ); + // The team assert matters MORE here than anywhere: Coolify scopes what a + // token can see to its team, so a wrong-team token sweeps an instance and + // truthfully reports that it is empty. + const team = await assertTeam(client, sweepBinding.team, envName); + console.log(`team ${formatTeam(team)} ✓`); + const projects: SweepProject[] = []; + for (const p of await client.projects()) { + const environments: SweepEnvironment[] = []; + for (const name of await client.environments(p.uuid)) { + const found = await fetchLive(client, p.name, name); + environments.push({ + name, + resources: found.found + ? found.live.map((l) => ({ kind: l.kind, name: l.name })) + : [], + }); + } + projects.push({ name: p.name, environments }); + } + console.log( + renderSweep(projects, { + instance: instance.name, + baseUrl: instance.baseUrl, + }), + ); + return 0; + } const repoShort = orgRepo.split("/")[1]; const projectName = values.project ?? repoShort; const coolifyEnv = values.environment ?? envName; diff --git a/src/coolify.ts b/src/coolify.ts index 6bbd117..364d94b 100644 --- a/src/coolify.ts +++ b/src/coolify.ts @@ -109,6 +109,44 @@ export class CoolifyClient { this.resolve("github app", "/github-apps", name); projectUuid = (name: string) => this.resolve("project", "/projects", name); + // Every project the TOKEN can see. Team-scoped by Coolify itself, which is + // why a sweep still asserts the team first: a wrong-team token sees nothing, + // and "nothing" would render as "this instance is empty". + async projects(): Promise> { + return (await this.get("/projects")) as Array<{ + uuid: string; + name: string; + }>; + } + + // A project's environment NAMES. + // + // Two roads, because the vendored OpenAPI has been wrong before and this is a + // discovery path — the one place where failing to enumerate is worse than + // being slow. GET /projects/{uuid}/environments is documented; if a given + // instance does not serve it, GET /projects/{uuid} carries the same list as a + // relation on the project (ProjectController@show eager-loads it). Falling + // back beats reporting "no environments" for a project that has several. + async environments(projectUuid: string): Promise { + const names = (items: unknown): string[] => + Array.isArray(items) + ? items + .map((e) => (e as { name?: unknown })?.name) + .filter((n): n is string => typeof n === "string") + : []; + try { + const direct = await this.get(`/projects/${projectUuid}/environments`); + const found = names(direct); + if (found.length > 0) return found; + } catch (err) { + if (!(err instanceof HttpError) || err.status !== 404) throw err; + } + const project = (await this.get(`/projects/${projectUuid}`)) as { + environments?: unknown; + } | null; + return names(project?.environments); + } + async deploy(uuid: string): Promise { await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`); } diff --git a/src/inventory.ts b/src/inventory.ts index ede0d11..0f8d6b3 100644 --- a/src/inventory.ts +++ b/src/inventory.ts @@ -54,6 +54,75 @@ export type Reconciliation = { const sorted = (xs: Iterable) => [...xs].sort(); +// --- The sweep: what is on this box, before any manifest is involved --- +// +// The verb's premise is that you are looking at a box you did not build, so you +// do NOT know its coordinates yet. Requiring a project and an environment to +// look at it made it a discovery tool that needed you to have already +// discovered — and the operator went back to hand-curling /projects to find out +// where anything lived. This is that pass, and it needs no manifest at all. + +export type SweepEnvironment = { + name: string; + resources: Array<{ kind: string; name: string }>; +}; + +export type SweepProject = { + name: string; + environments: SweepEnvironment[]; +}; + +export function renderSweep( + projects: SweepProject[], + ctx: { instance: string; baseUrl: string }, +): string { + const lines = [ + `sweep — instance ${ctx.instance} (${ctx.baseUrl})`, + "", + " Every project, every environment, every resource this token can see.", + " No manifest involved: this is what is HERE, not how it compares to anything.", + "", + ]; + if (projects.length === 0) { + lines.push( + " (no projects at all)", + "", + " An instance with no projects is more often a token that cannot see them", + " than an empty instance — the team assert above is what rules that out.", + ); + return lines.join("\n"); + } + for (const p of projects) { + lines.push(` ${p.name}`); + if (p.environments.length === 0) { + lines.push(" (no environments)"); + } + for (const e of p.environments) { + const counts = Object.entries( + e.resources.reduce>((acc, r) => { + acc[r.kind] = (acc[r.kind] ?? 0) + 1; + return acc; + }, {}), + ) + .map(([kind, n]) => `${n} ${kind}${n === 1 ? "" : "s"}`) + .join(", "); + // An empty environment is worth seeing, not hiding: Coolify auto-creates + // `production` in every project, and an operator who assumes that is where + // things live will aim every later command at nothing. + lines.push(` ${e.name.padEnd(14)} ${counts || "(empty)"}`); + for (const r of e.resources) { + lines.push(` ${r.kind.padEnd(12)} ${r.name}`); + } + } + lines.push(""); + } + lines.push( + "Point a reconciliation at one of these with --project / --environment, and", + "map any resource whose name differs with --resource =.", + ); + return lines.join("\n"); +} + export function reconcile( manifest: ManifestResource[], live: LiveResource[], @@ -98,6 +167,40 @@ export function renderInventory( environment: string; }, ): string { + // The box has NOTHING in it. Not "nothing that matched" — nothing at all. + // + // This is the third face of the D-237 lie, and the quietest: an environment + // with zero resources reads back exactly like a box you have not built yet, + // and the report then consists entirely of the manifest talking to itself. + // The overall impression — "the box has nothing, the manifest has five things" + // — is how a full-create plan gets laundered into a pass. It happened: pointed + // at a project's auto-created `production` environment, this verb reported + // five differences against a box whose resources were alive and serving + // production the whole time, in an environment named `staging`. + if (rec.matched.length === 0 && rec.boxOnly.length === 0) { + return [ + `inventory — ${ctx.orgRepo} ${ctx.env}`, + "", + ` looked in: project "${ctx.project}", environment "${ctx.environment}"`, + ` found: NOTHING. Not one resource.`, + "", + "This environment is EMPTY — so there is nothing here to reconcile, and the", + "manifest's list below would just be the manifest talking to itself.", + "", + "An environment with zero resources is far more often the WRONG COORDINATE", + "than an empty one. Coolify auto-creates a `production` environment in every", + "project, and a box built by hand keeps its real resources wherever someone", + "put them — which may well be an environment called something else entirely.", + "", + "Sweep the instance and see where things actually are:", + "", + ` cast inventory --env ${ctx.env} --instance ${ctx.instance}`, + "", + "(The manifest declares: " + + rec.manifestOnly.map((m) => m.name).join(", ") + + ".)", + ].join("\n"); + } const lines = [ `inventory — ${ctx.orgRepo} ${ctx.env}`, "", diff --git a/test/sweep-cli.test.ts b/test/sweep-cli.test.ts new file mode 100644 index 0000000..abd004e --- /dev/null +++ b/test/sweep-cli.test.ts @@ -0,0 +1,212 @@ +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"; + +// The sweep, against a stub shaped like the box that made it necessary. +// +// Three projects — one ours, two unrelated third-party client sites nobody knew +// were there. Our project has TWO environments: `production`, which Coolify +// auto-created and which is EMPTY, and `staging`, which is where the live +// founder-facing system has been running the whole time because nobody ever +// swapped it. +// +// Pointed at `production`, the old inventory reported "5 differences" against a +// box whose resources were alive and serving production — the manifest talking +// to itself, and an impression ("the box has nothing") that is exactly how a +// full-create plan gets laundered into a pass. + +type Stub = { url: string; close: () => Promise }; +const stubs: Stub[] = []; + +async function stubCoolify(): Promise { + const server = createServer((req, res) => { + const path = (req.url ?? "").replace("/api/v1", ""); + const json = (body: unknown) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (path === "/teams/current") return json({ id: 0, name: "Root Team" }); + if (path === "/projects") + return json([ + { uuid: "p1", name: "Incubator" }, + { uuid: "p2", name: "La Familia Site" }, + { uuid: "p3", name: "Martin Reyes Barber Shop" }, + ]); + if (path === "/projects/p1/environments") + return json([{ name: "production" }, { name: "staging" }]); + if (path === "/projects/p2/environments") + return json([{ name: "production" }]); + if (path === "/projects/p3/environments") + return json([{ name: "production" }]); + // Coolify auto-creates `production`. It is empty. Everything real lives in + // `staging`, under names a human typed. + if (path === "/projects/p1/production") return json({}); + if (path === "/projects/p1/staging") + return json({ + applications: [ + { name: "Incubator Stack v2", uuid: "a1" }, + { name: "Incubator Landing", uuid: "a2" }, + ], + postgresqls: [{ name: "Incubator Database v2", uuid: "d1" }], + services: [{ name: "Incubator Umami", uuid: "s1" }], + }); + if (path === "/projects/p2/production") + return json({ applications: [{ name: "lafamilia-web", uuid: "a9" }] }); + if (path === "/projects/p3/production") + return json({ applications: [{ name: "barber-web", uuid: "a8" }] }); + if (path.endsWith("/envs")) return json([]); + res.writeHead(404); + res.end("{}"); + }); + 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}`, + 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 MANIFEST = `project: incubator +environments: + staging: + applications: + core: + source: { repo: heavy-duty/incubator, branch: main } + build: { pack: nixpacks, base_directory: / } + domains: ["http://core.example.com"] +`; + +function fixture(url: string) { + const checkout = mkdtempSync(join(tmpdir(), "cast-co-")); + mkdirSync(join(checkout, ".infra", "env"), { recursive: true }); + writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST); + const state = mkdtempSync(join(tmpdir(), "cast-state-")); + mkdirSync(join(state, "secrets")); + writeFileSync( + join(state, ".coolify.env"), + `COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`, + ); + writeFileSync( + join(state, "environments.yaml"), + [ + "environments:", + " staging:", + " server: staging-box", + " team: { id: 0, name: Root Team }", + "github_apps:", + " incubator: hdb-coolify", + "", + ].join("\n"), + ); + return { checkout, state }; +} + +function run(args: string[]): Promise<{ code: number; output: string }> { + return new Promise((resolve) => { + const child = spawn("node", ["dist/cli.js", "inventory", ...args], { + stdio: ["pipe", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (d) => { + output += String(d); + }); + child.stderr.on("data", (d) => { + output += String(d); + }); + child.stdin.end(); + child.on("close", (code) => resolve({ code: code ?? 0, output })); + }); +} + +describe("cast inventory — the sweep (#22)", () => { + it("with no repo, shows every project, environment and resource", async () => { + const f = fixture((await stubCoolify()).url); + const r = await run(["--env", "staging", "--state", f.state]); + expect(r.code).toBe(0); + + // Every project the token can see — including the two nobody knew were on + // the box. (On the real instance, that discovery is what stopped a plan + // whose next step would have deleted the box.) + expect(r.output).toContain("Incubator"); + expect(r.output).toContain("La Familia Site"); + expect(r.output).toContain("Martin Reyes Barber Shop"); + + // Both environments, and the empty one is SHOWN, not hidden — an operator + // who assumes `production` is where things live aims every later command at + // nothing. + expect(r.output).toContain("production"); + expect(r.output).toContain("(empty)"); + expect(r.output).toContain("staging"); + + // And the resources, under the names the box actually uses. + expect(r.output).toContain("Incubator Stack v2"); + expect(r.output).toContain("Incubator Database v2"); + }); + + it("needs no repo, no manifest, no store — it runs before adoption exists", async () => { + const f = fixture((await stubCoolify()).url); + // No org/repo positional at all: nothing is cloned, no manifest is read. + const r = await run(["--env", "staging", "--state", f.state]); + expect(r.code).toBe(0); + expect(r.output).toContain("No manifest involved"); + }); + + it("still asserts the team — a wrong-team token would sweep an empty instance", async () => { + const f = fixture((await stubCoolify()).url); + writeFileSync( + join(f.state, "environments.yaml"), + [ + "environments:", + " staging:", + " server: staging-box", + " team: { id: 9, name: Some Other Team }", + "", + ].join("\n"), + ); + const r = await run(["--env", "staging", "--state", f.state]); + // Coolify scopes what a token can see to its team, so an unasserted sweep + // would truthfully report that the instance is empty. + expect(r.code).not.toBe(0); + expect(r.output).not.toContain("La Familia Site"); + }); +}); + +describe("an empty environment shouts (#22)", () => { + it("says NOTHING is here, and names the sweep — not '5 differences'", async () => { + const f = fixture((await stubCoolify()).url); + const r = await run([ + "heavy-duty/incubator", + "--env", + "staging", + "--state", + f.state, + "--path", + f.checkout, + "--project", + "Incubator", + "--environment", + "production", // auto-created by Coolify, and empty + ]); + expect(r.code).toBe(0); + expect(r.output).toContain("NOTHING"); + expect(r.output).toContain("WRONG COORDINATE"); + expect(r.output).toContain("cast inventory --env staging"); + // NOT the old output, whose overall impression was "the box has nothing and + // the manifest has five things" — a full-create plan in all but name. + expect(r.output).not.toContain("difference(s) between the manifest"); + }); +}); From 619b94b7dff08fafdc2c75f3e76cfba26c595714 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 19:22:07 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(inventory):=20biome=20lint=20=E2=80=94?= =?UTF-8?q?=20a=20bare=20template=20literal=20and=20a=20string=20concat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what my local gate did not: `npm run check 2>&1 | tail -1 && commit` takes the exit status of `tail`, not of biome, so the `&&` gated nothing. The check had been failing locally too; the pipe was swallowing it. - noUnusedTemplateLiteral: a backtick string with no interpolation - useTemplate: string concatenation where a template literal belongs Co-Authored-By: Claude Opus 4.8 --- src/inventory.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/inventory.ts b/src/inventory.ts index 0f8d6b3..0f952a1 100644 --- a/src/inventory.ts +++ b/src/inventory.ts @@ -182,7 +182,7 @@ export function renderInventory( `inventory — ${ctx.orgRepo} ${ctx.env}`, "", ` looked in: project "${ctx.project}", environment "${ctx.environment}"`, - ` found: NOTHING. Not one resource.`, + " found: NOTHING. Not one resource.", "", "This environment is EMPTY — so there is nothing here to reconcile, and the", "manifest's list below would just be the manifest talking to itself.", @@ -196,9 +196,7 @@ export function renderInventory( "", ` cast inventory --env ${ctx.env} --instance ${ctx.instance}`, "", - "(The manifest declares: " + - rec.manifestOnly.map((m) => m.name).join(", ") + - ".)", + `(The manifest declares: ${rec.manifestOnly.map((m) => m.name).join(", ")}.)`, ].join("\n"); } const lines = [