cast/src/config.ts

131 lines
4.8 KiB
TypeScript
Raw Normal View History

feat: select the Coolify instance by name instead of editing .coolify.env (#14) loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from <state>/.coolify.env, with no flag or env override: the connection target was implicit in a file's current contents. Retargeting cast meant hand-editing a live credential file — and putting it back afterwards. The failure mode of getting that wrong is running `apply` against production. That is not hypothetical during the prod migration (incubator D-193): the state repo's .coolify.env holds a write+deploy token for the NEW control plane, while the verification gate needs a --full diff against the legacy, hand-built box still serving live users. - Named instances: <state>/.coolify/<name>.env, each with its own base URL and token. --instance <name> on every verb that reaches Coolify. - environments.yaml may bind one per environment (`instance: prod-cp`), so --env selects the right control plane with no flag and no file edit at all. An explicit --instance still wins, so a one-off read against a legacy box needs no change to that file either. - Refuse, don't guess, on an unknown --instance — naming the instances that do exist, in the same spirit as the absent-target refusal (#12/D-237). Falling back to the default here is exactly how a diff meant for a legacy box gets run against production. - An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add then refuse it before their first call, even though the token itself would permit the writes. "I pointed the wrong token at the wrong box" becomes an exit code rather than a live incident. - Every command that reaches a Coolify now SAYS which one, next to the team assert. It is the most consequential input and the least visible one. With no --instance and no binding, behavior is byte-for-byte what it was. The CLI tests spawn cast against stub Coolifys that record what they were asked, so "which instance did it actually talk to" is answered from the wire rather than from cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
feat: select the Coolify instance by name instead of editing .coolify.env (#14) loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from <state>/.coolify.env, with no flag or env override: the connection target was implicit in a file's current contents. Retargeting cast meant hand-editing a live credential file — and putting it back afterwards. The failure mode of getting that wrong is running `apply` against production. That is not hypothetical during the prod migration (incubator D-193): the state repo's .coolify.env holds a write+deploy token for the NEW control plane, while the verification gate needs a --full diff against the legacy, hand-built box still serving live users. - Named instances: <state>/.coolify/<name>.env, each with its own base URL and token. --instance <name> on every verb that reaches Coolify. - environments.yaml may bind one per environment (`instance: prod-cp`), so --env selects the right control plane with no flag and no file edit at all. An explicit --instance still wins, so a one-off read against a legacy box needs no change to that file either. - Refuse, don't guess, on an unknown --instance — naming the instances that do exist, in the same spirit as the absent-target refusal (#12/D-237). Falling back to the default here is exactly how a diff meant for a legacy box gets run against production. - An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add then refuse it before their first call, even though the token itself would permit the writes. "I pointed the wrong token at the wrong box" becomes an exit code rather than a live incident. - Every command that reaches a Coolify now SAYS which one, next to the team assert. It is the most consequential input and the least visible one. With no --instance and no binding, behavior is byte-for-byte what it was. The CLI tests spawn cast against stub Coolifys that record what they were asked, so "which instance did it actually talk to" is answered from the wire rather than from cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
// Which Coolify cast is about to talk to. The single most consequential input
// to any command — so it is a value that gets resolved, named, and printed,
// not an implicit property of whatever `.coolify.env` happens to contain right
// now.
export type CoolifyInstance = {
// "default" for <state>/.coolify.env, else the --instance name.
name: string;
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
baseUrl: string;
token: string;
feat: select the Coolify instance by name instead of editing .coolify.env (#14) loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from <state>/.coolify.env, with no flag or env override: the connection target was implicit in a file's current contents. Retargeting cast meant hand-editing a live credential file — and putting it back afterwards. The failure mode of getting that wrong is running `apply` against production. That is not hypothetical during the prod migration (incubator D-193): the state repo's .coolify.env holds a write+deploy token for the NEW control plane, while the verification gate needs a --full diff against the legacy, hand-built box still serving live users. - Named instances: <state>/.coolify/<name>.env, each with its own base URL and token. --instance <name> on every verb that reaches Coolify. - environments.yaml may bind one per environment (`instance: prod-cp`), so --env selects the right control plane with no flag and no file edit at all. An explicit --instance still wins, so a one-off read against a legacy box needs no change to that file either. - Refuse, don't guess, on an unknown --instance — naming the instances that do exist, in the same spirit as the absent-target refusal (#12/D-237). Falling back to the default here is exactly how a diff meant for a legacy box gets run against production. - An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add then refuse it before their first call, even though the token itself would permit the writes. "I pointed the wrong token at the wrong box" becomes an exit code rather than a live incident. - Every command that reaches a Coolify now SAYS which one, next to the team assert. It is the most consequential input and the least visible one. With no --instance and no binding, behavior is byte-for-byte what it was. The CLI tests spawn cast against stub Coolifys that record what they were asked, so "which instance did it actually talk to" is answered from the wire rather than from cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
// Declared by the instance, not inferred from the token: an instance
// configured for inspection must not be writable even if its token would
// permit the writes. See assertWritable.
readOnly: boolean;
// The file it came from, so every message can name it.
file: string;
};
export const DEFAULT_INSTANCE = "default";
const DEFAULT_FILE = ".coolify.env";
const INSTANCE_DIR = ".coolify";
function parseEnvFile(path: string): Record<string, string> {
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const vars: Record<string, string> = {};
for (const raw of readFileSync(path, "utf8").split("\n")) {
const line = raw.trim();
if (line === "" || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
vars[line.slice(0, eq)] = line.slice(eq + 1).replace(/^"|"$/g, "");
}
feat: select the Coolify instance by name instead of editing .coolify.env (#14) loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from <state>/.coolify.env, with no flag or env override: the connection target was implicit in a file's current contents. Retargeting cast meant hand-editing a live credential file — and putting it back afterwards. The failure mode of getting that wrong is running `apply` against production. That is not hypothetical during the prod migration (incubator D-193): the state repo's .coolify.env holds a write+deploy token for the NEW control plane, while the verification gate needs a --full diff against the legacy, hand-built box still serving live users. - Named instances: <state>/.coolify/<name>.env, each with its own base URL and token. --instance <name> on every verb that reaches Coolify. - environments.yaml may bind one per environment (`instance: prod-cp`), so --env selects the right control plane with no flag and no file edit at all. An explicit --instance still wins, so a one-off read against a legacy box needs no change to that file either. - Refuse, don't guess, on an unknown --instance — naming the instances that do exist, in the same spirit as the absent-target refusal (#12/D-237). Falling back to the default here is exactly how a diff meant for a legacy box gets run against production. - An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add then refuse it before their first call, even though the token itself would permit the writes. "I pointed the wrong token at the wrong box" becomes an exit code rather than a live incident. - Every command that reaches a Coolify now SAYS which one, next to the team assert. It is the most consequential input and the least visible one. With no --instance and no binding, behavior is byte-for-byte what it was. The CLI tests spawn cast against stub Coolifys that record what they were asked, so "which instance did it actually talk to" is answered from the wire rather than from cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
return vars;
}
// Coolify's base URL + API token, read from <state>/.coolify.env — the one
// file in the state repo that is never committed (it is a live credential).
export function loadCoolifyEnv(path: string): {
baseUrl: string;
token: string;
readOnly: boolean;
} {
const vars = parseEnvFile(path);
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
const baseUrl = vars.COOLIFY_BASE_URL;
const token = vars.COOLIFY_ACCESS_TOKEN;
if (!baseUrl || !token) {
throw new Error(
`${path}: COOLIFY_BASE_URL and COOLIFY_ACCESS_TOKEN are required`,
);
}
feat: select the Coolify instance by name instead of editing .coolify.env (#14) loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from <state>/.coolify.env, with no flag or env override: the connection target was implicit in a file's current contents. Retargeting cast meant hand-editing a live credential file — and putting it back afterwards. The failure mode of getting that wrong is running `apply` against production. That is not hypothetical during the prod migration (incubator D-193): the state repo's .coolify.env holds a write+deploy token for the NEW control plane, while the verification gate needs a --full diff against the legacy, hand-built box still serving live users. - Named instances: <state>/.coolify/<name>.env, each with its own base URL and token. --instance <name> on every verb that reaches Coolify. - environments.yaml may bind one per environment (`instance: prod-cp`), so --env selects the right control plane with no flag and no file edit at all. An explicit --instance still wins, so a one-off read against a legacy box needs no change to that file either. - Refuse, don't guess, on an unknown --instance — naming the instances that do exist, in the same spirit as the absent-target refusal (#12/D-237). Falling back to the default here is exactly how a diff meant for a legacy box gets run against production. - An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add then refuse it before their first call, even though the token itself would permit the writes. "I pointed the wrong token at the wrong box" becomes an exit code rather than a live incident. - Every command that reaches a Coolify now SAYS which one, next to the team assert. It is the most consequential input and the least visible one. With no --instance and no binding, behavior is byte-for-byte what it was. The CLI tests spawn cast against stub Coolifys that record what they were asked, so "which instance did it actually talk to" is answered from the wire rather than from cast's own console output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
return { baseUrl, token, readOnly: vars.COOLIFY_READ_ONLY === "true" };
}
// The named instances configured in a state dir: <state>/.coolify/<name>.env.
export function knownInstances(stateDir: string): string[] {
const dir = join(stateDir, INSTANCE_DIR);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith(".env"))
.map((f) => f.slice(0, -".env".length))
.sort();
}
export function instanceFile(stateDir: string, name?: string): string {
return name === undefined
? join(stateDir, DEFAULT_FILE)
: join(stateDir, INSTANCE_DIR, `${name}.env`);
}
// Refuse, don't guess — the same position `diff` takes on an absent target
// (#12). An unknown instance name is not a reason to fall back to the default
// one: "the instance I asked for isn't there, so I used a different one" is
// how a diff meant for a legacy box gets run against production.
export function loadInstance(stateDir: string, name?: string): CoolifyInstance {
const file = instanceFile(stateDir, name);
if (name !== undefined && !existsSync(file)) {
const known = knownInstances(stateDir);
throw new Error(
[
`no Coolify instance named "${name}"`,
"",
` looked for: ${file}`,
` configured: ${known.join(", ") || "(none)"}`,
"",
"A named instance is an env file holding COOLIFY_BASE_URL +",
"COOLIFY_ACCESS_TOKEN (and optionally COOLIFY_READ_ONLY=true). Create the",
"file above, or pass one of the names that exist. With no --instance, cast",
`reads ${join(stateDir, DEFAULT_FILE)}.`,
].join("\n"),
);
}
const { baseUrl, token, readOnly } = loadCoolifyEnv(file);
return {
name: name ?? DEFAULT_INSTANCE,
baseUrl,
token,
readOnly,
file,
};
}
// A read-only instance is one the operator declared for inspection. The token
// it holds may well be able to write — that is exactly the point: this turns
// "I pointed the wrong token at the wrong box" from a live incident into an
// exit code, before the first mutating call rather than after it.
export function assertWritable(instance: CoolifyInstance, verb: string): void {
if (!instance.readOnly) return;
throw new Error(
[
`refusing to ${verb}: Coolify instance "${instance.name}" is read-only`,
"",
` declared by: COOLIFY_READ_ONLY=true in ${instance.file}`,
` base url: ${instance.baseUrl}`,
"",
`\`${verb}\` writes. An instance configured for inspection cannot be written`,
"to, even if its token would permit it. Run `cast diff` or `cast team`",
"against this instance, or pass an --instance that is not read-only.",
].join("\n"),
);
}
// What cast prints before it touches a Coolify, on every command that reaches
// one. The instance is the input most likely to be wrong and least likely to
// be noticed — so it gets said out loud, next to the team assert.
export function formatInstance(instance: CoolifyInstance): string {
return `instance ${instance.name}${instance.baseUrl}${
instance.readOnly ? " (read-only)" : ""
}`;
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
}