diff --git a/README.md b/README.md index 6f20e7d..c35f662 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,104 @@ baseline-tolerance model) and [`examples/permissions.config.ts`](examples/permissions.config.ts) for a runnable example. +## Environments (dev → prod promotion) + +One config repo can drive several ChurchTools instances — e.g. an `eqrm-dev` +rehearsal env and the real `prod` — Terraform-workspace-style, with **no file +edits** when switching. Declare the environments once in a committed +`ct.envs.json` in the config repo (default path; override with `CT_ENVS`): + +```json +{ + "environments": { + "dev": { "host": "https://eqrm-dev.church.tools" }, + "prod": { + "host": "https://eqrm.church.tools", + "state": "ct-state.prod.json", + "protected": true, + "tokenEnv": "CT_PROD_TOKEN" + } + } +} +``` + +Each profile is a `(host, state file, token reference)` triple: + +- **`host`** — the instance this env targets (the source of truth for `--env`; + it overrides any ambient `CT_HOST`). +- **`state`** — the committed state file. Defaults to the `ct-state..json` + convention (`ct-state.dev.json`, `ct-state.prod.json`), overridable per env. + Both files live in the config repo and are committed, so `dev` and `prod` + never share a state file. +- **`tokenEnv`** — the **name** of an environment variable holding the login + token (for CI); never a literal secret, so the file is safe to commit. +- **`protected`** — see the guardrail below. + +Every state/host-touching command takes `--env ` (`-e`): + +```bash +ct plan --env dev # diff dev's config against the dev host, using ct-state.dev.json +ct apply --env dev +ct plan --env prod # SAME checkout, no edits — prod host + ct-state.prod.json +ct state list --env prod +ct get groups --env dev +``` + +Without `--env`, behaviour is unchanged (single stored login, `ct-state.json`). + +**Token resolution** for a chosen env: `CT_LOGINTOKEN` env (CI — a profile +`tokenEnv` is copied here when set) → the host-keyed Keychain entry. `ct auth +login` now stores credentials **per host**, so one machine can hold logins for +`dev` and `prod` at once (a pre-existing single login still works as a fallback). + +**Cross-contamination is impossible:** every state file is bound to its host, and +loading a state file against a different host is refused — +`State file host (…) does not match … Refusing to mix instances.` — so `--env prod` +can never read or write a dev-bound state file. + +**Version gate per env:** envs may run different ChurchTools versions. +`ct plan --env ` surfaces the target env's name **and** its live CT version +in the header (e.g. `env: prod · host: … · ChurchTools 3.123.0 · …`), so a +dev/prod version skew is visible before you promote. + +### Protected environments + +Mark an env `"protected": true` and **apply/destroy against it ALWAYS require +typed confirmation of the environment name — even with `--auto-approve` (apply) +or `--force` (destroy)**. For non-interactive/CI use, pass `--confirm-env `, +which must match the target env name exactly and substitutes for the typed input: + +```bash +ct apply --env prod # prompts: type "prod" to confirm +ct apply --env prod --auto-approve # STILL prompts — auto-approve does not bypass a protected env +ct apply --env prod --auto-approve --confirm-env prod # CI: applies (flag matches) +ct destroy --env prod --target old --confirm-env prod # --force alone is NOT enough on a protected env +``` + +### Promotion workflow + +Promote a change dev → prod, verifying against the rehearsal env before the real one: + +```bash +# 1. Plan + apply against dev (rehearsal) +ct plan --env dev +ct apply --env dev + +# 2. Verify the change on dev: re-plan should be a clean no-op (round-trip), +# optionally recomputing dynamic-group membership. +ct plan --env dev # expect "No changes" +ct apply --env dev --refresh # (only if the change touched dynamic groups) + +# 3. Plan against prod — inspect the header's CT version and the diff carefully. +ct plan --env prod + +# 4. Apply to prod. Protected → confirm the env name (or --confirm-env prod in CI). +ct apply --env prod +``` + +Commit both state files (`ct-state.dev.json`, `ct-state.prod.json`) after each +apply — they are the record of what is managed on each instance. + ## Auth model The personal login token authenticates via a session handshake, **not** an @@ -250,6 +348,9 @@ npm run generate:client # regenerate the typed client from the live OpenAP - `plan` is the default; `apply` is explicit, with a confirmation prompt. - `apply` never deletes; destruction is explicit via `destroy --target`. - Destroy-protection (`preventDestroy` config flag); never implicit deletions. +- Protected environments (`"protected": true`): apply/destroy always require typed + confirmation of the env name — `--auto-approve`/`--force` never bypass it. +- Per-env state files are host-bound: `--env prod` can never touch a dev-bound state. - People/memberships are never touched (hard boundary in code). - Backup/export before every `apply` and `destroy`. - Rate-limit + retry on API calls (writes are never blindly re-sent on 5xx). diff --git a/examples/ct.envs.json b/examples/ct.envs.json new file mode 100644 index 0000000..d295a35 --- /dev/null +++ b/examples/ct.envs.json @@ -0,0 +1,13 @@ +{ + "environments": { + "dev": { + "host": "https://eqrm-dev.church.tools" + }, + "prod": { + "host": "https://eqrm.church.tools", + "state": "ct-state.prod.json", + "protected": true, + "tokenEnv": "CT_PROD_TOKEN" + } + } +} diff --git a/src/api/ctClient.ts b/src/api/ctClient.ts index 7100944..8a5b940 100644 --- a/src/api/ctClient.ts +++ b/src/api/ctClient.ts @@ -75,6 +75,15 @@ export class CtClient { return this.config.host; } + /** + * The ChurchTools release this client is talking to, once known (populated by + * {@link assertMinVersion} / any `/info` read). `null` until then. Surfaced in + * the `--env` plan header so a per-env version gate is visible (#22). + */ + get version(): string | null { + return this.ctVersion; + } + /** * Hard-fail if the ChurchTools instance is below the minimum version the CLI * requires (group hierarchy / metadata CRUD need v3.96+). One `/info` GET, diff --git a/src/api/session.ts b/src/api/session.ts index c6d8fa9..b8f5e13 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -27,7 +27,9 @@ export interface AuthedSession { export async function authedSession(): Promise { const config = await resolveConfig(); const envToken = process.env.CT_LOGINTOKEN?.trim(); - const stored = await readCredentials(); + // Resolve the stored token for the host we intend to hit (multi-host, #22): with several logins on + // one machine, the per-host account is picked by host, keeping the token↔host binding below intact. + const stored = await readCredentials(config.host); const token = envToken || stored?.token; if (!token) { throw new Error("Not logged in. Run `ct auth login --host --token ` first."); diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index 5f229be..ba02975 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -3,13 +3,20 @@ * personal **login token**, stored together so a token is always bound to the * instance it authenticates against. * - * They live in the macOS Keychain (via the `security` CLI) as a single JSON - * blob. There is no file fallback: on CI or non-macOS hosts, supply the host and - * token through the `CT_HOST` / `CT_LOGINTOKEN` environment variables instead. + * They live in the macOS Keychain (via the `security` CLI) as JSON blobs. There + * is no file fallback: on CI or non-macOS hosts, supply the host and token + * through the `CT_HOST` / `CT_LOGINTOKEN` environment variables instead. + * + * Multi-host (#22): each host's credentials are stored under a **per-host account** + * (account name = the host), so one machine can hold logins for several instances + * (e.g. `eqrm-dev` and `prod`) at once. A legacy single `"credentials"` account is + * still written as the "default / last login" pointer (so the single-host path — + * `readStoredHost()` with no host — keeps working) and is still READ as a fallback + * for a host that has no per-host account yet (a login made before this change). * * Read precedence is applied by the callers: - * - token: `CT_LOGINTOKEN` env → stored credentials - * - host: `CT_HOST` env → stored credentials (see config.ts) + * - token: `CT_LOGINTOKEN` env → stored credentials for the host + * - host: `CT_HOST` env → stored default host (see config.ts) * * Note: `security ... -w ` passes the value as an argv, briefly visible * to `ps`. Acceptable for a local developer CLI; the value never touches git. @@ -20,7 +27,8 @@ import { promisify } from "node:util"; const run = promisify(execFile); const KEYCHAIN_SERVICE = "ct-cli"; -const KEYCHAIN_ACCOUNT = "credentials"; +/** The "default / last login" account — a single blob, also the single-host (pre-#22) location. */ +const DEFAULT_ACCOUNT = "credentials"; /** Pre-host account name; a bare token used to live here. Cleared on logout so no secret is orphaned. */ const LEGACY_KEYCHAIN_ACCOUNT = "login-token"; @@ -51,52 +59,56 @@ export function parseCredentials(raw: string): Credentials | null { return { host, token }; } -async function keychainSet(value: string): Promise { +async function keychainSet(account: string, value: string): Promise { await run("security", [ "add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", - KEYCHAIN_ACCOUNT, + account, "-w", value, ]); } /** - * Memoized keychain blob for this process. A single run resolves the host - * (via `resolveConfig`) AND the token (via `authedSession`) — each of which - * reaches for the stored credentials — so without this cache the same entry is - * fetched up to 3× per command, spawning `security find-generic-password` - * (and prompting to unlock a locked Keychain) every time. `undefined` = not yet - * read; `null` = read and absent. Invalidated on any write (`resetKeychainCache`). + * Memoized keychain reads for this process, keyed by account. A single run + * resolves the host (via `resolveConfig`) AND the token (via `authedSession`) — + * each of which reaches for the stored credentials — so without this cache the + * same entry is fetched multiple times per command, spawning + * `security find-generic-password` (and prompting to unlock a locked Keychain) + * every time. A cached value of `null` = read and absent. Invalidated wholesale + * on any write (`resetKeychainCache`). */ -let cachedKeychainBlob: string | null | undefined; +const cachedBlobs = new Map(); -/** Drop the memoized keychain read. Called after every store/clear; exported for tests. */ +/** Drop the memoized keychain reads. Called after every store/clear; exported for tests. */ export function resetKeychainCache(): void { - cachedKeychainBlob = undefined; + cachedBlobs.clear(); } -async function keychainGet(): Promise { - if (cachedKeychainBlob !== undefined) { - return cachedKeychainBlob; +async function keychainGet(account: string): Promise { + const cached = cachedBlobs.get(account); + if (cached !== undefined) { + return cached; } + let value: string | null; try { const { stdout } = await run("security", [ "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", - KEYCHAIN_ACCOUNT, + account, "-w", ]); - cachedKeychainBlob = stdout.trim() || null; + value = stdout.trim() || null; } catch { - cachedKeychainBlob = null; + value = null; } - return cachedKeychainBlob; + cachedBlobs.set(account, value); + return value; } async function keychainDelete(account: string): Promise { @@ -107,44 +119,76 @@ async function keychainDelete(account: string): Promise { } } -/** Persist host + token in the macOS Keychain; returns a human-readable location. */ +/** + * Persist host + token in the macOS Keychain; returns a human-readable location. + * Written to BOTH the per-host account (so `readCredentials(host)` finds it) and + * the default `"credentials"` account (so the single-host path — resolve the host + * with no `--env` — keeps working, and this login becomes the default). + */ export async function storeCredentials(creds: Credentials): Promise { if (!isMac()) { throw new Error( "Credential storage requires the macOS Keychain. On other platforms, set CT_HOST and CT_LOGINTOKEN instead.", ); } - await keychainSet(JSON.stringify(creds)); + const blob = JSON.stringify(creds); + await keychainSet(creds.host, blob); + await keychainSet(DEFAULT_ACCOUNT, blob); resetKeychainCache(); // a fresh login must invalidate any read the process already cached - return `macOS Keychain (service "${KEYCHAIN_SERVICE}", account "${KEYCHAIN_ACCOUNT}")`; + return `macOS Keychain (service "${KEYCHAIN_SERVICE}", account "${creds.host}")`; } -/** The stored credentials, or null when nothing valid is stored. */ -export async function readCredentials(): Promise { +/** + * The stored credentials. With a `host`, prefer that host's per-host account, + * then fall back to the legacy default blob ONLY when its host matches (so a + * pre-#22 single login keeps working, but one host's token never leaks for + * another). With no `host`, return the default / last-login blob (single-host path). + */ +export async function readCredentials(host?: string): Promise { if (!isMac()) { return null; } - const raw = await keychainGet(); - return raw ? parseCredentials(raw) : null; + if (host === undefined) { + const raw = await keychainGet(DEFAULT_ACCOUNT); + return raw ? parseCredentials(raw) : null; + } + const keyed = await keychainGet(host); + const keyedCreds = keyed ? parseCredentials(keyed) : null; + if (keyedCreds) { + return keyedCreds; + } + const fallbackRaw = await keychainGet(DEFAULT_ACCOUNT); + const fallback = fallbackRaw ? parseCredentials(fallbackRaw) : null; + return fallback && fallback.host === host ? fallback : null; } -/** The login token: `CT_LOGINTOKEN` env wins, else the stored credentials. */ -export async function readToken(): Promise { +/** The login token: `CT_LOGINTOKEN` env wins, else the stored credentials for `host`. */ +export async function readToken(host?: string): Promise { const fromEnv = process.env.CT_LOGINTOKEN?.trim(); if (fromEnv) { return fromEnv; } - return (await readCredentials())?.token ?? null; + return (await readCredentials(host))?.token ?? null; } -/** The stored instance host (no env fallback — env precedence lives in resolveConfig). */ +/** The stored default instance host (no env fallback — env precedence lives in resolveConfig). */ export async function readStoredHost(): Promise { return (await readCredentials())?.host ?? null; } +/** + * Remove stored credentials. Clears the default blob, the pre-host bare-token + * entry, and — for the current default login — its per-host account, so a + * single-host logout leaves no secret behind. (Additional per-host logins for + * OTHER hosts are left in place; re-login overwrites them.) + */ export async function clearCredentials(): Promise { if (isMac()) { - await keychainDelete(KEYCHAIN_ACCOUNT); + const current = await readCredentials(); // default blob → its host's per-host account + if (current) { + await keychainDelete(current.host); + } + await keychainDelete(DEFAULT_ACCOUNT); // Also drop the pre-host bare-token entry so an upgrade doesn't leave a secret behind. await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT); } diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts index def1246..f201923 100644 --- a/src/commands/adopt-grants.ts +++ b/src/commands/adopt-grants.ts @@ -1,14 +1,16 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; import { resolveConfig } from "../config.js"; +import { prepareEnv } from "../env/context.js"; import { assertNotPeople } from "../engine/guard.js"; import { emitAdoptedGrants } from "../permissions/adopt.js"; import type { DomainType, RawPermission } from "../permissions/grants.js"; -import { loadState, resolveStatePath } from "../state/state.js"; +import { loadState } from "../state/state.js"; import { info, warn } from "../ui.js"; interface AdoptGrantsOptions { state?: string; + env?: string; } /** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */ @@ -31,6 +33,7 @@ export function adoptGrantsCommand(): Command { .argument("", "group_role | group_type_role") .argument("", "the domainId of the permission domain object") .option("-s, --state ", "state file path (or set CT_STATE) — used to resolve scope group ids to keys") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .action(async (rawType: string, rawId: string, opts: AdoptGrantsOptions) => { const domainType = normalizeDomainType(rawType); if (!/^\d+$/.test(rawId.trim())) { @@ -42,8 +45,9 @@ export function adoptGrantsCommand(): Command { // Load + validate the state file (host guard) BEFORE any network call, mirroring `ct adopt`, // so a state file recorded against another instance never triggers a request to the wrong host. + const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); - const statePath = resolveStatePath(opts.state); + const statePath = cmdEnv.statePath; const state = await loadState(statePath, config.host); const { client } = await authedSession(); diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts index 5c30502..343426d 100644 --- a/src/commands/adopt.ts +++ b/src/commands/adopt.ts @@ -1,14 +1,16 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; import { resolveConfig } from "../config.js"; +import { prepareEnv } from "../env/context.js"; import { resourceType, configSnippet } from "../resources/registry.js"; -import { loadState, saveState, resolveStatePath, upsert } from "../state/state.js"; +import { loadState, saveState, upsert } from "../state/state.js"; import { success, info, warn, out } from "../ui.js"; import { adoptGrantsCommand } from "./adopt-grants.js"; interface AdoptOptions { key?: string; state?: string; + env?: string; dryRun?: boolean; } @@ -19,6 +21,7 @@ export function adoptCommand(): Command { .argument("", "ChurchTools id of the resource") .option("-k, --key ", "logical key (defaults to a slug of the resource name)") .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .option("--dry-run", "preview the config entry and state change without writing") .action(async (type: string, rawId: string, opts: AdoptOptions) => { const spec = resourceType(type); @@ -30,8 +33,9 @@ export function adoptCommand(): Command { // Load + validate the state file (host guard included) BEFORE any network // call, so a state file recorded against another instance never triggers a // live authenticated request against the wrong ChurchTools host. + const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); - const statePath = resolveStatePath(opts.state); + const statePath = cmdEnv.statePath; const state = await loadState(statePath, config.host); const { client } = await authedSession(); diff --git a/src/commands/apply.ts b/src/commands/apply.ts index b11f220..91be137 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -2,7 +2,8 @@ import { dirname, join } from "node:path"; import { Command } from "commander"; import { authedSession } from "../api/session.js"; import { resolveConfig } from "../config.js"; -import { loadState, resolveStatePath, saveState } from "../state/state.js"; +import { prepareEnv } from "../env/context.js"; +import { loadState, saveState } from "../state/state.js"; import { loadConfig, resolveConfigPath } from "../config/load.js"; import { buildPlan } from "../engine/build.js"; import { Resolver } from "../resolve/resolver.js"; @@ -14,13 +15,15 @@ import { summarize } from "../engine/types.js"; import { buildPermissionPlan } from "../permissions/plan.js"; import { renderPermissionPlan } from "../permissions/render.js"; import { applyPermissionPlan } from "../permissions/apply.js"; -import { confirm } from "../ui/prompt.js"; +import { confirm, confirmEnv } from "../ui/prompt.js"; import { resolveWithEnv } from "../util/resolve.js"; import { info, warn, success, error } from "../ui.js"; interface ApplyOptions { config?: string; state?: string; + env?: string; + confirmEnv?: string; backupDir?: string; autoApprove?: boolean; refresh?: boolean; @@ -40,6 +43,11 @@ export function applyCommand(): Command { .description("Apply the plan: idempotent create + update in dependency order (never deletes)") .option("-c, --config ", "config file (or set CT_CONFIG)") .option("-s, --state ", "state file (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option( + "--confirm-env ", + "confirm a protected env non-interactively (must match --env exactly)", + ) .option("--backup-dir ", "directory for the pre-apply backup (or set CT_BACKUP_DIR)") .option("-y, --auto-approve", "skip the confirmation prompt") .option( @@ -47,9 +55,10 @@ export function applyCommand(): Command { "after a successful apply, POST /dynamicgroups/{id}/refresh for each changed dynamic group (per-group only)", ) .action(async (opts: ApplyOptions) => { + const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); - const statePath = resolveStatePath(opts.state); + const statePath = cmdEnv.statePath; const { resources: desired, permissions, configDir } = await loadConfig(configPath); const state = await loadState(statePath, config.host); @@ -97,9 +106,18 @@ export function applyCommand(): Command { return; } - const ok = await confirm(`Apply ${changeCount} change(s)?`, { assumeYes: opts.autoApprove }); + // Protected env (#22): typed confirmation of the env name is MANDATORY — --auto-approve does not + // bypass it. --confirm-env substitutes for the typed input in CI. Otherwise the normal + // y/N (skippable with --auto-approve) applies. + const ok = cmdEnv.protected + ? await confirmEnv(cmdEnv.name!, { confirmFlag: opts.confirmEnv }) + : await confirm(`Apply ${changeCount} change(s)?`, { assumeYes: opts.autoApprove }); if (!ok) { - warn("Aborted — no changes made."); + warn( + cmdEnv.protected + ? `Aborted — protected environment "${cmdEnv.name}" was not confirmed (no changes made).` + : "Aborted — no changes made.", + ); process.exitCode = 1; return; } diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index fad584e..dbfc0d1 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -2,7 +2,8 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; import { CtApiError, type CtClient } from "../api/ctClient.js"; import { resolveConfig } from "../config.js"; -import { loadState, resolveStatePath, saveState, type State } from "../state/state.js"; +import { prepareEnv } from "../env/context.js"; +import { loadState, saveState, type State } from "../state/state.js"; import { RESOURCES } from "../resources/registry.js"; import { assertNotPeople } from "../engine/guard.js"; import { orderKeys } from "../engine/graph.js"; @@ -11,12 +12,14 @@ import { parentIdsByGroupId, managedParentKeys, type HierarchyEntry } from "../e import type { DesiredResource } from "../engine/types.js"; import { writeBackup } from "../engine/backup.js"; import { resolveBackupDir } from "./apply.js"; -import { confirmTyped } from "../ui/prompt.js"; +import { confirmTyped, confirmEnv } from "../ui/prompt.js"; import { info, warn, success, error } from "../ui.js"; interface DestroyOptions { target?: string[]; state?: string; + env?: string; + confirmEnv?: string; backupDir?: string; force?: boolean; } @@ -99,6 +102,11 @@ export function destroyCommand(): Command { .description("Explicitly delete managed resources (protected; never implicit)") .requiredOption("--target ", "logical key(s) to destroy (repeatable or comma-separated)") .option("-s, --state ", "state file (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option( + "--confirm-env ", + "confirm a protected env non-interactively (must match --env exactly)", + ) .option("--backup-dir ", "directory for the pre-destroy backup (or set CT_BACKUP_DIR)") .option("--force", "skip the typed confirmation (preventDestroy is still enforced)") .action(async (opts: DestroyOptions) => { @@ -107,8 +115,9 @@ export function destroyCommand(): Command { throw new Error("No --target given. Destroy never deletes implicitly."); } + const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); - const statePath = resolveStatePath(opts.state); + const statePath = cmdEnv.statePath; const state = await loadState(statePath, config.host); for (const key of targets) { @@ -151,10 +160,18 @@ export function destroyCommand(): Command { info(`Backup written: ${backupPath}`); warn(`About to DELETE: ${ordered.join(", ")}`); + // Protected env (#22): typed confirmation of the env NAME is mandatory and --force does NOT bypass + // it (--confirm-env substitutes in CI). Otherwise the usual per-target typed confirmation. const expected = targets.length === 1 ? targets[0]! : "destroy"; - const ok = await confirmTyped(expected, { force: opts.force }); + const ok = cmdEnv.protected + ? await confirmEnv(cmdEnv.name!, { confirmFlag: opts.confirmEnv }) + : await confirmTyped(expected, { force: opts.force }); if (!ok) { - warn("Aborted — nothing deleted."); + warn( + cmdEnv.protected + ? `Aborted — protected environment "${cmdEnv.name}" was not confirmed. Nothing deleted.` + : "Aborted — nothing deleted.", + ); process.exitCode = 1; return; } diff --git a/src/commands/get.ts b/src/commands/get.ts index f277a16..75f93a3 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; +import { prepareEnvHost } from "../env/context.js"; import { CATALOG } from "../permissions/catalog.js"; import { info, out } from "../ui.js"; @@ -44,7 +45,9 @@ export function getCommand(): Command { cmd .command(name) .description(`GET ${spec.path}`) - .action(async () => { + .option("-e, --env ", "environment profile from ct.envs.json (targets that host)") + .action(async (opts: { env?: string }) => { + await prepareEnvHost(opts); // #22: wire the env's host/token before authenticating const { client } = await authedSession(); if (spec.paginated === false) { out(await client.get(spec.path)); @@ -76,7 +79,9 @@ export function getCommand(): Command { cmd .command("raw ") .description("GET an arbitrary API path, e.g. `ct get raw /groups/42`") - .action(async (path: string) => { + .option("-e, --env ", "environment profile from ct.envs.json (targets that host)") + .action(async (path: string, opts: { env?: string }) => { + await prepareEnvHost(opts); // #22: wire the env's host/token before authenticating const { client } = await authedSession(); out(await client.get(path.startsWith("/") ? path : `/${path}`)); }); diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 29e0d69..8e54212 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,7 +1,8 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; import { resolveConfig } from "../config.js"; -import { loadState, resolveStatePath } from "../state/state.js"; +import { prepareEnv } from "../env/context.js"; +import { loadState } from "../state/state.js"; import { loadConfig, resolveConfigPath } from "../config/load.js"; import { buildPlan } from "../engine/build.js"; import { Resolver } from "../resolve/resolver.js"; @@ -13,6 +14,7 @@ import { info, warn, out } from "../ui.js"; interface PlanOptions { config?: string; state?: string; + env?: string; json?: boolean; } @@ -21,13 +23,16 @@ export function planCommand(): Command { .description("Show the diff between the desired-state config and ChurchTools (read-only)") .option("-c, --config ", "config file (or set CT_CONFIG)") .option("-s, --state ", "state file (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .option("--json", "emit the raw plan as JSON instead of the rendered diff") .action(async (opts: PlanOptions) => { + // Resolve the env FIRST — it wires the target host/token into the process env before resolveConfig. + const cmdEnv = await prepareEnv(opts); const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); const { resources: desired, permissions, configDir } = await loadConfig(configPath); // loadState already refuses a host mismatch (state.ts) — no second guard needed here. - const state = await loadState(resolveStatePath(opts.state), config.host); + const state = await loadState(cmdEnv.statePath, config.host); const { client } = await authedSession(); // One shared resolver (#20): buildPlan and buildPermissionPlan run concurrently, so a single @@ -42,7 +47,16 @@ export function planCommand(): Command { if (opts.json) { out({ plan, permissions: permItems }); } else { - info(`config: ${configPath} · state host: ${state.host}`); + // Under --env, surface the target env name + its CT version (per-env version gate, #22) so a + // dev/prod version skew is visible before applying. No --env keeps the original header byte-identical. + if (cmdEnv.name) { + info( + `env: ${cmdEnv.name} · host: ${config.host} · ChurchTools ${client.version ?? "unknown"} · ` + + `config: ${configPath} · state host: ${state.host}`, + ); + } else { + info(`config: ${configPath} · state host: ${state.host}`); + } process.stdout.write(`${renderPlan(plan)}\n`); if (permItems.length > 0) { process.stdout.write(`\n${renderPermissionPlan(permItems)}\n`); diff --git a/src/commands/state.ts b/src/commands/state.ts index fc23309..1365e06 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -1,10 +1,12 @@ import { Command } from "commander"; import { resolveConfig } from "../config.js"; -import { loadState, resolveStatePath } from "../state/state.js"; +import { prepareEnv } from "../env/context.js"; +import { loadState } from "../state/state.js"; import { info, out } from "../ui.js"; interface StateOptions { state?: string; + env?: string; } export function stateCommand(): Command { @@ -14,8 +16,10 @@ export function stateCommand(): Command { .command("list") .description("List every resource under management (JSON to stdout)") .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .action(async (opts: StateOptions) => { - const statePath = resolveStatePath(opts.state); + const cmdEnv = await prepareEnv(opts); + const statePath = cmdEnv.statePath; const state = await loadState(statePath, (await resolveConfig()).host); const resources = Object.values(state.resources); info(`${resources.length} managed resource(s) in ${statePath} (host ${state.host}).`); diff --git a/src/env/context.ts b/src/env/context.ts new file mode 100644 index 0000000..06e4da9 --- /dev/null +++ b/src/env/context.ts @@ -0,0 +1,74 @@ +/** + * Command-level wiring for `--env` (#22). Bridges an environment profile to the + * existing single-host resolution: rather than thread a host/token/state triple + * through every command and helper, `prepareEnv` resolves the named profile and + * writes its host (and, for CI, token) into `process.env` so the unchanged + * `resolveConfig` / `authedSession` / `resolveStatePath` pick them up. This keeps + * the `--env`-less path byte-identical: with no `--env`, nothing is mutated and + * the default state path is returned. + * + * Token resolution order for a chosen env stays: `CT_LOGINTOKEN` env (CI) → + * host-keyed Keychain entry. A profile `tokenEnv` names the env var CI populated; + * when set and present we copy it into `CT_LOGINTOKEN` so the standard order holds. + */ +import { resolveStatePath } from "../state/state.js"; +import { loadEnvProfile, resolveEnvsPath, type EnvProfile } from "./envs.js"; + +export interface CommandEnv { + /** The selected env name, or null when no `--env` was passed (single-host default). */ + name: string | null; + /** Whether the selected env is protected (apply/destroy require typed confirmation). */ + protected: boolean; + /** The resolved state-file path (per-env under `--env`, else the single-host default). */ + statePath: string; +} + +interface EnvOpts { + env?: string; + state?: string; +} + +/** + * Resolve the profile for `opts.env` (if any) and wire its host + token into + * `env` so downstream host/token resolution targets that instance. Returns the + * resolved profile, or null when no `--env` was requested. + */ +async function wireEnv(opts: EnvOpts, env: NodeJS.ProcessEnv): Promise { + if (!opts.env) { + return null; + } + const profile = await loadEnvProfile(opts.env, resolveEnvsPath(undefined, env)); + // The profile is the source of truth for this env's host — it overrides any ambient CT_HOST. + env.CT_HOST = profile.host; + if (profile.tokenEnv) { + const token = env[profile.tokenEnv]?.trim(); + if (token) { + env.CT_LOGINTOKEN = token; + } + } + return profile; +} + +/** + * Prepare a state-touching command's environment. Call this FIRST — before + * `resolveConfig` / `authedSession` — so the wired host/token take effect. + */ +export async function prepareEnv( + opts: EnvOpts, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const profile = await wireEnv(opts, env); + const statePath = resolveStatePath(opts.state, env, profile?.statePath); + return { name: profile?.name ?? null, protected: profile?.protected ?? false, statePath }; +} + +/** + * Host-only variant for read-only, non-state commands (`ct get`). Wires the + * env's host + token into `env`; returns nothing. A no-op without `--env`. + */ +export async function prepareEnvHost( + opts: { env?: string }, + env: NodeJS.ProcessEnv = process.env, +): Promise { + await wireEnv(opts, env); +} diff --git a/src/env/envs.ts b/src/env/envs.ts new file mode 100644 index 0000000..5ac3841 --- /dev/null +++ b/src/env/envs.ts @@ -0,0 +1,137 @@ +/** + * Environment profiles (#22): named `(host, token reference, state file path)` + * triples that let one config repo drive several ChurchTools instances + * (Terraform-workspace-style — e.g. an `eqrm-dev` rehearsal env and a `prod` + * env), selected per command with `--env `. + * + * The profiles live in a small committed `ct.envs.json` in the config repo + * directory (default `ct.envs.json` in the cwd; override with `CT_ENVS`): + * + * ```json + * { + * "environments": { + * "dev": { "host": "https://eqrm-dev.church.tools" }, + * "prod": { "host": "https://eqrm.church.tools", "protected": true, "tokenEnv": "CT_PROD_TOKEN" } + * } + * } + * ``` + * + * A profile carries only NON-secret references: `tokenEnv` names an environment + * variable that holds the login token (for CI) — never a literal secret, so the + * file is safe to commit. The state file defaults to the `ct-state..json` + * convention and may be overridden per profile with `state`. + * + * NOTHING here reads the Keychain or the network — it is pure file loading + + * validation, so it is unit-testable without a live instance. + */ +import { readFile } from "node:fs/promises"; +import { normalizeHost } from "../config.js"; +import { resolveWithEnv } from "../util/resolve.js"; + +export const DEFAULT_ENVS_PATH = "ct.envs.json"; + +/** A fully-resolved environment profile. `statePath` is defaulted; `host` normalized. */ +export interface EnvProfile { + name: string; + /** Base host, normalized (no trailing slash, no `/api`). */ + host: string; + /** State file for this env — profile override or the `ct-state..json` convention. */ + statePath: string; + /** Name of an env var holding the login token (CI). Never a literal secret. */ + tokenEnv?: string; + /** Protected env: apply/destroy ALWAYS require typed confirmation (even with --auto-approve/--force). */ + protected: boolean; +} + +/** Path precedence for the profile file: explicit → `CT_ENVS` → `ct.envs.json`. */ +export function resolveEnvsPath(explicit?: string, env: NodeJS.ProcessEnv = process.env): string { + return resolveWithEnv(explicit, env.CT_ENVS, DEFAULT_ENVS_PATH); +} + +/** The `ct-state..json` state-file convention for a named env. */ +export function defaultEnvStatePath(name: string): string { + return `ct-state.${name}.json`; +} + +type RawProfile = Record; + +/** Parse + shape-validate the profile file; returns the raw environments map. */ +function validateEnvsFile(parsed: unknown, path: string): Record { + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Malformed environments file ${path}: expected a JSON object at the top level.`); + } + const envs = (parsed as Record).environments; + if (typeof envs !== "object" || envs === null || Array.isArray(envs)) { + throw new Error( + `Malformed environments file ${path}: expected an "environments" object mapping env name → profile.`, + ); + } + return envs as Record; +} + +/** Read + parse + validate the profile file. A missing file throws a friendly, actionable error. */ +async function loadEnvsFile(path: string): Promise> { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (err) { + if (typeof err === "object" && err !== null && (err as { code?: string }).code === "ENOENT") { + throw new Error( + `Environment profile file not found: ${path} (default: ${DEFAULT_ENVS_PATH}). ` + + `Create it with an "environments" map, or drop --env to use the single-host default.`, + ); + } + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error( + `Malformed environments file ${path}: not valid JSON (${(err as Error).message}).`, + ); + } + return validateEnvsFile(parsed, path); +} + +/** Resolve one named profile from an already-loaded environments map. */ +function resolveProfile(name: string, raw: RawProfile): EnvProfile { + const host = raw.host; + if (typeof host !== "string" || host.trim() === "") { + throw new Error(`Environment "${name}" is missing a non-empty "host".`); + } + if (raw.state !== undefined && typeof raw.state !== "string") { + throw new Error(`Environment "${name}": "state" must be a string path.`); + } + if (raw.tokenEnv !== undefined && typeof raw.tokenEnv !== "string") { + throw new Error(`Environment "${name}": "tokenEnv" must be the NAME of an env var (a string).`); + } + if (raw.protected !== undefined && typeof raw.protected !== "boolean") { + throw new Error(`Environment "${name}": "protected" must be a boolean.`); + } + return { + name, + host: normalizeHost(host.trim()), + statePath: (raw.state as string | undefined) ?? defaultEnvStatePath(name), + tokenEnv: raw.tokenEnv as string | undefined, + protected: raw.protected === true, + }; +} + +/** + * Load and resolve the profile named `name` from the profile file at `path`. + * Throws a friendly error when the file is missing, malformed, or has no such env. + */ +export async function loadEnvProfile(name: string, path: string): Promise { + const envs = await loadEnvsFile(path); + const raw = envs[name]; + if (raw === undefined) { + const known = Object.keys(envs); + const list = known.length ? known.join(", ") : "(none defined)"; + throw new Error(`Unknown environment "${name}" in ${path}. Defined: ${list}.`); + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error(`Environment "${name}" in ${path} must be a JSON object.`); + } + return resolveProfile(name, raw); +} diff --git a/src/state/state.ts b/src/state/state.ts index f0c7847..8c70961 100644 --- a/src/state/state.ts +++ b/src/state/state.ts @@ -41,8 +41,17 @@ export interface State { export const DEFAULT_STATE_PATH = "ct-state.json"; -export function resolveStatePath(explicit?: string, env: NodeJS.ProcessEnv = process.env): string { - return resolveWithEnv(explicit, env.CT_STATE, DEFAULT_STATE_PATH); +/** + * State-file precedence: explicit `--state` → `CT_STATE` → `fallback`. + * `fallback` defaults to `ct-state.json` (single-host); under `--env`, the caller + * passes the env profile's state path (e.g. `ct-state..json`) as the fallback. + */ +export function resolveStatePath( + explicit?: string, + env: NodeJS.ProcessEnv = process.env, + fallback: string = DEFAULT_STATE_PATH, +): string { + return resolveWithEnv(explicit, env.CT_STATE, fallback); } export function emptyState(host: string): State { diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts index 1e70ec3..9225258 100644 --- a/src/ui/prompt.ts +++ b/src/ui/prompt.ts @@ -55,3 +55,27 @@ export async function confirmTyped( const answer = await ask(`Type "${expected}" to confirm: `); return answer.trim() === expected; } + +/** + * Protected-environment gate (#22): applying or destroying against a protected env + * ALWAYS requires typed confirmation of the environment name — there is NO + * `force`/`assumeYes` escape here by design. For non-interactive/CI use, the + * `--confirm-env ` flag (passed as `confirmFlag`) substitutes for the typed + * input and must match `envName` exactly. On a non-TTY with no flag, this refuses. + */ +export async function confirmEnv( + envName: string, + opts: PromptOptions & { confirmFlag?: string } = {}, +): Promise { + if (opts.confirmFlag !== undefined) { + return opts.confirmFlag === envName; + } + if (!ttyState(opts)) { + return false; + } + const ask = opts.ask ?? realAsk; + const answer = await ask( + `Protected environment "${envName}". Type the environment name to confirm: `, + ); + return answer.trim() === envName; +} diff --git a/tests/apply-env-command.test.ts b/tests/apply-env-command.test.ts new file mode 100644 index 0000000..c584e7c --- /dev/null +++ b/tests/apply-env-command.test.ts @@ -0,0 +1,111 @@ +/** + * Command-level protected-env guardrail for `ct apply --env` (#22): + * - a PROTECTED env refuses to apply on --auto-approve alone (typed confirmation is mandatory); + * - --confirm-env that matches substitutes for the typed input (CI path) and applies; + * - a mismatched --confirm-env still refuses; + * - an UNPROTECTED env applies with --auto-approve as before. + * Mocks the apply pipeline (session/config/plan/execute/backup) so the real env wiring runs. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Plan } from "../src/engine/types.js"; + +const plan: Plan = { + items: [ + { type: "campus", key: "mainz", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Mainz" }] }, + ], +}; + +const executePlan = vi.fn(async () => ({ created: ["mainz"], updated: [], skippedDeletes: [] })); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { request: vi.fn(), get: vi.fn() }, me: { id: 1 } })), +})); +vi.mock("../src/config/load.js", () => ({ + DEFAULT_CONFIG_PATH: "ct.config.ts", + resolveConfigPath: (explicit?: string) => explicit ?? "ct.config.ts", + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "." })), +})); +vi.mock("../src/engine/build.js", () => ({ + buildPlan: vi.fn(async () => ({ plan, actual: new Map(), fetchErrors: [] })), +})); +vi.mock("../src/engine/execute.js", () => ({ executePlan })); +vi.mock("../src/permissions/plan.js", () => ({ + buildPermissionPlan: vi.fn(async () => ({ items: [], fetchErrors: [] })), +})); +vi.mock("../src/permissions/apply.js", () => ({ + applyPermissionPlan: vi.fn(async () => ({ granted: 0, deleted: 0, failed: [] })), +})); +vi.mock("../src/engine/backup.js", () => ({ writeBackup: vi.fn(async () => "backup.json") })); + +const { applyCommand } = await import("../src/commands/apply.js"); +const { saveState, emptyState } = await import("../src/state/state.js"); + +const DEV = "https://eqrm-dev.church.tools"; +const PROD = "https://eqrm.church.tools"; +const envsPath = join(tmpdir(), `ct-cli-applyenv-envs-${process.pid}.json`); +const devState = join(tmpdir(), `ct-cli-applyenv-dev-${process.pid}.json`); +const prodState = join(tmpdir(), `ct-cli-applyenv-prod-${process.pid}.json`); +const backupDir = join(tmpdir(), `ct-cli-applyenv-backups-${process.pid}`); + +const saved = { host: process.env.CT_HOST, envs: process.env.CT_ENVS }; + +async function runApply(args: string[]): Promise { + await applyCommand().parseAsync(["--backup-dir", backupDir, ...args], { from: "user" }); +} + +beforeEach(async () => { + executePlan.mockClear(); + delete process.env.CT_HOST; + process.env.CT_ENVS = envsPath; + await writeFile( + envsPath, + JSON.stringify({ + environments: { + dev: { host: DEV, state: devState }, + prod: { host: PROD, state: prodState, protected: true }, + }, + }), + "utf8", + ); + await saveState(devState, emptyState(DEV)); + await saveState(prodState, emptyState(PROD)); +}); + +afterEach(async () => { + process.exitCode = 0; + if (saved.host === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = saved.host; + if (saved.envs === undefined) delete process.env.CT_ENVS; + else process.env.CT_ENVS = saved.envs; + await rm(envsPath, { force: true }); + await rm(devState, { force: true }); + await rm(prodState, { force: true }); + await rm(backupDir, { recursive: true, force: true }); +}); + +describe("ct apply --env protected guardrail", () => { + it("refuses a protected env on --auto-approve alone (no --confirm-env, non-TTY)", async () => { + await runApply(["--env", "prod", "--auto-approve"]); + expect(executePlan).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("applies a protected env when --confirm-env matches the env name", async () => { + await runApply(["--env", "prod", "--auto-approve", "--confirm-env", "prod"]); + expect(executePlan).toHaveBeenCalledTimes(1); + }); + + it("refuses when --confirm-env does not match the env name", async () => { + await runApply(["--env", "prod", "--auto-approve", "--confirm-env", "dev"]); + expect(executePlan).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("applies an UNPROTECTED env with --auto-approve as before (no --confirm-env needed)", async () => { + await runApply(["--env", "dev", "--auto-approve"]); + expect(executePlan).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 2141762..7917e96 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -22,4 +22,18 @@ describe("ct program", () => { const cmd = buildProgram().commands.find((c) => c.name() === "destroy")!; expect(cmd.options.some((o) => o.long === "--target")).toBe(true); }); + + it("registers --env on the state/host-touching commands (#22)", () => { + for (const name of ["plan", "apply", "destroy"]) { + const cmd = buildProgram().commands.find((c) => c.name() === name)!; + expect(cmd.options.some((o) => o.long === "--env")).toBe(true); + } + }); + + it("registers --confirm-env on apply and destroy (protected-env CI path, #22)", () => { + for (const name of ["apply", "destroy"]) { + const cmd = buildProgram().commands.find((c) => c.name() === name)!; + expect(cmd.options.some((o) => o.long === "--confirm-env")).toBe(true); + } + }); }); diff --git a/tests/destroy-env-command.test.ts b/tests/destroy-env-command.test.ts new file mode 100644 index 0000000..5f95fe5 --- /dev/null +++ b/tests/destroy-env-command.test.ts @@ -0,0 +1,88 @@ +/** + * Command-level protected-env guardrail for `ct destroy --env` (#22): --force does + * NOT bypass a protected env — typed confirmation of the env name is mandatory, and + * --confirm-env substitutes for it in CI. An unprotected env keeps --force. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const deletes: string[] = []; +const requestMock = vi.fn(async (method: string, path: string) => { + if (method === "DELETE") deletes.push(path); + return {}; +}); +const getMock = vi.fn(async (path: string) => (path === "/groups/hierarchies" ? [] : { name: path })); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client: { get: getMock, request: requestMock }, me: { id: 1 } })), +})); +vi.mock("../src/engine/backup.js", () => ({ writeBackup: vi.fn(async () => "backup.json") })); + +const { destroyCommand } = await import("../src/commands/destroy.js"); +const { saveState, loadState, emptyState } = await import("../src/state/state.js"); + +const PROD = "https://eqrm.church.tools"; +const envsPath = join(tmpdir(), `ct-cli-destroyenv-envs-${process.pid}.json`); +const prodState = join(tmpdir(), `ct-cli-destroyenv-prod-${process.pid}.json`); +const backupDir = join(tmpdir(), `ct-cli-destroyenv-backups-${process.pid}`); +const saved = { host: process.env.CT_HOST, envs: process.env.CT_ENVS }; + +async function seedState(): Promise { + const state = emptyState(PROD); + state.resources.area = { type: "group", id: 1, key: "area", fields: {}, adoptedAt: "t", updatedAt: "t" }; + await saveState(prodState, state); +} + +async function runDestroy(args: string[]): Promise { + await destroyCommand().parseAsync(["--backup-dir", backupDir, ...args], { from: "user" }); +} + +beforeEach(async () => { + requestMock.mockClear(); + deletes.length = 0; + getMock.mockClear(); + delete process.env.CT_HOST; + process.env.CT_ENVS = envsPath; + await writeFile( + envsPath, + JSON.stringify({ environments: { prod: { host: PROD, state: prodState, protected: true } } }), + "utf8", + ); + await seedState(); +}); + +afterEach(async () => { + process.exitCode = 0; + if (saved.host === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = saved.host; + if (saved.envs === undefined) delete process.env.CT_ENVS; + else process.env.CT_ENVS = saved.envs; + await rm(envsPath, { force: true }); + await rm(prodState, { force: true }); + await rm(backupDir, { recursive: true, force: true }); +}); + +describe("ct destroy --env protected guardrail", () => { + it("refuses a protected env even with --force (no --confirm-env, non-TTY)", async () => { + await runDestroy(["--env", "prod", "--target", "area", "--force"]); + expect(requestMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + const after = await loadState(prodState, PROD); + expect(Object.keys(after.resources)).toEqual(["area"]); // nothing deleted + }); + + it("deletes when --confirm-env matches the protected env name", async () => { + await runDestroy(["--env", "prod", "--target", "area", "--confirm-env", "prod"]); + expect(deletes).toEqual(["/groups/1"]); + const after = await loadState(prodState, PROD); + expect(Object.keys(after.resources)).toEqual([]); + }); + + it("refuses when --confirm-env does not match", async () => { + await runDestroy(["--env", "prod", "--target", "area", "--confirm-env", "nope"]); + expect(requestMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/env-context.test.ts b/tests/env-context.test.ts new file mode 100644 index 0000000..9a83701 --- /dev/null +++ b/tests/env-context.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { prepareEnv } from "../src/env/context.js"; + +const envsPath = join(tmpdir(), `ct-cli-envctx-${process.pid}.json`); + +async function writeEnvs(): Promise { + await writeFile( + envsPath, + JSON.stringify({ + environments: { + dev: { host: "https://eqrm-dev.church.tools" }, + prod: { + host: "https://eqrm.church.tools", + state: "custom-prod-state.json", + protected: true, + tokenEnv: "CT_PROD_TOKEN", + }, + }, + }), + "utf8", + ); +} + +afterEach(async () => { + await rm(envsPath, { force: true }); +}); + +describe("prepareEnv without --env (backward compat)", () => { + it("does not touch the env and resolves the default state path", async () => { + const env: NodeJS.ProcessEnv = {}; + const result = await prepareEnv({}, env); + expect(result).toEqual({ name: null, protected: false, statePath: "ct-state.json" }); + expect(env.CT_HOST).toBeUndefined(); + expect(env.CT_LOGINTOKEN).toBeUndefined(); + }); + + it("honours an explicit --state and CT_STATE with no --env", async () => { + expect((await prepareEnv({ state: "s.json" }, {})).statePath).toBe("s.json"); + expect((await prepareEnv({}, { CT_STATE: "e.json" })).statePath).toBe("e.json"); + }); +}); + +describe("prepareEnv with --env", () => { + it("wires the profile host into the env and defaults the per-env state path", async () => { + await writeEnvs(); + const env: NodeJS.ProcessEnv = { CT_ENVS: envsPath }; + const result = await prepareEnv({ env: "dev" }, env); + expect(result).toEqual({ name: "dev", protected: false, statePath: "ct-state.dev.json" }); + expect(env.CT_HOST).toBe("https://eqrm-dev.church.tools"); + }); + + it("uses the profile state override and surfaces the protected flag", async () => { + await writeEnvs(); + const env: NodeJS.ProcessEnv = { CT_ENVS: envsPath }; + const result = await prepareEnv({ env: "prod" }, env); + expect(result).toEqual({ + name: "prod", + protected: true, + statePath: "custom-prod-state.json", + }); + expect(env.CT_HOST).toBe("https://eqrm.church.tools"); + }); + + it("copies the profile's tokenEnv value into CT_LOGINTOKEN (CI path) when present", async () => { + await writeEnvs(); + const env: NodeJS.ProcessEnv = { CT_ENVS: envsPath, CT_PROD_TOKEN: "prod-secret" }; + await prepareEnv({ env: "prod" }, env); + expect(env.CT_LOGINTOKEN).toBe("prod-secret"); + }); + + it("leaves CT_LOGINTOKEN untouched when the referenced tokenEnv var is unset", async () => { + await writeEnvs(); + const env: NodeJS.ProcessEnv = { CT_ENVS: envsPath }; + await prepareEnv({ env: "prod" }, env); + expect(env.CT_LOGINTOKEN).toBeUndefined(); + }); + + it("lets an explicit --state override the per-env default", async () => { + await writeEnvs(); + const env: NodeJS.ProcessEnv = { CT_ENVS: envsPath }; + const result = await prepareEnv({ env: "dev", state: "override.json" }, env); + expect(result.statePath).toBe("override.json"); + }); +}); diff --git a/tests/envs.test.ts b/tests/envs.test.ts new file mode 100644 index 0000000..a6a611a --- /dev/null +++ b/tests/envs.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DEFAULT_ENVS_PATH, + resolveEnvsPath, + defaultEnvStatePath, + loadEnvProfile, +} from "../src/env/envs.js"; + +const envsPath = join(tmpdir(), `ct-cli-envs-${process.pid}.json`); + +async function writeEnvs(obj: unknown): Promise { + await writeFile(envsPath, JSON.stringify(obj), "utf8"); +} + +afterEach(async () => { + await rm(envsPath, { force: true }); +}); + +describe("resolveEnvsPath", () => { + it("prefers an explicit path, then CT_ENVS, then the default", () => { + expect(resolveEnvsPath("x.json", {})).toBe("x.json"); + expect(resolveEnvsPath(undefined, { CT_ENVS: "y.json" })).toBe("y.json"); + expect(resolveEnvsPath(undefined, {})).toBe(DEFAULT_ENVS_PATH); + }); +}); + +describe("defaultEnvStatePath", () => { + it("follows the ct-state..json convention", () => { + expect(defaultEnvStatePath("dev")).toBe("ct-state.dev.json"); + expect(defaultEnvStatePath("prod")).toBe("ct-state.prod.json"); + }); +}); + +describe("loadEnvProfile", () => { + it("resolves a named profile with a normalized host and defaulted state path", async () => { + await writeEnvs({ + environments: { + dev: { host: "https://eqrm-dev.church.tools/" }, + prod: { host: "https://eqrm.church.tools", state: "ct-state.prod.json", protected: true }, + }, + }); + const dev = await loadEnvProfile("dev", envsPath); + expect(dev).toEqual({ + name: "dev", + host: "https://eqrm-dev.church.tools", // trailing slash stripped + statePath: "ct-state.dev.json", // convention default + protected: false, + tokenEnv: undefined, + }); + const prod = await loadEnvProfile("prod", envsPath); + expect(prod).toMatchObject({ + name: "prod", + host: "https://eqrm.church.tools", + statePath: "ct-state.prod.json", + protected: true, + }); + }); + + it("carries a tokenEnv reference when present", async () => { + await writeEnvs({ + environments: { prod: { host: "https://eqrm.church.tools", tokenEnv: "CT_PROD_TOKEN" } }, + }); + expect((await loadEnvProfile("prod", envsPath)).tokenEnv).toBe("CT_PROD_TOKEN"); + }); + + it("throws a friendly error when the envs file is missing", async () => { + await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow( + /Environment profile file not found/, + ); + }); + + it("names the known environments when the requested one is absent", async () => { + await writeEnvs({ environments: { dev: { host: "https://d.church.tools" } } }); + await expect(loadEnvProfile("prod", envsPath)).rejects.toThrow( + /Unknown environment "prod".*dev/s, + ); + }); + + it("rejects a profile missing a host", async () => { + await writeEnvs({ environments: { dev: { protected: true } } }); + await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow(/missing.*host/i); + }); + + it("rejects a non-boolean protected flag", async () => { + await writeEnvs({ environments: { dev: { host: "https://d.church.tools", protected: "yes" } } }); + await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow(/protected/i); + }); + + it("rejects a file without an environments object", async () => { + await writeEnvs({ dev: { host: "https://d.church.tools" } }); + await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow(/environments/); + }); + + it("rejects invalid JSON with a friendly error", async () => { + await writeFile(envsPath, "{ not json", "utf8"); + await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow(/not valid JSON/); + }); +}); diff --git a/tests/plan-env-command.test.ts b/tests/plan-env-command.test.ts new file mode 100644 index 0000000..c8fc9cb --- /dev/null +++ b/tests/plan-env-command.test.ts @@ -0,0 +1,123 @@ +/** + * Command-level `ct plan --env` (#22): one config repo drives two hosts with two + * state files and NO file edits. Mocks the session + config + plan pipeline (like + * apply-refresh-command.test.ts) so the real env→host→state wiring runs: + * - `--env dev` and `--env prod` each resolve their own host + state file. + * - the header surfaces the env name and the target CT version (per-env gate). + * - cross-contamination is impossible: an env whose state file was recorded + * against a DIFFERENT host is rejected by the state host-check. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Plan } from "../src/engine/types.js"; + +const plan: Plan = { items: [] }; + +const versionByHost: Record = { + "https://eqrm-dev.church.tools": "3.100.0", + "https://eqrm.church.tools": "3.123.0", +}; + +// authedSession is mocked, but resolveConfig (real) has already read CT_HOST that prepareEnv wired, +// so we echo a per-host CT version off process.env.CT_HOST to prove the header reflects the target. +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ + client: { get: vi.fn(), version: versionByHost[process.env.CT_HOST ?? ""] ?? null }, + me: { id: 1 }, + })), +})); + +vi.mock("../src/config/load.js", () => ({ + DEFAULT_CONFIG_PATH: "ct.config.ts", + resolveConfigPath: (explicit?: string) => explicit ?? "ct.config.ts", + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "." })), +})); + +vi.mock("../src/engine/build.js", () => ({ + buildPlan: vi.fn(async () => ({ plan, actual: new Map(), fetchErrors: [] })), +})); + +vi.mock("../src/permissions/plan.js", () => ({ + buildPermissionPlan: vi.fn(async () => ({ items: [], fetchErrors: [] })), +})); + +const { planCommand } = await import("../src/commands/plan.js"); +const { saveState, emptyState } = await import("../src/state/state.js"); + +const DEV = "https://eqrm-dev.church.tools"; +const PROD = "https://eqrm.church.tools"; +const envsPath = join(tmpdir(), `ct-cli-planenv-envs-${process.pid}.json`); +const devState = join(tmpdir(), `ct-cli-planenv-dev-${process.pid}.json`); +const prodState = join(tmpdir(), `ct-cli-planenv-prod-${process.pid}.json`); + +const saved = { host: process.env.CT_HOST, envs: process.env.CT_ENVS }; + +let stderr = ""; +let stderrSpy: { mockRestore: () => void }; + +async function runPlan(args: string[]): Promise { + await planCommand().parseAsync(args, { from: "user" }); +} + +beforeEach(async () => { + delete process.env.CT_HOST; + process.env.CT_ENVS = envsPath; + await writeFile( + envsPath, + JSON.stringify({ + environments: { + dev: { host: DEV, state: devState }, + prod: { host: PROD, state: prodState }, + }, + }), + "utf8", + ); + await saveState(devState, emptyState(DEV)); + await saveState(prodState, emptyState(PROD)); + stderr = ""; + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as (typeof process.stderr)["write"]); +}); + +afterEach(async () => { + stderrSpy.mockRestore(); + if (saved.host === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = saved.host; + if (saved.envs === undefined) delete process.env.CT_ENVS; + else process.env.CT_ENVS = saved.envs; + await rm(envsPath, { force: true }); + await rm(devState, { force: true }); + await rm(prodState, { force: true }); +}); + +describe("ct plan --env", () => { + it("plans against dev's host + state and shows the env + CT version in the header", async () => { + await runPlan(["--env", "dev"]); + expect(stderr).toContain("env: dev"); + expect(stderr).toContain(`host: ${DEV}`); + expect(stderr).toContain("ChurchTools 3.100.0"); + expect(stderr).toContain(`state host: ${DEV}`); + }); + + it("plans against prod's host + state from the SAME checkout (no file edits)", async () => { + await runPlan(["--env", "prod"]); + expect(stderr).toContain("env: prod"); + expect(stderr).toContain(`host: ${PROD}`); + expect(stderr).toContain("ChurchTools 3.123.0"); + expect(stderr).toContain(`state host: ${PROD}`); + }); + + it("refuses when an env's state file was recorded against a different host (no cross-contamination)", async () => { + // Point prod's profile at dev's state file: prod host vs a dev-bound state file must be rejected. + await writeFile( + envsPath, + JSON.stringify({ environments: { prod: { host: PROD, state: devState } } }), + "utf8", + ); + await expect(runPlan(["--env", "prod"])).rejects.toThrow(/Refusing to mix instances/); + }); +}); diff --git a/tests/prompt.test.ts b/tests/prompt.test.ts index d268a45..24d2857 100644 --- a/tests/prompt.test.ts +++ b/tests/prompt.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { confirm, confirmTyped } from "../src/ui/prompt.js"; +import { confirm, confirmTyped, confirmEnv } from "../src/ui/prompt.js"; describe("confirm", () => { it("short-circuits true with assumeYes", async () => { @@ -30,3 +30,25 @@ describe("confirmTyped", () => { expect(await confirmTyped("x", { isTTY: false })).toBe(false); }); }); + +describe("confirmEnv (protected environment)", () => { + it("accepts a --confirm-env flag that exactly matches the env name", async () => { + expect(await confirmEnv("prod", { confirmFlag: "prod" })).toBe(true); + }); + + it("rejects a --confirm-env flag that does not match", async () => { + expect(await confirmEnv("prod", { confirmFlag: "dev" })).toBe(false); + expect(await confirmEnv("prod", { confirmFlag: "" })).toBe(false); + }); + + it("requires a typed match interactively, even when a force/assumeYes would normally skip", async () => { + // No confirmFlag → the typed environment name is mandatory. There is no force/assumeYes escape: + // confirmEnv exposes none, so a protected apply/destroy can never auto-approve past it. + expect(await confirmEnv("prod", { isTTY: true, ask: async () => "prod" })).toBe(true); + expect(await confirmEnv("prod", { isTTY: true, ask: async () => "nope" })).toBe(false); + }); + + it("refuses on a non-TTY with no --confirm-env (CI must pass the flag)", async () => { + expect(await confirmEnv("prod", { isTTY: false })).toBe(false); + }); +}); diff --git a/tests/tokenStore-multihost.test.ts b/tests/tokenStore-multihost.test.ts new file mode 100644 index 0000000..c9ee69e --- /dev/null +++ b/tests/tokenStore-multihost.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Multi-host token store (#22 item 2): credentials live under a per-host Keychain account, with a +// backward-compatible fallback to the legacy single "credentials" blob. Mock macOS + the `security` +// spawn so we can model what each account currently holds and assert the read/fallback behaviour. +const execFileMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", () => ({ execFile: execFileMock })); +vi.mock("node:os", () => ({ platform: () => "darwin" })); + +import { + readCredentials, + readStoredHost, + storeCredentials, + resetKeychainCache, +} from "../src/auth/tokenStore.js"; + +const DEV = "https://eqrm-dev.church.tools"; +const PROD = "https://eqrm.church.tools"; + +/** Model a keychain as account → stored blob; wire the `security` CLI mock to read/write it. */ +function mockKeychain(store: Map): void { + execFileMock.mockImplementation( + (_cmd: string, args: string[], cb: (e: unknown, v: unknown) => void) => { + const sub = args[0]; + const account = args[args.indexOf("-a") + 1]!; + if (sub === "find-generic-password") { + const val = store.get(account); + if (val === undefined) return cb(new Error("not found"), null); + return cb(null, { stdout: val, stderr: "" }); + } + if (sub === "add-generic-password") { + store.set(account, args[args.indexOf("-w") + 1]!); + return cb(null, { stdout: "", stderr: "" }); + } + if (sub === "delete-generic-password") { + store.delete(account); + return cb(null, { stdout: "", stderr: "" }); + } + return cb(new Error(`unexpected security subcommand ${sub}`), null); + }, + ); +} + +beforeEach(() => { + execFileMock.mockReset(); + resetKeychainCache(); +}); + +describe("multi-host token store", () => { + it("stores credentials under a per-host account and reads them back by host", async () => { + const store = new Map(); + mockKeychain(store); + + await storeCredentials({ host: DEV, token: "dev-tok" }); + await storeCredentials({ host: PROD, token: "prod-tok" }); + + expect(await readCredentials(DEV)).toEqual({ host: DEV, token: "dev-tok" }); + expect(await readCredentials(PROD)).toEqual({ host: PROD, token: "prod-tok" }); + // The host-keyed account exists for each host. + expect(store.has(DEV)).toBe(true); + expect(store.has(PROD)).toBe(true); + }); + + it("falls back to the legacy single blob when its host matches the requested host", async () => { + // Simulate a user who logged in BEFORE the multi-host change: only the legacy account exists. + const store = new Map([ + ["credentials", JSON.stringify({ host: PROD, token: "legacy-tok" })], + ]); + mockKeychain(store); + + expect(await readCredentials(PROD)).toEqual({ host: PROD, token: "legacy-tok" }); + }); + + it("does NOT return the legacy blob for a different host (no cross-host leak)", async () => { + const store = new Map([ + ["credentials", JSON.stringify({ host: PROD, token: "legacy-tok" })], + ]); + mockKeychain(store); + + expect(await readCredentials(DEV)).toBeNull(); + }); + + it("readStoredHost (no host) returns the default/last-login blob for the single-host path", async () => { + const store = new Map(); + mockKeychain(store); + await storeCredentials({ host: PROD, token: "prod-tok" }); + expect(await readStoredHost()).toBe(PROD); + }); + + it("prefers the host-keyed account over the legacy blob when both exist", async () => { + const store = new Map([ + ["credentials", JSON.stringify({ host: DEV, token: "legacy-dev" })], + [DEV, JSON.stringify({ host: DEV, token: "keyed-dev" })], + ]); + mockKeychain(store); + expect(await readCredentials(DEV)).toEqual({ host: DEV, token: "keyed-dev" }); + }); +});