diff --git a/README.md b/README.md
index f5243f3..93ba16b 100644
--- a/README.md
+++ b/README.md
@@ -60,9 +60,10 @@ Pass it with `--state
`, or set `CAST_STATE`. Defaults to the cwd.
## Commands
```sh
-cast apply / --env [--path ] [--hostname-overlay ]
-cast diff / --env [--full]
-cast capture / --env [--generated ] [--override ]
+cast apply / --env [--path ] [--hostname-overlay ]
+cast diff / --env [--full]
+cast capture / --env [--generated ] [--override ]
+cast inventory / --env
cast server add --ip --key --env [--user root] [--port 22]
cast smoke --env
cast team [--env ]
@@ -75,6 +76,12 @@ 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*.
- **`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.
@@ -155,8 +162,26 @@ assert. It is the most consequential input to any run, and the least visible.
## Adopting a hand-built instance
cast is otherwise scoped to the steady state: manifest → Coolify, forever.
-`capture` is the one-way-in — it bootstraps an environment's age store from an
-instance that was built by hand, before any manifest existed.
+Adoption is the one way in, and it has two verbs and a fixed order:
+
+**`inventory` → you read it → a manifest PR → `capture` → `apply`**
+
+**Look before you adopt.** A box nobody declared does not use your vocabulary:
+its project is called whatever someone typed, its environment is Coolify's
+default (`production`, not `prod`), and its resources are named by whoever
+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.
+
+```sh
+cast inventory heavy-duty/incubator --env prod --instance legacy \
+ --project Incubator --environment production
+```
+
+It never reads a value, needs no store and no key, and its output is **not**
+desired state. What you do with it is decide, resource by resource and key by
+key, what the manifest should *gain* and what is cruft that must not travel —
+and land that as a manifest PR. Only then:
```sh
CAST_CAPTURE_ADMIN_EMAIL=me@example.com \
@@ -178,6 +203,32 @@ values off the instance, and classifies every name:
Then it prints a plan of **names and provenance — never values** — and waits for
you to type the environment's name.
+Before any of that, it checks that the resources the manifest names **exist**.
+An absent resource reads back exactly like one with no env vars set: every name
+it declares reports *missing*, and `--override` would then have you hand-carry
+values that are sitting right there under a different name — writing a perfectly
+valid store while the actual finding (the manifest and the box disagree about
+what this thing is called) is never discovered. So a resource that isn't there
+refuses, and names what is. `inventory` is how you reconcile it.
+
+### Three names that are not yours
+
+A hand-built box names things without asking you, at three levels, and cast takes
+each as a coordinate to *read* with — never as a reason to rename anything of
+yours:
+
+| flag | when |
+| --- | --- |
+| `--project ` | the project isn't named after the repo (`Incubator`, not `incubator`) |
+| `--environment ` | the environment isn't named after `--env` (Coolify's default is `production`, not `prod`) |
+| — | a resource isn't named after the manifest's → **refuses**; reconcile with `inventory` first |
+
+`--env` stays **ours**: it selects the manifest block, the `environments.yaml`
+binding, the age key, the store path. `--environment` is *theirs*, on the wire,
+and nothing else. Collapsing the two lets a box that is being deleted next week
+name the environment of the box that replaces it — `apply` creates the
+environment from that value, so it would be inherited permanently.
+
The mapping is not mechanical, and that is the whole design. A `DATABASE_URL`
copied off the source box points at the *source box's* Postgres: confidently
wrong, entirely plausible, and the target's real URL does not exist until Coolify
diff --git a/src/capture.ts b/src/capture.ts
index 958dcfc..f12dc85 100644
--- a/src/capture.ts
+++ b/src/capture.ts
@@ -33,6 +33,54 @@ export type Classification = {
conflicts: Array<{ ref: string; values: Site[] }>;
};
+// The manifest resources that no resource of that name exists for on the source.
+//
+// This is the absent-target lie (#12/D-237) one level deeper in the tree, and it
+// fails in exactly the same way: a resource that is ABSENT reads back
+// identically to one that is PRESENT with no env vars set. Every name it
+// declares reports MISSING, the run refuses, and the message tells the operator
+// to supply each of them with --override — which would "work", writing a
+// perfectly valid store, while the real finding (the manifest and the box
+// disagree about what this resource is called) is never discovered and the
+// operator hand-carries values that were sitting right there under another name.
+//
+// So: refuse on the RESOURCE first, and only report per-name MISSING for
+// resources that were actually found — where it means what it says.
+export function absentResources(
+ required: RequiredSecret[],
+ liveNames: Iterable,
+): string[] {
+ const live = new Set(liveNames);
+ const declared = new Set(required.map((r) => r.resource));
+ return [...declared].filter((name) => !live.has(name)).sort();
+}
+
+export function renderAbsentResources(
+ absent: string[],
+ live: Array<{ kind: string; name: string }>,
+ ctx: { project: string; environment: string },
+): string {
+ const width = Math.max(0, ...live.map((l) => l.kind.length));
+ return [
+ `refusing to capture: the manifest declares ${absent.length} resource(s) that do not exist here`,
+ "",
+ ` looked in: project "${ctx.project}", environment "${ctx.environment}"`,
+ ` looked for: ${absent.join(", ")}`,
+ " exists here:",
+ ...(live.length > 0
+ ? live.map((l) => ` ${l.kind.padEnd(width)} ${l.name}`)
+ : [" (nothing at all)"]),
+ "",
+ "A resource that is absent reads back exactly like one with no env vars set:",
+ "every name it declares reports MISSING, and --override would then have you",
+ "hand-carry values that are sitting right there under a different name. The",
+ "finding is not that the secrets are missing — it is that the manifest and this",
+ "box disagree about what these resources are called.",
+ "",
+ "Reconcile the names first (`cast inventory` shows both sides), then capture.",
+ ].join("\n");
+}
+
function groupByRef(required: RequiredSecret[]): Map {
const byRef = new Map();
for (const { ref, resource, key } of required) {
diff --git a/src/cli.ts b/src/cli.ts
index 56039ac..f7a76c2 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -6,9 +6,16 @@ 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 { type LiveEnvs, classify, renderCapturePlan } from "./capture.js";
+import {
+ type LiveEnvs,
+ absentResources,
+ classify,
+ renderAbsentResources,
+ renderCapturePlan,
+} from "./capture.js";
import {
type CoolifyInstance,
+ DEFAULT_INSTANCE,
assertWritable,
formatInstance,
loadInstance,
@@ -21,8 +28,10 @@ import {
renderDiff,
} from "./diff.js";
import { assertEnvVarPolicy } from "./envtemplate.js";
+import { type LiveResource, reconcile, renderInventory } from "./inventory.js";
import {
desiredFromManifest,
+ manifestResources,
requiredSecrets,
resolveCheckout,
} from "./resolve.js";
@@ -36,9 +45,10 @@ import { serverAdd } from "./server.js";
import { smoke } from "./smoke.js";
import { assertTeam, formatTeam } from "./team.js";
-const USAGE = `usage: cast apply / --env [--path ] [--project ] [--hostname-overlay ]
- cast diff / --env [--full] [--project ]
- cast capture / --env [--path ] [--project ] [--generated ] [--override ] [--force]
+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 server add --ip --key --env [--user root] [--port 22]
cast smoke --env
cast team [--env ]
@@ -63,6 +73,13 @@ const USAGE = `usage: cast apply / --env [--path ] [--pr
whatever someone typed; \`diff\` refuses rather than reporting an
absent project as an empty one, and this is how you point it at
the real name.
+ --environment
+ the Coolify environment to act on, when it is not named after
+ --env (the default). Same problem as --project, one level down:
+ a box built by hand has whatever Coolify defaulted to, which is
+ \`production\`, not \`prod\`. This changes ONLY the name on the
+ wire — --env still selects the manifest block, the
+ environments.yaml binding, the age key and the store path.
capture (adopt a hand-built instance into the age secret store):
--generated force NAME to the \`pending-coolify-generated\` placeholder,
@@ -311,7 +328,12 @@ export async function fetchLive(
// exists next to it.
export function renderAbsentTarget(
lookup: Extract,
- ctx: { orgRepo: string; overridden: boolean; verb?: string },
+ ctx: {
+ orgRepo: string;
+ overridden: boolean;
+ envOverridden?: boolean;
+ verb?: string;
+ },
): string {
// `capture` takes the same position as `diff`, and for the same reason: it
// is only ever a claim about something that already exists. Against an
@@ -321,6 +343,7 @@ export function renderAbsentTarget(
const origin = ctx.overridden
? "--project"
: `derived from the repo slug ${ctx.orgRepo}`;
+ const envOrigin = ctx.envOverridden ? "--environment" : "derived from --env";
const head =
lookup.missing === "project"
? [
@@ -332,10 +355,10 @@ export function renderAbsentTarget(
: [
`refusing to ${verb}: project "${lookup.project}" has no environment "${lookup.environment}"`,
"",
- ` looked for: environment "${lookup.environment}" in project "${lookup.project}"`,
- " note: cast names environments after --env, so a project built by",
- " hand in the Coolify UI may well use a different name for the",
- " same tier (Coolify's own default is `production`).",
+ ` looked for: environment "${lookup.environment}" in project "${lookup.project}" (${envOrigin})`,
+ " note: a project built by hand in the Coolify UI may well use a",
+ " different name for the same tier — Coolify's own default is",
+ " `production`, not `prod`.",
];
return [
...head,
@@ -347,7 +370,12 @@ export function renderAbsentTarget(
"",
lookup.missing === "project"
? "Pass --project if this instance names it differently."
- : "Re-run with --env naming the environment as it exists here.",
+ : // NOT "rename your environment to match the box". The box does not get to
+ // name our environments: --env selects the manifest block, the binding, the
+ // age key and the store path, and a hand-built box being evicted next week
+ // must not decide any of them. --environment is the coordinate for reading
+ // it, and it changes nothing on our side of the line.
+ "Pass --environment if this instance names it differently.",
].join("\n");
}
@@ -439,6 +467,7 @@ async function main(): Promise {
path: { type: "string" },
state: { type: "string" },
project: { type: "string" },
+ environment: { type: "string" },
instance: { type: "string" },
"hostname-overlay": { type: "string" },
full: { type: "boolean", default: false },
@@ -458,6 +487,14 @@ async function main(): Promise {
// whatever someone typed. --project overrides that one, and nothing else —
// secrets stay keyed by the repo (a state-repo convention we own).
const projectName = values.project ?? repoShort;
+ // Exactly the same split, one level down. `--env` is OUR name for the
+ // environment: it selects the manifest block, the environments.yaml
+ // binding, the age key, the store path, the team to assert. `--environment`
+ // is THEIR name for it on the wire, and nothing else. Collapsing the two
+ // (as cast did until now) means a box built by hand in someone's UI gets to
+ // name our environment — and since apply creates the environment from this
+ // value, a legacy box's accident would be inherited by the new one forever.
+ const coolifyEnv = values.environment ?? envName;
const checkout = resolveCheckout(orgRepo, {
env: envName,
path: values.path,
@@ -499,7 +536,7 @@ async function main(): Promise {
const team = await assertTeam(client, binding.team, envName);
console.log(`team ${formatTeam(team)} ✓`);
const mode = command === "apply" || values.full ? "full" : "structural";
- const lookup = await fetchLive(client, projectName, envName);
+ const lookup = await fetchLive(client, projectName, coolifyEnv);
// apply and diff take opposite (and both correct) positions on absence:
// apply is *allowed* to be the thing that brings a project into existence,
// so [] is a legitimate starting point. diff is only ever a claim about
@@ -511,6 +548,7 @@ async function main(): Promise {
renderAbsentTarget(lookup, {
orgRepo,
overridden: values.project !== undefined,
+ envOverridden: values.environment !== undefined,
}),
);
return 2;
@@ -530,7 +568,10 @@ async function main(): Promise {
);
const exec = buildExecutor(client, {
projectName,
- envName,
+ // The name the environment gets ON COOLIFY when apply creates it — so an
+ // apply that adopts an existing hand-named environment writes into that
+ // one, rather than creating a second environment beside it.
+ envName: coolifyEnv,
serverUuid,
githubAppUuid,
s3DestinationUuid: binding.s3_destination,
@@ -553,6 +594,7 @@ async function main(): Promise {
state: { type: "string" },
path: { type: "string" },
project: { type: "string" },
+ environment: { type: "string" },
instance: { type: "string" },
generated: { type: "string", multiple: true },
override: { type: "string", multiple: true },
@@ -568,6 +610,11 @@ async function main(): Promise {
const stateDir = stateDirFrom(values.state);
const repoShort = orgRepo.split("/")[1];
const projectName = values.project ?? repoShort;
+ // Their name for the environment, on the wire. The store below stays keyed
+ // by OUR name (--env) — capture is the verb most likely to be pointed at a
+ // hand-built box, and the store it writes must not inherit that box's
+ // vocabulary.
+ const coolifyEnv = values.environment ?? envName;
const store = secretsFileFor(stateDir, repoShort, envName);
// Never overwrite a store by accident. `apply` never deletes; the verb
// that WRITES the store gets the same disposition, because the thing it
@@ -623,22 +670,43 @@ async function main(): Promise {
// as "every secret is missing" against a box that is fine.
const team = await assertTeam(client, binding.team, envName);
console.log(`team ${formatTeam(team)} ✓`);
- const lookup = await fetchLive(client, projectName, envName);
+ const lookup = await fetchLive(client, projectName, coolifyEnv);
if (!lookup.found) {
console.error(
renderAbsentTarget(lookup, {
orgRepo,
overridden: values.project !== undefined,
+ envOverridden: values.environment !== undefined,
verb: "capture",
}),
);
return 2;
}
+ // Databases hold no manifest-templated env of their own — their URL is what
+ // the APPS reference, and that name is generated, not captured.
+ const envBearing = lookup.live.filter((l) => l.kind !== "database");
+ // Before reading a single env: does every resource the manifest requires a
+ // secret FROM actually exist here? An absent resource reads back exactly
+ // like one with no env vars set — every name it declares reports MISSING —
+ // and the suggested remedy for MISSING (--override) would then have the
+ // operator hand-carry values that are sitting right there under a different
+ // name, burying the real finding. Same lie as the absent project, one level
+ // deeper. See absentResources.
+ const absent = absentResources(
+ required,
+ envBearing.map((l) => l.name),
+ );
+ if (absent.length > 0) {
+ console.error(
+ renderAbsentResources(absent, lookup.live, {
+ project: projectName,
+ environment: coolifyEnv,
+ }),
+ );
+ return 2;
+ }
const liveEnvs: LiveEnvs = {};
- for (const l of lookup.live) {
- // Databases hold no manifest-templated env of their own — their URL is
- // what the APPS reference, and that name is generated, not captured.
- if (l.kind === "database") continue;
+ for (const l of envBearing) {
liveEnvs[l.name] = await fetchEnv(client, l);
}
const classification = classify(
@@ -677,6 +745,81 @@ async function main(): Promise {
);
return 0;
}
+ if (command === "inventory") {
+ const { values, positionals } = parseArgs({
+ args: rest,
+ allowPositionals: true,
+ options: {
+ env: { type: "string" },
+ state: { type: "string" },
+ path: { type: "string" },
+ project: { type: "string" },
+ environment: { type: "string" },
+ instance: { type: "string" },
+ },
+ });
+ const orgRepo = positionals[0];
+ const envName = values.env;
+ if (!orgRepo || !envName) {
+ console.error(USAGE);
+ return 2;
+ }
+ const stateDir = stateDirFrom(values.state);
+ const repoShort = orgRepo.split("/")[1];
+ const projectName = values.project ?? repoShort;
+ const coolifyEnv = values.environment ?? envName;
+ const bindings = loadBindings(join(stateDir, "environments.yaml"));
+ const binding = bindings.environments[envName];
+ if (!binding) {
+ console.error(`environment ${envName} not in environments.yaml`);
+ return 2;
+ }
+ // Deliberately NOT resolveCheckout's prod ban, no secrets, no age key, no
+ // age_recipient: inventory runs BEFORE any store exists — that is the whole
+ // point of it — and it reads nothing it could leak. A read token is enough.
+ const checkout = resolveCheckout(orgRepo, {
+ env: envName,
+ path: values.path,
+ });
+ const manifest = manifestResources(checkout, envName);
+ const { client } = openCoolify(stateDir, values.instance, binding);
+ // Read-only instances are exactly what this verb is for. It still takes the
+ // team assert: a wrong-team token reads back nothing, and "nothing" would
+ // render here as "the box is empty" — the same lie, dressed as a report.
+ const team = await assertTeam(client, binding.team, envName);
+ console.log(`team ${formatTeam(team)} ✓`);
+ const lookup = await fetchLive(client, projectName, coolifyEnv);
+ if (!lookup.found) {
+ console.error(
+ renderAbsentTarget(lookup, {
+ orgRepo,
+ overridden: values.project !== undefined,
+ envOverridden: values.environment !== undefined,
+ verb: "inventory",
+ }),
+ );
+ return 2;
+ }
+ const live: LiveResource[] = [];
+ for (const l of lookup.live) {
+ // Keys, never values — see renderInventory. Databases carry no env of
+ // their own worth reconciling (their URL is what the apps reference).
+ const envKeys =
+ l.kind === "database" ? [] : Object.keys(await fetchEnv(client, l));
+ live.push({ kind: l.kind, name: l.name, envKeys });
+ }
+ console.log(
+ renderInventory(reconcile(manifest, live), {
+ orgRepo,
+ env: envName,
+ instance: values.instance ?? binding.instance ?? DEFAULT_INSTANCE,
+ project: projectName,
+ environment: coolifyEnv,
+ }),
+ );
+ // Always 0: this is a report, not a gate. `diff` is the gate.
+ return 0;
+ }
if (command === "server" && rest[0] === "add") {
const { values, positionals } = parseArgs({
args: rest.slice(1),
diff --git a/src/envtemplate.ts b/src/envtemplate.ts
index 0c2f5b5..7e3ba9f 100644
--- a/src/envtemplate.ts
+++ b/src/envtemplate.ts
@@ -61,6 +61,17 @@ export function templateRefs(
);
}
+// Every env var key a template declares — refs and literals alike. `capture`
+// only cares about the ${...} refs (the secrets); `inventory` needs all of them,
+// because the question it answers is "what does the manifest put on this
+// resource, and what does the box actually have?", and a literal the manifest
+// sets (a feature flag, NODE_ENV) is just as much a difference as a secret.
+//
+// Same parser as everything else in this file — see parseTemplate.
+export function templateKeys(text: string): string[] {
+ return parseTemplate(text).map(({ key }) => key);
+}
+
// An environment may forbid variables by name pattern, declared as
// `environments..forbidden_var_patterns` in the state repo. The rule is
// PRESENCE, not value: a forbidden var set to "false" still refuses the apply,
diff --git a/src/inventory.ts b/src/inventory.ts
new file mode 100644
index 0000000..9effe35
--- /dev/null
+++ b/src/inventory.ts
@@ -0,0 +1,173 @@
+import type { ManifestResource } from "./resolve.js";
+
+// `inventory` answers the question every other verb assumes you already
+// answered: **what is actually on this box?**
+//
+// cast can describe a Coolify it built (`diff`), change one (`apply`), and take
+// secret values off one for names a manifest already declares (`capture`). None
+// of those can tell you what is on a box you did NOT build — and that is the
+// first thing anyone needs when adopting an existing deployment. Without it,
+// every mismatch surfaces later, one at a time, as a refusal from a verb that is
+// already committed to a course of action; and the tempting fix for a refusal is
+// to bend the manifest toward the legacy box, which is exactly backwards.
+//
+// The output is a DOCUMENT, read by a person. It is deliberately not desired
+// state, not a store, and not consumed by `apply`:
+//
+// inventory → human reads → manifest PR → capture → apply
+//
+// That boundary is what keeps `capture` safe to be strict about. `inventory` may
+// read everything, because a person reads its output. `capture` may only ever
+// write what the manifest declares, because `apply` reads its output. Same box,
+// two consumers, two contracts.
+
+export type LiveResource = {
+ kind: string;
+ name: string;
+ envKeys: string[];
+};
+
+export type Matched = {
+ kind: string;
+ name: string;
+ // Declared by the manifest, absent from the box.
+ manifestOnlyKeys: string[];
+ // On the box, and the manifest knows nothing about it. Either something the
+ // manifest must gain, or cruft that must not travel — and only a human can
+ // say which. That judgment is the whole reason this verb exists.
+ boxOnlyKeys: string[];
+ sharedKeys: string[];
+};
+
+export type Reconciliation = {
+ matched: Matched[];
+ manifestOnly: ManifestResource[];
+ boxOnly: LiveResource[];
+};
+
+const sorted = (xs: Iterable) => [...xs].sort();
+
+export function reconcile(
+ manifest: ManifestResource[],
+ live: LiveResource[],
+): Reconciliation {
+ // Matched by NAME, not by kind: a manifest `application` that the box models
+ // as a `service` is a real and interesting finding, and collapsing it into
+ // "manifest-only + box-only" would hide the fact that they are the same thing.
+ const liveByName = new Map(live.map((l) => [l.name, l]));
+ const matched: Matched[] = [];
+ const manifestOnly: ManifestResource[] = [];
+ for (const m of manifest) {
+ const l = liveByName.get(m.name);
+ if (!l) {
+ manifestOnly.push(m);
+ continue;
+ }
+ const boxKeys = new Set(l.envKeys);
+ const manifestKeys = new Set(m.envKeys);
+ matched.push({
+ kind: m.kind === l.kind ? m.kind : `${m.kind} / ${l.kind} on the box`,
+ name: m.name,
+ manifestOnlyKeys: sorted(m.envKeys.filter((k) => !boxKeys.has(k))),
+ boxOnlyKeys: sorted(l.envKeys.filter((k) => !manifestKeys.has(k))),
+ sharedKeys: sorted(m.envKeys.filter((k) => boxKeys.has(k))),
+ });
+ }
+ const manifestNames = new Set(manifest.map((m) => m.name));
+ const boxOnly = live.filter((l) => !manifestNames.has(l.name));
+ return { matched, manifestOnly, boxOnly };
+}
+
+// Names and keys. NEVER values — the whole artifact is meant to be read, pasted,
+// and committed to a PR discussion, so it must be safe to do all three with.
+export function renderInventory(
+ rec: Reconciliation,
+ ctx: {
+ orgRepo: string;
+ env: string;
+ instance: string;
+ project: string;
+ environment: string;
+ },
+): string {
+ const lines = [
+ `inventory — ${ctx.orgRepo} ${ctx.env}`,
+ "",
+ ` source: instance ${ctx.instance}`,
+ ` project: ${ctx.project}`,
+ ` environment: ${ctx.environment}`,
+ "",
+ " Env var KEYS only — no values are read or printed.",
+ "",
+ ];
+
+ const bullet = (kind: string, name: string) =>
+ ` ${kind.padEnd(12)} ${name}`;
+
+ lines.push("on the box, and in the manifest");
+ if (rec.matched.length === 0) {
+ lines.push(" (nothing matched — see both lists below)");
+ }
+ for (const m of rec.matched) {
+ lines.push(bullet(m.kind, m.name));
+ if (m.sharedKeys.length > 0) {
+ lines.push(` both: ${m.sharedKeys.join(", ")}`);
+ }
+ if (m.manifestOnlyKeys.length > 0) {
+ lines.push(` manifest only: ${m.manifestOnlyKeys.join(", ")}`);
+ }
+ if (m.boxOnlyKeys.length > 0) {
+ lines.push(` box only: ${m.boxOnlyKeys.join(", ")}`);
+ }
+ }
+
+ lines.push("", "in the manifest, NOT on the box");
+ if (rec.manifestOnly.length === 0) lines.push(" (none)");
+ for (const m of rec.manifestOnly) {
+ lines.push(bullet(m.kind, m.name));
+ if (m.envKeys.length > 0) {
+ lines.push(` declares: ${sorted(m.envKeys).join(", ")}`);
+ }
+ }
+
+ lines.push("", "on the box, NOT in the manifest");
+ if (rec.boxOnly.length === 0) lines.push(" (none)");
+ for (const l of rec.boxOnly) {
+ lines.push(bullet(l.kind, l.name));
+ // A resource the manifest has never heard of: EVERY key on it is box-only,
+ // and they are the most interesting keys in the report — this is where a
+ // resource that the manifest calls something else shows up, carrying the
+ // values `capture` went looking for and could not find.
+ if (l.envKeys.length > 0) {
+ lines.push(` carries: ${sorted(l.envKeys).join(", ")}`);
+ }
+ }
+
+ const drift =
+ rec.manifestOnly.length +
+ rec.boxOnly.length +
+ rec.matched.reduce(
+ (n, m) => n + m.manifestOnlyKeys.length + m.boxOnlyKeys.length,
+ 0,
+ );
+ lines.push(
+ "",
+ drift === 0
+ ? "The manifest and this box name the same things. (On a hand-built box, treat"
+ : `${drift} difference(s) between the manifest and this box.`,
+ );
+ if (drift === 0) {
+ lines.push(
+ "that with suspicion rather than relief — a box nobody declared agreeing",
+ "perfectly with a manifest nobody applied is more often a wrong lookup than",
+ "a true match.)",
+ );
+ }
+ lines.push(
+ "",
+ "This is a document, not desired state. Nothing here is read by `apply` — the",
+ "path from here is: decide what the manifest should GAIN and what is cruft that",
+ "must not travel, land that as a manifest PR, then `capture` and `apply`.",
+ );
+ return lines.join("\n");
+}
diff --git a/src/resolve.ts b/src/resolve.ts
index 799261a..4ad6c51 100644
--- a/src/resolve.ts
+++ b/src/resolve.ts
@@ -6,6 +6,7 @@ import type { Desired } from "./diff.js";
import {
type ResolvedEnv,
resolveTemplate,
+ templateKeys,
templateRefs,
} from "./envtemplate.js";
import { loadManifest } from "./manifest.js";
@@ -245,6 +246,55 @@ export function requiredSecrets(
return { required, generated };
}
+// What the manifest declares for an environment, as names only — no secrets, no
+// age key, no store. `inventory` runs BEFORE any of those exist (that is the
+// point of it: you read the box before you can possibly have adopted it), so it
+// must be able to describe the manifest side without resolving a single value.
+export type ManifestResource = {
+ kind: "application" | "database" | "service";
+ name: string;
+ envKeys: string[];
+};
+
+export function manifestResources(
+ checkoutDir: string,
+ envName: string,
+): ManifestResource[] {
+ const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
+ const envSpec = manifest.environments[envName];
+ if (!envSpec) {
+ throw new Error(
+ `environment ${envName} not in manifest (has: ${Object.keys(manifest.environments).join(", ") || "none"})`,
+ );
+ }
+ const keysOf = (resource: string, template?: string): string[] => {
+ if (!template) return [];
+ const file = join(checkoutDir, ".infra", "env", template);
+ if (!existsSync(file))
+ throw new Error(
+ `env template missing: ${file} (referenced by ${resource})`,
+ );
+ return templateKeys(readFileSync(file, "utf8"));
+ };
+ return [
+ ...Object.entries(envSpec.applications).map(([name, app]) => ({
+ kind: "application" as const,
+ name,
+ envKeys: keysOf(name, app.env_template),
+ })),
+ ...Object.entries(envSpec.databases ?? {}).map(([name]) => ({
+ kind: "database" as const,
+ name,
+ envKeys: [],
+ })),
+ ...Object.entries(envSpec.services ?? {}).map(([name, svc]) => ({
+ kind: "service" as const,
+ name,
+ envKeys: keysOf(name, svc.env_template),
+ })),
+ ];
+}
+
export function desiredFromManifest(
checkoutDir: string,
envName: string,
diff --git a/test/inventory.test.ts b/test/inventory.test.ts
new file mode 100644
index 0000000..7b2b5b9
--- /dev/null
+++ b/test/inventory.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+import { absentResources } from "../src/capture.js";
+import { reconcile, renderInventory } from "../src/inventory.js";
+import type { ManifestResource } from "../src/resolve.js";
+
+const manifest: ManifestResource[] = [
+ {
+ kind: "application",
+ name: "core",
+ envKeys: ["NODE_ENV", "DATABASE_URL", "MAILGUN_API_KEY"],
+ },
+ { kind: "service", name: "umami", envKeys: ["APP_SECRET"] },
+ { kind: "database", name: "postgres", envKeys: [] },
+];
+
+describe("reconcile", () => {
+ it("sorts resources into both / manifest-only / box-only", () => {
+ const rec = reconcile(manifest, [
+ { kind: "application", name: "core", envKeys: ["NODE_ENV"] },
+ { kind: "application", name: "landing", envKeys: [] },
+ ]);
+ expect(rec.matched.map((m) => m.name)).toEqual(["core"]);
+ expect(rec.manifestOnly.map((m) => m.name)).toEqual(["umami", "postgres"]);
+ expect(rec.boxOnly.map((l) => l.name)).toEqual(["landing"]);
+ });
+
+ it("splits a matched resource's env KEYS the same three ways", () => {
+ const rec = reconcile(manifest, [
+ {
+ kind: "application",
+ name: "core",
+ envKeys: ["NODE_ENV", "MAILGUN_API_KEY", "LEFTOVER_FROM_2019"],
+ },
+ ]);
+ const core = rec.matched[0];
+ expect(core.sharedKeys).toEqual(["MAILGUN_API_KEY", "NODE_ENV"]);
+ // The manifest wants it; the box has never heard of it.
+ expect(core.manifestOnlyKeys).toEqual(["DATABASE_URL"]);
+ // On the box, unknown to the manifest. Either the manifest must gain it or
+ // it is cruft that must not travel — the judgment this verb exists to serve.
+ expect(core.boxOnlyKeys).toEqual(["LEFTOVER_FROM_2019"]);
+ });
+
+ it("matches by name across a kind mismatch rather than hiding it", () => {
+ // A manifest `service` the box models as an `application` is the same thing
+ // under two vocabularies. Reporting it as manifest-only + box-only would
+ // read as "two unrelated resources", which is exactly the wrong conclusion.
+ const rec = reconcile(
+ [{ kind: "service", name: "umami", envKeys: [] }],
+ [{ kind: "application", name: "umami", envKeys: [] }],
+ );
+ expect(rec.manifestOnly).toEqual([]);
+ expect(rec.boxOnly).toEqual([]);
+ expect(rec.matched[0].kind).toContain("service");
+ expect(rec.matched[0].kind).toContain("application");
+ });
+});
+
+describe("renderInventory", () => {
+ const ctx = {
+ orgRepo: "heavy-duty/incubator",
+ env: "prod",
+ instance: "box-b",
+ project: "Incubator",
+ environment: "production",
+ };
+
+ it("names both sides and every bucket", () => {
+ const out = renderInventory(
+ reconcile(manifest, [
+ {
+ kind: "application",
+ name: "incubator-stack",
+ envKeys: ["MAILGUN_API_KEY"],
+ },
+ ]),
+ ctx,
+ );
+ // The two names that are NOT ours — the coordinates that made this readable.
+ expect(out).toContain("Incubator");
+ expect(out).toContain("production");
+ // Declared, absent.
+ expect(out).toContain("core");
+ // Present, undeclared — the finding that a MISSING-per-name report buries.
+ expect(out).toContain("incubator-stack");
+ expect(out).toContain("This is a document, not desired state");
+ });
+
+ it("treats a zero-drift hand-built box as suspicious, not as a pass", () => {
+ const out = renderInventory(
+ reconcile(
+ [{ kind: "application", name: "core", envKeys: [] }],
+ [{ kind: "application", name: "core", envKeys: [] }],
+ ),
+ ctx,
+ );
+ expect(out).toContain("suspicion rather than relief");
+ });
+});
+
+describe("absentResources", () => {
+ const required = [
+ { ref: "MAILGUN_API_KEY", resource: "core", key: "MAILGUN_API_KEY" },
+ { ref: "DATABASE_URL_PROD", resource: "core", key: "DATABASE_URL" },
+ { ref: "UMAMI_APP_SECRET", resource: "umami", key: "APP_SECRET" },
+ ];
+
+ it("names the manifest resources the box does not have", () => {
+ expect(absentResources(required, ["incubator-stack", "umami"])).toEqual([
+ "core",
+ ]);
+ });
+
+ it("is empty when every declaring resource exists", () => {
+ expect(absentResources(required, ["core", "umami", "landing"])).toEqual([]);
+ });
+
+ it("reports each absent resource once, not once per secret it declares", () => {
+ // `core` declares two of the three refs. The old failure reported one
+ // MISSING per NAME (15 of them, in the live incident) and buried the single
+ // fact that mattered: one resource is called something else here.
+ expect(absentResources(required, [])).toEqual(["core", "umami"]);
+ });
+});
diff --git a/test/read-side-cli.test.ts b/test/read-side-cli.test.ts
new file mode 100644
index 0000000..734b19e
--- /dev/null
+++ b/test/read-side-cli.test.ts
@@ -0,0 +1,293 @@
+import { execFileSync, spawn } from "node:child_process";
+import { existsSync, 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, beforeAll, describe, expect, it } from "vitest";
+
+// The read side, end to end, against a stub Coolify shaped like a box nobody
+// declared: its project is `Incubator` (capital I), its environment is
+// `production` (Coolify's default, not ours), and its application is called
+// `incubator-stack` rather than the manifest's `core`.
+//
+// Every one of those three is a real name from the live box this work came out
+// of, and each one used to fail differently and badly:
+//
+// project → refused (already fixed, #12)
+// environment → refused, and the "obvious" fix was to rename OUR environment
+// to match the box — letting a machine due for deletion name
+// the new box's environment forever (#17)
+// resource → NOT refused: reported every required secret as individually
+// MISSING from a box that was serving production at the time,
+// and invited --override to hand-carry all of them (#18)
+
+const LIVE_ENV = {
+ MAILGUN_API_KEY: "key-REAL-MAILGUN-SECRET",
+ ADMIN_EMAIL: "founder@real-company.com",
+ LEFTOVER_FROM_2019: "nobody-asked-for-this",
+};
+
+let recipient: string;
+
+beforeAll(() => {
+ const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
+ const keyFile = join(dir, "age.key");
+ execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
+ recipient = execFileSync("age-keygen", ["-y", keyFile], {
+ encoding: "utf8",
+ }).trim();
+});
+
+type Stub = { url: string; close: () => Promise };
+const stubs: Stub[] = [];
+
+// appName is the knob: `incubator-stack` is the hand-built box (the manifest's
+// `core` does not exist on it); `core` is the box whose names happen to line up.
+async function stubCoolify(appName: string): 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" });
+ // Named as someone typed it, not as the repo slug would derive it.
+ if (path === "/projects") return json([{ uuid: "p1", name: "Incubator" }]);
+ // Coolify's default environment name — ours is `staging`.
+ if (path === "/projects/p1/production")
+ return json({ applications: [{ name: appName, uuid: "a1" }] });
+ if (path === "/applications/a1/envs")
+ return json(
+ Object.entries(LIVE_ENV).map(([key, real_value]) => ({
+ key,
+ real_value,
+ value: "REDACTED",
+ })),
+ );
+ 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"]
+ env_template: core.staging.env.template
+`;
+
+const TEMPLATE = `NODE_ENV=production
+MAILGUN_API_KEY=\${MAILGUN_API_KEY}
+ADMIN_EMAIL=\${ADMIN_EMAIL}
+`;
+
+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);
+ writeFileSync(
+ join(checkout, ".infra", "env", "core.staging.env.template"),
+ TEMPLATE,
+ );
+ 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 }",
+ ` age_recipient: ${recipient}`,
+ "github_apps:",
+ " incubator: hdb-coolify",
+ "",
+ ].join("\n"),
+ );
+ return {
+ checkout,
+ state,
+ store: join(state, "secrets", "incubator.staging.env.age"),
+ };
+}
+
+function run(
+ verb: string,
+ args: string[],
+ opts: { stdin?: string; env?: Record } = {},
+): Promise<{ code: number; output: string }> {
+ return new Promise((resolve) => {
+ const child = spawn("node", ["dist/cli.js", verb, ...args], {
+ stdio: ["pipe", "pipe", "pipe"],
+ env: { ...process.env, ...opts.env },
+ });
+ let output = "";
+ child.stdout.on("data", (d) => {
+ output += String(d);
+ });
+ child.stderr.on("data", (d) => {
+ output += String(d);
+ });
+ child.stdin.end(opts.stdin ?? "");
+ child.on("close", (code) => resolve({ code: code ?? 0, output }));
+ });
+}
+
+const base = (f: ReturnType) => [
+ "heavy-duty/incubator",
+ "--env",
+ "staging",
+ "--state",
+ f.state,
+ "--path",
+ f.checkout,
+];
+
+describe("--environment (the read-side coordinate, #17)", () => {
+ it("reads the box's environment name while the store keeps OURS", async () => {
+ const f = fixture((await stubCoolify("core")).url);
+ const r = await run(
+ "capture",
+ [
+ ...base(f),
+ "--project",
+ "Incubator",
+ "--environment",
+ "production",
+ "--override",
+ "ADMIN_EMAIL",
+ ],
+ {
+ stdin: "staging\n",
+ env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
+ },
+ );
+ expect(r.code).toBe(0);
+ // THE POINT: the box calls it `production`; we call it `staging`. The store
+ // is keyed by OUR name. A hand-built box does not get to name our
+ // environments, our store, or our age key — it only gets to be read.
+ expect(existsSync(f.store)).toBe(true);
+ expect(
+ existsSync(join(f.state, "secrets", "incubator.production.env.age")),
+ ).toBe(false);
+ });
+
+ it("refuses an absent environment by naming --environment, not by inviting a rename", async () => {
+ const f = fixture((await stubCoolify("core")).url);
+ // No --environment: cast looks for `staging`, the box has `production`.
+ const r = await run("capture", [...base(f), "--project", "Incubator"]);
+ expect(r.code).toBe(2);
+ expect(r.output).toContain('has no environment "staging"');
+ expect(r.output).toContain("Pass --environment ");
+ // The old message said "re-run with --env naming the environment as it
+ // exists here" — i.e. adopt the box's vocabulary as our own. That is the
+ // sentence that cost us a rename across three repos.
+ expect(r.output).not.toContain("Re-run with --env");
+ expect(existsSync(f.store)).toBe(false);
+ });
+});
+
+describe("absent resource (#18)", () => {
+ it("refuses on the RESOURCE, naming what exists, instead of reporting every secret missing", async () => {
+ const f = fixture((await stubCoolify("incubator-stack")).url);
+ const r = await run("capture", [
+ ...base(f),
+ "--project",
+ "Incubator",
+ "--environment",
+ "production",
+ ]);
+ expect(r.code).toBe(2);
+ // The finding: the manifest and the box disagree about what this is called.
+ expect(r.output).toContain("core");
+ expect(r.output).toContain("incubator-stack");
+ expect(r.output).toContain("do not exist here");
+ // NOT the old report — a wall of per-name MISSING against a box that HAS
+ // every one of those secrets, sitting right there under another name, with
+ // --override offered as the remedy (which would have written a perfectly
+ // valid store and buried the real problem forever).
+ //
+ // The assertion is that no INDIVIDUAL SECRET is named at all: this is a
+ // resource-level finding, and reporting it per-secret is what made the
+ // original failure so convincingly wrong. (Asserting on the word "MISSING"
+ // would only catch the prose that explains why we are not doing that.)
+ for (const name of Object.keys(LIVE_ENV)) {
+ expect(r.output).not.toContain(name);
+ }
+ expect(existsSync(f.store)).toBe(false);
+ });
+});
+
+describe("cast inventory (#19)", () => {
+ it("shows both sides of a hand-built box — and no values", async () => {
+ const f = fixture((await stubCoolify("incubator-stack")).url);
+ const r = await run("inventory", [
+ ...base(f),
+ "--project",
+ "Incubator",
+ "--environment",
+ "production",
+ ]);
+ expect(r.code).toBe(0);
+ // Declared, absent from the box.
+ expect(r.output).toContain("core");
+ // On the box, undeclared — including a var the manifest never heard of.
+ expect(r.output).toContain("incubator-stack");
+ expect(r.output).toContain("LEFTOVER_FROM_2019");
+ // Keys, never values. This artifact is meant to be pasted into a PR.
+ for (const value of Object.values(LIVE_ENV)) {
+ expect(r.output).not.toContain(value);
+ }
+ });
+
+ it("needs no secret store, no age key, and no age_recipient to run", async () => {
+ // The whole point: inventory runs BEFORE adoption, when none of those exist.
+ const f = fixture((await stubCoolify("incubator-stack")).url);
+ writeFileSync(
+ join(f.state, "environments.yaml"),
+ [
+ "environments:",
+ " staging:",
+ " server: staging-box",
+ " team: { id: 0, name: Root Team }",
+ "github_apps:",
+ " incubator: hdb-coolify",
+ "",
+ ].join("\n"),
+ );
+ const r = await run("inventory", [
+ ...base(f),
+ "--project",
+ "Incubator",
+ "--environment",
+ "production",
+ ]);
+ expect(r.code).toBe(0);
+ expect(r.output).toContain("inventory — heavy-duty/incubator staging");
+ });
+});