From 61ad133a876df07f744021efdd9107a756932dde Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 15:56:01 +0200 Subject: [PATCH 01/16] docs(phase-4): apply/destroy + guardrails design spec --- ...2026-07-07-phase-4-apply-destroy-design.md | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md diff --git a/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md b/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md new file mode 100644 index 0000000..90aee5b --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-phase-4-apply-destroy-design.md @@ -0,0 +1,241 @@ +# Phase 4 — Apply (idempotent CRUD) + `destroy` + guardrails + +Design doc for GitHub issue #6. Depends on the Phase 3 declarative engine +(config → `computePlan` → ordered `Plan`). Makes the plan real, safely. + +## Goal + +Execute a computed plan against ChurchTools: + +- `ct apply` — idempotent **create + update** in dependency order; re-running a + clean plan is a no-op. +- `ct destroy --target …` — explicit-only deletion, with destroy-protection; + never implicit deletions. + +Plus the Phase 4 guardrails: confirmation, automatic backup, a hard "people are +never touched" boundary, rate-limit/retry on writes, and state persisted after +each successful action (crash-safe / resumable). + +## Decisions (locked) + +1. **`apply` never deletes.** It executes creates + updates only. A resource + dropped from config surfaces as a notice ("run `ct destroy --target `"), + never an automatic deletion. Deletion is exclusively `destroy`'s job. This is + the deliberate divergence from Terraform demanded by the "never implicit + deletions" guardrail. +2. **Destroy-protection = a config `preventDestroy` lifecycle flag** plus a typed + confirmation. A targeted destroy of a flagged resource hard-fails until the + flag (or the whole declaration) is removed from config. +3. **Backup = a JSON snapshot** of the current *actual* ChurchTools values of all + managed resources, written to `backups/ct-backup-.json` beside + the state file, before any write. Directory overridable via `--backup-dir` / + `CT_BACKUP_DIR`. +4. **Type scope = all writable types.** `campus`, `group`, `group-type` (already + in the registry) plus `age-group`, `target-group`, `relationship-type`, + `group-role`, and group **hierarchy** edges. Their managed-field sets are + provisional and will be live-verified during implementation. + +## Command surface + +### `ct apply` + +1. Load config + state (host guard, as `plan` does). +2. `buildPlan` — fetch actual, fold hierarchy, `computePlan` (shared with `plan`). +3. If any fetch failed → **abort** (acting on an incomplete picture is unsafe). +4. Render the plan. If there are no creates/updates → report "no changes" and + exit 0 (deletes-only plans print the destroy notice and still exit 0). +5. Surface any `delete` items as a notice: they will **not** be applied. +6. Confirm: `Apply N changes? [y/N]` (skipped by `-y/--auto-approve`; non-TTY + without `-y` aborts). +7. Write a backup of `actual`. +8. Execute creates + updates in dependency order, saving state after each. +9. On the first write error: stop, print what succeeded, exit 1 (state is + already persisted up to that point; re-running resumes). + +Flags: `-c/--config`, `-s/--state`, `--backup-dir`, `-y/--auto-approve`. + +### `ct destroy --target ` + +1. Require at least one `--target` (repeatable and/or comma-separated). None ⇒ + hard error — nothing is ever deleted implicitly. +2. Load config + state. Every target must be a managed resource (in state). +3. `preventDestroy` guard: if a target is still declared in config with + `preventDestroy: true`, hard-fail ("remove the flag in config first"). + Enforced even under `--force`. +4. Order targets in **reverse** dependency order (children before parents), + reusing the topological sort over edges recorded in state. +5. Write a backup of the targets' actual values. +6. Typed confirmation: type the single key, or the word `destroy` for multiple + targets. `--force` skips the typed prompt (but never the `preventDestroy` + guard). +7. `DELETE` each, prune it from state, save after each. + +Flags: `--target`, `-c/--config`, `-s/--state`, `--backup-dir`, `--force`. + +## Modules + +### `engine/build.ts` (new) — shared plan building + +Extract the actual-fetch + hierarchy-fold + `computePlan` currently inlined in +`commands/plan.ts` into: + +``` +buildPlan(client, state, desired) -> { plan, actual, fetchErrors } +``` + +`commands/plan.ts` and `commands/apply.ts` both call it. `actual` (keyed by +logical key) is reused for the backup, so apply fetches once. + +### `resources/registry.ts` (extended) — write specs + +Extend `AdoptableResource`: + +- `collectionPath: string` — `POST ` creates; `itemPath` derives + from it (already the case). +- `updateMethod: "PUT" | "PATCH"` — `group` is `PATCH`; every other type is + `PUT`. +- Delete is always `DELETE `. + +Add read+write entries for the new types. **Provisional** managed fields (to be +confirmed against the live API — fetch one of each with `ct get`, adjust): + +| type | collection path | update | provisional managed fields | +| ------------------- | --------------------------- | ------ | ----------------------------------- | +| `campus` | `/campuses` | PUT | `name`, `shortName` | +| `group` | `/groups` | PATCH | `name`, `groupTypeId`, `groupStatusId` | +| `group-type` | `/group/grouptypes` | PUT | `name`, `nameTranslated` | +| `age-group` | `/group/agegroups` | PUT | `name`, `sortKey` | +| `target-group` | `/group/targetgroups` | PUT | `name`, `sortKey` | +| `relationship-type` | `/person/relationshiptypes` | PUT | `name`, `degreeForward`, `degreeReverse` | +| `group-role` | `/group/roles` | PUT | `name`, `groupTypeId` | + +Provisional field sets are a registry-local detail; the engine is field-agnostic, +so refining them later touches only this file (+ its test). + +### `engine/execute.ts` (new) — the executor + +``` +executePlan({ client, plan, state, statePath, save }) -> ExecuteResult +``` + +For each item in plan order: + +- `delete` / `no-op` → skip (deletes counted for the caller's notice). +- `create` → `POST collectionPath` with the desired fields (minus lifecycle/ + hierarchy fields); capture the new id from the response; `upsert` into state; + save. +- `update` → apply the field changes (see hierarchy below); `PUT`/`PATCH` + `itemPath(id)`; refresh the state snapshot; save. + +Stop on the first error; return `{ created, updated, skippedDeletes, error? }`. +Every write goes through `assertNotPeople(path)` first. + +### Hierarchy edges (inside execute) + +A group's `parents` set-field is **not** part of the group's `PATCH` body. On an +update whose changes include `parents`: + +1. Diff desired parent keys vs actual parent keys (both are logical-key sets). +2. Resolve each key → CT id via state (parents are applied earlier by dep order + and their ids are already saved). +3. For each added key: `PUT /groups/{childId}/parents/{parentId}`. +4. For each removed key: `DELETE /groups/{childId}/parents/{parentId}`. +5. Remaining (non-`parents`) group fields go via the normal `PATCH`. + +The group's state snapshot is updated to the new parents set after success. + +### `engine/backup.ts` (new) + +``` +writeBackup(dir, host, actual) -> filePath +``` + +Writes `{ host, capturedAt, resources }` (resources = the `actual` map) to +`backups/ct-backup-.json`. Timestamp is ISO-8601 with `:` replaced by +`-` for filename safety. Directory is created if missing. + +### `ui/prompt.ts` (new) + +- `confirm(message, { assumeYes }): Promise` — `[y/N]` on stderr, + reads stdin. `assumeYes` short-circuits true. Non-TTY without `assumeYes` + returns false (caller aborts). +- `confirmTyped(expected, { force }): Promise` — require the user to + type `expected`. `force` short-circuits true. Non-TTY without `force` false. + +### `engine/guard.ts` (new) — hard people boundary + +`assertNotPeople(path)` throws if a write path matches a denylist: `/persons`, +`/persons/…`, `/memberships`, `/groups/{id}/members`, `/groups/{id}/memberships`. +This is belt-and-suspenders atop the structural-only registry (which is the +primary allowlist — no people type is ever registered). + +### `config/context.ts` + `engine/types.ts` + +Add `preventDestroy?: boolean` to `ResourceInput` and `DesiredResource`. It is a +**lifecycle flag**, extracted before building `fields`, so it is never diffed, +snapshotted, or sent to the API — it only gates `destroy`. + +### CLI wiring + +Add `commands/apply.ts` and `commands/destroy.ts`; remove both entries from +`commands/placeholders.ts` (leaving that file empty of Phase-4 verbs). + +## Guardrail → mechanism map + +| Guardrail (issue #6) | Mechanism | +| -------------------------------- | --------------------------------------------------------------- | +| Confirmation before any change | `confirm` (apply) / `confirmTyped` (destroy) | +| Automatic backup before apply | `writeBackup(actual)` before the first write | +| People/memberships never touched | structural-only registry + `assertNotPeople` denylist | +| Rate-limit + retry on writes | existing `fetchWithRetry` (429 retried; 5xx/network not, for writes) | +| State updated after each action | `save` after every create/update/edge/delete | +| Destroy-protection | `preventDestroy` flag + explicit `--target` + typed confirm | + +Rate-limit/retry needs **no code change**: `fetchWithRetry` already retries `429` +for every method (safe — rejected before processing) and deliberately does not +retry `5xx`/network errors for non-idempotent writes. + +## Crash-safety / resumability + +`saveState` runs after each successful action, so a mid-apply crash leaves the +state file reflecting completed work. Re-running recomputes the plan against the +new state and continues; completed creates/updates show as no-ops. + +## Failure handling + +- `buildPlan` fetch errors → apply/destroy abort before any write. +- apply stops on the first write error (state already persisted); exit 1; re-run + resumes. + +## Testing (vitest, fake `CtClient`) + +- **execute**: create captures id + saves state; update uses the right + method/body; hierarchy edge diff → exact `PUT`/`DELETE` calls; deletes skipped; + `assertNotPeople` throws; stop-on-error leaves state consistent + partial. +- **registry**: write-spec paths + methods for every type. +- **backup**: file written with the right shape; dir created. +- **prompt**: `assumeYes`/`force`, typed match vs mismatch, non-TTY. +- **destroy**: `preventDestroy` blocks; missing `--target` errors; reverse order; + state pruned; backup produced. +- **guard**: denylist matches people paths, allows structural paths. +- **CLI**: `apply`/`destroy` registered and in `--help`. +- **integration**: adopt → modify config → apply → re-plan shows no drift + (against the fake client). + +## Definition of done (issue #6) + +- Apply creates an adopted-then-modified structure end-to-end; a second `plan` + shows no drift. → integration test + live-verify. +- Destroy-protection blocks an unintended delete in a test. → `preventDestroy` + test + explicit-target test. +- A backup artifact is produced before apply. → backup test + apply flow. + +## Out of scope (later phases) + +- Symbolic cross-references resolved at create time (e.g. a brand-new group + referencing a brand-new group-type by logical key). Phase 4 fields are literal + values / already-existing ids; the DoD is "adopted-then-modified", where ids + already exist. This belongs to Phase 5 (blueprints). +- `dynamic-group` ruleset apply, `permission` grants — their own later issues + (#14, #13). +- Runtime CT version gate (`GET /info` ≥ 3.96) — not in issue #6's scope. From 3c9f2ca5a045cc9b07477704af4f91749472e56f Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 16:02:57 +0200 Subject: [PATCH 02/16] docs(phase-4): implementation plan --- .../plans/2026-07-07-phase-4-apply-destroy.md | 1578 +++++++++++++++++ 1 file changed, 1578 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md diff --git a/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md b/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md new file mode 100644 index 0000000..537465f --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-phase-4-apply-destroy.md @@ -0,0 +1,1578 @@ +# Phase 4 — Apply + Destroy + Guardrails Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Phase 3 plan real — `ct apply` (idempotent create + update in dependency order) and `ct destroy --target` (explicit, protected deletion), with the Phase 4 guardrails. + +**Architecture:** A shared `buildPlan` feeds both `plan` and `apply`. A field-agnostic `executePlan` walks the ordered plan, creating/updating via registry-declared write specs and reconciling group hierarchy through the parents endpoints, saving state after each action. `destroy` is a separate, explicit command with a `preventDestroy` config guard and typed confirmation. Every write passes a people-boundary guard; every apply/destroy writes a JSON backup first. + +**Tech Stack:** TypeScript (ESM, `.js` import specifiers), commander, vitest, picocolors, node:readline. No new dependencies. + +## Global Constraints + +- ESM throughout: import local modules with `.js` specifiers (e.g. `../state/state.js`). +- CT ids can be `0` — never use truthiness for id presence; use explicit null/undefined checks. +- `apply` NEVER deletes. Deletion is exclusively `destroy`'s job. +- People/memberships are never touched (structural-only registry + `assertNotPeople` guard). +- Machine output → stdout (`out`); human messages → stderr (`info`/`warn`/`success`/`error`). +- Writes are non-idempotent: never blindly retried on 5xx/network (existing `fetchWithRetry` already enforces this; do not change it). +- Follow existing test style: vitest `describe/it/expect`, fakes over network. +- State snapshot for a group NEVER stores `parents` (hierarchy is diffed live), matching the adopt convention. + +--- + +## File structure + +- Create `src/engine/guard.ts` — `assertNotPeople(path)`. +- Create `src/engine/backup.ts` — `writeBackup(dir, host, actual, now?)`. +- Create `src/ui/prompt.ts` — `confirm`, `confirmTyped`. +- Create `src/engine/build.ts` — `buildPlan(client, state, desired)`. +- Create `src/engine/execute.ts` — `executePlan(plan, deps)`. +- Create `src/commands/apply.ts`, `src/commands/destroy.ts`. +- Modify `src/engine/types.ts` — add `preventDestroy?` to `DesiredResource`. +- Modify `src/config/context.ts` — add `preventDestroy?` to `ResourceInput`, extract it. +- Modify `src/resources/registry.ts` — add `collectionPath`, `updateMethod`; add new type entries. +- Modify `src/commands/plan.ts` — call `buildPlan`. +- Modify `src/commands/placeholders.ts` — drop apply/destroy stubs. +- Modify `src/index.ts` — register `apply`/`destroy`. +- Tests alongside in `tests/`. + +--- + +## Task 1: `preventDestroy` lifecycle flag + +**Files:** +- Modify: `src/engine/types.ts` +- Modify: `src/config/context.ts` +- Test: `tests/context.test.ts` + +**Interfaces:** +- Produces: `DesiredResource.preventDestroy?: boolean`; `ResourceInput.preventDestroy?: boolean`. The flag is extracted before building `fields`, so it is never diffed or sent to the API. + +- [ ] **Step 1: Write the failing test** — append to `tests/context.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { evaluateConfig } from "../src/config/context.js"; + +describe("preventDestroy lifecycle flag", () => { + it("is carried on the resource but kept out of managed fields", async () => { + const resources = await evaluateConfig((ct) => { + ct.group({ key: "kids_lead", name: "Kids Leitung", preventDestroy: true }); + }); + const group = resources.find((r) => r.key === "kids_lead")!; + expect(group.preventDestroy).toBe(true); + expect(group.fields).toEqual({ name: "Kids Leitung" }); + }); + + it("defaults to undefined when not declared", async () => { + const resources = await evaluateConfig((ct) => { + ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + }); + expect(resources[0]!.preventDestroy).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/context.test.ts` +Expected: FAIL — `group.fields` still contains `preventDestroy`. + +- [ ] **Step 3: Implement** — in `src/engine/types.ts`, add to `DesiredResource` (after `parents?`): + +```ts + /** Lifecycle flag: block `ct destroy` for this resource. Never diffed or sent to the API. */ + preventDestroy?: boolean; +``` + +In `src/config/context.ts`, add to `ResourceInput` (after `parents?`): + +```ts + /** Block `ct destroy` for this resource until the flag is removed. */ + preventDestroy?: boolean; +``` + +And change `toDesired` to extract it: + +```ts +function toDesired(type: string, input: ResourceInput): DesiredResource { + const { key, parent, parents, dependsOn = [], preventDestroy, ...fields } = input; + if (!key || typeof key !== "string") { + throw new Error(`${type} declaration is missing a string "key".`); + } + const declared = parents !== undefined || parent !== undefined; + const parentKeys = declared ? [...new Set([...(parents ?? []), ...(parent ? [parent] : [])])] : undefined; + const edges = [...dependsOn, ...(parentKeys ?? [])]; + return { type, key, fields, parent, parents: parentKeys, dependsOn: edges, preventDestroy }; +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `npx vitest run tests/context.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/types.ts src/config/context.ts tests/context.test.ts +git commit -m "feat(config): preventDestroy lifecycle flag" +``` + +--- + +## Task 2: Registry write specs + new writable types + +**Files:** +- Modify: `src/resources/registry.ts` +- Test: `tests/registry.test.ts` + +**Interfaces:** +- Produces: `AdoptableResource` gains `collectionPath: string` and `updateMethod: "PUT" | "PATCH"`. `itemPath(id)` still returns `\`${collectionPath}/${id}\``. New entries: `age-group`, `target-group`, `relationship-type`, `group-role`. + +- [ ] **Step 1: Write the failing test** — append to `tests/registry.test.ts`: + +```ts +import { describe as d2, it as i2, expect as e2 } from "vitest"; +import { RESOURCES as R2 } from "../src/resources/registry.js"; + +d2("write specs", () => { + i2("campus creates via POST /campuses and updates via PUT", () => { + e2(R2.campus?.collectionPath).toBe("/campuses"); + e2(R2.campus?.updateMethod).toBe("PUT"); + }); + i2("group updates via PATCH", () => { + e2(R2.group?.collectionPath).toBe("/groups"); + e2(R2.group?.updateMethod).toBe("PATCH"); + }); + i2("registers the new writable types with their collection paths", () => { + e2(R2["age-group"]?.collectionPath).toBe("/group/agegroups"); + e2(R2["target-group"]?.collectionPath).toBe("/group/targetgroups"); + e2(R2["relationship-type"]?.collectionPath).toBe("/person/relationshiptypes"); + e2(R2["group-role"]?.collectionPath).toBe("/group/roles"); + e2(R2["age-group"]?.itemPath(3)).toBe("/group/agegroups/3"); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/registry.test.ts` +Expected: FAIL — `collectionPath`/`updateMethod` undefined; new types missing. + +- [ ] **Step 3: Implement** — rewrite the interface, helper, and `RESOURCES` in `src/resources/registry.ts`. Replace the `AdoptableResource` interface, the `item` helper, and the `RESOURCES` object with: + +```ts +export interface AdoptableResource { + /** Collection path: `POST` here creates. */ + collectionPath: string; + /** GET/PUT/PATCH/DELETE path for a single resource by id. */ + itemPath: (id: number) => string; + /** Update verb: `group` is PATCH; every other type is PUT. */ + updateMethod: "PUT" | "PATCH"; + /** Stable logical key derived from the fetched resource. */ + deriveKey: (resource: Record) => string; + /** The subset of fields we manage — the desired-state baseline. */ + managedFields: (resource: Record) => Record; +} + +/** Build a full spec, deriving `itemPath` from the collection path so each entry names its path once. */ +function define(spec: Omit): AdoptableResource { + return { ...spec, itemPath: (id: number) => `${spec.collectionPath}/${id}` }; +} +``` + +Delete the old `const item = ...` line. Then set `RESOURCES`: + +```ts +export const RESOURCES: Record = { + campus: define({ + collectionPath: "/campuses", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "shortName") || str(r, "name")), + managedFields: (r) => ({ name: r.name, shortName: r.shortName }), + }), + group: define({ + collectionPath: "/groups", + updateMethod: "PATCH", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ + name: r.name, + groupTypeId: fromInformation(r, "groupTypeId"), + groupStatusId: fromInformation(r, "groupStatusId"), + }), + }), + "group-type": define({ + collectionPath: "/group/grouptypes", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated }), + }), + "age-group": define({ + collectionPath: "/group/agegroups", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ name: r.name, sortKey: r.sortKey }), + }), + "target-group": define({ + collectionPath: "/group/targetgroups", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ name: r.name, sortKey: r.sortKey }), + }), + "relationship-type": define({ + collectionPath: "/person/relationshiptypes", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ + name: r.name, + degreeForward: r.degreeForward, + degreeReverse: r.degreeReverse, + }), + }), + "group-role": define({ + collectionPath: "/group/roles", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ name: r.name, groupTypeId: r.groupTypeId }), + }), +}; +``` + +> Managed-field sets for the four new types are provisional; live-verify with `ct get` in Task 11 and adjust this file + the registry test if the real fields differ. + +- [ ] **Step 4: Run, expect pass** + +Run: `npx vitest run tests/registry.test.ts` +Expected: PASS (existing itemPath tests still pass — `itemPath` behaviour is unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add src/resources/registry.ts tests/registry.test.ts +git commit -m "feat(registry): write specs (collectionPath, updateMethod) + new writable types" +``` + +--- + +## Task 3: People-boundary guard + +**Files:** +- Create: `src/engine/guard.ts` +- Test: `tests/guard.test.ts` + +**Interfaces:** +- Produces: `assertNotPeople(path: string): void` — throws for any people/membership write path; returns silently for structural paths (including `/groups/{id}/parents/{id}`). + +- [ ] **Step 1: Write the failing test** — create `tests/guard.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { assertNotPeople } from "../src/engine/guard.js"; + +describe("assertNotPeople", () => { + it("throws for people and membership paths", () => { + for (const p of [ + "/persons", + "/persons/42", + "/memberships/7", + "/groups/3/members", + "/groups/3/members/9", + "/groups/3/memberships", + ]) { + expect(() => assertNotPeople(p), p).toThrow(/people|member/i); + } + }); + + it("allows structural paths including hierarchy edges", () => { + for (const p of [ + "/campuses", + "/groups", + "/groups/3", + "/groups/3/parents/5", + "/group/grouptypes/2", + "/person/relationshiptypes/1", + ]) { + expect(() => assertNotPeople(p)).not.toThrow(); + } + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/guard.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** — create `src/engine/guard.ts`: + +```ts +/** + * Hard people boundary. The CLI manages the *structure* only — people and + * memberships are never touched. This is belt-and-suspenders atop the + * structural-only registry (the primary allowlist): every write in apply/destroy + * passes its path through here first. + * + * Note `/person/relationshiptypes` is master data (relationship *types*), not a + * person — the denylist matches `/persons` (plural), not `/person`. + */ +const FORBIDDEN: RegExp[] = [ + /^\/persons(\/|$)/, + /^\/memberships(\/|$)/, + /\/groups\/\d+\/members(hips)?(\/|$)/, +]; + +export function assertNotPeople(path: string): void { + if (FORBIDDEN.some((re) => re.test(path))) { + throw new Error( + `Refusing to write to "${path}": people/memberships are never managed by this tool.`, + ); + } +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `npx vitest run tests/guard.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/guard.ts tests/guard.test.ts +git commit -m "feat(engine): assertNotPeople hard boundary guard" +``` + +--- + +## Task 4: Backup writer + +**Files:** +- Create: `src/engine/backup.ts` +- Test: `tests/backup.test.ts` + +**Interfaces:** +- Produces: `writeBackup(dir: string, host: string, actual: Map>, now?: Date): Promise` — returns the written file path. Creates `dir` if missing. Filename `ct-backup-.json`. Content: `{ host, capturedAt, resources }`. + +- [ ] **Step 1: Write the failing test** — create `tests/backup.test.ts`: + +```ts +import { describe, it, expect, afterEach } from "vitest"; +import { readFile, rm, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeBackup } from "../src/engine/backup.js"; + +let dir: string | undefined; +afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + dir = undefined; +}); + +describe("writeBackup", () => { + it("writes a timestamped JSON snapshot of actual resources", async () => { + dir = await mkdtemp(join(tmpdir(), "ct-backup-test-")); + const actual = new Map>([["mainz", { name: "Mainz" }]]); + const now = new Date("2026-07-07T14:30:00.000Z"); + const path = await writeBackup(dir, "https://x.church.tools", actual, now); + expect(path).toBe(join(dir, "ct-backup-2026-07-07T14-30-00.000Z.json")); + const parsed = JSON.parse(await readFile(path, "utf8")); + expect(parsed).toEqual({ + host: "https://x.church.tools", + capturedAt: "2026-07-07T14:30:00.000Z", + resources: { mainz: { name: "Mainz" } }, + }); + }); + + it("creates the backup directory if it does not exist", async () => { + const base = await mkdtemp(join(tmpdir(), "ct-backup-test-")); + dir = base; + const nested = join(base, "backups"); + const path = await writeBackup(nested, "h", new Map(), new Date("2026-01-01T00:00:00.000Z")); + expect(await readFile(path, "utf8")).toContain('"resources": {}'); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/backup.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** — create `src/engine/backup.ts`: + +```ts +/** + * Automatic pre-write backup. Before any apply/destroy touches ChurchTools, the + * current *actual* values of the managed resources are dumped to a timestamped + * JSON file, so the affected area can be inspected/restored. + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export async function writeBackup( + dir: string, + host: string, + actual: Map>, + now: Date = new Date(), +): Promise { + await mkdir(dir, { recursive: true }); + const stamp = now.toISOString().replace(/:/g, "-"); + const path = join(dir, `ct-backup-${stamp}.json`); + const payload = { + host, + capturedAt: now.toISOString(), + resources: Object.fromEntries(actual), + }; + await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + return path; +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `npx vitest run tests/backup.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/backup.ts tests/backup.test.ts +git commit -m "feat(engine): pre-write backup writer" +``` + +--- + +## Task 5: Confirmation prompts + +**Files:** +- Create: `src/ui/prompt.ts` +- Test: `tests/prompt.test.ts` + +**Interfaces:** +- Produces: + - `confirm(message: string, opts?: PromptOptions & { assumeYes?: boolean }): Promise` + - `confirmTyped(expected: string, opts?: PromptOptions & { force?: boolean }): Promise` + - `interface PromptOptions { isTTY?: boolean; ask?: (question: string) => Promise }` +- Behaviour: `assumeYes`/`force` short-circuit `true`. Non-TTY without a bypass ⇒ `false`. Otherwise ask; `confirm` accepts `y`/`yes` (case-insensitive); `confirmTyped` requires an exact trimmed match. + +- [ ] **Step 1: Write the failing test** — create `tests/prompt.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { confirm, confirmTyped } from "../src/ui/prompt.js"; + +describe("confirm", () => { + it("short-circuits true with assumeYes", async () => { + expect(await confirm("go?", { assumeYes: true })).toBe(true); + }); + it("returns false on a non-TTY without assumeYes", async () => { + expect(await confirm("go?", { isTTY: false })).toBe(false); + }); + it("accepts y/yes", async () => { + expect(await confirm("go?", { isTTY: true, ask: async () => "y" })).toBe(true); + expect(await confirm("go?", { isTTY: true, ask: async () => "Yes" })).toBe(true); + }); + it("rejects anything else", async () => { + expect(await confirm("go?", { isTTY: true, ask: async () => "" })).toBe(false); + expect(await confirm("go?", { isTTY: true, ask: async () => "n" })).toBe(false); + }); +}); + +describe("confirmTyped", () => { + it("short-circuits true with force", async () => { + expect(await confirmTyped("old_team", { force: true })).toBe(true); + }); + it("requires an exact match", async () => { + expect(await confirmTyped("old_team", { isTTY: true, ask: async () => "old_team" })).toBe(true); + expect(await confirmTyped("old_team", { isTTY: true, ask: async () => "nope" })).toBe(false); + }); + it("returns false on a non-TTY without force", async () => { + expect(await confirmTyped("x", { isTTY: false })).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/prompt.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** — create `src/ui/prompt.ts`: + +```ts +/** + * Interactive confirmation. Dependencies (`isTTY`, `ask`) are injectable so the + * prompts are testable without a real terminal. In production they default to + * the process's TTY state and a readline question on stdin. + */ +import { createInterface } from "node:readline"; + +export interface PromptOptions { + isTTY?: boolean; + ask?: (question: string) => Promise; +} + +function realAsk(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +function ttyState(opts: PromptOptions): boolean { + return opts.isTTY ?? Boolean(process.stdin.isTTY); +} + +/** Yes/No confirmation. `assumeYes` skips the prompt (for `-y`/CI). */ +export async function confirm( + message: string, + opts: PromptOptions & { assumeYes?: boolean } = {}, +): Promise { + if (opts.assumeYes) { + return true; + } + if (!ttyState(opts)) { + return false; + } + const ask = opts.ask ?? realAsk; + const answer = await ask(`${message} [y/N] `); + return /^y(es)?$/i.test(answer.trim()); +} + +/** Require the user to type `expected` exactly. `force` skips the prompt. */ +export async function confirmTyped( + expected: string, + opts: PromptOptions & { force?: boolean } = {}, +): Promise { + if (opts.force) { + return true; + } + if (!ttyState(opts)) { + return false; + } + const ask = opts.ask ?? realAsk; + const answer = await ask(`Type "${expected}" to confirm: `); + return answer.trim() === expected; +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `npx vitest run tests/prompt.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/prompt.ts tests/prompt.test.ts +git commit -m "feat(ui): confirm + confirmTyped prompts" +``` + +--- + +## Task 6: Shared plan building (`buildPlan`) + +**Files:** +- Create: `src/engine/build.ts` +- Modify: `src/commands/plan.ts` +- Test: `tests/build.test.ts` + +**Interfaces:** +- Produces: `buildPlan(client, state, desired): Promise` where + - `client: Pick` + - `interface BuildResult { plan: Plan; actual: Map>; fetchErrors: string[] }` +- It fetches each managed resource's actual fields (concurrently), folds group hierarchy, and returns `computePlan(...)`. Emits the same per-resource `warn`s the old plan command did. + +- [ ] **Step 1: Write the failing test** — create `tests/build.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { buildPlan } from "../src/engine/build.js"; +import { emptyState } from "../src/state/state.js"; +import type { DesiredResource } from "../src/engine/types.js"; + +function fakeClient(byPath: Record) { + return { + get: async (path: string): Promise => { + if (!(path in byPath)) { + const err = new Error(`404 ${path}`) as Error & { status?: number }; + // mimic CtApiError.status so buildPlan's 404 branch triggers + (err as { status?: number }).status = 404; + throw err; + } + return byPath[path] as T; + }, + }; +} + +describe("buildPlan", () => { + it("diffs desired against fetched actual and returns an ordered plan", async () => { + const state = emptyState("https://x.church.tools"); + state.resources.mainz = { + type: "campus", + id: 0, + key: "mainz", + fields: { name: "Mainz", shortName: "MZ" }, + adoptedAt: "t", + updatedAt: "t", + }; + const desired: DesiredResource[] = [ + { type: "campus", key: "mainz", fields: { name: "Mainz City", shortName: "MZ" }, dependsOn: [] }, + ]; + const client = fakeClient({ "/campuses/0": { name: "Mainz", shortName: "MZ" } }); + const { plan, actual, fetchErrors } = await buildPlan(client, state, desired); + expect(fetchErrors).toEqual([]); + expect(actual.get("mainz")).toEqual({ name: "Mainz", shortName: "MZ" }); + const item = plan.items.find((i) => i.key === "mainz")!; + expect(item.action).toBe("update"); + expect(item.changes).toEqual([{ field: "name", from: "Mainz", to: "Mainz City" }]); + }); +}); +``` + +> Note: `buildPlan` treats a thrown error with `status === 404` as "vanished". The real code uses `CtApiError`; this fake sets `.status` so the same branch fires. (Confirm the 404 branch checks `err instanceof CtApiError && err.status === 404` — keep that, and additionally accept a plain `{ status: 404 }` is NOT needed; instead the fake below throws a `CtApiError`.) **Replace the fake's throw with a real `CtApiError`:** + +```ts +import { CtApiError } from "../src/api/ctClient.js"; +// ...in fakeClient, on miss: +throw new CtApiError(`not found`, 404, null); +``` + +(Use the `CtApiError` version in the actual test file; the `.status` note above is just explanation.) + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/build.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** — create `src/engine/build.ts` by lifting the fetch+hierarchy+computePlan logic out of `plan.ts`: + +```ts +/** + * Shared plan building: fetch the actual ChurchTools values of every managed + * resource, fold group hierarchy into a `parents` set-field, and diff against + * the desired config + state. Used by both `ct plan` and `ct apply`, so apply + * fetches exactly once (its `actual` map is reused for the backup). + */ +import type { CtClient } from "../api/ctClient.js"; +import { CtApiError } from "../api/ctClient.js"; +import type { State } from "../state/state.js"; +import type { DesiredResource, Plan } from "./types.js"; +import { RESOURCES } from "../resources/registry.js"; +import { computePlan } from "./plan.js"; +import { parentIdsByGroupId, applyHierarchy, type HierarchyEntry } from "./hierarchy.js"; +import { mapConcurrent } from "../util/concurrency.js"; +import { warn } from "../ui.js"; + +/** How many managed resources to fetch from ChurchTools at once. */ +const FETCH_CONCURRENCY = 8; + +export interface BuildResult { + plan: Plan; + actual: Map>; + fetchErrors: string[]; +} + +export async function buildPlan( + client: Pick, + state: State, + desired: DesiredResource[], +): Promise { + const actual = new Map>(); + const unresolved = new Set(); + const fetchErrors: string[] = []; + + await mapConcurrent(Object.values(state.resources), FETCH_CONCURRENCY, async (managed) => { + const spec = RESOURCES[managed.type]; + if (!spec) { + unresolved.add(managed.key); + warn( + `No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`, + ); + return; + } + try { + const raw = await client.get>(spec.itemPath(managed.id)); + actual.set(managed.key, spec.managedFields(raw)); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) { + return; + } + const message = err instanceof Error ? err.message : String(err); + fetchErrors.push(`${managed.type}.${managed.key} (#${managed.id}): ${message}`); + warn(`Failed to fetch ${managed.type}.${managed.key} (#${managed.id}): ${message}`); + } + }); + + let parentIds = new Map(); + const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); + if (hasManagedGroups) { + try { + const raw = await client.get("/groups/hierarchies"); + parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + fetchErrors.push(`group hierarchies: ${message}`); + warn(`Failed to fetch group hierarchies: ${message}`); + } + } + const desiredWithHierarchy = applyHierarchy(desired, state, actual, parentIds); + const plan = computePlan(desiredWithHierarchy, state, actual, { unresolved }); + return { plan, actual, fetchErrors }; +} +``` + +Now rewrite `src/commands/plan.ts` to use it (replace the whole action body's fetch/compute section). The new `plan.ts`: + +```ts +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import { resolveConfig } from "../config.js"; +import { loadState, resolveStatePath } from "../state/state.js"; +import { loadConfig, resolveConfigPath } from "../config/load.js"; +import { buildPlan } from "../engine/build.js"; +import { renderPlan } from "../engine/render.js"; +import { info, warn, out } from "../ui.js"; + +interface PlanOptions { + config?: string; + state?: string; + json?: boolean; +} + +export function planCommand(): Command { + return new Command("plan") + .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("--json", "emit the raw plan as JSON instead of the rendered diff") + .action(async (opts: PlanOptions) => { + const config = resolveConfig(); + const configPath = resolveConfigPath(opts.config); + const desired = await loadConfig(configPath); + const state = await loadState(resolveStatePath(opts.state), config.host); + if (state.host !== config.host) { + throw new Error(`State host (${state.host}) does not match CT_HOST (${config.host}).`); + } + + const { client } = await authedSession(); + const { plan, fetchErrors } = await buildPlan(client, state, desired); + if (opts.json) { + out(plan); + } else { + info(`config: ${configPath} · state host: ${state.host}`); + process.stdout.write(`${renderPlan(plan)}\n`); + } + if (fetchErrors.length > 0) { + warn( + `Plan is INCOMPLETE — ${fetchErrors.length} resource(s) could not be fetched; their diff is missing. Re-run to retry.`, + ); + process.exitCode = 1; + } + }); +} +``` + +- [ ] **Step 4: Run the whole suite, expect pass** (plan behaviour is unchanged; existing tests still pass) + +Run: `npx vitest run tests/build.test.ts tests/plan.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/build.ts src/commands/plan.ts tests/build.test.ts +git commit -m "refactor(engine): extract buildPlan; plan command reuses it" +``` + +--- + +## Task 7: The executor (`executePlan`) + +**Files:** +- Create: `src/engine/execute.ts` +- Test: `tests/execute.test.ts` + +**Interfaces:** +- Consumes: `RESOURCES` (Task 2), `assertNotPeople` (Task 3), `upsert`/`saveState` from `state.ts`, `Plan`/`PlanItem`/`FieldChange` from `types.ts`. +- Produces: + - `interface ExecuteDeps { client: Pick; state: State; statePath: string; now?: () => string; save?: (path: string, state: State) => Promise }` + - `interface ExecuteResult { created: string[]; updated: string[]; skippedDeletes: string[]; failed?: { key: string; message: string } }` + - `executePlan(plan: Plan, deps: ExecuteDeps): Promise` +- Behaviour: walks `plan.items` in order. `delete` → record in `skippedDeletes` and skip (apply never deletes). `no-op` → skip. `create` → POST collection, capture `id`, upsert+save, then apply parent edges. `update` → PUT/PATCH item (only if a non-`parents` field changed), refresh snapshot, save, then apply parent edges. Group `parents` are reconciled via `PUT`/`DELETE /groups/{id}/parents/{parentId}` (keys resolved to ids via state). Stops on first error, returning `failed`. + +- [ ] **Step 1: Write the failing test** — create `tests/execute.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { executePlan } from "../src/engine/execute.js"; +import { emptyState, type State } from "../src/state/state.js"; +import type { Plan } from "../src/engine/types.js"; + +interface Call { + method: string; + path: string; + body?: unknown; +} + +function recorder(responses: Record = {}) { + const calls: Call[] = []; + const client = { + request: async (method: string, path: string, body?: unknown): Promise => { + calls.push({ method, path, body }); + const key = `${method} ${path}`; + return (responses[key] ?? {}) as T; + }, + }; + return { client, calls }; +} + +const noSave = async (_p: string, _s: State) => {}; +const fixedNow = () => "2026-07-07T00:00:00.000Z"; + +describe("executePlan", () => { + it("creates a resource, captures its id, and records it in state", async () => { + const state = emptyState("h"); + const { client, calls } = recorder({ "POST /campuses": { id: 5 } }); + const plan: Plan = { + items: [ + { + type: "campus", + key: "zurich", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Zürich" }, + { field: "shortName", from: undefined, to: "ZH" }, + ], + }, + ], + }; + const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + expect(result.created).toEqual(["zurich"]); + expect(calls[0]).toEqual({ method: "POST", path: "/campuses", body: { name: "Zürich", shortName: "ZH" } }); + expect(state.resources.zurich).toMatchObject({ type: "campus", id: 5, key: "zurich", fields: { name: "Zürich", shortName: "ZH" } }); + }); + + it("updates a group via PATCH with the full managed snapshot", async () => { + const state = emptyState("h"); + state.resources.team = { type: "group", id: 9, key: "team", fields: { name: "Team", groupTypeId: 2, groupStatusId: 1 }, adoptedAt: "t", updatedAt: "t" }; + const { client, calls } = recorder(); + const plan: Plan = { + items: [ + { type: "group", key: "team", id: 9, action: "update", changes: [{ field: "name", from: "Team", to: "Team A" }] }, + ], + }; + const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + expect(result.updated).toEqual(["team"]); + expect(calls[0]).toEqual({ method: "PATCH", path: "/groups/9", body: { name: "Team A", groupTypeId: 2, groupStatusId: 1 } }); + expect(state.resources.team!.fields).toEqual({ name: "Team A", groupTypeId: 2, groupStatusId: 1 }); + }); + + it("reconciles hierarchy edges via PUT/DELETE and never stores parents in state", async () => { + const state = emptyState("h"); + state.resources.parent = { type: "group", id: 1, key: "parent", fields: { name: "P" }, adoptedAt: "t", updatedAt: "t" }; + state.resources.child = { type: "group", id: 2, key: "child", fields: { name: "C" }, adoptedAt: "t", updatedAt: "t" }; + const { client, calls } = recorder(); + const plan: Plan = { + items: [ + { type: "group", key: "child", id: 2, action: "update", changes: [{ field: "parents", from: [], to: ["parent"] }] }, + ], + }; + await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + expect(calls).toEqual([{ method: "PUT", path: "/groups/2/parents/1", body: undefined }]); + expect(state.resources.child!.fields.parents).toBeUndefined(); + }); + + it("skips deletes (apply never deletes)", async () => { + const state = emptyState("h"); + state.resources.old = { type: "campus", id: 3, key: "old", fields: {}, adoptedAt: "t", updatedAt: "t" }; + const { client, calls } = recorder(); + const plan: Plan = { items: [{ type: "campus", key: "old", id: 3, action: "delete", changes: [] }] }; + const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + expect(result.skippedDeletes).toEqual(["old"]); + expect(calls).toEqual([]); + expect(state.resources.old).toBeDefined(); + }); + + it("stops on the first write error and reports it", async () => { + const state = emptyState("h"); + const client = { + request: async () => { + throw new Error("boom"); + }, + }; + const plan: Plan = { + items: [{ type: "campus", key: "zurich", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Z" }] }], + }; + const result = await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + expect(result.failed).toEqual({ key: "zurich", message: "boom" }); + expect(result.created).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/execute.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** — create `src/engine/execute.ts`: + +```ts +/** + * The executor: walk a computed plan and make it real. Field-agnostic — every + * resource's write path/verb comes from the registry, so adding a type never + * touches this file. State is saved after each successful action, so a crash + * mid-apply leaves a consistent, resumable state file. + * + * apply NEVER deletes: delete items are recorded and skipped. Group hierarchy is + * reconciled through the parents endpoints, not the group body. + */ +import type { CtClient } from "../api/ctClient.js"; +import type { State } from "../state/state.js"; +import { upsert, saveState } from "../state/state.js"; +import type { FieldChange, Plan } from "./types.js"; +import { RESOURCES } from "../resources/registry.js"; +import { assertNotPeople } from "./guard.js"; + +export interface ExecuteDeps { + client: Pick; + state: State; + statePath: string; + now?: () => string; + save?: (path: string, state: State) => Promise; +} + +export interface ExecuteResult { + created: string[]; + updated: string[]; + skippedDeletes: string[]; + failed?: { key: string; message: string }; +} + +/** The managed field snapshot after a write: base ∪ changed fields, minus the hierarchy `parents` set-field. */ +function snapshotFromChanges(base: Record, changes: FieldChange[]): Record { + const snap = { ...base }; + for (const c of changes) { + if (c.field !== "parents") { + snap[c.field] = c.to; + } + } + delete snap.parents; + return snap; +} + +function parentEdges(from: unknown, to: unknown): { added: string[]; removed: string[] } { + const f = new Set(Array.isArray(from) ? (from as string[]) : []); + const t = new Set(Array.isArray(to) ? (to as string[]) : []); + return { + added: [...t].filter((k) => !f.has(k)), + removed: [...f].filter((k) => !t.has(k)), + }; +} + +function resolveId(state: State, key: string): number { + const managed = state.resources[key]; + if (!managed) { + throw new Error(`Cannot resolve parent "${key}" — not under management yet.`); + } + return managed.id; +} + +async function applyParentEdges( + client: Pick, + state: State, + childId: number, + changes: FieldChange[], +): Promise { + const change = changes.find((c) => c.field === "parents"); + if (!change) { + return; + } + const { added, removed } = parentEdges(change.from, change.to); + for (const key of added) { + const path = `/groups/${childId}/parents/${resolveId(state, key)}`; + assertNotPeople(path); + await client.request("PUT", path); + } + for (const key of removed) { + const path = `/groups/${childId}/parents/${resolveId(state, key)}`; + assertNotPeople(path); + await client.request("DELETE", path); + } +} + +export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise { + const { client, state, statePath } = deps; + const now = deps.now ?? (() => new Date().toISOString()); + const save = deps.save ?? saveState; + const created: string[] = []; + const updated: string[] = []; + const skippedDeletes: string[] = []; + + for (const item of plan.items) { + if (item.action === "delete") { + skippedDeletes.push(item.key); + continue; + } + if (item.action === "no-op") { + continue; + } + const spec = RESOURCES[item.type]; + if (!spec) { + return { created, updated, skippedDeletes, failed: { key: item.key, message: `No write spec for type "${item.type}".` } }; + } + + try { + if (item.action === "create") { + const body = snapshotFromChanges({}, item.changes); + assertNotPeople(spec.collectionPath); + const res = await client.request<{ id: number }>("POST", spec.collectionPath, body); + if (typeof res.id !== "number") { + throw new Error(`create returned no numeric id (got ${JSON.stringify(res.id)})`); + } + upsert(state, { type: item.type, id: res.id, key: item.key, fields: body }, now()); + await save(statePath, state); + await applyParentEdges(client, state, res.id, item.changes); + created.push(item.key); + } else { + const id = item.id; + if (id === null) { + throw new Error("update item has no id"); + } + const base = state.resources[item.key]?.fields ?? {}; + const snapshot = snapshotFromChanges(base, item.changes); + const hasFieldChange = item.changes.some((c) => c.field !== "parents"); + if (hasFieldChange) { + const path = spec.itemPath(id); + assertNotPeople(path); + await client.request(spec.updateMethod, path, snapshot); + } + upsert(state, { type: item.type, id, key: item.key, fields: snapshot }, now()); + await save(statePath, state); + await applyParentEdges(client, state, id, item.changes); + updated.push(item.key); + } + } catch (err) { + return { created, updated, skippedDeletes, failed: { key: item.key, message: err instanceof Error ? err.message : String(err) } }; + } + } + + return { created, updated, skippedDeletes }; +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `npx vitest run tests/execute.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/execute.ts tests/execute.test.ts +git commit -m "feat(engine): executePlan — idempotent create/update + hierarchy edges" +``` + +--- + +## Task 8: `ct apply` command + +**Files:** +- Create: `src/commands/apply.ts` +- Test: `tests/apply.test.ts` (backup-dir resolution unit) + +**Interfaces:** +- Consumes: `buildPlan` (Task 6), `executePlan` (Task 7), `writeBackup` (Task 4), `confirm` (Task 5), `renderPlan`, `summarize`. +- Produces: `applyCommand(): Command`; exported helper `resolveBackupDir(explicit, statePath, env?): string` for testing. + +- [ ] **Step 1: Write the failing test** — create `tests/apply.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { resolveBackupDir } from "../src/commands/apply.js"; + +describe("resolveBackupDir", () => { + it("prefers the explicit flag", () => { + expect(resolveBackupDir("./out", "cfg/ct-state.json", {})).toBe("./out"); + }); + it("falls back to CT_BACKUP_DIR", () => { + expect(resolveBackupDir(undefined, "cfg/ct-state.json", { CT_BACKUP_DIR: "/b" })).toBe("/b"); + }); + it("defaults to a backups/ dir beside the state file", () => { + expect(resolveBackupDir(undefined, "cfg/ct-state.json", {})).toBe("cfg/backups"); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/apply.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** — create `src/commands/apply.ts`: + +```ts +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 { loadConfig, resolveConfigPath } from "../config/load.js"; +import { buildPlan } from "../engine/build.js"; +import { executePlan } from "../engine/execute.js"; +import { writeBackup } from "../engine/backup.js"; +import { renderPlan } from "../engine/render.js"; +import { summarize } from "../engine/types.js"; +import { confirm } from "../ui/prompt.js"; +import { info, warn, success, error } from "../ui.js"; + +interface ApplyOptions { + config?: string; + state?: string; + backupDir?: string; + autoApprove?: boolean; +} + +/** backups/ dir: explicit flag → CT_BACKUP_DIR → `backups/` beside the state file. */ +export function resolveBackupDir( + explicit: string | undefined, + statePath: string, + env: NodeJS.ProcessEnv = process.env, +): string { + return explicit?.trim() || env.CT_BACKUP_DIR?.trim() || join(dirname(statePath), "backups"); +} + +export function applyCommand(): Command { + return new Command("apply") + .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("--backup-dir ", "directory for the pre-apply backup (or set CT_BACKUP_DIR)") + .option("-y, --auto-approve", "skip the confirmation prompt") + .action(async (opts: ApplyOptions) => { + const config = resolveConfig(); + const configPath = resolveConfigPath(opts.config); + const statePath = resolveStatePath(opts.state); + const desired = await loadConfig(configPath); + const state = await loadState(statePath, config.host); + + const { client } = await authedSession(); + const { plan, actual, fetchErrors } = await buildPlan(client, state, desired); + + if (fetchErrors.length > 0) { + error(`Aborting: ${fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`); + process.exitCode = 1; + return; + } + + process.stdout.write(`${renderPlan(plan)}\n`); + + const deletes = plan.items.filter((i) => i.action === "delete"); + if (deletes.length > 0) { + warn(`${deletes.length} resource(s) dropped from config will NOT be deleted by apply:`); + for (const d of deletes) { + info(` ${d.type}.${d.key} (#${d.id}) — run: ct destroy --target ${d.key}`); + } + } + + const s = summarize(plan); + const changeCount = s.create + s.update; + if (changeCount === 0) { + success("No changes to apply."); + return; + } + + const ok = await confirm(`Apply ${changeCount} change(s)?`, { assumeYes: opts.autoApprove }); + if (!ok) { + warn("Aborted — no changes made."); + process.exitCode = 1; + return; + } + + const backupPath = await writeBackup(resolveBackupDir(opts.backupDir, statePath), config.host, actual); + info(`Backup written: ${backupPath}`); + + const result = await executePlan(plan, { client, state, statePath, save: saveState }); + success(`Applied: ${result.created.length} created, ${result.updated.length} updated.`); + if (result.failed) { + error(`Stopped at ${result.failed.key}: ${result.failed.message}. State saved up to this point — re-run to resume.`); + process.exitCode = 1; + } + }); +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `npx vitest run tests/apply.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/apply.ts tests/apply.test.ts +git commit -m "feat(cli): ct apply — plan, confirm, backup, execute" +``` + +--- + +## Task 9: `ct destroy` command + +**Files:** +- Create: `src/commands/destroy.ts` +- Test: `tests/destroy.test.ts` (target parsing + reverse ordering + preventDestroy set) + +**Interfaces:** +- Consumes: `RESOURCES`, `assertNotPeople`, `confirmTyped`, `writeBackup`, `tierOf` from `engine/graph.js`, `saveState`. +- Produces: `destroyCommand(): Command`; exported helpers `parseTargets(raw: string[]): string[]` and `orderDestroy(state, keys): string[]`. + +- [ ] **Step 1: Write the failing test** — create `tests/destroy.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { parseTargets, orderDestroy } from "../src/commands/destroy.js"; +import { emptyState } from "../src/state/state.js"; + +describe("parseTargets", () => { + it("splits commas, trims, and dedupes", () => { + expect(parseTargets(["a,b", " c ", "a"])).toEqual(["a", "b", "c"]); + }); +}); + +describe("orderDestroy", () => { + it("orders higher tiers first (reverse of apply): groups before campuses", () => { + const state = emptyState("h"); + state.resources.mainz = { type: "campus", id: 0, key: "mainz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + state.resources.team = { type: "group", id: 1, key: "team", fields: {}, adoptedAt: "t", updatedAt: "t" }; + expect(orderDestroy(state, ["mainz", "team"])).toEqual(["team", "mainz"]); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npx vitest run tests/destroy.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** — create `src/commands/destroy.ts`: + +```ts +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import { CtApiError } from "../api/ctClient.js"; +import { resolveConfig } from "../config.js"; +import { loadState, resolveStatePath, saveState, type State } from "../state/state.js"; +import { loadConfig, resolveConfigPath } from "../config/load.js"; +import { RESOURCES } from "../resources/registry.js"; +import { assertNotPeople } from "../engine/guard.js"; +import { tierOf } from "../engine/graph.js"; +import { writeBackup } from "../engine/backup.js"; +import { resolveBackupDir } from "./apply.js"; +import { confirmTyped } from "../ui/prompt.js"; +import { info, warn, success, error } from "../ui.js"; + +interface DestroyOptions { + target?: string[]; + config?: string; + state?: string; + backupDir?: string; + force?: boolean; +} + +/** Flatten repeated/comma-separated `--target` values into a deduped key list. */ +export function parseTargets(raw: string[]): string[] { + const out: string[] = []; + for (const chunk of raw) { + for (const part of chunk.split(",")) { + const key = part.trim(); + if (key && !out.includes(key)) { + out.push(key); + } + } + } + return out; +} + +/** Reverse dependency order: highest tier first (children/leaves before their base metadata). */ +export function orderDestroy(state: State, keys: string[]): string[] { + return [...keys].sort((a, b) => tierOf(state.resources[b]!.type) - tierOf(state.resources[a]!.type)); +} + +export function destroyCommand(): Command { + return new Command("destroy") + .description("Explicitly delete managed resources (protected; never implicit)") + .requiredOption("--target ", "logical key(s) to destroy (repeatable or comma-separated)") + .option("-c, --config ", "config file (or set CT_CONFIG)") + .option("-s, --state ", "state file (or set CT_STATE)") + .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) => { + const targets = parseTargets(opts.target ?? []); + if (targets.length === 0) { + throw new Error("No --target given. Destroy never deletes implicitly."); + } + + const config = resolveConfig(); + const statePath = resolveStatePath(opts.state); + const state = await loadState(statePath, config.host); + + for (const key of targets) { + if (!state.resources[key]) { + throw new Error(`"${key}" is not managed (not in the state file). Nothing to destroy.`); + } + } + + // preventDestroy guard: a target still declared with the flag is blocked. + const desired = await loadConfig(resolveConfigPath(opts.config)); + const protectedKeys = new Set(desired.filter((d) => d.preventDestroy).map((d) => d.key)); + const blocked = targets.filter((k) => protectedKeys.has(k)); + if (blocked.length > 0) { + throw new Error(`preventDestroy is set for: ${blocked.join(", ")}. Remove the flag in config first.`); + } + + const ordered = orderDestroy(state, targets); + const { client } = await authedSession(); + + // Backup: fetch each target's current actual values (best-effort; 404 → empty). + const actual = new Map>(); + for (const key of ordered) { + const managed = state.resources[key]!; + const spec = RESOURCES[managed.type]; + if (!spec) { + continue; + } + try { + const raw = await client.get>(spec.itemPath(managed.id)); + actual.set(key, spec.managedFields(raw)); + } catch (err) { + if (!(err instanceof CtApiError && err.status === 404)) { + throw err; + } + } + } + const backupPath = await writeBackup(resolveBackupDir(opts.backupDir, statePath), config.host, actual); + info(`Backup written: ${backupPath}`); + + warn(`About to DELETE: ${ordered.join(", ")}`); + const expected = targets.length === 1 ? targets[0]! : "destroy"; + const ok = await confirmTyped(expected, { force: opts.force }); + if (!ok) { + warn("Aborted — nothing deleted."); + process.exitCode = 1; + return; + } + + for (const key of ordered) { + const managed = state.resources[key]!; + const spec = RESOURCES[managed.type]; + if (!spec) { + error(`No write spec for type "${managed.type}" — skipping ${key}.`); + continue; + } + const path = spec.itemPath(managed.id); + assertNotPeople(path); + await client.request("DELETE", path); + delete state.resources[key]; + await saveState(statePath, state); + success(`Destroyed ${managed.type}.${key} (#${managed.id})`); + } + }); +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `npx vitest run tests/destroy.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/destroy.ts tests/destroy.test.ts +git commit -m "feat(cli): ct destroy — explicit, protected, reverse-order deletion" +``` + +--- + +## Task 10: Wire commands + integration test + cleanup + +**Files:** +- Modify: `src/index.ts` +- Modify: `src/commands/placeholders.ts` +- Test: `tests/cli.test.ts`, `tests/integration.test.ts` + +**Interfaces:** +- Consumes: `applyCommand`, `destroyCommand`. +- Produces: `apply`/`destroy` registered on the program; `placeholders.ts` no longer lists them. + +- [ ] **Step 1: Write the failing tests.** Append to `tests/cli.test.ts` a check that `apply`/`destroy` are real (have their options, not the "not implemented" stub). First inspect the existing `tests/cli.test.ts` to match its harness, then add: + +```ts +import { describe as dCli, it as iCli, expect as eCli } from "vitest"; +import { buildProgram } from "../src/index.js"; + +dCli("apply/destroy are wired", () => { + iCli("registers apply with --auto-approve", () => { + const cmd = buildProgram().commands.find((c) => c.name() === "apply")!; + eCli(cmd.options.some((o) => o.long === "--auto-approve")).toBe(true); + }); + iCli("registers destroy with required --target", () => { + const cmd = buildProgram().commands.find((c) => c.name() === "destroy")!; + eCli(cmd.options.some((o) => o.long === "--target")).toBe(true); + }); +}); +``` + +Create `tests/integration.test.ts` — the DoD "apply then re-plan shows no drift" against a stateful fake client: + +```ts +import { describe, it, expect } from "vitest"; +import { buildPlan } from "../src/engine/build.js"; +import { executePlan } from "../src/engine/execute.js"; +import { emptyState, type State } from "../src/state/state.js"; +import { CtApiError } from "../src/api/ctClient.js"; +import type { DesiredResource } from "../src/engine/types.js"; + +/** A tiny in-memory ChurchTools: campuses store, supports GET/POST/PUT and hierarchy is empty. */ +function fakeCt() { + const campuses = new Map>([[0, { id: 0, name: "Mainz", shortName: "MZ" }]]); + let nextId = 1; + return { + campuses, + get: async (path: string): Promise => { + const m = /^\/campuses\/(\d+)$/.exec(path); + if (m) { + const c = campuses.get(Number(m[1])); + if (!c) throw new CtApiError("nf", 404, null); + return c as T; + } + if (path === "/groups/hierarchies") return [] as T; + throw new CtApiError("nf", 404, null); + }, + request: async (method: string, path: string, body?: Record): Promise => { + if (method === "POST" && path === "/campuses") { + const id = nextId++; + campuses.set(id, { id, ...body }); + return { id } as T; + } + const m = /^\/campuses\/(\d+)$/.exec(path); + if (method === "PUT" && m) { + campuses.set(Number(m[1]), { id: Number(m[1]), ...body }); + return {} as T; + } + throw new Error(`unexpected ${method} ${path}`); + }, + }; +} + +const noSave = async (_p: string, _s: State) => {}; + +describe("apply → re-plan shows no drift", () => { + it("creates a new campus and updates an adopted one, then plans clean", async () => { + const client = fakeCt(); + const state = emptyState("h"); + // adopt the existing Mainz campus + state.resources.mz = { type: "campus", id: 0, key: "mz", fields: { name: "Mainz", shortName: "MZ" }, adoptedAt: "t", updatedAt: "t" }; + + const desired: DesiredResource[] = [ + { type: "campus", key: "mz", fields: { name: "Mainz City", shortName: "MZ" }, dependsOn: [] }, + { type: "campus", key: "zh", fields: { name: "Zürich", shortName: "ZH" }, dependsOn: [] }, + ]; + + const first = await buildPlan(client, state, desired); + expect(first.plan.items.filter((i) => i.action !== "no-op").length).toBe(2); // 1 update + 1 create + + const result = await executePlan(first.plan, { client, state, statePath: "s", save: noSave }); + expect(result.failed).toBeUndefined(); + expect(result.created).toEqual(["zh"]); + expect(result.updated).toEqual(["mz"]); + + const second = await buildPlan(client, state, desired); + expect(second.plan.items.every((i) => i.action === "no-op")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail** + +Run: `npx vitest run tests/cli.test.ts tests/integration.test.ts` +Expected: FAIL — apply/destroy are still stubs; integration import chain works but assertions on wiring fail. + +- [ ] **Step 3: Implement.** In `src/commands/placeholders.ts`, empty the `PLANNED` array (both verbs now exist): + +```ts +const PLANNED: Planned[] = []; +``` + +In `src/index.ts`, import and register the real commands: + +```ts +import { applyCommand } from "./commands/apply.js"; +import { destroyCommand } from "./commands/destroy.js"; +``` + +and, after `program.addCommand(planCommand());`: + +```ts + program.addCommand(applyCommand()); + program.addCommand(destroyCommand()); +``` + +(The `for (const cmd of plannedCommands())` loop stays — it just adds nothing now.) + +- [ ] **Step 4: Run the whole suite, expect pass** + +Run: `npx vitest run` +Expected: PASS (all suites). + +- [ ] **Step 5: Commit** + +```bash +git add src/index.ts src/commands/placeholders.ts tests/cli.test.ts tests/integration.test.ts +git commit -m "feat(cli): wire apply/destroy; drop placeholders; DoD integration test" +``` + +--- + +## Task 11: Docs, live-verify field sets, lint/typecheck/build + +**Files:** +- Modify: `README.md` +- Modify: `src/resources/registry.ts` (only if live-verify shows different fields) + +- [ ] **Step 1: Typecheck, lint, build, full test.** + +Run: `npx tsc --noEmit && npx eslint . && npx vitest run && npm run build` +Expected: all clean. Fix any issues. + +- [ ] **Step 2: Live-verify the new type field sets** (requires a logged-in session against the test instance). For each new type, fetch one and confirm the managed fields exist and round-trip: + +Run: `node dist/index.js get group/agegroups 2>/dev/null || node dist/index.js get age-group ` +(Use whatever `ct get` supports — inspect `src/commands/get.ts` first.) Adjust `managedFields`/`collectionPath` in `src/resources/registry.ts` and its test if the real payload differs. Re-run `npx vitest run tests/registry.test.ts`. + +- [ ] **Step 3: Live-verify the DoD end-to-end** on the test instance: adopt a campus, edit its name in a config, `ct plan` (see the `~` diff), `ct apply` (confirm, backup written), `ct plan` again (no changes). Then test destroy-protection: set `preventDestroy` and confirm `ct destroy --target ` refuses. + +- [ ] **Step 4: Update `README.md`** — add `apply` and `destroy` to the command list with the guardrails (confirmation, backup, never deletes on apply, `preventDestroy`, `--target`). Match the existing README section style. + +- [ ] **Step 5: Commit** + +```bash +git add README.md src/resources/registry.ts tests/registry.test.ts +git commit -m "docs(phase-4): README apply/destroy; live-verified field sets" +``` + +--- + +## Self-review notes + +- **Spec coverage:** apply (T6–T8), destroy + preventDestroy (T1, T9), backup (T4, T8, T9), people boundary (T3, used in T7/T9), rate-limit/retry (existing — noted, no task), state-after-each (T7 saves; T9 saves), confirmation (T5, T8, T9), all writable types (T2), DoD integration (T10), live-verify (T11). No gaps. +- **Type consistency:** `ExecuteDeps`/`ExecuteResult`/`BuildResult`/`PromptOptions` names are used identically across tasks. `snapshotFromChanges` is the single field-merge helper. `resolveBackupDir` defined in T8, reused by T9. +- **Placeholder scan:** none — every code step is complete. The only deferred item is the *provisional field sets*, which are explicitly a live-verify step (T11) with a concrete verification command, not a plan gap. +- **Known limitation (documented):** `orderDestroy` uses reverse-tier order only (state doesn't store hierarchy edges), so deleting a parent group before its child within the same tier isn't auto-ordered; acceptable for Phase 4 and noted. From ac2b51e35b7d2f8f4b2e296cf2b7c6ee306094b5 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 16:07:02 +0200 Subject: [PATCH 03/16] feat(config): preventDestroy lifecycle flag --- src/config/context.ts | 6 ++++-- src/engine/types.ts | 2 ++ tests/context.test.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/config/context.ts b/src/config/context.ts index 57fd1c6..a2a1146 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -24,6 +24,8 @@ export interface ResourceInput { */ parents?: string[]; dependsOn?: string[]; + /** Block `ct destroy` for this resource until the flag is removed. */ + preventDestroy?: boolean; [field: string]: unknown; } @@ -39,7 +41,7 @@ export interface ConfigContext { export type ConfigModule = (ct: ConfigContext) => void | Promise; function toDesired(type: string, input: ResourceInput): DesiredResource { - const { key, parent, parents, dependsOn = [], ...fields } = input; + const { key, parent, parents, dependsOn = [], preventDestroy, ...fields } = input; if (!key || typeof key !== "string") { throw new Error(`${type} declaration is missing a string "key".`); } @@ -56,7 +58,7 @@ function toDesired(type: string, input: ResourceInput): DesiredResource { const parentKey = typeof parent === "string" && parent !== "" ? parent : undefined; const parentKeys = parents !== undefined ? [...new Set(parents)] : undefined; const edges = [...new Set([...dependsOn, ...(parentKey ? [parentKey] : []), ...(parentKeys ?? [])])]; - return { type, key, fields, parent: parentKey, parents: parentKeys, dependsOn: edges }; + return { type, key, fields, parent: parentKey, parents: parentKeys, dependsOn: edges, preventDestroy }; } /** diff --git a/src/engine/types.ts b/src/engine/types.ts index cfc1fe6..d7baeb1 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -14,6 +14,8 @@ export interface DesiredResource { parents?: string[]; /** Logical keys this resource must be applied after (includes `parent`/`parents`). */ dependsOn: string[]; + /** Lifecycle flag: block `ct destroy` for this resource. Never diffed or sent to the API. */ + preventDestroy?: boolean; } export type PlanAction = "create" | "update" | "delete" | "no-op"; diff --git a/tests/context.test.ts b/tests/context.test.ts index bf5ab10..36a67de 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -122,3 +122,21 @@ describe("config context", () => { expect(resources.map((r) => r.key)).toEqual(["mainz", "mainz_kids", "berlin", "berlin_kids"]); }); }); + +describe("preventDestroy lifecycle flag", () => { + it("is carried on the resource but kept out of managed fields", async () => { + const resources = await evaluateConfig((ct) => { + ct.group({ key: "kids_lead", name: "Kids Leitung", preventDestroy: true }); + }); + const group = resources.find((r) => r.key === "kids_lead")!; + expect(group.preventDestroy).toBe(true); + expect(group.fields).toEqual({ name: "Kids Leitung" }); + }); + + it("defaults to undefined when not declared", async () => { + const resources = await evaluateConfig((ct) => { + ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + }); + expect(resources[0]!.preventDestroy).toBeUndefined(); + }); +}); From 2820449da6ae6f98bc524b2142d4b39483239d67 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 16:08:17 +0200 Subject: [PATCH 04/16] feat(registry): write specs (collectionPath, updateMethod) + new writable types --- src/resources/registry.ts | 61 +++++++++++++++++++++++++++++++-------- tests/registry.test.ts | 20 +++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/resources/registry.ts b/src/resources/registry.ts index d7289e2..42d3e79 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -8,16 +8,22 @@ */ export interface AdoptableResource { - /** GET path for a single resource by id. */ + /** Collection path: `POST` here creates. */ + collectionPath: string; + /** GET/PUT/PATCH/DELETE path for a single resource by id. */ itemPath: (id: number) => string; + /** Update verb: `group` is PATCH; every other type is PUT. */ + updateMethod: "PUT" | "PATCH"; /** Stable logical key derived from the fetched resource. */ deriveKey: (resource: Record) => string; /** The subset of fields we manage — the desired-state baseline. */ managedFields: (resource: Record) => Record; } -/** Build an `itemPath` from a collection path, so each entry names its path once. */ -const item = (collectionPath: string) => (id: number) => `${collectionPath}/${id}`; +/** Build a full spec, deriving `itemPath` from the collection path so each entry names its path once. */ +function define(spec: Omit): AdoptableResource { + return { ...spec, itemPath: (id: number) => `${spec.collectionPath}/${id}` }; +} /** * kebab/underscore slug: "Kids Leitung" → "kids_leitung", "Zürich" → "zurich". @@ -45,25 +51,56 @@ function fromInformation(resource: Record, key: string): unknow } export const RESOURCES: Record = { - campus: { - itemPath: item("/campuses"), + campus: define({ + collectionPath: "/campuses", + updateMethod: "PUT", deriveKey: (r) => slug(str(r, "shortName") || str(r, "name")), managedFields: (r) => ({ name: r.name, shortName: r.shortName }), - }, - group: { - itemPath: item("/groups"), + }), + group: define({ + collectionPath: "/groups", + updateMethod: "PATCH", deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, groupTypeId: fromInformation(r, "groupTypeId"), groupStatusId: fromInformation(r, "groupStatusId"), }), - }, - "group-type": { - itemPath: item("/group/grouptypes"), + }), + "group-type": define({ + collectionPath: "/group/grouptypes", + updateMethod: "PUT", deriveKey: (r) => slug(str(r, "name")), managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated }), - }, + }), + "age-group": define({ + collectionPath: "/group/agegroups", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ name: r.name, sortKey: r.sortKey }), + }), + "target-group": define({ + collectionPath: "/group/targetgroups", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ name: r.name, sortKey: r.sortKey }), + }), + "relationship-type": define({ + collectionPath: "/person/relationshiptypes", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ + name: r.name, + degreeForward: r.degreeForward, + degreeReverse: r.degreeReverse, + }), + }), + "group-role": define({ + collectionPath: "/group/roles", + updateMethod: "PUT", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ name: r.name, groupTypeId: r.groupTypeId }), + }), }; export function resourceType(type: string): AdoptableResource { diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 6371e81..c4f6311 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -63,3 +63,23 @@ describe("configSnippet", () => { ); }); }); + +describe("write specs", () => { + it("campus creates via POST /campuses and updates via PUT", () => { + expect(RESOURCES.campus?.collectionPath).toBe("/campuses"); + expect(RESOURCES.campus?.updateMethod).toBe("PUT"); + }); + + it("group updates via PATCH", () => { + expect(RESOURCES.group?.collectionPath).toBe("/groups"); + expect(RESOURCES.group?.updateMethod).toBe("PATCH"); + }); + + it("registers the new writable types with their collection + item paths", () => { + expect(RESOURCES["age-group"]?.collectionPath).toBe("/group/agegroups"); + expect(RESOURCES["target-group"]?.collectionPath).toBe("/group/targetgroups"); + expect(RESOURCES["relationship-type"]?.collectionPath).toBe("/person/relationshiptypes"); + expect(RESOURCES["group-role"]?.collectionPath).toBe("/group/roles"); + expect(RESOURCES["age-group"]?.itemPath(3)).toBe("/group/agegroups/3"); + }); +}); From c198d49f45fd3cbf80e7e691c142b001cb9fa1b7 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 16:08:40 +0200 Subject: [PATCH 05/16] feat(engine): assertNotPeople hard boundary guard --- src/engine/guard.ts | 22 ++++++++++++++++++++++ tests/guard.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/engine/guard.ts create mode 100644 tests/guard.test.ts diff --git a/src/engine/guard.ts b/src/engine/guard.ts new file mode 100644 index 0000000..e6c0938 --- /dev/null +++ b/src/engine/guard.ts @@ -0,0 +1,22 @@ +/** + * Hard people boundary. The CLI manages the *structure* only — people and + * memberships are never touched. This is belt-and-suspenders atop the + * structural-only registry (the primary allowlist): every write in apply/destroy + * passes its path through here first. + * + * Note `/person/relationshiptypes` is master data (relationship *types*), not a + * person — the denylist matches `/persons` (plural), not `/person`. + */ +const FORBIDDEN: RegExp[] = [ + /^\/persons(\/|$)/, + /^\/memberships(\/|$)/, + /\/groups\/\d+\/members(hips)?(\/|$)/, +]; + +export function assertNotPeople(path: string): void { + if (FORBIDDEN.some((re) => re.test(path))) { + throw new Error( + `Refusing to write to "${path}": people/memberships are never managed by this tool.`, + ); + } +} diff --git a/tests/guard.test.ts b/tests/guard.test.ts new file mode 100644 index 0000000..3bd9e61 --- /dev/null +++ b/tests/guard.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { assertNotPeople } from "../src/engine/guard.js"; + +describe("assertNotPeople", () => { + it("throws for people and membership paths", () => { + for (const p of [ + "/persons", + "/persons/42", + "/memberships/7", + "/groups/3/members", + "/groups/3/members/9", + "/groups/3/memberships", + ]) { + expect(() => assertNotPeople(p), p).toThrow(/people|member/i); + } + }); + + it("allows structural paths including hierarchy edges", () => { + for (const p of [ + "/campuses", + "/groups", + "/groups/3", + "/groups/3/parents/5", + "/group/grouptypes/2", + "/person/relationshiptypes/1", + ]) { + expect(() => assertNotPeople(p)).not.toThrow(); + } + }); +}); From 25ba4eb27d4e36536b1c68622a7b2d3f36ca464f Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 16:09:12 +0200 Subject: [PATCH 06/16] feat(engine): pre-write backup writer --- src/engine/backup.ts | 25 +++++++++++++++++++++++++ tests/backup.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/engine/backup.ts create mode 100644 tests/backup.test.ts diff --git a/src/engine/backup.ts b/src/engine/backup.ts new file mode 100644 index 0000000..f0e4b88 --- /dev/null +++ b/src/engine/backup.ts @@ -0,0 +1,25 @@ +/** + * Automatic pre-write backup. Before any apply/destroy touches ChurchTools, the + * current *actual* values of the managed resources are dumped to a timestamped + * JSON file, so the affected area can be inspected/restored. + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export async function writeBackup( + dir: string, + host: string, + actual: Map>, + now: Date = new Date(), +): Promise { + await mkdir(dir, { recursive: true }); + const stamp = now.toISOString().replace(/:/g, "-"); + const path = join(dir, `ct-backup-${stamp}.json`); + const payload = { + host, + capturedAt: now.toISOString(), + resources: Object.fromEntries(actual), + }; + await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + return path; +} diff --git a/tests/backup.test.ts b/tests/backup.test.ts new file mode 100644 index 0000000..dc689a1 --- /dev/null +++ b/tests/backup.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { readFile, rm, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeBackup } from "../src/engine/backup.js"; + +let dir: string | undefined; +afterEach(async () => { + if (dir) { + await rm(dir, { recursive: true, force: true }); + } + dir = undefined; +}); + +describe("writeBackup", () => { + it("writes a timestamped JSON snapshot of actual resources", async () => { + dir = await mkdtemp(join(tmpdir(), "ct-backup-test-")); + const actual = new Map>([["mainz", { name: "Mainz" }]]); + const now = new Date("2026-07-07T14:30:00.000Z"); + const path = await writeBackup(dir, "https://x.church.tools", actual, now); + expect(path).toBe(join(dir, "ct-backup-2026-07-07T14-30-00.000Z.json")); + const parsed = JSON.parse(await readFile(path, "utf8")); + expect(parsed).toEqual({ + host: "https://x.church.tools", + capturedAt: "2026-07-07T14:30:00.000Z", + resources: { mainz: { name: "Mainz" } }, + }); + }); + + it("creates the backup directory if it does not exist", async () => { + const base = await mkdtemp(join(tmpdir(), "ct-backup-test-")); + dir = base; + const nested = join(base, "backups"); + const path = await writeBackup(nested, "h", new Map(), new Date("2026-01-01T00:00:00.000Z")); + expect(await readFile(path, "utf8")).toContain('"resources": {}'); + }); +}); From df6b93c533c59a20a2e7953a9adb1395f66fc34f Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 17:50:04 +0200 Subject: [PATCH 07/16] feat(ui): confirm + confirmTyped prompts --- src/ui/prompt.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++ tests/prompt.test.ts | 32 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/ui/prompt.ts create mode 100644 tests/prompt.test.ts diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts new file mode 100644 index 0000000..1e70ec3 --- /dev/null +++ b/src/ui/prompt.ts @@ -0,0 +1,57 @@ +/** + * Interactive confirmation. Dependencies (`isTTY`, `ask`) are injectable so the + * prompts are testable without a real terminal. In production they default to + * the process's TTY state and a readline question on stdin. + */ +import { createInterface } from "node:readline"; + +export interface PromptOptions { + isTTY?: boolean; + ask?: (question: string) => Promise; +} + +function realAsk(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +function ttyState(opts: PromptOptions): boolean { + return opts.isTTY ?? Boolean(process.stdin.isTTY); +} + +/** Yes/No confirmation. `assumeYes` skips the prompt (for `-y`/CI). */ +export async function confirm( + message: string, + opts: PromptOptions & { assumeYes?: boolean } = {}, +): Promise { + if (opts.assumeYes) { + return true; + } + if (!ttyState(opts)) { + return false; + } + const ask = opts.ask ?? realAsk; + const answer = await ask(`${message} [y/N] `); + return /^y(es)?$/i.test(answer.trim()); +} + +/** Require the user to type `expected` exactly. `force` skips the prompt. */ +export async function confirmTyped( + expected: string, + opts: PromptOptions & { force?: boolean } = {}, +): Promise { + if (opts.force) { + return true; + } + if (!ttyState(opts)) { + return false; + } + const ask = opts.ask ?? realAsk; + const answer = await ask(`Type "${expected}" to confirm: `); + return answer.trim() === expected; +} diff --git a/tests/prompt.test.ts b/tests/prompt.test.ts new file mode 100644 index 0000000..d268a45 --- /dev/null +++ b/tests/prompt.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { confirm, confirmTyped } from "../src/ui/prompt.js"; + +describe("confirm", () => { + it("short-circuits true with assumeYes", async () => { + expect(await confirm("go?", { assumeYes: true })).toBe(true); + }); + it("returns false on a non-TTY without assumeYes", async () => { + expect(await confirm("go?", { isTTY: false })).toBe(false); + }); + it("accepts y/yes", async () => { + expect(await confirm("go?", { isTTY: true, ask: async () => "y" })).toBe(true); + expect(await confirm("go?", { isTTY: true, ask: async () => "Yes" })).toBe(true); + }); + it("rejects anything else", async () => { + expect(await confirm("go?", { isTTY: true, ask: async () => "" })).toBe(false); + expect(await confirm("go?", { isTTY: true, ask: async () => "n" })).toBe(false); + }); +}); + +describe("confirmTyped", () => { + it("short-circuits true with force", async () => { + expect(await confirmTyped("old_team", { force: true })).toBe(true); + }); + it("requires an exact match", async () => { + expect(await confirmTyped("old_team", { isTTY: true, ask: async () => "old_team" })).toBe(true); + expect(await confirmTyped("old_team", { isTTY: true, ask: async () => "nope" })).toBe(false); + }); + it("returns false on a non-TTY without force", async () => { + expect(await confirmTyped("x", { isTTY: false })).toBe(false); + }); +}); From 5f88d564545605a9b83bb39c6976c09e39135d80 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 17:51:21 +0200 Subject: [PATCH 08/16] refactor(engine): extract buildPlan; plan command reuses it --- src/commands/plan.ts | 58 ++------------------------------ src/engine/build.ts | 79 ++++++++++++++++++++++++++++++++++++++++++++ tests/build.test.ts | 41 +++++++++++++++++++++++ 3 files changed, 122 insertions(+), 56 deletions(-) create mode 100644 src/engine/build.ts create mode 100644 tests/build.test.ts diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 1fb3e1f..b5bd9f3 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,14 +1,10 @@ import { Command } from "commander"; import { authedSession } from "../api/session.js"; -import { CtApiError } from "../api/ctClient.js"; import { resolveConfig } from "../config.js"; -import { RESOURCES } from "../resources/registry.js"; import { loadState, resolveStatePath } from "../state/state.js"; import { loadConfig, resolveConfigPath } from "../config/load.js"; -import { computePlan } from "../engine/plan.js"; +import { buildPlan } from "../engine/build.js"; import { renderPlan } from "../engine/render.js"; -import { parentIdsByGroupId, applyHierarchy, type HierarchyEntry } from "../engine/hierarchy.js"; -import { mapConcurrent } from "../util/concurrency.js"; import { info, warn, out } from "../ui.js"; interface PlanOptions { @@ -17,9 +13,6 @@ interface PlanOptions { json?: boolean; } -/** How many managed resources to fetch from ChurchTools at once. */ -const FETCH_CONCURRENCY = 8; - export function planCommand(): Command { return new Command("plan") .description("Show the diff between the desired-state config and ChurchTools (read-only)") @@ -36,54 +29,7 @@ export function planCommand(): Command { } const { client } = await authedSession(); - // Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0). - const actual = new Map>(); - const unresolved = new Set(); - const fetchErrors: string[] = []; - - await mapConcurrent(Object.values(state.resources), FETCH_CONCURRENCY, async (managed) => { - const spec = RESOURCES[managed.type]; - if (!spec) { - unresolved.add(managed.key); - warn( - `No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`, - ); - return; - } - try { - const raw = await client.get>(spec.itemPath(managed.id)); - actual.set(managed.key, spec.managedFields(raw)); - } catch (err) { - if (err instanceof CtApiError && err.status === 404) { - return; // vanished in CT — the plan will propose recreating (or pruning) it - } - // A read-only plan should not abort on one bad fetch: record it, keep going, flag the plan as partial. - const message = err instanceof Error ? err.message : String(err); - fetchErrors.push(`${managed.type}.${managed.key} (#${managed.id}): ${message}`); - warn(`Failed to fetch ${managed.type}.${managed.key} (#${managed.id}): ${message}`); - } - }); - - // Group hierarchy: one bulk call, folded into each opted-in group's `parents` set-field. - let parentIds = new Map(); - let hierarchyOk = true; - const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); - if (hasManagedGroups) { - try { - const raw = await client.get("/groups/hierarchies"); - parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - fetchErrors.push(`group hierarchies: ${message}`); - warn(`Failed to fetch group hierarchies: ${message}`); - hierarchyOk = false; - } - } - // On a hierarchy-fetch failure, leave `parents` undiffed rather than folding an empty map - // (which would fabricate spurious "add all parents" changes). The plan is flagged INCOMPLETE below. - const desiredWithHierarchy = hierarchyOk ? applyHierarchy(desired, state, actual, parentIds) : desired; - - const plan = computePlan(desiredWithHierarchy, state, actual, { unresolved }); + const { plan, fetchErrors } = await buildPlan(client, state, desired); if (opts.json) { out(plan); } else { diff --git a/src/engine/build.ts b/src/engine/build.ts new file mode 100644 index 0000000..f2db63b --- /dev/null +++ b/src/engine/build.ts @@ -0,0 +1,79 @@ +/** + * Shared plan building: fetch the actual ChurchTools values of every managed + * resource, fold group hierarchy into a `parents` set-field, and diff against + * the desired config + state. Used by both `ct plan` and `ct apply`, so apply + * fetches exactly once (its `actual` map is reused for the backup). + */ +import type { CtClient } from "../api/ctClient.js"; +import { CtApiError } from "../api/ctClient.js"; +import type { State } from "../state/state.js"; +import type { DesiredResource, Plan } from "./types.js"; +import { RESOURCES } from "../resources/registry.js"; +import { computePlan } from "./plan.js"; +import { parentIdsByGroupId, applyHierarchy, type HierarchyEntry } from "./hierarchy.js"; +import { mapConcurrent } from "../util/concurrency.js"; +import { warn } from "../ui.js"; + +/** How many managed resources to fetch from ChurchTools at once. */ +const FETCH_CONCURRENCY = 8; + +export interface BuildResult { + plan: Plan; + actual: Map>; + fetchErrors: string[]; +} + +export async function buildPlan( + client: Pick, + state: State, + desired: DesiredResource[], +): Promise { + // Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0). + const actual = new Map>(); + const unresolved = new Set(); + const fetchErrors: string[] = []; + + await mapConcurrent(Object.values(state.resources), FETCH_CONCURRENCY, async (managed) => { + const spec = RESOURCES[managed.type]; + if (!spec) { + unresolved.add(managed.key); + warn( + `No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`, + ); + return; + } + try { + const raw = await client.get>(spec.itemPath(managed.id)); + actual.set(managed.key, spec.managedFields(raw)); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) { + return; // vanished in CT — the plan will propose recreating (or pruning) it + } + // A read-only plan should not abort on one bad fetch: record it, keep going, flag the plan as partial. + const message = err instanceof Error ? err.message : String(err); + fetchErrors.push(`${managed.type}.${managed.key} (#${managed.id}): ${message}`); + warn(`Failed to fetch ${managed.type}.${managed.key} (#${managed.id}): ${message}`); + } + }); + + // Group hierarchy: one bulk call, folded into each opted-in group's `parents` set-field. + let parentIds = new Map(); + let hierarchyOk = true; + const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group"); + if (hasManagedGroups) { + try { + const raw = await client.get("/groups/hierarchies"); + parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + fetchErrors.push(`group hierarchies: ${message}`); + warn(`Failed to fetch group hierarchies: ${message}`); + hierarchyOk = false; + } + } + // On a hierarchy-fetch failure, leave `parents` undiffed rather than folding an empty map + // (which would fabricate spurious "add all parents" changes). The plan is flagged INCOMPLETE. + const desiredWithHierarchy = hierarchyOk ? applyHierarchy(desired, state, actual, parentIds) : desired; + const plan = computePlan(desiredWithHierarchy, state, actual, { unresolved }); + return { plan, actual, fetchErrors }; +} diff --git a/tests/build.test.ts b/tests/build.test.ts new file mode 100644 index 0000000..f98f626 --- /dev/null +++ b/tests/build.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { buildPlan } from "../src/engine/build.js"; +import { emptyState } from "../src/state/state.js"; +import { CtApiError } from "../src/api/ctClient.js"; +import type { DesiredResource } from "../src/engine/types.js"; + +/** A fake client that returns canned bodies by path and throws a 404 CtApiError on a miss. */ +function fakeClient(byPath: Record) { + return { + get: async (path: string): Promise => { + if (!(path in byPath)) { + throw new CtApiError(`not found: ${path}`, 404, null); + } + return byPath[path] as T; + }, + }; +} + +describe("buildPlan", () => { + it("diffs desired against fetched actual and returns an ordered plan", async () => { + const state = emptyState("https://x.church.tools"); + state.resources.mainz = { + type: "campus", + id: 0, + key: "mainz", + fields: { name: "Mainz", shortName: "MZ" }, + adoptedAt: "t", + updatedAt: "t", + }; + const desired: DesiredResource[] = [ + { type: "campus", key: "mainz", fields: { name: "Mainz City", shortName: "MZ" }, dependsOn: [] }, + ]; + const client = fakeClient({ "/campuses/0": { name: "Mainz", shortName: "MZ" } }); + const { plan, actual, fetchErrors } = await buildPlan(client, state, desired); + expect(fetchErrors).toEqual([]); + expect(actual.get("mainz")).toEqual({ name: "Mainz", shortName: "MZ" }); + const item = plan.items.find((i) => i.key === "mainz")!; + expect(item.action).toBe("update"); + expect(item.changes).toEqual([{ field: "name", from: "Mainz", to: "Mainz City" }]); + }); +}); From 3060fec8f74e3c71968ebe2da6b7fd8e61081750 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 17:52:47 +0200 Subject: [PATCH 09/16] =?UTF-8?q?feat(engine):=20executePlan=20=E2=80=94?= =?UTF-8?q?=20idempotent=20create/update=20+=20hierarchy=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/execute.ts | 154 ++++++++++++++++++++++++++++++++++ tests/execute.test.ts | 191 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 src/engine/execute.ts create mode 100644 tests/execute.test.ts diff --git a/src/engine/execute.ts b/src/engine/execute.ts new file mode 100644 index 0000000..7406ca6 --- /dev/null +++ b/src/engine/execute.ts @@ -0,0 +1,154 @@ +/** + * The executor: walk a computed plan and make it real. Field-agnostic — every + * resource's write path/verb comes from the registry, so adding a type never + * touches this file. State is saved after each successful action, so a crash + * mid-apply leaves a consistent, resumable state file. + * + * apply NEVER deletes: delete items are recorded and skipped. Group hierarchy is + * reconciled through the parents endpoints, not the group body. + */ +import type { CtClient } from "../api/ctClient.js"; +import type { State } from "../state/state.js"; +import { upsert, saveState } from "../state/state.js"; +import type { FieldChange, Plan } from "./types.js"; +import { RESOURCES } from "../resources/registry.js"; +import { assertNotPeople } from "./guard.js"; + +export interface ExecuteDeps { + client: Pick; + state: State; + statePath: string; + now?: () => string; + save?: (path: string, state: State) => Promise; +} + +export interface ExecuteResult { + created: string[]; + updated: string[]; + skippedDeletes: string[]; + failed?: { key: string; message: string }; +} + +/** The managed field snapshot after a write: base ∪ changed fields, minus the hierarchy `parents` set-field. */ +function snapshotFromChanges( + base: Record, + changes: FieldChange[], +): Record { + const snap = { ...base }; + for (const c of changes) { + if (c.field !== "parents") { + snap[c.field] = c.to; + } + } + delete snap.parents; + return snap; +} + +function parentEdges(from: unknown, to: unknown): { added: string[]; removed: string[] } { + const f = new Set(Array.isArray(from) ? (from as string[]) : []); + const t = new Set(Array.isArray(to) ? (to as string[]) : []); + return { + added: [...t].filter((k) => !f.has(k)), + removed: [...f].filter((k) => !t.has(k)), + }; +} + +function resolveId(state: State, key: string): number { + const managed = state.resources[key]; + if (!managed) { + throw new Error(`Cannot resolve parent "${key}" — not under management yet.`); + } + return managed.id; +} + +async function applyParentEdges( + client: Pick, + state: State, + childId: number, + changes: FieldChange[], +): Promise { + const change = changes.find((c) => c.field === "parents"); + if (!change) { + return; + } + const { added, removed } = parentEdges(change.from, change.to); + for (const key of added) { + const path = `/groups/${childId}/parents/${resolveId(state, key)}`; + assertNotPeople(path); + await client.request("PUT", path); + } + for (const key of removed) { + const path = `/groups/${childId}/parents/${resolveId(state, key)}`; + assertNotPeople(path); + await client.request("DELETE", path); + } +} + +export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise { + const { client, state, statePath } = deps; + const now = deps.now ?? (() => new Date().toISOString()); + const save = deps.save ?? saveState; + const created: string[] = []; + const updated: string[] = []; + const skippedDeletes: string[] = []; + + for (const item of plan.items) { + if (item.action === "delete") { + skippedDeletes.push(item.key); + continue; + } + if (item.action === "no-op") { + continue; + } + const spec = RESOURCES[item.type]; + if (!spec) { + return { + created, + updated, + skippedDeletes, + failed: { key: item.key, message: `No write spec for type "${item.type}".` }, + }; + } + + try { + if (item.action === "create") { + const body = snapshotFromChanges({}, item.changes); + assertNotPeople(spec.collectionPath); + const res = await client.request<{ id: number }>("POST", spec.collectionPath, body); + if (typeof res.id !== "number") { + throw new Error(`create returned no numeric id (got ${JSON.stringify(res.id)})`); + } + upsert(state, { type: item.type, id: res.id, key: item.key, fields: body }, now()); + await save(statePath, state); + await applyParentEdges(client, state, res.id, item.changes); + created.push(item.key); + } else { + const id = item.id; + if (id === null) { + throw new Error("update item has no id"); + } + const base = state.resources[item.key]?.fields ?? {}; + const snapshot = snapshotFromChanges(base, item.changes); + const hasFieldChange = item.changes.some((c) => c.field !== "parents"); + if (hasFieldChange) { + const path = spec.itemPath(id); + assertNotPeople(path); + await client.request(spec.updateMethod, path, snapshot); + } + upsert(state, { type: item.type, id, key: item.key, fields: snapshot }, now()); + await save(statePath, state); + await applyParentEdges(client, state, id, item.changes); + updated.push(item.key); + } + } catch (err) { + return { + created, + updated, + skippedDeletes, + failed: { key: item.key, message: err instanceof Error ? err.message : String(err) }, + }; + } + } + + return { created, updated, skippedDeletes }; +} diff --git a/tests/execute.test.ts b/tests/execute.test.ts new file mode 100644 index 0000000..daefe5d --- /dev/null +++ b/tests/execute.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from "vitest"; +import { executePlan } from "../src/engine/execute.js"; +import { emptyState, type State } from "../src/state/state.js"; +import type { Plan } from "../src/engine/types.js"; + +interface Call { + method: string; + path: string; + body?: unknown; +} + +function recorder(responses: Record = {}) { + const calls: Call[] = []; + const client = { + request: async (method: string, path: string, body?: unknown): Promise => { + calls.push({ method, path, body }); + const key = `${method} ${path}`; + return (responses[key] ?? {}) as T; + }, + }; + return { client, calls }; +} + +const noSave = async (_p: string, _s: State) => {}; +const fixedNow = () => "2026-07-07T00:00:00.000Z"; + +describe("executePlan", () => { + it("creates a resource, captures its id, and records it in state", async () => { + const state = emptyState("h"); + const { client, calls } = recorder({ "POST /campuses": { id: 5 } }); + const plan: Plan = { + items: [ + { + type: "campus", + key: "zurich", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Zürich" }, + { field: "shortName", from: undefined, to: "ZH" }, + ], + }, + ], + }; + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); + expect(result.created).toEqual(["zurich"]); + expect(calls[0]).toEqual({ + method: "POST", + path: "/campuses", + body: { name: "Zürich", shortName: "ZH" }, + }); + expect(state.resources.zurich).toMatchObject({ + type: "campus", + id: 5, + key: "zurich", + fields: { name: "Zürich", shortName: "ZH" }, + }); + }); + + it("updates a group via PATCH with the full managed snapshot", async () => { + const state = emptyState("h"); + state.resources.team = { + type: "group", + id: 9, + key: "team", + fields: { name: "Team", groupTypeId: 2, groupStatusId: 1 }, + adoptedAt: "t", + updatedAt: "t", + }; + const { client, calls } = recorder(); + const plan: Plan = { + items: [ + { + type: "group", + key: "team", + id: 9, + action: "update", + changes: [{ field: "name", from: "Team", to: "Team A" }], + }, + ], + }; + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); + expect(result.updated).toEqual(["team"]); + expect(calls[0]).toEqual({ + method: "PATCH", + path: "/groups/9", + body: { name: "Team A", groupTypeId: 2, groupStatusId: 1 }, + }); + expect(state.resources.team!.fields).toEqual({ name: "Team A", groupTypeId: 2, groupStatusId: 1 }); + }); + + it("reconciles hierarchy edges via PUT/DELETE and never stores parents in state", async () => { + const state = emptyState("h"); + state.resources.parent = { + type: "group", + id: 1, + key: "parent", + fields: { name: "P" }, + adoptedAt: "t", + updatedAt: "t", + }; + state.resources.child = { + type: "group", + id: 2, + key: "child", + fields: { name: "C" }, + adoptedAt: "t", + updatedAt: "t", + }; + const { client, calls } = recorder(); + const plan: Plan = { + items: [ + { + type: "group", + key: "child", + id: 2, + action: "update", + changes: [{ field: "parents", from: [], to: ["parent"] }], + }, + ], + }; + await executePlan(plan, { client, state, statePath: "s.json", save: noSave, now: fixedNow }); + expect(calls).toEqual([{ method: "PUT", path: "/groups/2/parents/1", body: undefined }]); + expect(state.resources.child!.fields.parents).toBeUndefined(); + }); + + it("skips deletes (apply never deletes)", async () => { + const state = emptyState("h"); + state.resources.old = { + type: "campus", + id: 3, + key: "old", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + const { client, calls } = recorder(); + const plan: Plan = { items: [{ type: "campus", key: "old", id: 3, action: "delete", changes: [] }] }; + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); + expect(result.skippedDeletes).toEqual(["old"]); + expect(calls).toEqual([]); + expect(state.resources.old).toBeDefined(); + }); + + it("stops on the first write error and reports it", async () => { + const state = emptyState("h"); + const client = { + request: async (): Promise => { + throw new Error("boom"); + }, + }; + const plan: Plan = { + items: [ + { + type: "campus", + key: "zurich", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Z" }], + }, + ], + }; + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); + expect(result.failed).toEqual({ key: "zurich", message: "boom" }); + expect(result.created).toEqual([]); + }); +}); From 423711f6769b904e1ae5dc3432a93ddd13334c05 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 17:53:27 +0200 Subject: [PATCH 10/16] =?UTF-8?q?feat(cli):=20ct=20apply=20=E2=80=94=20pla?= =?UTF-8?q?n,=20confirm,=20backup,=20execute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/apply.ts | 96 +++++++++++++++++++++++++++++++++++++++++++ tests/apply.test.ts | 14 +++++++ 2 files changed, 110 insertions(+) create mode 100644 src/commands/apply.ts create mode 100644 tests/apply.test.ts diff --git a/src/commands/apply.ts b/src/commands/apply.ts new file mode 100644 index 0000000..6a1d500 --- /dev/null +++ b/src/commands/apply.ts @@ -0,0 +1,96 @@ +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 { loadConfig, resolveConfigPath } from "../config/load.js"; +import { buildPlan } from "../engine/build.js"; +import { executePlan } from "../engine/execute.js"; +import { writeBackup } from "../engine/backup.js"; +import { renderPlan } from "../engine/render.js"; +import { summarize } from "../engine/types.js"; +import { confirm } from "../ui/prompt.js"; +import { info, warn, success, error } from "../ui.js"; + +interface ApplyOptions { + config?: string; + state?: string; + backupDir?: string; + autoApprove?: boolean; +} + +/** backups/ dir: explicit flag → CT_BACKUP_DIR → `backups/` beside the state file. */ +export function resolveBackupDir( + explicit: string | undefined, + statePath: string, + env: NodeJS.ProcessEnv = process.env, +): string { + return explicit?.trim() || env.CT_BACKUP_DIR?.trim() || join(dirname(statePath), "backups"); +} + +export function applyCommand(): Command { + return new Command("apply") + .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("--backup-dir ", "directory for the pre-apply backup (or set CT_BACKUP_DIR)") + .option("-y, --auto-approve", "skip the confirmation prompt") + .action(async (opts: ApplyOptions) => { + const config = resolveConfig(); + const configPath = resolveConfigPath(opts.config); + const statePath = resolveStatePath(opts.state); + const desired = await loadConfig(configPath); + const state = await loadState(statePath, config.host); + + const { client } = await authedSession(); + const { plan, actual, fetchErrors } = await buildPlan(client, state, desired); + + if (fetchErrors.length > 0) { + error( + `Aborting: ${fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`, + ); + process.exitCode = 1; + return; + } + + process.stdout.write(`${renderPlan(plan)}\n`); + + const deletes = plan.items.filter((i) => i.action === "delete"); + if (deletes.length > 0) { + warn(`${deletes.length} resource(s) dropped from config will NOT be deleted by apply:`); + for (const d of deletes) { + info(` ${d.type}.${d.key} (#${d.id}) — run: ct destroy --target ${d.key}`); + } + } + + const s = summarize(plan); + const changeCount = s.create + s.update; + if (changeCount === 0) { + success("No changes to apply."); + return; + } + + const ok = await confirm(`Apply ${changeCount} change(s)?`, { assumeYes: opts.autoApprove }); + if (!ok) { + warn("Aborted — no changes made."); + process.exitCode = 1; + return; + } + + const backupPath = await writeBackup( + resolveBackupDir(opts.backupDir, statePath), + config.host, + actual, + ); + info(`Backup written: ${backupPath}`); + + const result = await executePlan(plan, { client, state, statePath, save: saveState }); + success(`Applied: ${result.created.length} created, ${result.updated.length} updated.`); + if (result.failed) { + error( + `Stopped at ${result.failed.key}: ${result.failed.message}. State saved up to this point — re-run to resume.`, + ); + process.exitCode = 1; + } + }); +} diff --git a/tests/apply.test.ts b/tests/apply.test.ts new file mode 100644 index 0000000..18ff9b2 --- /dev/null +++ b/tests/apply.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { resolveBackupDir } from "../src/commands/apply.js"; + +describe("resolveBackupDir", () => { + it("prefers the explicit flag", () => { + expect(resolveBackupDir("./out", "cfg/ct-state.json", {})).toBe("./out"); + }); + it("falls back to CT_BACKUP_DIR", () => { + expect(resolveBackupDir(undefined, "cfg/ct-state.json", { CT_BACKUP_DIR: "/b" })).toBe("/b"); + }); + it("defaults to a backups/ dir beside the state file", () => { + expect(resolveBackupDir(undefined, "cfg/ct-state.json", {})).toBe("cfg/backups"); + }); +}); From 3f975eee5336a08e2a4f69a680d5d0054befbf86 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 17:54:05 +0200 Subject: [PATCH 11/16] =?UTF-8?q?feat(cli):=20ct=20destroy=20=E2=80=94=20e?= =?UTF-8?q?xplicit,=20protected,=20reverse-order=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/destroy.ts | 123 ++++++++++++++++++++++++++++++++++++++++ tests/destroy.test.ts | 32 +++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/commands/destroy.ts create mode 100644 tests/destroy.test.ts diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts new file mode 100644 index 0000000..84a9efb --- /dev/null +++ b/src/commands/destroy.ts @@ -0,0 +1,123 @@ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import { CtApiError } from "../api/ctClient.js"; +import { resolveConfig } from "../config.js"; +import { loadState, resolveStatePath, saveState, type State } from "../state/state.js"; +import { loadConfig, resolveConfigPath } from "../config/load.js"; +import { RESOURCES } from "../resources/registry.js"; +import { assertNotPeople } from "../engine/guard.js"; +import { tierOf } from "../engine/graph.js"; +import { writeBackup } from "../engine/backup.js"; +import { resolveBackupDir } from "./apply.js"; +import { confirmTyped } from "../ui/prompt.js"; +import { info, warn, success, error } from "../ui.js"; + +interface DestroyOptions { + target?: string[]; + config?: string; + state?: string; + backupDir?: string; + force?: boolean; +} + +/** Flatten repeated/comma-separated `--target` values into a deduped key list. */ +export function parseTargets(raw: string[]): string[] { + const out: string[] = []; + for (const chunk of raw) { + for (const part of chunk.split(",")) { + const key = part.trim(); + if (key && !out.includes(key)) { + out.push(key); + } + } + } + return out; +} + +/** Reverse dependency order: highest tier first (leaves before their base metadata). */ +export function orderDestroy(state: State, keys: string[]): string[] { + return [...keys].sort((a, b) => tierOf(state.resources[b]!.type) - tierOf(state.resources[a]!.type)); +} + +export function destroyCommand(): Command { + return new Command("destroy") + .description("Explicitly delete managed resources (protected; never implicit)") + .requiredOption("--target ", "logical key(s) to destroy (repeatable or comma-separated)") + .option("-c, --config ", "config file (or set CT_CONFIG)") + .option("-s, --state ", "state file (or set CT_STATE)") + .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) => { + const targets = parseTargets(opts.target ?? []); + if (targets.length === 0) { + throw new Error("No --target given. Destroy never deletes implicitly."); + } + + const config = resolveConfig(); + const statePath = resolveStatePath(opts.state); + const state = await loadState(statePath, config.host); + + for (const key of targets) { + if (!state.resources[key]) { + throw new Error(`"${key}" is not managed (not in the state file). Nothing to destroy.`); + } + } + + // preventDestroy guard: a target still declared with the flag is blocked. + const desired = await loadConfig(resolveConfigPath(opts.config)); + const protectedKeys = new Set(desired.filter((d) => d.preventDestroy).map((d) => d.key)); + const blocked = targets.filter((k) => protectedKeys.has(k)); + if (blocked.length > 0) { + throw new Error( + `preventDestroy is set for: ${blocked.join(", ")}. Remove the flag in config first.`, + ); + } + + const ordered = orderDestroy(state, targets); + const { client } = await authedSession(); + + // Backup: fetch each target's current actual values (best-effort; 404 → skip). + const actual = new Map>(); + for (const key of ordered) { + const managed = state.resources[key]!; + const spec = RESOURCES[managed.type]; + if (!spec) { + continue; + } + try { + const raw = await client.get>(spec.itemPath(managed.id)); + actual.set(key, spec.managedFields(raw)); + } catch (err) { + if (!(err instanceof CtApiError && err.status === 404)) { + throw err; + } + } + } + const backupPath = await writeBackup(resolveBackupDir(opts.backupDir, statePath), config.host, actual); + info(`Backup written: ${backupPath}`); + + warn(`About to DELETE: ${ordered.join(", ")}`); + const expected = targets.length === 1 ? targets[0]! : "destroy"; + const ok = await confirmTyped(expected, { force: opts.force }); + if (!ok) { + warn("Aborted — nothing deleted."); + process.exitCode = 1; + return; + } + + for (const key of ordered) { + const managed = state.resources[key]!; + const spec = RESOURCES[managed.type]; + if (!spec) { + error(`No write spec for type "${managed.type}" — skipping ${key}.`); + continue; + } + const path = spec.itemPath(managed.id); + assertNotPeople(path); + await client.request("DELETE", path); + delete state.resources[key]; + await saveState(statePath, state); + success(`Destroyed ${managed.type}.${key} (#${managed.id})`); + } + }); +} diff --git a/tests/destroy.test.ts b/tests/destroy.test.ts new file mode 100644 index 0000000..3b3070f --- /dev/null +++ b/tests/destroy.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { parseTargets, orderDestroy } from "../src/commands/destroy.js"; +import { emptyState } from "../src/state/state.js"; + +describe("parseTargets", () => { + it("splits commas, trims, and dedupes", () => { + expect(parseTargets(["a,b", " c ", "a"])).toEqual(["a", "b", "c"]); + }); +}); + +describe("orderDestroy", () => { + it("orders higher tiers first (reverse of apply): groups before campuses", () => { + const state = emptyState("h"); + state.resources.mainz = { + type: "campus", + id: 0, + key: "mainz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + state.resources.team = { + type: "group", + id: 1, + key: "team", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + expect(orderDestroy(state, ["mainz", "team"])).toEqual(["team", "mainz"]); + }); +}); From d8d00f670fca0b6d6a17afdf283c07a2946cb461 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 17:55:56 +0200 Subject: [PATCH 12/16] feat(cli): wire apply/destroy; drop placeholders; DoD integration test --- src/commands/placeholders.ts | 6 +-- src/index.ts | 4 ++ tests/cli.test.ts | 10 +++++ tests/execute.test.ts | 2 +- tests/integration.test.ts | 77 ++++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tests/integration.test.ts diff --git a/src/commands/placeholders.ts b/src/commands/placeholders.ts index b9b6d95..9a28b5c 100644 --- a/src/commands/placeholders.ts +++ b/src/commands/placeholders.ts @@ -11,10 +11,8 @@ interface Planned { issue: string; } -const PLANNED: Planned[] = [ - { name: "apply", description: "Apply the plan (idempotent, in dependency order)", issue: "Phase 4 (#6)" }, - { name: "destroy", description: "Explicitly remove managed resources (protected)", issue: "Phase 4 (#6)" }, -]; +// apply/destroy landed in Phase 4; no verbs are gated at present. +const PLANNED: Planned[] = []; export function plannedCommands(): Command[] { return PLANNED.map(({ name, description, issue }) => diff --git a/src/index.ts b/src/index.ts index db9d402..130b189 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ import { getCommand } from "./commands/get.js"; import { adoptCommand } from "./commands/adopt.js"; import { stateCommand } from "./commands/state.js"; import { planCommand } from "./commands/plan.js"; +import { applyCommand } from "./commands/apply.js"; +import { destroyCommand } from "./commands/destroy.js"; import { plannedCommands } from "./commands/placeholders.js"; import { isMainModule } from "./isMain.js"; import { error } from "./ui.js"; @@ -24,6 +26,8 @@ export function buildProgram(): Command { program.addCommand(adoptCommand()); program.addCommand(stateCommand()); program.addCommand(planCommand()); + program.addCommand(applyCommand()); + program.addCommand(destroyCommand()); for (const cmd of plannedCommands()) { program.addCommand(cmd); } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 50d292a..2141762 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -12,4 +12,14 @@ describe("ct program", () => { const subs = auth?.commands.map((c) => c.name()) ?? []; expect(subs).toEqual(expect.arrayContaining(["login", "status", "logout"])); }); + + it("registers apply with --auto-approve", () => { + const cmd = buildProgram().commands.find((c) => c.name() === "apply")!; + expect(cmd.options.some((o) => o.long === "--auto-approve")).toBe(true); + }); + + it("registers destroy with a required --target", () => { + const cmd = buildProgram().commands.find((c) => c.name() === "destroy")!; + expect(cmd.options.some((o) => o.long === "--target")).toBe(true); + }); }); diff --git a/tests/execute.test.ts b/tests/execute.test.ts index daefe5d..516bd55 100644 --- a/tests/execute.test.ts +++ b/tests/execute.test.ts @@ -21,7 +21,7 @@ function recorder(responses: Record = {}) { return { client, calls }; } -const noSave = async (_p: string, _s: State) => {}; +const noSave: (path: string, state: State) => Promise = async () => {}; const fixedNow = () => "2026-07-07T00:00:00.000Z"; describe("executePlan", () => { diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..196646c --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { buildPlan } from "../src/engine/build.js"; +import { executePlan } from "../src/engine/execute.js"; +import { emptyState, type State } from "../src/state/state.js"; +import { CtApiError } from "../src/api/ctClient.js"; +import type { DesiredResource } from "../src/engine/types.js"; + +/** A tiny in-memory ChurchTools: a campuses store supporting GET/POST/PUT; hierarchy is empty. */ +function fakeCt() { + const campuses = new Map>([ + [0, { id: 0, name: "Mainz", shortName: "MZ" }], + ]); + let nextId = 1; + return { + get: async (path: string): Promise => { + const m = /^\/campuses\/(\d+)$/.exec(path); + if (m) { + const c = campuses.get(Number(m[1])); + if (!c) { + throw new CtApiError("nf", 404, null); + } + return c as T; + } + if (path === "/groups/hierarchies") { + return [] as T; + } + throw new CtApiError("nf", 404, null); + }, + request: async (method: string, path: string, body?: Record): Promise => { + if (method === "POST" && path === "/campuses") { + const id = nextId++; + campuses.set(id, { id, ...body }); + return { id } as T; + } + const m = /^\/campuses\/(\d+)$/.exec(path); + if (method === "PUT" && m) { + campuses.set(Number(m[1]), { id: Number(m[1]), ...body }); + return {} as T; + } + throw new Error(`unexpected ${method} ${path}`); + }, + }; +} + +const noSave: (path: string, state: State) => Promise = async () => {}; + +describe("apply → re-plan shows no drift", () => { + it("creates a new campus and updates an adopted one, then plans clean", async () => { + const client = fakeCt(); + const state = emptyState("h"); + // adopt the existing Mainz campus + state.resources.mz = { + type: "campus", + id: 0, + key: "mz", + fields: { name: "Mainz", shortName: "MZ" }, + adoptedAt: "t", + updatedAt: "t", + }; + + const desired: DesiredResource[] = [ + { type: "campus", key: "mz", fields: { name: "Mainz City", shortName: "MZ" }, dependsOn: [] }, + { type: "campus", key: "zh", fields: { name: "Zürich", shortName: "ZH" }, dependsOn: [] }, + ]; + + const first = await buildPlan(client, state, desired); + expect(first.plan.items.filter((i) => i.action !== "no-op").length).toBe(2); // 1 update + 1 create + + const result = await executePlan(first.plan, { client, state, statePath: "s", save: noSave }); + expect(result.failed).toBeUndefined(); + expect(result.created).toEqual(["zh"]); + expect(result.updated).toEqual(["mz"]); + + const second = await buildPlan(client, state, desired); + expect(second.plan.items.every((i) => i.action === "no-op")).toBe(true); + }); +}); From 00b44492c27a5a4f511489e4335ee075d7b233a2 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 17:56:53 +0200 Subject: [PATCH 13/16] docs(phase-4): README apply/destroy + guardrails --- README.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 79a98d9..9504daf 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,11 @@ Like Terraform, the tool never lives in the same repo as the infra config. 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 — this scaffold. -- ⬜ Phases 2–5: adopt/state, engine (plan/diff), apply, blueprints. +- ✅ **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. ## Requirements @@ -58,9 +61,23 @@ 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 | destroy # not yet implemented — see phase issues +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: @@ -101,7 +118,9 @@ npm run generate:client # regenerate the typed client from the live OpenAP ## Guardrails (by design) - `plan` is the default; `apply` is explicit, with a confirmation prompt. -- Destroy-protection; never implicit deletions. +- `apply` never deletes; destruction is explicit via `destroy --target`. +- Destroy-protection (`preventDestroy` config flag); never implicit deletions. - People/memberships are never touched (hard boundary in code). -- Backup/export before every `apply`. +- 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. From 85a986a3e82b3d74b99e3cc0c6811dab97c8a540 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Wed, 8 Jul 2026 09:58:42 +0200 Subject: [PATCH 14/16] fix(registry): correct new-type managed fields to live API (degreeNameA/B, nameTranslated) Live-verified against eqrm.church.tools: relationship-type uses degreeNameA/ degreeNameB (my provisional degreeForward/degreeReverse did not exist and would have PUT phantom keys). Added nameTranslated to age-group/target-group/group-role to match the real payloads. Locked with tests built from the live responses. --- src/resources/registry.ts | 12 +++++++----- tests/registry.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 42d3e79..670d03e 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -77,29 +77,31 @@ export const RESOURCES: Record = { collectionPath: "/group/agegroups", updateMethod: "PUT", deriveKey: (r) => slug(str(r, "name")), - managedFields: (r) => ({ name: r.name, sortKey: r.sortKey }), + managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated, sortKey: r.sortKey }), }), "target-group": define({ collectionPath: "/group/targetgroups", updateMethod: "PUT", deriveKey: (r) => slug(str(r, "name")), - managedFields: (r) => ({ name: r.name, sortKey: r.sortKey }), + managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated, sortKey: r.sortKey }), }), "relationship-type": define({ collectionPath: "/person/relationshiptypes", updateMethod: "PUT", deriveKey: (r) => slug(str(r, "name")), + // CT names the two ends degreeNameA/degreeNameB (verified live) — not degreeForward/Reverse. managedFields: (r) => ({ name: r.name, - degreeForward: r.degreeForward, - degreeReverse: r.degreeReverse, + nameTranslated: r.nameTranslated, + degreeNameA: r.degreeNameA, + degreeNameB: r.degreeNameB, }), }), "group-role": define({ collectionPath: "/group/roles", updateMethod: "PUT", deriveKey: (r) => slug(str(r, "name")), - managedFields: (r) => ({ name: r.name, groupTypeId: r.groupTypeId }), + managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated, groupTypeId: r.groupTypeId }), }), }; diff --git a/tests/registry.test.ts b/tests/registry.test.ts index c4f6311..c736ebe 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -82,4 +82,30 @@ describe("write specs", () => { expect(RESOURCES["group-role"]?.collectionPath).toBe("/group/roles"); expect(RESOURCES["age-group"]?.itemPath(3)).toBe("/group/agegroups/3"); }); + + // Field sets verified live against eqrm.church.tools (2026-07-08). + it("snapshots real relationship-type degree fields (degreeNameA/B, not degreeForward/Reverse)", () => { + const raw = { + name: "relationship.couple", + nameTranslated: "Couple", + degreeNameA: "spouse", + degreeNameB: "spouse", + securityLevelId: 1, + }; + expect(RESOURCES["relationship-type"]?.managedFields(raw)).toEqual({ + name: "relationship.couple", + nameTranslated: "Couple", + degreeNameA: "spouse", + degreeNameB: "spouse", + }); + }); + + it("snapshots age-group / group-role fields that exist on the live payload", () => { + expect( + RESOURCES["age-group"]?.managedFields({ name: "NextGen", nameTranslated: "NextGen", sortKey: 8 }), + ).toEqual({ name: "NextGen", nameTranslated: "NextGen", sortKey: 8 }); + expect( + RESOURCES["group-role"]?.managedFields({ name: "Mitglied", nameTranslated: "Mitglied", groupTypeId: 2 }), + ).toEqual({ name: "Mitglied", nameTranslated: "Mitglied", groupTypeId: 2 }); + }); }); From e63d9f5f25e8de2c0f60c9990795307102eea2de Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Wed, 8 Jul 2026 11:02:40 +0200 Subject: [PATCH 15/16] fix(registry): campus short name is `shorty`, not `shortName` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live write-test against eqrm-dev surfaced this: POST /campuses requires `shorty` (1–10 chars); `shortName` is a vestigial, usually-null sibling. The generic executor sends managedFields as the create body, so campus create failed with a 400 until the model used `shorty`. Switched campus managedFields + deriveKey to `shorty` and updated the coupled tests + docs. --- README.md | 2 +- src/config/context.ts | 2 +- src/resources/registry.ts | 6 ++++-- tests/adopt.test.ts | 4 ++-- tests/build.test.ts | 8 ++++---- tests/fixtures/sample.config.ts | 2 +- tests/integration.test.ts | 8 ++++---- tests/registry.test.ts | 4 ++-- 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9504daf..362c573 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ default-exports a function receiving the DSL: ```ts export default (ct) => { - ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 }); // 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 diff --git a/src/config/context.ts b/src/config/context.ts index a2a1146..d8e3a74 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -3,7 +3,7 @@ * context and declares resources: * * export default (ct: ConfigContext) => { - * ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + * ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); * ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 }); * ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupTypeId: 2, parents: ["mainz_area"] }); * }; diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 670d03e..d18c65d 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -54,8 +54,10 @@ export const RESOURCES: Record = { campus: define({ collectionPath: "/campuses", updateMethod: "PUT", - deriveKey: (r) => slug(str(r, "shortName") || str(r, "name")), - managedFields: (r) => ({ name: r.name, shortName: r.shortName }), + // CT's campus short name is `shorty` (1–10 chars, required on create) — verified + // live. `shortName` is a vestigial, usually-null sibling; do not use it for writes. + deriveKey: (r) => slug(str(r, "shorty") || str(r, "name")), + managedFields: (r) => ({ name: r.name, shorty: r.shorty }), }), group: define({ collectionPath: "/groups", diff --git a/tests/adopt.test.ts b/tests/adopt.test.ts index 92a548f..584d32f 100644 --- a/tests/adopt.test.ts +++ b/tests/adopt.test.ts @@ -3,7 +3,7 @@ import { readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const fakeCampus = { id: 0, name: "Mainz", shortName: "MZ" }; +const fakeCampus = { id: 0, name: "Mainz", shorty: "MZ" }; const getMock = vi.fn(async () => fakeCampus); vi.mock("../src/api/session.js", () => ({ @@ -36,7 +36,7 @@ describe("ct adopt", () => { type: "campus", id: 0, key: "mz", - fields: { name: "Mainz", shortName: "MZ" }, + fields: { name: "Mainz", shorty: "MZ" }, }); }); diff --git a/tests/build.test.ts b/tests/build.test.ts index f98f626..8e60533 100644 --- a/tests/build.test.ts +++ b/tests/build.test.ts @@ -23,17 +23,17 @@ describe("buildPlan", () => { type: "campus", id: 0, key: "mainz", - fields: { name: "Mainz", shortName: "MZ" }, + fields: { name: "Mainz", shorty: "MZ" }, adoptedAt: "t", updatedAt: "t", }; const desired: DesiredResource[] = [ - { type: "campus", key: "mainz", fields: { name: "Mainz City", shortName: "MZ" }, dependsOn: [] }, + { type: "campus", key: "mainz", fields: { name: "Mainz City", shorty: "MZ" }, dependsOn: [] }, ]; - const client = fakeClient({ "/campuses/0": { name: "Mainz", shortName: "MZ" } }); + const client = fakeClient({ "/campuses/0": { name: "Mainz", shorty: "MZ" } }); const { plan, actual, fetchErrors } = await buildPlan(client, state, desired); expect(fetchErrors).toEqual([]); - expect(actual.get("mainz")).toEqual({ name: "Mainz", shortName: "MZ" }); + expect(actual.get("mainz")).toEqual({ name: "Mainz", shorty: "MZ" }); const item = plan.items.find((i) => i.key === "mainz")!; expect(item.action).toBe("update"); expect(item.changes).toEqual([{ field: "name", from: "Mainz", to: "Mainz City" }]); diff --git a/tests/fixtures/sample.config.ts b/tests/fixtures/sample.config.ts index baad797..4cb0245 100644 --- a/tests/fixtures/sample.config.ts +++ b/tests/fixtures/sample.config.ts @@ -1,6 +1,6 @@ import type { ConfigContext } from "../../src/config/context.js"; export default (ct: ConfigContext): void => { - ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); ct.group({ key: "kids_lead", name: "Kids Leitung", parent: "mainz" }); }; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 196646c..d316ee7 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -8,7 +8,7 @@ import type { DesiredResource } from "../src/engine/types.js"; /** A tiny in-memory ChurchTools: a campuses store supporting GET/POST/PUT; hierarchy is empty. */ function fakeCt() { const campuses = new Map>([ - [0, { id: 0, name: "Mainz", shortName: "MZ" }], + [0, { id: 0, name: "Mainz", shorty: "MZ" }], ]); let nextId = 1; return { @@ -53,14 +53,14 @@ describe("apply → re-plan shows no drift", () => { type: "campus", id: 0, key: "mz", - fields: { name: "Mainz", shortName: "MZ" }, + fields: { name: "Mainz", shorty: "MZ" }, adoptedAt: "t", updatedAt: "t", }; const desired: DesiredResource[] = [ - { type: "campus", key: "mz", fields: { name: "Mainz City", shortName: "MZ" }, dependsOn: [] }, - { type: "campus", key: "zh", fields: { name: "Zürich", shortName: "ZH" }, dependsOn: [] }, + { type: "campus", key: "mz", fields: { name: "Mainz City", shorty: "MZ" }, dependsOn: [] }, + { type: "campus", key: "zh", fields: { name: "Zürich", shorty: "ZH" }, dependsOn: [] }, ]; const first = await buildPlan(client, state, desired); diff --git a/tests/registry.test.ts b/tests/registry.test.ts index c736ebe..2050278 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -28,8 +28,8 @@ describe("resourceType", () => { expect(() => resourceType("nope")).toThrow(/Adoptable types/); }); - it("derives a key from campus shortName", () => { - expect(RESOURCES.campus?.deriveKey({ name: "Mainz", shortName: "MZ" })).toBe("mz"); + it("derives a key from campus shorty", () => { + expect(RESOURCES.campus?.deriveKey({ name: "Mainz", shorty: "MZ" })).toBe("mz"); }); it("snapshots group ids whether they are nested under information or top-level", () => { From c152ea67f36d4fe0939c9eb1cc1b5dfad44e4243 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Wed, 8 Jul 2026 11:20:07 +0200 Subject: [PATCH 16/16] fix(apply/destroy): recreate no longer duplicates; destroy fails gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply: a resource that vanished from ChurchTools but remains in state was replanned as a create; executePlan POSTed a fresh copy, then upsert threw on the stale same-key entry (old id) and never recorded the new one — so state was never updated and every re-run leaked another duplicate. A create now drops the stale entry before upsert, so the new id takes over the key. destroy: the DELETE loop was unguarded — a mid-list failure threw a raw error after earlier targets were already deleted and persisted, with no resume guidance. Wrap each DELETE and stop cleanly, mirroring apply's crash-safe report (state is saved per target, so re-running with the remaining targets resumes). --- src/commands/destroy.ts | 11 ++++++++++- src/engine/execute.ts | 5 +++++ tests/execute.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 84a9efb..952e3c6 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -114,7 +114,16 @@ export function destroyCommand(): Command { } const path = spec.itemPath(managed.id); assertNotPeople(path); - await client.request("DELETE", path); + try { + await client.request("DELETE", path); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + error( + `Stopped at ${key}: ${message}. Already-deleted targets are removed from state — re-run with the remaining targets to resume.`, + ); + process.exitCode = 1; + return; + } delete state.resources[key]; await saveState(statePath, state); success(`Destroyed ${managed.type}.${key} (#${managed.id})`); diff --git a/src/engine/execute.ts b/src/engine/execute.ts index 7406ca6..624fcba 100644 --- a/src/engine/execute.ts +++ b/src/engine/execute.ts @@ -118,6 +118,11 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise { }); }); + it("recreates a vanished resource: the new id takes over the key instead of colliding on the stale entry", async () => { + // Resource deleted out-of-band in CT but still in state → computePlan emits a create item + // while the stale entry (old id 5) is still under this key. The create must replace it. + const state = emptyState("h"); + state.resources.zurich = { + type: "campus", + id: 5, + key: "zurich", + fields: { name: "Zürich", shorty: "ZH" }, + adoptedAt: "t", + updatedAt: "t", + }; + const { client } = recorder({ "POST /campuses": { id: 8 } }); + const plan: Plan = { + items: [ + { + type: "campus", + key: "zurich", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Zürich" }, + { field: "shorty", from: undefined, to: "ZH" }, + ], + }, + ], + }; + const result = await executePlan(plan, { + client, + state, + statePath: "s.json", + save: noSave, + now: fixedNow, + }); + expect(result.failed).toBeUndefined(); + expect(result.created).toEqual(["zurich"]); + expect(state.resources.zurich).toMatchObject({ id: 8, key: "zurich" }); + }); + it("updates a group via PATCH with the full managed snapshot", async () => { const state = emptyState("h"); state.resources.team = {