From 1c6720238975a6d273f36427bf1a2058cfb2705d Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 10 Aug 2026 20:56:45 +0200 Subject: [PATCH] docs: rewrite the README as a landing page, extract the manual into docs/ (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README had grown to 572 lines — the environments story, the whole CI section, the auth handshake and every per-feature detail inline. Someone landing on the repo could not tell in thirty seconds what this is or how to try it, and the hook (a config plus the plan it produces) sat around line 140. README is now ~210 lines in reading order: what it is → why you'd want it (a clicking-vs-`ct` table) → a config and its real `ct plan` output → install → first run → what it manages → environments/CI → guardrails → docs → status. The plan output in "Show me" is genuine `renderPlan` output, not hand-written. Nothing was dropped; it moved: - environments + dev→prod promotion -> docs/environments.md - CI usage, auth model, --detailed-exitcode, --json shape, PR-comment job -> docs/ci.md - portable references, campus assignment, allowDuplicateName, output conventions -> docs/configuration.md - the phase-by-phase status list -> the epics it was duplicating (#1, #19) Claude-Session: https://claude.ai/code/session_01KPxjSEkqMiU7WE3vg6C3am --- README.md | 650 ++++++++++-------------------------------- docs/ci.md | 188 ++++++++++++ docs/configuration.md | 86 ++++++ docs/environments.md | 104 +++++++ 4 files changed, 522 insertions(+), 506 deletions(-) create mode 100644 docs/ci.md create mode 100644 docs/configuration.md create mode 100644 docs/environments.md diff --git a/README.md b/README.md index 675f0f4..caee814 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,75 @@ # ct-cli -ChurchTools **structure-as-code** CLI. Describe the _overarching, rights-bearing -structure_ of a ChurchTools instance — campuses, structural groups, hierarchies, -group types/roles, permission & auto-groups — as versionable **desired-state -code**, and reconcile it idempotently against the ChurchTools API with -Terraform-style **`plan` / `apply`**. +**ChurchTools structure-as-code.** Describe the overarching, rights-bearing +structure of a ChurchTools instance — campuses, structural groups, hierarchies, +group types and roles, permissions, auto-groups — as versioned desired-state +code, and reconcile it against the ChurchTools API with Terraform-style +**`plan` / `apply`**. > **People are never managed.** This tool touches only the scaffold, and only > resources that are _explicitly_ declared or adopted. Everything else is > invisible: never shown, never changed, never proposed for deletion. -## Two-repo model +## Why -| Repo | Contents | -| ----------------------------- | --------------------------------------------------------------------------------------------- | -| **`eqrm/ct-cli`** (this repo) | The tool: CLI, API client, plan/apply engine. Generic, reusable. | -| **`eqrm/ct-structure`** | Equippers' actual desired-state config (`.ts` blueprints) + state file. Depends on this tool. | +A ChurchTools instance's structure is normally maintained by clicking. That +works — until you need to answer questions clicking cannot: -Like Terraform, the tool never lives in the same repo as the infra config. +| Clicking | With `ct` | +| --- | --- | +| "Who changed this group's rights, and why?" | `git log`, and the PR that changed it | +| "Set up the next campus like the last one" | Call the same blueprint function again | +| "Try it somewhere safe first" | `ct plan --env dev` → `ct apply --env dev` → promote | +| "Has anyone edited this by hand?" | `ct plan` reports drift against the last known state | +| "What will this actually change?" | `ct plan` prints it before anything is written | -## Status +## Show me -Early scaffold. See the [epic (#1)](https://github.com/eqrm/ct-cli/issues/1) and phase issues. - -- ✅ **Phase 0 — Spike** ([#2](https://github.com/eqrm/ct-cli/issues/2)): API CRUD coverage mapped — see [`docs/api-coverage.md`](docs/api-coverage.md). Instance runs CT **3.123.0**; 7 resources have full CRUD. -- ✅ **Phase 1 — CLI + client** ([#3](https://github.com/eqrm/ct-cli/issues/3)): `auth login`, `get` commands, session handshake. -- ✅ **Phase 2 — Read/Adopt** ([#4](https://github.com/eqrm/ct-cli/issues/4)): `ct adopt` + JSON state file. -- ✅ **Phase 3 — Declarative engine** ([#5](https://github.com/eqrm/ct-cli/issues/5)): config DSL, `plan`/diff, dependency graph, group hierarchy. -- ✅ **Phase 4 — Apply + guardrails** ([#6](https://github.com/eqrm/ct-cli/issues/6)): `ct apply` / `ct destroy`, confirmation, backup, `preventDestroy`. -- ✅ **Phase 5 — Blueprints** ([#7](https://github.com/eqrm/ct-cli/issues/7)): - all three planned features landed — auto-groups / dynamic groups - ([#14](https://github.com/eqrm/ct-cli/issues/14)), permissions - ([#13](https://github.com/eqrm/ct-cli/issues/13)), and blueprints - ([#7](https://github.com/eqrm/ct-cli/issues/7)) — see - [Auto-groups](#auto-groups), [Permissions](#permissions), and - [Blueprints](#blueprints) below. -- 🚧 **Phase 6 — Reproducibility** ([#26](https://github.com/eqrm/ct-cli/issues/26)): - what `ct` can't (yet) automate — API gaps, not-yet-implemented DSL surface, - and deliberately out-of-scope areas — is tracked in - [`docs/runbook-manual-surface.md`](docs/runbook-manual-surface.md), so instance - bootstrap ([#23](https://github.com/eqrm/ct-cli/issues/23)) can scope - "selective adoption" deliberately. -- 📖 **Field definitions & security levels** ([#47](https://github.com/eqrm/ct-cli/issues/47), - [#48](https://github.com/eqrm/ct-cli/issues/48)): the person master-data model, - security levels, and person/group custom-field DEFINITIONS ("Datenfelder") are - readable (`ct get person-masterdata`, `ct get data-fields`) — schema in scope, - per-record field **values** never. Definitions are read-only (no REST write - endpoint); the boundary + writability evidence is in - [`docs/handbuch/field-definitions.md`](docs/handbuch/field-definitions.md). - -## Requirements - -- Node ≥ 20 (repo pins 22 via `.nvmrc`) — only for the npm tarball or dev install; - the standalone binaries below need nothing but the OS -- A ChurchTools **personal login token** (ChurchTools → your user settings) +```ts +// ct.config.ts — no ChurchTools ids anywhere; names resolve per instance +export default (ct) => { + ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); + + ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupType: "ministry_team" }); + ct.group({ + key: "mainz_kids_lead", + name: "Mainz · Kids Leitung", + groupType: "ministry_team", + parents: ["mainz_area"], + }); + ct.group({ + key: "mainz_kids", + name: "Mainz · Kids", + groupType: "ministry_team", + campus: "mainz", + parents: ["mainz_kids_lead"], + }); +}; +``` + +```console +$ ct plan + + campus.mainz + name: "Mainz" + shorty: "MZ" + + group.mainz_kids_lead + name: "Mainz · Kids Leitung" + groupTypeId: 2 + ~ group.mainz_kids (#148) + campusId: null -> 3 + +Drift detected (changed in ChurchTools since adoption): + ! group.mainz_kids (#148): note = "bitte nicht loeschen" (last known "") + +Plan: 2 to create, 1 to update, 0 to delete. +``` + +Nothing has been written yet. `ct apply` executes exactly that plan, in +dependency order, after a confirmation prompt and a backup. ## Install -Grab the standalone binary from the [Releases page](https://github.com/eqrm/ct-cli/releases/latest) — -**no Node required**: +Standalone binary — **no Node required**: ```bash # macOS, Apple Silicon @@ -73,499 +84,126 @@ sudo mv ct /usr/local/bin/ct # or anywhere on your PATH ct --help ``` -Or, with Node ≥ 20 already installed, the npm-pack tarball: +With Node ≥ 20 already installed, the npm-pack tarball works too: ```bash npm install -g https://github.com/eqrm/ct-cli/releases/latest/download/ct-cli.tgz -ct --help ``` -Each release also attaches an `INSTALL.md` with the exact commands. - -Every push to `main` that lands a `feat:`/`fix:`/breaking-change commit runs the -full CI gate, compiles the binaries above, smoke-tests each one on its native -OS/arch, and — only if all of that is green — cuts the version + changelog via -[semantic-release](https://semantic-release.gitbook.io/) and publishes the GitHub -Release. No manual tag push. See -[`.github/workflows/release.yml`](.github/workflows/release.yml) and -[`.releaserc.json`](.releaserc.json). - -## Install (dev) - -```bash -npm ci -npm run build # -> dist/index.js (the `ct` binary) -npm link # optional: puts `ct` on your PATH -``` +You also need a ChurchTools **personal login token** (ChurchTools → your user +settings). Each release additionally attaches an `INSTALL.md` with the exact +commands. -## Usage +## First run ```bash -# The host is captured at login (stored with the token). No hardcoded default — -# CT_HOST overrides the stored host for CI / one-off use. +# The host is captured at login and stored with the token; CT_HOST overrides it for CI. ct auth login --host https://mychurch.church.tools --token -ct auth status # who am I? - -ct get campuses # JSON to stdout — pipe into jq -ct get groups -ct get group-types -ct get person-masterdata # person master-data model incl. security levels (schema, read-only) -ct get data-fields # field DEFINITIONS: person + group custom fields (schema, read-only) -ct get raw /groups/42 # arbitrary GET - -ct adopt campus 0 # bring an existing resource under management (→ state file) -ct state list # show the managed set -ct plan # diff the desired-state config against ChurchTools (read-only) -ct plan --json # the raw plan as JSON - -ct apply # create + update in dependency order (confirmation + backup first) -ct apply --auto-approve # skip the prompt (CI); apply NEVER deletes -ct destroy --target old # explicit, protected deletion (typed confirmation) -``` - -`apply` reconciles **creates and updates** only, in dependency order, saving -state after each action (crash-safe / resumable). It **never deletes**: a -resource dropped from the config is surfaced as a notice pointing at `destroy`. -Before any write it prints the plan, asks for confirmation (`-y` to skip), and -writes a JSON backup of the affected resources to `backups/` beside the state -file (override with `--backup-dir` / `CT_BACKUP_DIR`). - -`destroy` deletes only the resources named by `--target` (repeatable or -comma-separated), in reverse dependency order, after a typed confirmation -(`--force` to skip). A resource declared with `preventDestroy: true` is blocked -until that flag is removed from the config. - -The desired state lives in a config file (default `ct.config.ts`) that -default-exports a function receiving the DSL: - -```ts -export default (ct) => { - ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); - // Reference master data BY NAME, not by hardcoded id: `groupType: "…"` resolves to the - // per-host group-type id at plan time, so this config is portable across instances (#20). - ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupType: "ministry_team" }); - // Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared - // in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged - // groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.) - ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupType: "ministry_team", parents: ["mainz_area"] }); - // Assign a group to a campus BY KEY: `campus: "mainz"` links to the campus above even though - // it is created in the same apply (its id is filled in at apply time). The numeric escape - // hatch still works — `campusId: 3` (or `campusId: null` to clear) targets an existing id. - ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupType: "ministry_team", campus: "mainz", parents: ["mainz_kids_lead"] }); -}; -``` - -**Portable references (#20):** logical fields (`campus`/`groupType` on a -group, `groupType` on a permission) and the inline `ref.*` helper compile to id-free -sentinels a per-host resolver maps to real ChurchTools ids at plan time — sourced from -resources this tool manages, then live master-data catalogs matched by name. So one -config file plans and applies unchanged against different instances (ids differ per -host). An unresolvable name fails the plan with a clear error naming the reference and -where it was used. Raw numeric ids remain a valid escape hatch everywhere; see -[`examples/portable.config.ts`](examples/portable.config.ts) for a zero-numeric-id config. -**Exception: `groupStatusId` (a group's lifecycle status) is numeric-only, always** — -ChurchTools exposes no REST catalog to resolve a status by name (`/group/memberstatus` -is a different dimension, member statuses; #67), so a `status:` declaration fails fast -at eval time rather than resolving against the wrong dimension. - -`campusId` is a managed group field: `ct plan` shows a campus assign/move/clear -as a normal field update, and `ct adopt group ` captures it. Which group -fields are managed vs. deliberately left to the CT UI is recorded in -[`docs/group-field-decisions.md`](docs/group-field-decisions.md). - -**Same-named groups (#75):** ChurchTools guards group creation by NAME, not by -this tool's logical key — `POST /groups` 400s (`forbidden.duplicate.group`) if a -group with that name already exists, even when the two are legitimately -distinct (e.g. an archived and an active "Kids Elternabend 2026" event signup). -Opt in per-declaration to create it anyway: - -```ts -ct.group({ key: "kids_2026_b", name: "Kids Elternabend 2026", groupTypeId: 2, allowDuplicateName: true }); -``` - -`allowDuplicateName` sends CT's `force: true` on the CREATE request only — it is -never a managed field (not diffed, not in state, not touched on update, and -never adopted). **Never set it as a default**; it exists for the rare -intentional-duplicate case. If a create 400s on this guard without the flag -set, `ct apply`'s stop message explains the likely cause (an unmanaged existing -group that should be adopted with `ct adopt group --key `) and the -opt-in as the alternative. - -Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go -to **stderr**. - -### Blueprints - -A "blueprint" is a plain function over the injected `ConfigContext` — no -special machinery. Pull a repeated structure (e.g. a campus's Kids area) -into a function and call it once per campus, prefixing every key with -`${campus}_` to keep each instantiation's resources unique and its managed -hierarchy (`parents`) scoped to that campus. `ct plan`/`ct apply` order -campuses → groups → hierarchy automatically via the dependency graph, and -an undeclared `parents` reference throws at config-load time (typo guard). -See [`docs/handbuch/blueprints.md`](docs/handbuch/blueprints.md) for the full guide and -[`examples/campus-blueprint.config.ts`](examples/campus-blueprint.config.ts) -for a runnable example. - -### Auto-groups - -A group can opt in to a `dynamic` block to manage its ChurchTools "dynamic -group" (auto-group) ruleset + status alongside its plain fields: - -```ts -ct.group({ - key: "all_mainz", - name: "Alle Mainz", - groupTypeId: 1, - dynamic: { status: "manual", ruleset: churchQueryRuleset /* inline object | { ref } | q + churchQuery */ }, -}); -``` - -`ct apply --refresh` opts in to a post-apply, per-group membership -recompute for every dynamic group that changed. See -[`docs/handbuch/dynamic-groups.md`](docs/handbuch/dynamic-groups.md) for the full guide (the -`status` states, the three ways to supply a ruleset, the typed query DSL, -and how drift normalization avoids cosmetic false diffs) and -[`examples/dynamic-group.config.ts`](examples/dynamic-group.config.ts) for a -runnable example. - -### Permissions - -`ct.groupRole` / `ct.groupTypeRole` / `ct.status` declare ChurchTools permission -grants (group-role, group-type-role and person-status rights) as code, -reconciled with the same `plan`/`apply` workflow: - -```ts -ct.groupTypeRole({ - key: "leiter_tpl", - groupType: "kids", // domain BY NAME — resolved to the group-type domainId per host (#20) - grants: [ - "churchgroup:view", // unscoped - { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped to a managed group - ], -}); - -ct.status({ - key: "core_external_login", - personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) - // A status grant reaches EVERY person carrying that status — the instance-wide lever. - // -1 is CT's "all values of this dimension" sentinel (here: every external system). - grants: [{ right: "churchcore:login to external system", scope: [-1] }], -}); -``` +ct auth status # who am I? -Right names (`"module:right"`) are validated against a static, offline -catalog — discover them with `ct get permissions-catalog`. Grants you remove -from the config are diffed as deletions and reconciled via `ct apply`, same -as any other resource; system-default and inherited grants are never touched -or surfaced. See [`docs/handbuch/permissions.md`](docs/handbuch/permissions.md) for the full -guide (domainId semantics, scope resolution, domain rules, the -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" - } - } -} +ct get groups # JSON to stdout — pipe into jq +ct adopt campus 0 # bring ONE existing resource under management +ct state list # what is managed +ct plan # diff the config against ChurchTools (read-only) +ct apply # create + update in dependency order (confirm + backup first) ``` -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`): +`apply` reconciles **creates and updates** only, saving state after each action +(crash-safe / resumable). It **never deletes**: a resource dropped from the +config is surfaced as a notice pointing at `destroy`. `destroy --target ` +deletes only what you name, in reverse dependency order, after a typed +confirmation — and a declaration marked `preventDestroy: true` blocks even that. -```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 -``` +## What it manages -Without `--env`, behaviour is unchanged (single stored login, `ct-state.json`). +| Resource | DSL | Guide | +| --- | --- | --- | +| Campuses | `ct.campus` | [config guide](docs/configuration.md) | +| Groups (fields, campus, multi-parent hierarchy) | `ct.group` | [config guide](docs/configuration.md) | +| Group types, roles, age/target groups, relationship types | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType` | reusable building blocks | +| Permissions (group-role, group-type-role, person-status) | `ct.groupRole`, `ct.groupTypeRole`, `ct.status` | [permissions](docs/handbuch/permissions.md) | +| Auto-groups (dynamic groups) | the `dynamic` block on a group | [dynamic groups](docs/handbuch/dynamic-groups.md) | +| Repeated structure, parametrized | a plain function over the DSL | [blueprints](docs/handbuch/blueprints.md) | -**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). +Read-only by design: the person master-data model, security levels and +custom-field *definitions* (`ct get person-masterdata`, `ct get data-fields`) — +schema in scope, per-record values never. See +[field definitions](docs/handbuch/field-definitions.md). -**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 -``` +References are logical throughout: `groupType: "ministry_team"` resolves to that +instance's id at plan time, so the same config drives a dev and a prod instance +unchanged. Numeric ids remain an escape hatch everywhere. -### Promotion workflow +## Environments and CI -Promote a change dev → prod, verifying against the rehearsal env before the real one: +One config repo, several ChurchTools instances, no file edits when switching: ```bash -# 1. Plan + apply against dev (rehearsal) -ct plan --env dev +ct plan --env dev # rehearse against the dev instance 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. - -## CI usage - -`ct` runs non-interactively out of the box — every state/host-touching command -works from a CI runner with no OS keychain. - -### Authentication - -Skip the keychain with two env vars (see `src/auth/tokenStore.ts`): - -```bash -export CT_HOST=https://eqrm.church.tools -export CT_LOGINTOKEN= -ct plan +ct plan --env prod # SAME checkout — prod host, prod state file +ct apply --env prod # protected env: type the env name to confirm ``` -`CT_LOGINTOKEN` always wins over stored credentials, so this works even on a -machine that also has an interactive login. Under `--env`, a profile's -`tokenEnv` (in `ct.envs.json`) names the secret env var to read for that -target — see [Environments](#environments-dev--prod-promotion) — and a -protected env's apply/destroy still needs `--confirm-env ` (below); -`CT_LOGINTOKEN`/`CT_HOST` alone do not bypass that guardrail. - -### Detecting whether there are changes: `--detailed-exitcode` +- [**Environments**](docs/environments.md) — `ct.envs.json`, per-env state files, + protected environments, the dev → prod promotion workflow. +- [**CI usage**](docs/ci.md) — the auth model and token-from-secret setup, + `--detailed-exitcode`, the `--json` plan shape, drift-vs-config attribution, + and a copy-pasteable job that posts the plan as a PR comment. -Terraform-style. With the flag, `ct plan` exits: - -| Exit code | Meaning | -|---|---| -| `0` | no changes — desired state already matches ChurchTools (resources AND permissions) | -| `1` | error — the plan is INCOMPLETE (a resource or permission fetch failed), or the command failed outright | -| `2` | changes are pending — at least one resource item is not a no-op, OR at least one permission item has a grant/revoke to apply | +## Guardrails (by design) -Without the flag, behaviour is byte-identical to before: `ct plan` exits `1` -only on an INCOMPLETE plan/error, `0` otherwise — so existing scripts that just -check for a nonzero exit code keep working unchanged. +- `plan` is the default; `apply` is explicit, with a confirmation prompt. +- `apply` never deletes; destruction is explicit via `destroy --target`. +- Destroy-protection (`preventDestroy`); never implicit deletions. +- Protected environments: 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 and memberships are never touched (a 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). +- Tokens live in the OS keychain / `.env`, never in git. -```bash -ct plan --detailed-exitcode --env prod -case $? in - 0) echo "no changes" ;; - 1) echo "plan failed"; exit 1 ;; - 2) echo "changes pending — needs review/apply" ;; -esac -``` +## Two-repo model -**Drift alone never sets exit `2`.** `--detailed-exitcode` mirrors what `ct -apply` would actually *do* — an item can carry drift (ChurchTools changed -since the last apply) while its `action` stays `no-op` (the drifted field -isn't managed by the current config, or happens to already match it), and -`apply` would write nothing for it. Drift is always visible in the human -render's "Drift detected" section and in `--json`'s per-item `drift` array -regardless of the exit code — see below. - -An INCOMPLETE plan is always exit `1`, even with `--detailed-exitcode` and -even if the (partial) plan has changes: an incomplete diff can't be trusted -enough to report "changes pending" instead of "this run failed". - -### Machine-readable output: `--json` - -`ct plan --json` prints **only** the plan JSON to stdout — the env/host -header and any `INCOMPLETE`/permission-catalog warnings go to stderr, so -piping/`jq`-ing stdout is always safe. It composes with `--detailed-exitcode`: -the exit code is derived from the exact same data that lands on stdout. - -Shape: - -```jsonc -{ - "plan": { - "items": [ - { - "type": "group", "key": "kids", "id": 7, "action": "update", - "changes": [ - { "field": "name", "from": "Kid's", "to": "Kids", "source": "config" } - ], - "drift": [ - { "field": "campusId", "from": 4, "to": 9 } - ] - } - ] - }, - "permissions": [ /* PermissionPlanItem[]: { key, domainType, domainId, pendingDomain?, diff: { toPut, toDelete, preserved } } */ ], - "summary": { - "resources": { "create": 0, "update": 1, "delete": 0, "no-op": 3 }, - "drifted": 1, - "permissions": { "toPut": 0, "toDelete": 0, "preserved": 0 }, - "hasChanges": true - } -} -``` +| Repo | Contents | +| --- | --- | +| **`eqrm/ct-cli`** (this repo) | The tool: CLI, API client, plan/apply engine. Generic, reusable. | +| **`eqrm/ct-structure`** | Equippers' actual desired-state config + state files. Depends on this tool. | -**Distinguishing drift from a config change**, per resource item: - -- `changes` is what `ct apply` would actually write: desired config vs. the - live ChurchTools value, field by field. Every `changes` entry on a create - is necessarily `"source": "config"` (there is no live baseline yet to - drift from). On an update, each entry carries a best-effort `source`, - attributed from the same three values the engine already has — the - last-known state snapshot, the desired config, and the fetched actual: - - `"config"` — ChurchTools still matches the last-known snapshot; the - diff exists purely because the desired config changed since the last - apply. - - `"drift"` — the config is unchanged, but ChurchTools was edited - manually since the last apply; applying reverts that manual edit. - - `"config+drift"` — both moved independently (config changed AND - ChurchTools drifted) to values that don't coincide. -- `drift` (top-level, on the item) is informational and present whenever - non-empty: every field where ChurchTools has moved away from the - last-known state snapshot, regardless of whether the current config even - manages that field — a **superset** of what `changes[].source` narrows - down to only the fields `apply` will actually touch. -- **Permission items carry no `source`.** The state file snapshots managed - *resource* fields only, not granted permissions, so there is no - last-known baseline to attribute a permission diff to config-vs-drift. - `diff.toPut`/`diff.toDelete` is honestly just desired-vs-actual — this is - the one place the tool cannot make the distinction, so it doesn't - pretend to. -- **A permission domain declared by reference to a same-run-created group - type** (e.g. `ct.groupTypeRole({ groupType: "struktur", ... })` against a - fresh instance where `struktur` is itself in the create-set) plans as a - **pending domain** instead of aborting. Its `domainId` is `null` and it - carries a `pendingDomain` object (the logical reference, e.g. - `{ kind: "group-type", key: "struktur", __ctRef: true }`); the human render - shows ``. Its grants land in - `diff.toPut` and count toward `summary.permissions.toPut`, `hasChanges`, - and exit code `2` — so a fresh-instance `ct plan` reports the create-set + - pending grants rather than failing. `ct apply` re-resolves the real domain - id after the group type is created and reconciles the grants in the same - run. The hard error is reserved for references that resolve to nothing at - all (a key absent from the config, state, and the live catalog — a typo). - -### Posting a plan as a PR comment - -The human renderer (`renderPlan` / `renderPermissionPlan`) is safe to paste -into a GitHub PR comment: it uses `picocolors`, which disables ANSI color -whenever stdout isn't a TTY or `NO_COLOR` is set, and the `+`/`~`/`-`/`!`/`?` -line prefixes already carry the create/update/delete/drift/unresolved meaning -— the rendering reads cleanly with no color at all. - -**One GitHub Actions gotcha:** picocolors treats the `CI=true` env var (set by -every GitHub Actions runner) as its own signal to force color ON, even when -stdout is redirected to a file — so `ct plan > plan.txt` on a runner still -embeds ANSI escapes unless you override it. Set `NO_COLOR=1` when capturing -output for a PR comment: - -```yaml -- name: Plan - id: plan - env: - CT_HOST: ${{ vars.CT_HOST }} - CT_LOGINTOKEN: ${{ secrets.CT_LOGINTOKEN }} - NO_COLOR: "1" - run: | - set +e - ct plan --detailed-exitcode > plan.txt - echo "exitcode=$?" >> "$GITHUB_OUTPUT" - set -e - -- name: Comment plan on PR - if: always() - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const body = fs.readFileSync('plan.txt', 'utf8'); - const exitcode = '${{ steps.plan.outputs.exitcode }}'; - const verdict = exitcode === '0' ? 'No changes' : exitcode === '2' ? 'Changes pending' : 'Plan FAILED'; - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: `### ct plan — ${verdict}\n
Show plan\n\n\`\`\`\n${body}\n\`\`\`\n
`, - }); -``` - -## Auth model +Like Terraform, the tool never lives in the same repo as the infra config. -The personal login token authenticates via a session handshake, **not** an -`Authorization` header: +## Documentation -1. `GET /api/whoami?login_token=` → sets the session cookie -2. `GET /api/csrftoken` → CSRF token -3. every request sends the cookie; every write sends `CSRF-Token` +- [`docs/configuration.md`](docs/configuration.md) — writing the config: keys, portable references, escape hatches +- [`docs/environments.md`](docs/environments.md) · [`docs/ci.md`](docs/ci.md) — multi-instance and automation +- [`docs/handbuch/`](docs/handbuch/) — the **generic ChurchTools reference** (permissions, dynamic groups, field definitions, blueprints), published into the Handbuch +- [`docs/api-coverage.md`](docs/api-coverage.md) · [`docs/runbook-manual-surface.md`](docs/runbook-manual-surface.md) — what the API supports, and what still has to be done by hand +- [`docs/README.md`](docs/README.md) — how the docs are organised and how pages stay in sync with the code +- Runnable configs: [`examples/`](examples/) ## Development ```bash -npm run dev -- get campuses # run from source (tsx) -npm test # vitest -npm run typecheck -npm run lint -npm run format -npm run generate:client # regenerate the typed client from the live OpenAPI spec +npm ci +npm run build # -> dist/index.js (the `ct` binary) +npm link # optional: puts `ct` on your PATH +npm run dev -- get campuses # run from source (tsx) +npm test # vitest +npm run typecheck && npm run lint +npm run generate:client # regenerate the typed client from the live OpenAPI spec ``` -## Guardrails (by design) +Node ≥ 20 (the repo pins 22 via `.nvmrc`). Every push to `main` that lands a +`feat:`/`fix:`/breaking-change commit runs the full CI gate, compiles the +binaries, smoke-tests each on its native OS/arch, and — only if all of that is +green — cuts the version and publishes the GitHub Release via +[semantic-release](https://semantic-release.gitbook.io/). No manual tag push. -- `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). -- Tokens live in the OS keychain / `.env`, never in git. +## Status + +Phases 0–5 (spike → CLI/client → adopt/state → declarative engine → apply + +guardrails → blueprints) are complete. Phase 6 — reproducible instances, +environments, GitOps — is tracked in +[#19](https://github.com/eqrm/ct-cli/issues/19); the overall design lives in +[#1](https://github.com/eqrm/ct-cli/issues/1). diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..8ad55b5 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,188 @@ +# CI usage & machine-readable output + +`ct` runs non-interactively out of the box — every state/host-touching command +works from a CI runner with no OS keychain. + +## Authentication + +### The auth model + +The personal login token authenticates via a session handshake, **not** an +`Authorization` header: + +1. `GET /api/whoami?login_token=` → sets the session cookie +2. `GET /api/csrftoken` → CSRF token +3. every request sends the cookie; every write sends `CSRF-Token` + +### In CI + +Skip the keychain with two env vars (see +[`src/auth/tokenStore.ts`](https://github.com/eqrm/ct-cli/blob/main/src/auth/tokenStore.ts)): + +```bash +export CT_HOST=https://eqrm.church.tools +export CT_LOGINTOKEN= +ct plan +``` + +`CT_LOGINTOKEN` always wins over stored credentials, so this works even on a +machine that also has an interactive login. Under `--env`, a profile's +`tokenEnv` (in `ct.envs.json`) names the secret env var to read for that +target — see [Environments](environments.md) — and a protected env's +apply/destroy still needs `--confirm-env `; `CT_LOGINTOKEN`/`CT_HOST` +alone do not bypass that guardrail. + +## Detecting whether there are changes: `--detailed-exitcode` + +Terraform-style. With the flag, `ct plan` exits: + +| Exit code | Meaning | +|---|---| +| `0` | no changes — desired state already matches ChurchTools (resources AND permissions) | +| `1` | error — the plan is INCOMPLETE (a resource or permission fetch failed), or the command failed outright | +| `2` | changes are pending — at least one resource item is not a no-op, OR at least one permission item has a grant/revoke to apply | + +Without the flag, behaviour is byte-identical to before: `ct plan` exits `1` +only on an INCOMPLETE plan/error, `0` otherwise — so existing scripts that just +check for a nonzero exit code keep working unchanged. + +```bash +ct plan --detailed-exitcode --env prod +case $? in + 0) echo "no changes" ;; + 1) echo "plan failed"; exit 1 ;; + 2) echo "changes pending — needs review/apply" ;; +esac +``` + +**Drift alone never sets exit `2`.** `--detailed-exitcode` mirrors what `ct +apply` would actually *do* — an item can carry drift (ChurchTools changed +since the last apply) while its `action` stays `no-op` (the drifted field +isn't managed by the current config, or happens to already match it), and +`apply` would write nothing for it. Drift is always visible in the human +render's "Drift detected" section and in `--json`'s per-item `drift` array +regardless of the exit code — see below. + +An INCOMPLETE plan is always exit `1`, even with `--detailed-exitcode` and +even if the (partial) plan has changes: an incomplete diff can't be trusted +enough to report "changes pending" instead of "this run failed". + +## Machine-readable output: `--json` + +`ct plan --json` prints **only** the plan JSON to stdout — the env/host +header and any `INCOMPLETE`/permission-catalog warnings go to stderr, so +piping/`jq`-ing stdout is always safe. It composes with `--detailed-exitcode`: +the exit code is derived from the exact same data that lands on stdout. + +Shape: + +```jsonc +{ + "plan": { + "items": [ + { + "type": "group", "key": "kids", "id": 7, "action": "update", + "changes": [ + { "field": "name", "from": "Kid's", "to": "Kids", "source": "config" } + ], + "drift": [ + { "field": "campusId", "from": 4, "to": 9 } + ] + } + ] + }, + "permissions": [ /* PermissionPlanItem[]: { key, domainType, domainId, pendingDomain?, diff: { toPut, toDelete, preserved } } */ ], + "summary": { + "resources": { "create": 0, "update": 1, "delete": 0, "no-op": 3 }, + "drifted": 1, + "permissions": { "toPut": 0, "toDelete": 0, "preserved": 0 }, + "hasChanges": true + } +} +``` + +### Distinguishing drift from a config change + +Per resource item: + +- `changes` is what `ct apply` would actually write: desired config vs. the + live ChurchTools value, field by field. Every `changes` entry on a create + is necessarily `"source": "config"` (there is no live baseline yet to + drift from). On an update, each entry carries a best-effort `source`, + attributed from the same three values the engine already has — the + last-known state snapshot, the desired config, and the fetched actual: + - `"config"` — ChurchTools still matches the last-known snapshot; the + diff exists purely because the desired config changed since the last + apply. + - `"drift"` — the config is unchanged, but ChurchTools was edited + manually since the last apply; applying reverts that manual edit. + - `"config+drift"` — both moved independently (config changed AND + ChurchTools drifted) to values that don't coincide. +- `drift` (top-level, on the item) is informational and present whenever + non-empty: every field where ChurchTools has moved away from the + last-known state snapshot, regardless of whether the current config even + manages that field — a **superset** of what `changes[].source` narrows + down to only the fields `apply` will actually touch. +- **Permission items carry no `source`.** The state file snapshots managed + *resource* fields only, not granted permissions, so there is no + last-known baseline to attribute a permission diff to config-vs-drift. + `diff.toPut`/`diff.toDelete` is honestly just desired-vs-actual — this is + the one place the tool cannot make the distinction, so it doesn't + pretend to. +- **A permission domain declared by reference to a same-run-created group + type** (e.g. `ct.groupTypeRole({ groupType: "struktur", ... })` against a + fresh instance where `struktur` is itself in the create-set) plans as a + **pending domain** instead of aborting. Its `domainId` is `null` and it + carries a `pendingDomain` object (the logical reference, e.g. + `{ kind: "group-type", key: "struktur", __ctRef: true }`); the human render + shows ``. Its grants land in + `diff.toPut` and count toward `summary.permissions.toPut`, `hasChanges`, + and exit code `2` — so a fresh-instance `ct plan` reports the create-set + + pending grants rather than failing. `ct apply` re-resolves the real domain + id after the group type is created and reconciles the grants in the same + run. The hard error is reserved for references that resolve to nothing at + all (a key absent from the config, state, and the live catalog — a typo). + +## Posting a plan as a PR comment + +The human renderer (`renderPlan` / `renderPermissionPlan`) is safe to paste +into a GitHub PR comment: it uses `picocolors`, which disables ANSI color +whenever stdout isn't a TTY or `NO_COLOR` is set, and the `+`/`~`/`-`/`!`/`?` +line prefixes already carry the create/update/delete/drift/unresolved meaning +— the rendering reads cleanly with no color at all. + +**One GitHub Actions gotcha:** picocolors treats the `CI=true` env var (set by +every GitHub Actions runner) as its own signal to force color ON, even when +stdout is redirected to a file — so `ct plan > plan.txt` on a runner still +embeds ANSI escapes unless you override it. Set `NO_COLOR=1` when capturing +output for a PR comment: + +```yaml +- name: Plan + id: plan + env: + CT_HOST: ${{ vars.CT_HOST }} + CT_LOGINTOKEN: ${{ secrets.CT_LOGINTOKEN }} + NO_COLOR: "1" + run: | + set +e + ct plan --detailed-exitcode > plan.txt + echo "exitcode=$?" >> "$GITHUB_OUTPUT" + set -e + +- name: Comment plan on PR + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('plan.txt', 'utf8'); + const exitcode = '${{ steps.plan.outputs.exitcode }}'; + const verdict = exitcode === '0' ? 'No changes' : exitcode === '2' ? 'Changes pending' : 'Plan FAILED'; + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `### ct plan — ${verdict}\n
Show plan\n\n\`\`\`\n${body}\n\`\`\`\n
`, + }); +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..970ba13 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,86 @@ +# Writing the config + +The desired state lives in a config file (default `ct.config.ts`) that +default-exports a function receiving the DSL: + +```ts +export default (ct) => { + ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); + // Reference master data BY NAME, not by hardcoded id: `groupType: "…"` resolves to the + // per-host group-type id at plan time, so this config is portable across instances (#20). + ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupType: "ministry_team" }); + // Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared + // in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged + // groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.) + ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupType: "ministry_team", parents: ["mainz_area"] }); + // Assign a group to a campus BY KEY: `campus: "mainz"` links to the campus above even though + // it is created in the same apply (its id is filled in at apply time). The numeric escape + // hatch still works — `campusId: 3` (or `campusId: null` to clear) targets an existing id. + ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupType: "ministry_team", campus: "mainz", parents: ["mainz_kids_lead"] }); +}; +``` + +Every declaration carries a **`key`** — the logical name, unique across the whole +config. It is what the state file maps to a ChurchTools id, what other +declarations reference, and what shows up in `ct plan` output. ChurchTools ids +never appear in the config unless you deliberately put them there. + +## Portable references (#20) + +Logical fields (`campus`/`groupType` on a group, `groupType` on a permission) +and the inline `ref.*` helper compile to id-free sentinels that a per-host +resolver maps to real ChurchTools ids at plan time — sourced from resources this +tool manages, then from live master-data catalogs matched by name. So one config +file plans and applies unchanged against different instances (ids differ per +host). + +An unresolvable name fails the plan with a clear error naming the reference and +where it was used. Raw numeric ids remain a valid escape hatch everywhere; see +[`examples/portable.config.ts`](https://github.com/eqrm/ct-cli/blob/main/examples/portable.config.ts) +for a zero-numeric-id config. + +**Exception: `groupStatusId` (a group's lifecycle status) is numeric-only, +always.** ChurchTools exposes no REST catalog to resolve a status by name +(`/group/memberstatus` is a different dimension, member statuses; #67), so a +`status:` declaration fails fast at eval time rather than resolving against the +wrong dimension. + +## Campus assignment + +`campusId` is a managed group field: `ct plan` shows a campus assign/move/clear +as a normal field update, and `ct adopt group ` captures it. Which group +fields are managed vs. deliberately left to the CT UI is recorded in +[`group-field-decisions.md`](group-field-decisions.md). + +## Same-named groups (#75) + +ChurchTools guards group creation by NAME, not by this tool's logical key — +`POST /groups` 400s (`forbidden.duplicate.group`) if a group with that name +already exists, even when the two are legitimately distinct (e.g. an archived and +an active "Kids Elternabend 2026" event signup). Opt in per-declaration to create +it anyway: + +```ts +ct.group({ key: "kids_2026_b", name: "Kids Elternabend 2026", groupTypeId: 2, allowDuplicateName: true }); +``` + +`allowDuplicateName` sends CT's `force: true` on the CREATE request only — it is +never a managed field (not diffed, not in state, not touched on update, and +never adopted). **Never set it as a default**; it exists for the rare +intentional-duplicate case. If a create 400s on this guard without the flag set, +`ct apply`'s stop message explains the likely cause (an unmanaged existing group +that should be adopted with `ct adopt group --key `) and the opt-in as +the alternative. + +## Output conventions + +Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go +to **stderr**. So `ct plan --json | jq` and `ct get groups > groups.json` are +always safe, whatever warnings the run prints. + +## Going further + +- [Permissions](handbuch/permissions.md) — `ct.groupRole`, `ct.groupTypeRole`, `ct.status` +- [Dynamic groups](handbuch/dynamic-groups.md) — the `dynamic` block and the typed query DSL +- [Blueprints](handbuch/blueprints.md) — one function per repeated structure, instantiated per campus +- [Environments](environments.md) — one config, several instances diff --git a/docs/environments.md b/docs/environments.md new file mode 100644 index 0000000..b4f53e7 --- /dev/null +++ b/docs/environments.md @@ -0,0 +1,104 @@ +# 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. + +## Declaring environments + +Declare them 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. + +## Using them + +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` 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.