From 1f687ff9a29495c5f92a7ce1ad71c86baa91f52f Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Sun, 23 Aug 2026 22:19:46 +0200 Subject: [PATCH 01/15] docs: plan symmetric UI and CLI core projection --- .../plans/2026-08-23-ui-core-projection.md | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-23-ui-core-projection.md diff --git a/docs/superpowers/plans/2026-08-23-ui-core-projection.md b/docs/superpowers/plans/2026-08-23-ui-core-projection.md new file mode 100644 index 0000000..e7153c1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-ui-core-projection.md @@ -0,0 +1,434 @@ +# UI as a Symmetric Core Projection — Implementation Plan + +> **For agentic workers:** Implement this plan task by task. Do not start the Vue UI before the +> application operations in Tasks 1–4 exist and the CLI uses them. Each task must leave the existing +> CLI behavior and safety guarantees intact. + +**Goal:** Add a local browser UI, launched by `ct server`, as a second, symmetric projection of the +same ct-cli core used by the command line. A capability is implemented once in the core and exposed +through thin CLI and HTTP/UI adapters; the UI must never become a second reconciliation engine. + +**Architecture:** Preserve the existing domain modules (`engine`, `permissions`, `state`, `env`, +`auth`, `api`) and introduce an application layer that owns use-case orchestration. Commander and +Hono translate external input into the same typed operation requests. Terminal renderers and Vue +render the same typed results and progress events. Safety policy, confirmation requirements, +backups, state persistence, environment protection and mutation ordering remain below both +adapters. + +**Tech Stack:** TypeScript, Commander, Hono, Vue 3, Vite, Vitest, Playwright, Bun standalone +executables. Keep the npm/Node >= 20 distribution working alongside the Bun binaries. + +--- + +## Architectural invariants + +These rules are acceptance criteria, not suggestions: + +1. **One use case, one implementation.** `plan`, `apply`, `coverage`, `adopt`, `state rm`, `refresh` + and `destroy` each have exactly one application operation. +2. **Both adapters call the same operation.** The server must not spawn `ct`, parse terminal output, + or reconstruct CLI behavior. Commander must not call HTTP endpoints. +3. **The application core is presentation-free.** No Commander, Hono or Vue imports; no ANSI; + no `process.stdout`, interactive prompt or `process.exitCode` access. +4. **Adapters do not access ChurchTools or state directly.** They translate input, invoke an + operation and render/serialize its output. +5. **Guardrails live below the adapters.** A protected environment, incomplete plan, + `preventDestroy`, backup requirement and confirmation type are decided and validated by the + application/core, never by a button or Commander action alone. +6. **Results are structured.** Human-readable terminal output and visual UI are projections of + typed result/error/event objects. +7. **Behavioral parity is tested.** Given the same project, environment and fake ChurchTools + responses, CLI and HTTP must expose the same canonical operation result and side effects. +8. **People remain out of scope.** The UI must not introduce a path around the existing hard + boundary. + +Forbidden shortcuts: + +```ts +// Never use the CLI as the server's application API. +spawn("ct", ["plan", "--json"]); + +// Never let an HTTP route assemble a plan or mutate state itself. +app.post("/api/apply", async (c) => executePlan(/* route-owned orchestration */)); +``` + +Target dependency direction: + +```text +adapters/cli ─┐ + ├──> application/operations ──> existing domain modules ──> infrastructure +adapters/http ─┘ + +web/Vue ──HTTP/SSE──> adapters/http +``` + +`application` may import the current `engine`, `permissions`, `state`, `env`, `auth`, `api`, +`config` and `resolve` modules. None of those modules may import an adapter. + +--- + +## Shared contracts + +Every operation exposes a typed request and a typed result. JSON transport types must not be a +second model: HTTP serializes these contracts or a deliberately small transport projection from +them. + +Minimum common envelope: + +```ts +export interface ProjectRequest { + cwd?: string; + configPath?: string; + statePath?: string; + environment?: string; +} + +export interface OperationResult { + operation: OperationName; + project: ResolvedProjectInfo; + value: T; + warnings: CtWarning[]; +} + +export type OperationEvent = + | { type: "phase-started"; phase: string } + | { type: "resource-reading"; resourceType: string; key: string } + | { type: "resource-created"; resourceType: string; key: string; id: number } + | { type: "resource-updated"; resourceType: string; key: string; id: number } + | { type: "backup-written"; path: string } + | { type: "warning"; warning: CtWarning }; + +export interface OperationObserver { + emit(event: OperationEvent): void; +} +``` + +Errors use stable codes and structured details, for example `PLAN_INCOMPLETE`, +`AUTH_REQUIRED`, `HOST_MISMATCH`, `PROTECTED_ENV_CONFIRMATION_REQUIRED`, +`PLAN_CONFIRMATION_MISMATCH` and `PREVENT_DESTROY`. CLI maps them to messages/exit codes; HTTP maps +them to problem responses; Vue maps them to panels and field feedback. + +### Prepared mutation model + +Interactive mutations use the same two-stage contract in CLI and UI: + +```text +prepareApply(request) -> PreparedApply { public result + opaque execution handle } +confirm in adapter +executePreparedApply(handle, confirmation proof) -> ApplyResult +``` + +The prepared object owns the exact plan, actual snapshot and required confirmation. The server +keeps it in a short-lived in-memory operation store and exposes only an unguessable operation ID. +The CLI keeps the same object in its process while prompting. The core validates the confirmation +proof and executes the prepared plan. This avoids both duplicate planning code and a UI-only +"apply whatever is current" path. + +Prepared mutations expire, are single-use, and are invalidated when their relevant state file +changes. Only one mutation per state file may execute at a time. + +--- + +## Task 1: Establish the application boundary and characterization tests + +**Files:** + +- Create: `src/application/contracts.ts` +- Create: `src/application/errors.ts` +- Create: `src/application/project.ts` +- Create: `src/application/ports.ts` +- Create: `tests/application/project.test.ts` +- Create: `tests/architecture-boundaries.test.ts` + +- [ ] Define `ProjectRequest`, `ResolvedProjectInfo`, operation result, warning, error and observer + contracts. Keep values JSON-compatible where practical. +- [ ] Extract shared project/environment resolution from the command wrappers into + `resolveProject(request)`. It must retain current precedence for flags, environment profiles, + `CT_CONFIG`, `CT_STATE`, host and token selection. +- [ ] Define narrow ports for clock/ID generation, operation events and mutation locking. Do not + wrap pure existing domain functions merely to rename them. +- [ ] Add characterization tests for default config/state lookup, explicit paths, environment + selection, protected environments and host-bound state. +- [ ] Add an architecture test that scans imports and fails when `src/application/**` imports + Commander, Hono or web code, or when adapter code imports mutation primitives such as + `executePlan`, `saveState` or `applyPermissionPlan` directly. +- [ ] Run `npm test`, `npm run typecheck` and `npm run lint`. + +**Exit criterion:** There is one shared way to resolve the project context, but no CLI behavior has +changed yet. + +--- + +## Task 2: Extract `plan` as the first shared operation + +**Files:** + +- Create: `src/application/operations/plan.ts` +- Create: `src/application/operations/index.ts` +- Modify: `src/commands/plan.ts` +- Modify: `src/engine/render.ts` only if needed to accept the shared result without recomputation +- Create: `tests/application/plan-operation.test.ts` +- Modify: existing plan command tests + +- [ ] Move all orchestration currently inside the Commander `.action()` into `runPlan(request, + dependencies?)`: environment preparation, config/catalog/state loading, session creation, + shared resolver creation, concurrent resource/permission plan construction, completeness and + summary calculation. +- [ ] Return `PlanResult` containing resource plan, permission items, summary, attribution, + warnings, fetch errors, environment/host/version metadata and `complete`. +- [ ] Keep `renderPlan` and `renderPermissionPlan` as terminal renderers. They consume the operation + result; they do not participate in planning. +- [ ] Reduce `src/commands/plan.ts` to option parsing, operation invocation, rendering and exit-code + mapping (`--detailed-exitcode` included). +- [ ] Prove that the text and `--json` shapes remain compatible with existing tests. +- [ ] Add a test that calls `runPlan` directly and the CLI adapter against the same fixtures and + compares their canonical plan/summary. + +**Exit criterion:** `ct plan` is only a projection of `runPlan`; a future HTTP handler can expose +the complete plan without importing an engine, resolver, state store or ChurchTools client. + +--- + +## Task 3: Extract `apply` with shared confirmation and progress policy + +**Files:** + +- Create: `src/application/operations/apply.ts` +- Create: `src/application/prepared-operation-store.ts` +- Modify: `src/commands/apply.ts` +- Modify: engine/permission execution modules only to emit optional structured events +- Create: `tests/application/apply-operation.test.ts` +- Modify: existing apply and environment protection tests + +- [ ] Implement `prepareApply(request)` using the same plan-building primitives as `runPlan`. + Factor a private/shared plan builder rather than copy the orchestration. +- [ ] Return the rendered-independent proposal, exact prepared execution data, change count, + warnings and a core-decided confirmation requirement (`yes` or exact environment name). +- [ ] Implement `executePreparedApply(prepared, proof)` so the core validates completeness, + confirmation, expiry, state-file identity and mutation lock before any write. +- [ ] Keep backup-before-write, crash-safe state saves, dependency order, permission reconciliation + and optional dynamic-group refresh in this operation. +- [ ] Convert informational milestones to optional `OperationEvent`s. Terminal rendering must remain + byte-compatible where covered by tests. +- [ ] Reduce the Commander action to prepare, render, prompt, execute and map result/errors to exit + status. +- [ ] Test that CLI and direct operation calls produce identical writes, backup behavior and + protected-environment refusal. +- [ ] Test that an expired/reused prepared operation and a changed state file are refused. + +**Exit criterion:** There is no safety decision or mutation orchestration unique to the CLI. + +--- + +## Task 4: Extract all remaining operations before adding UI controls + +**Files:** + +- Create: `src/application/operations/coverage.ts` +- Create: `src/application/operations/adopt.ts` +- Create: `src/application/operations/state.ts` +- Create: `src/application/operations/refresh.ts` +- Create: `src/application/operations/destroy.ts` +- Create: `src/application/operations/auth.ts` +- Modify: corresponding files in `src/commands/` +- Create/modify: operation and CLI adapter tests + +- [ ] Extract each command's orchestration into one operation with structured request/result/error + contracts. +- [ ] Use the prepared mutation pattern for `destroy` and any adopt/state action requiring a prompt. +- [ ] Keep `preventDestroy`, typed target confirmation and protected-environment confirmation in the + application operation. +- [ ] Keep auth tokens in the existing keychain/token store; return only non-secret auth status to + adapters. +- [ ] Decide explicitly which `get` subcommands belong in the first UI. Expose selected reads through + a shared query operation, not route-specific client calls. +- [ ] Add parity tests for every operation exposed in both adapters. + +**Exit criterion:** The operation catalog is the authoritative product surface. A UI capability can +only be added by projecting an existing operation, or by first adding a core operation used by both +CLI and UI. + +--- + +## Task 5: Add the local HTTP adapter and `ct server` + +**Files:** + +- Create: `src/server/app.ts` +- Create: `src/server/routes.ts` +- Create: `src/server/session.ts` +- Create: `src/server/operation-store.ts` +- Create: `src/server/static.ts` +- Create: `src/commands/server.ts` +- Modify: `src/index.ts` +- Create: `tests/server/*.test.ts` + +**Command surface:** + +```console +ct server +ct server --env dev +ct server --env prod --no-open +ct server --port 8765 +ct server --config ./ct.config.ts --state ./ct.state.json +``` + +- [ ] Start on `127.0.0.1` only, using a free port by default. Do not add a public-listen mode in + this milestone. +- [ ] Generate a high-entropy bootstrap secret, open the browser with the secret in the URL fragment, + exchange it once for an `HttpOnly`, `SameSite=Strict` session cookie, then remove it from the + browser URL. +- [ ] Enforce exact Origin checks, restrictive CORS behavior, CSP and no secret-bearing logs. +- [ ] Add thin endpoints whose handlers only validate transport input, call an application operation + and serialize its result/error. +- [ ] Expose prepared mutations by opaque, expiring operation ID. Keep the prepared core object on + the server; never serialize ChurchTools clients, tokens or mutable execution internals. +- [ ] Stream shared `OperationEvent`s via SSE. Do not create a separate web-only progress vocabulary. +- [ ] Serialize mutation execution per state file and return a structured busy response. +- [ ] Handle SIGINT/SIGTERM cleanly and print the local URL for `--no-open` use. +- [ ] Test session bootstrap, Origin/CSRF rejection, operation expiry, concurrent mutation rejection, + no token exposure and graceful shutdown. + +**Exit criterion:** The server is a transport adapter. Route tests can mock the operation catalog; +they never need to mock engine internals. + +--- + +## Task 6: Build the Vue projection + +**Files:** + +- Create: `web/` Vue 3 + Vite application +- Create: `web/src/api/contracts.ts` or generate/re-export transport types from application contracts +- Create: views/components and frontend tests +- Modify: root build scripts and TypeScript configuration as needed + +- [ ] Implement a typed API client and central operation/error/event handling. Do not reproduce + plan summaries, confirmation policy or environment protection in the browser. +- [ ] Add the shared shell: current environment, host, ChurchTools version, auth status, config/state + paths and connectivity. +- [ ] Add read-only projections first: dashboard, plan, hierarchy/resources, permissions, coverage, + state and run progress. +- [ ] Render field attribution (`config`, `drift`, `config+drift`), unreadable resources and incomplete + plans from core fields rather than recalculating them. +- [ ] Add Apply only after the read-only views are stable. Display the core-provided confirmation + requirement and submit the proof with the prepared operation ID. +- [ ] Add adopt, state removal, refresh and destroy by projecting their shared operation contracts. +- [ ] Keep config read-only in the first milestone. A future editor may validate/save source, but it + must not introduce a form-to-TypeScript reconciliation engine. +- [ ] Add component tests plus Playwright flows using a fake application-operation backend. + +**Exit criterion:** Removing Vue leaves every ct capability available through the CLI, and removing +Commander leaves the same operations available to the server. Neither removal affects domain logic. + +--- + +## Task 7: Embed assets and ship macOS/Windows binaries + +**Files:** + +- Modify: `package.json` +- Modify: `tsup.config.ts` +- Modify: `.github/workflows/release.yml` +- Modify/Create: binary smoke-test scripts +- Modify: npm package `files` list/build output + +- [ ] Build the Vue assets once and make the same bytes available to both distributions: embedded + in Bun standalone executables and packaged under `dist/web` for npm/Node users. +- [ ] Keep `ct` and `ct server` in one executable; do not introduce a separately versioned UI + product. +- [ ] Add `bun-windows-x64-baseline` (or document a tested reason for the non-baseline target) and + retain Darwin arm64/x64. Add Windows arm64 only after native CI coverage exists. +- [ ] Native-smoke-test macOS and Windows artifacts: `ct --help`, server startup with `--no-open`, + `/api/health`, Vue asset delivery, fixture config load and clean shutdown. +- [ ] Smoke-test the npm tarball under Node >= 20 as well; Bun-only server APIs must not leak into the + npm execution path. +- [ ] Add release asset names and installation instructions for Windows. + +**Exit criterion:** A user downloads one `ct` binary and receives the same CLI and UI/core behavior +on macOS and Windows. + +--- + +## Task 8: Documentation and enforcement + +**Files:** + +- Create: `docs/ui.md` +- Create: `docs/architecture.md` or extend an existing architecture document +- Modify: `README.md` +- Modify: `CONTRIBUTING.md` + +- [ ] Document `ct server`, local-only security, lifecycle, supported platforms and troubleshooting. +- [ ] Document the dependency rule and the required sequence for new features: operation contract, + core implementation/tests, CLI projection, HTTP/UI projection. +- [ ] Add a contributor checklist: no adapter-direct state/API access, no shelling out, no duplicated + summaries/policy, parity test added. +- [ ] Document deliberate projection differences: terminal vs visual rendering and prompt vs dialog + are allowed; business meaning, defaults, guardrails and side effects are not. +- [ ] Run the complete CI gate and both browser/binary smoke suites. + +--- + +## UI surface for the first release + +The first release projects existing capabilities; it does not broaden ct-cli's management scope. + +| Core operation | CLI projection | UI projection | +| -------------- | --------------------- | -------------------------------------------- | +| plan | `ct plan` | Plan page and “Plan erstellen” | +| apply | `ct apply` | prepared plan, confirmation dialog, progress | +| coverage | `ct coverage` | coverage summary and searchable tables | +| state list/rm | `ct state ...` | state page and guarded removal | +| adopt | `ct adopt ...` | resource detail action | +| refresh | `ct refresh` | dynamic-group action | +| destroy | `ct destroy --target` | isolated danger-zone flow | +| auth status | `ct auth status` | non-secret header/status panel | + +Allowed presentation-specific behavior: + +- CLI renders text/ANSI; Vue renders tables, badges, trees and dialogs. +- CLI prompts on stdin; Vue collects the same required proof in a dialog. +- CLI streams lines; Vue consumes the same events over SSE. + +Not allowed: + +- UI-only plan filters that change plan semantics. +- UI-only apply defaults or weaker confirmations. +- Recomputed summary/drift/coverage logic in JavaScript components. +- Direct ChurchTools requests from the browser. +- Browser storage of ChurchTools tokens. + +--- + +## Verification matrix + +For each operation exposed through both projections, test the following fixture scenarios: + +| Scenario | Core | CLI adapter | HTTP adapter | Vue | +| -------------------- | ------------------------ | ----------------------------------- | --------------------- | -------------- | +| success/no changes | canonical result | text + exit code | JSON status | empty state | +| changes pending | exact items/summary | text/JSON + exit 2 where applicable | same data | diff view | +| drift | attribution | drift text | same attribution | drift badge | +| partial fetch | `complete=false` | exit 1 | problem/result status | Apply disabled | +| protected env | confirmation requirement | typed prompt | same requirement | typed dialog | +| host mismatch | stable error | message + exit 1 | problem response | error panel | +| interrupted mutation | persisted progress | resumable message | resumable result | run status | + +The canonical assertions belong to operation tests. Adapter tests assert translation only; they must +not duplicate all reconciliation cases already proven below the boundary. + +--- + +## Definition of done + +- `ct plan` and the UI plan are projections of the same `runPlan` result. +- `ct apply` and UI Apply execute the same prepared operation and core-owned confirmation policy. +- No server route imports ChurchTools clients, plan executors, permission writers or state writers. +- No application operation imports Commander, Hono, Vue or presentation helpers. +- CLI compatibility tests remain green. +- Parity tests cover all operations available in both adapters. +- Tokens never reach frontend state, browser storage, URLs sent to the server or logs. +- The server binds locally by default and rejects cross-origin mutation attempts. +- macOS and Windows standalone binaries, plus the npm/Node distribution, pass native smoke tests. +- The UI adds no person or membership management and no implicit deletion path. From 475c17c1d1b02bb9acbd1a5472b6bc193c17dcdf Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 21:55:27 +0200 Subject: [PATCH 02/15] feat(gui): establish shared application boundary --- .../plans/2026-08-23-ui-core-projection.md | 2 +- src/application/contracts.ts | 46 +++++++ src/application/errors.ts | 40 ++++++ src/application/ports.ts | 21 ++++ src/application/project.ts | 63 ++++++++++ tests/application/project.test.ts | 114 ++++++++++++++++++ tests/architecture-boundaries.test.ts | 74 ++++++++++++ 7 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 src/application/contracts.ts create mode 100644 src/application/errors.ts create mode 100644 src/application/ports.ts create mode 100644 src/application/project.ts create mode 100644 tests/application/project.test.ts create mode 100644 tests/architecture-boundaries.test.ts diff --git a/docs/superpowers/plans/2026-08-23-ui-core-projection.md b/docs/superpowers/plans/2026-08-23-ui-core-projection.md index e7153c1..9cf98ef 100644 --- a/docs/superpowers/plans/2026-08-23-ui-core-projection.md +++ b/docs/superpowers/plans/2026-08-23-ui-core-projection.md @@ -171,7 +171,7 @@ changed yet. - Modify: existing plan command tests - [ ] Move all orchestration currently inside the Commander `.action()` into `runPlan(request, - dependencies?)`: environment preparation, config/catalog/state loading, session creation, +dependencies?)`: environment preparation, config/catalog/state loading, session creation, shared resolver creation, concurrent resource/permission plan construction, completeness and summary calculation. - [ ] Return `PlanResult` containing resource plan, permission items, summary, attribution, diff --git a/src/application/contracts.ts b/src/application/contracts.ts new file mode 100644 index 0000000..f5d9900 --- /dev/null +++ b/src/application/contracts.ts @@ -0,0 +1,46 @@ +/** JSON-compatible values used at the application/adapter boundary. */ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type OperationName = + "plan" | "apply" | "coverage" | "adopt" | "state" | "refresh" | "destroy" | "auth"; + +/** Common project selection accepted by CLI and, later, HTTP adapters. */ +export interface ProjectRequest { + cwd?: string; + configPath?: string; + statePath?: string; + environment?: string; +} + +/** Public, non-secret project context resolved before an operation starts. */ +export interface ResolvedProjectInfo { + cwd: string; + configPath: string; + statePath: string; + environmentsPath: string; + environment: string | null; + protected: boolean; + host: string; +} + +export interface CtWarning { + code: string; + message: string; + details?: Record; +} + +export interface OperationResult { + operation: OperationName; + project: ResolvedProjectInfo; + value: T; + warnings: CtWarning[]; +} + +export type OperationEvent = + | { type: "phase-started"; phase: string } + | { type: "resource-reading"; resourceType: string; key: string } + | { type: "resource-created"; resourceType: string; key: string; id: number } + | { type: "resource-updated"; resourceType: string; key: string; id: number } + | { type: "backup-written"; path: string } + | { type: "warning"; warning: CtWarning }; diff --git a/src/application/errors.ts b/src/application/errors.ts new file mode 100644 index 0000000..d5e3aa1 --- /dev/null +++ b/src/application/errors.ts @@ -0,0 +1,40 @@ +import type { JsonValue } from "./contracts.js"; + +export const APPLICATION_ERROR_CODES = [ + "PLAN_INCOMPLETE", + "AUTH_REQUIRED", + "HOST_MISMATCH", + "PROTECTED_ENV_CONFIRMATION_REQUIRED", + "PLAN_CONFIRMATION_MISMATCH", + "PREVENT_DESTROY", + "OPERATION_EXPIRED", + "OPERATION_ALREADY_USED", + "MUTATION_BUSY", +] as const; + +export type ApplicationErrorCode = (typeof APPLICATION_ERROR_CODES)[number]; + +/** Stable application error translated independently by CLI, HTTP and UI adapters. */ +export class CtApplicationError extends Error { + readonly code: ApplicationErrorCode; + readonly details?: Record; + + constructor( + code: ApplicationErrorCode, + message: string, + options: { details?: Record; cause?: unknown } = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = "CtApplicationError"; + this.code = code; + this.details = options.details; + } + + toJSON(): { code: ApplicationErrorCode; message: string; details?: Record } { + return { + code: this.code, + message: this.message, + ...(this.details ? { details: this.details } : {}), + }; + } +} diff --git a/src/application/ports.ts b/src/application/ports.ts new file mode 100644 index 0000000..0088fab --- /dev/null +++ b/src/application/ports.ts @@ -0,0 +1,21 @@ +import type { OperationEvent } from "./contracts.js"; + +export interface Clock { + now(): Date; +} + +export interface IdGenerator { + nextId(): string; +} + +export interface OperationObserver { + emit(event: OperationEvent): void; +} + +/** Serialize mutations that target the same state file. */ +export interface MutationLock { + runExclusive(statePath: string, operation: () => Promise): Promise; +} + +export const systemClock: Clock = { now: () => new Date() }; +export const noopObserver: OperationObserver = { emit: () => undefined }; diff --git a/src/application/project.ts b/src/application/project.ts new file mode 100644 index 0000000..36ebb82 --- /dev/null +++ b/src/application/project.ts @@ -0,0 +1,63 @@ +import { resolve } from "node:path"; +import { resolveConfig } from "../config.js"; +import { resolveConfigPath } from "../config/load.js"; +import { loadEnvProfile, resolveEnvsPath, type EnvProfile } from "../env/envs.js"; +import { resolveStatePath } from "../state/state.js"; +import type { ProjectRequest, ResolvedProjectInfo } from "./contracts.js"; + +export interface ProjectResolutionDependencies { + /** Runtime variables to resolve and wire. Defaults to process.env for CLI compatibility. */ + env?: NodeJS.ProcessEnv; + /** Base cwd provider, injectable so tests and a future server never need process.chdir(). */ + cwd?: () => string; + /** Stored-login host reader; the production default remains the existing keychain lookup. */ + readStoredHost?: () => Promise; +} + +function absoluteFrom(cwd: string, path: string): string { + return resolve(cwd, path); +} + +function wireProfile(profile: EnvProfile, env: NodeJS.ProcessEnv): void { + // A selected profile is authoritative and deliberately overrides ambient CT_HOST. + env.CT_HOST = profile.host; + if (profile.tokenEnv) { + const token = env[profile.tokenEnv]?.trim(); + if (token) env.CT_LOGINTOKEN = token; + } +} + +/** + * Resolve the common, non-secret project context for every application operation. + * + * Precedence remains identical to the CLI: explicit request → environment variable → default; + * a selected environment supplies the authoritative host and state fallback, while CT_STATE may + * still override that fallback. Relative paths are anchored to request.cwd instead of whichever + * directory an HTTP server happens to use. + */ +export async function resolveProject( + request: ProjectRequest = {}, + dependencies: ProjectResolutionDependencies = {}, +): Promise { + const env = dependencies.env ?? process.env; + const baseCwd = dependencies.cwd?.() ?? process.cwd(); + const cwd = resolve(baseCwd, request.cwd ?? "."); + const environmentsPath = absoluteFrom(cwd, resolveEnvsPath(undefined, env)); + + const profile = request.environment ? await loadEnvProfile(request.environment, environmentsPath) : null; + if (profile) wireProfile(profile, env); + + const configPath = absoluteFrom(cwd, resolveConfigPath(request.configPath, env)); + const statePath = absoluteFrom(cwd, resolveStatePath(request.statePath, env, profile?.statePath)); + const { host } = await resolveConfig(env, dependencies.readStoredHost); + + return { + cwd, + configPath, + statePath, + environmentsPath, + environment: profile?.name ?? null, + protected: profile?.protected ?? false, + host, + }; +} diff --git a/tests/application/project.test.ts b/tests/application/project.test.ts new file mode 100644 index 0000000..b131428 --- /dev/null +++ b/tests/application/project.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveProject } from "../../src/application/project.js"; +import { loadState } from "../../src/state/state.js"; + +const dirs: string[] = []; + +async function projectDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "ct-application-project-")); + dirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("resolveProject", () => { + it("resolves the default config and state from cwd without changing single-host precedence", async () => { + const cwd = await projectDir(); + const project = await resolveProject( + { cwd }, + { env: {}, cwd: () => "/ignored", readStoredHost: async () => "https://stored.church.tools/" }, + ); + + expect(project).toEqual({ + cwd, + configPath: join(cwd, "ct.config.ts"), + statePath: join(cwd, "ct-state.json"), + environmentsPath: join(cwd, "ct.envs.json"), + environment: null, + protected: false, + host: "https://stored.church.tools", + }); + }); + + it("keeps explicit paths ahead of environment variables", async () => { + const cwd = await projectDir(); + const project = await resolveProject( + { cwd, configPath: "explicit.config.ts", statePath: "explicit-state.json" }, + { + env: { + CT_CONFIG: "environment.config.ts", + CT_STATE: "environment-state.json", + CT_HOST: "https://env.church.tools/", + }, + }, + ); + + expect(project.configPath).toBe(join(cwd, "explicit.config.ts")); + expect(project.statePath).toBe(join(cwd, "explicit-state.json")); + expect(project.host).toBe("https://env.church.tools"); + }); + + it("selects one environment's host, state, protection and token reference", async () => { + const cwd = await projectDir(); + await writeFile( + join(cwd, "ct.envs.json"), + JSON.stringify({ + environments: { + prod: { + host: "https://prod.church.tools/", + state: "instances/prod/state.json", + protected: true, + tokenEnv: "CT_PROD_TOKEN", + }, + }, + }), + ); + const env: NodeJS.ProcessEnv = { + CT_HOST: "https://ambient.church.tools", + CT_PROD_TOKEN: " secret-token ", + }; + + const project = await resolveProject({ cwd, environment: "prod" }, { env }); + + expect(project).toMatchObject({ + environment: "prod", + protected: true, + host: "https://prod.church.tools", + statePath: join(cwd, "instances/prod/state.json"), + }); + expect(env.CT_HOST).toBe("https://prod.church.tools"); + expect(env.CT_LOGINTOKEN).toBe("secret-token"); + }); + + it("keeps CT_STATE above an environment's state fallback", async () => { + const cwd = await projectDir(); + await writeFile( + join(cwd, "profiles.json"), + JSON.stringify({ environments: { test: { host: "https://test.church.tools" } } }), + ); + const project = await resolveProject( + { cwd, environment: "test" }, + { env: { CT_ENVS: "profiles.json", CT_STATE: "override.json" } }, + ); + + expect(project.environmentsPath).toBe(join(cwd, "profiles.json")); + expect(project.statePath).toBe(join(cwd, "override.json")); + }); + + it("preserves the host-bound state refusal", async () => { + const cwd = await projectDir(); + await writeFile( + join(cwd, "ct-state.json"), + JSON.stringify({ version: 1, host: "https://other.church.tools", resources: {} }), + ); + const project = await resolveProject({ cwd }, { env: { CT_HOST: "https://target.church.tools" } }); + + await expect(loadState(project.statePath, project.host)).rejects.toThrow(/does not match CT_HOST/); + }); +}); diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts new file mode 100644 index 0000000..05eed9b --- /dev/null +++ b/tests/architecture-boundaries.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { readFile, readdir } from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +async function typescriptFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const nested = await Promise.all( + entries.map((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return typescriptFiles(path); + return entry.isFile() && entry.name.endsWith(".ts") ? [path] : []; + }), + ); + return nested.flat(); +} + +function imports(source: string): string[] { + return [...source.matchAll(/\bfrom\s+["']([^"']+)["']/g)].flatMap((match) => (match[1] ? [match[1]] : [])); +} + +describe("application architecture boundaries", () => { + it("keeps the application layer independent of presentation and transport adapters", async () => { + const files = await typescriptFiles(join(root, "src/application")); + const violations: string[] = []; + for (const file of files) { + const source = await readFile(file, "utf8"); + for (const specifier of imports(source)) { + if ( + ["commander", "hono", "vue"].includes(specifier) || + /(^|\/)commands\//.test(specifier) || + /(^|\/)server\//.test(specifier) || + /(^|\/)web\//.test(specifier) || + /(^|\/)ui(?:\/|\.js$)/.test(specifier) + ) { + violations.push(`${relative(root, file)} -> ${specifier}`); + } + } + } + expect(violations).toEqual([]); + }); + + it("pins the existing CLI mutation imports so no new adapter bypass is introduced", async () => { + const files = await typescriptFiles(join(root, "src/commands")); + const guarded = ["executePlan", "saveState", "writeBackup", "applyPermissionPlan"]; + const violations: string[] = []; + for (const file of files) { + const source = await readFile(file, "utf8"); + for (const symbol of guarded) { + if (new RegExp(`\\b${symbol}\\b`).test(source)) { + violations.push(`${relative(root, file)}:${symbol}`); + } + } + } + + // Tasks 2–4 remove this migration baseline as each command becomes a thin operation adapter. + // Until then, an additional direct mutation import fails this test instead of expanding silently. + expect(violations.sort()).toEqual( + [ + "src/commands/adopt-group.ts:saveState", + "src/commands/adopt.ts:saveState", + "src/commands/apply.ts:applyPermissionPlan", + "src/commands/apply.ts:executePlan", + "src/commands/apply.ts:saveState", + "src/commands/apply.ts:writeBackup", + "src/commands/destroy.ts:saveState", + "src/commands/destroy.ts:writeBackup", + "src/commands/state.ts:saveState", + ].sort(), + ); + }); +}); From b18c6a20b96b6ed42822b6e545fe442b0c78cc8d Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:05:39 +0200 Subject: [PATCH 03/15] refactor(plan): expose shared application operation --- .../plans/2026-08-23-ui-core-projection.md | 24 ++-- src/application/contracts.ts | 4 + src/application/operations/index.ts | 1 + src/application/operations/plan.ts | 135 ++++++++++++++++++ src/application/project.ts | 8 +- src/commands/plan.ts | 100 ++++--------- tests/application/plan-operation.test.ts | 114 +++++++++++++++ tests/application/project.test.ts | 4 + 8 files changed, 302 insertions(+), 88 deletions(-) create mode 100644 src/application/operations/index.ts create mode 100644 src/application/operations/plan.ts create mode 100644 tests/application/plan-operation.test.ts diff --git a/docs/superpowers/plans/2026-08-23-ui-core-projection.md b/docs/superpowers/plans/2026-08-23-ui-core-projection.md index 9cf98ef..a3d0eeb 100644 --- a/docs/superpowers/plans/2026-08-23-ui-core-projection.md +++ b/docs/superpowers/plans/2026-08-23-ui-core-projection.md @@ -140,19 +140,19 @@ changes. Only one mutation per state file may execute at a time. - Create: `tests/application/project.test.ts` - Create: `tests/architecture-boundaries.test.ts` -- [ ] Define `ProjectRequest`, `ResolvedProjectInfo`, operation result, warning, error and observer +- [x] Define `ProjectRequest`, `ResolvedProjectInfo`, operation result, warning, error and observer contracts. Keep values JSON-compatible where practical. -- [ ] Extract shared project/environment resolution from the command wrappers into +- [x] Extract shared project/environment resolution from the command wrappers into `resolveProject(request)`. It must retain current precedence for flags, environment profiles, `CT_CONFIG`, `CT_STATE`, host and token selection. -- [ ] Define narrow ports for clock/ID generation, operation events and mutation locking. Do not +- [x] Define narrow ports for clock/ID generation, operation events and mutation locking. Do not wrap pure existing domain functions merely to rename them. -- [ ] Add characterization tests for default config/state lookup, explicit paths, environment +- [x] Add characterization tests for default config/state lookup, explicit paths, environment selection, protected environments and host-bound state. -- [ ] Add an architecture test that scans imports and fails when `src/application/**` imports +- [x] Add an architecture test that scans imports and fails when `src/application/**` imports Commander, Hono or web code, or when adapter code imports mutation primitives such as `executePlan`, `saveState` or `applyPermissionPlan` directly. -- [ ] Run `npm test`, `npm run typecheck` and `npm run lint`. +- [x] Run `npm test`, `npm run typecheck` and `npm run lint`. **Exit criterion:** There is one shared way to resolve the project context, but no CLI behavior has changed yet. @@ -170,18 +170,18 @@ changed yet. - Create: `tests/application/plan-operation.test.ts` - Modify: existing plan command tests -- [ ] Move all orchestration currently inside the Commander `.action()` into `runPlan(request, +- [x] Move all orchestration currently inside the Commander `.action()` into `runPlan(request, dependencies?)`: environment preparation, config/catalog/state loading, session creation, shared resolver creation, concurrent resource/permission plan construction, completeness and summary calculation. -- [ ] Return `PlanResult` containing resource plan, permission items, summary, attribution, +- [x] Return `PlanResult` containing resource plan, permission items, summary, attribution, warnings, fetch errors, environment/host/version metadata and `complete`. -- [ ] Keep `renderPlan` and `renderPermissionPlan` as terminal renderers. They consume the operation +- [x] Keep `renderPlan` and `renderPermissionPlan` as terminal renderers. They consume the operation result; they do not participate in planning. -- [ ] Reduce `src/commands/plan.ts` to option parsing, operation invocation, rendering and exit-code +- [x] Reduce `src/commands/plan.ts` to option parsing, operation invocation, rendering and exit-code mapping (`--detailed-exitcode` included). -- [ ] Prove that the text and `--json` shapes remain compatible with existing tests. -- [ ] Add a test that calls `runPlan` directly and the CLI adapter against the same fixtures and +- [x] Prove that the text and `--json` shapes remain compatible with existing tests. +- [x] Add a test that calls `runPlan` directly and the CLI adapter against the same fixtures and compares their canonical plan/summary. **Exit criterion:** `ct plan` is only a projection of `runPlan`; a future HTTP handler can expose diff --git a/src/application/contracts.ts b/src/application/contracts.ts index f5d9900..0715fe2 100644 --- a/src/application/contracts.ts +++ b/src/application/contracts.ts @@ -16,9 +16,13 @@ export interface ProjectRequest { /** Public, non-secret project context resolved before an operation starts. */ export interface ResolvedProjectInfo { cwd: string; + /** Absolute paths used by operations. */ configPath: string; statePath: string; environmentsPath: string; + /** Effective flag/env/default spelling retained for byte-compatible CLI messages. */ + configDisplayPath: string; + stateDisplayPath: string; environment: string | null; protected: boolean; host: string; diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts new file mode 100644 index 0000000..d3d197f --- /dev/null +++ b/src/application/operations/index.ts @@ -0,0 +1 @@ +export * from "./plan.js"; diff --git a/src/application/operations/plan.ts b/src/application/operations/plan.ts new file mode 100644 index 0000000..f862431 --- /dev/null +++ b/src/application/operations/plan.ts @@ -0,0 +1,135 @@ +import { join } from "node:path"; +import { authedSession, type AuthedSession } from "../../api/session.js"; +import { loadConfig } from "../../config/load.js"; +import { buildPlan } from "../../engine/build.js"; +import { summarize, type Plan, type PlanAction } from "../../engine/types.js"; +import { CATALOG_DIR, loadHostCatalog } from "../../permissions/catalog-store.js"; +import { buildPermissionPlan, type PermissionPlanItem } from "../../permissions/plan.js"; +import { Resolver } from "../../resolve/resolver.js"; +import { loadState } from "../../state/state.js"; +import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; +import { noopObserver, type OperationObserver } from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; + +export type PlanRequest = ProjectRequest; + +export interface PlanSummary { + resources: Record; + drifted: number; + unreadable: number; + permissions: { + toPut: number; + toDelete: number; + preserved: number; + }; + hasChanges: boolean; +} + +export interface PlanValue { + plan: Plan; + permissions: PermissionPlanItem[]; + summary: PlanSummary; + complete: boolean; + fetchErrors: string[]; + churchToolsVersion: string | null; + stateHost: string; + permissionCatalogPath: string | null; +} + +export type PlanResult = OperationResult; + +type ResolverOptions = ConstructorParameters[0]; + +export interface PlanOperationDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadHostCatalog?: typeof loadHostCatalog; + loadConfig?: typeof loadConfig; + loadState?: typeof loadState; + authedSession?: () => Promise; + buildPlan?: typeof buildPlan; + buildPermissionPlan?: typeof buildPermissionPlan; + createResolver?: (options: ResolverOptions) => Resolver; + observer?: OperationObserver; +} + +function summarizePlan(plan: Plan, permissions: PermissionPlanItem[]): PlanSummary { + const hasResourceChanges = plan.items.some((item) => item.action !== "no-op"); + const hasPermissionChanges = permissions.some( + (item) => item.diff.toPut.length > 0 || item.diff.toDelete.length > 0, + ); + return { + resources: summarize(plan), + drifted: plan.items.filter((item) => item.drift && item.drift.length > 0).length, + unreadable: plan.items.filter((item) => item.note === "fetch-failed").length, + permissions: { + toPut: permissions.reduce((count, item) => count + item.diff.toPut.length, 0), + toDelete: permissions.reduce((count, item) => count + item.diff.toDelete.length, 0), + preserved: permissions.reduce((count, item) => count + item.diff.preserved.length, 0), + }, + hasChanges: hasResourceChanges || hasPermissionChanges, + }; +} + +/** Build the canonical read-only plan consumed by CLI and future HTTP/UI adapters. */ +export async function runPlan( + request: PlanRequest = {}, + dependencies: PlanOperationDependencies = {}, +): Promise { + const observer = dependencies.observer ?? noopObserver; + observer.emit({ type: "phase-started", phase: "resolve-project" }); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + + observer.emit({ type: "phase-started", phase: "load-project" }); + const catalogPath = await (dependencies.loadHostCatalog ?? loadHostCatalog)( + project.host, + join(project.cwd, CATALOG_DIR), + ); + const { + resources: desired, + permissions, + configDir, + } = await (dependencies.loadConfig ?? loadConfig)(project.configPath); + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const { client } = await (dependencies.authedSession ?? authedSession)(); + const resolver = (dependencies.createResolver ?? ((options) => new Resolver(options)))({ + client, + state, + desired, + host: project.host, + }); + + observer.emit({ type: "phase-started", phase: "build-plan" }); + const [resourceResult, permissionResult] = await Promise.all([ + (dependencies.buildPlan ?? buildPlan)(client, state, desired, { configDir, resolver }), + (dependencies.buildPermissionPlan ?? buildPermissionPlan)( + client, + state, + permissions, + desired, + resolver, + client.version ?? undefined, + ), + ]); + const fetchErrors = [...resourceResult.fetchErrors, ...permissionResult.fetchErrors]; + const warnings: CtWarning[] = permissionResult.warnings.map((message) => ({ + code: "PERMISSION_CATALOG", + message, + })); + + return { + operation: "plan", + project, + warnings, + value: { + plan: resourceResult.plan, + permissions: permissionResult.items, + summary: summarizePlan(resourceResult.plan, permissionResult.items), + complete: fetchErrors.length === 0, + fetchErrors, + churchToolsVersion: client.version, + stateHost: state.host, + permissionCatalogPath: catalogPath, + }, + }; +} diff --git a/src/application/project.ts b/src/application/project.ts index 36ebb82..659e405 100644 --- a/src/application/project.ts +++ b/src/application/project.ts @@ -47,8 +47,10 @@ export async function resolveProject( const profile = request.environment ? await loadEnvProfile(request.environment, environmentsPath) : null; if (profile) wireProfile(profile, env); - const configPath = absoluteFrom(cwd, resolveConfigPath(request.configPath, env)); - const statePath = absoluteFrom(cwd, resolveStatePath(request.statePath, env, profile?.statePath)); + const configDisplayPath = resolveConfigPath(request.configPath, env); + const stateDisplayPath = resolveStatePath(request.statePath, env, profile?.statePath); + const configPath = absoluteFrom(cwd, configDisplayPath); + const statePath = absoluteFrom(cwd, stateDisplayPath); const { host } = await resolveConfig(env, dependencies.readStoredHost); return { @@ -56,6 +58,8 @@ export async function resolveProject( configPath, statePath, environmentsPath, + configDisplayPath, + stateDisplayPath, environment: profile?.name ?? null, protected: profile?.protected ?? false, host, diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 4f13434..17f5b03 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,15 +1,7 @@ import { Command } from "commander"; -import { authedSession } from "../api/session.js"; -import { resolveConfig } from "../config.js"; -import { prepareEnv } from "../env/context.js"; -import { loadState } from "../state/state.js"; -import { loadConfig, resolveConfigPath } from "../config/load.js"; -import { buildPlan } from "../engine/build.js"; -import { Resolver } from "../resolve/resolver.js"; +import { relative } from "node:path"; +import { runPlan } from "../application/operations/plan.js"; import { renderPlan } from "../engine/render.js"; -import { summarize } from "../engine/types.js"; -import { buildPermissionPlan } from "../permissions/plan.js"; -import { loadHostCatalog } from "../permissions/catalog-store.js"; import { renderPermissionPlan } from "../permissions/render.js"; import { info, warn, out } from "../ui.js"; @@ -33,95 +25,55 @@ export function planCommand(): Command { "Terraform-style exit code: 0 = no changes, 1 = error, 2 = changes pending (resource or permission)", ) .action(async (opts: PlanOptions) => { - // Resolve the env FIRST — it wires the target host/token into the process env before resolveConfig. - const cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const configPath = resolveConfigPath(opts.config); - // A per-instance permission catalog this repo committed for THIS host wins over the one bundled - // with the release (#105). Loaded BEFORE loadConfig, not just before the plan: config evaluation - // validates `preserveUnknown` dimensions against the active catalog's KNOWN_SCOPE_FIELDS, so - // loading it later would validate against the bundled catalog and plan against the captured one. - const hostCatalog = await loadHostCatalog(config.host); - if (hostCatalog) info(`permission catalog: ${hostCatalog}`); - const { resources: desired, permissions, configDir } = await loadConfig(configPath); - // loadState already refuses a host mismatch (state.ts) — no second guard needed here. - const state = await loadState(cmdEnv.statePath, config.host); - - const { client } = await authedSession(); - // One shared resolver (#20): buildPlan and buildPermissionPlan run concurrently, so a single - // instance means each master-data catalog is fetched at most once (cache is Promise-keyed). - const resolver = new Resolver({ client, state, desired, host: config.host }); - // Independent fetches run concurrently (see commands/apply.ts). - const [ - { plan, fetchErrors }, - { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }, - ] = await Promise.all([ - buildPlan(client, state, desired, { configDir, resolver }), - buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), - ]); - // "Changes present" for --detailed-exitcode / the JSON summary: anything `ct apply` would - // actually act on — a resource item whose action isn't a no-op, OR a permission item with a - // grant/revoke to write. Drift by itself does NOT count: an item can carry `drift` while - // staying a no-op (the field drifted but isn't managed by config, or coincidentally matches - // desired again), and apply would write nothing for it — see docs/README "CI usage". - const hasResourceChanges = plan.items.some((i) => i.action !== "no-op"); - const hasPermissionChanges = permItems.some( - (i) => i.diff.toPut.length > 0 || i.diff.toDelete.length > 0, - ); - const hasChanges = hasResourceChanges || hasPermissionChanges; + const result = await runPlan({ + configPath: opts.config, + statePath: opts.state, + environment: opts.env, + }); + const { project, value } = result; + const catalogPath = value.permissionCatalogPath + ? relative(project.cwd, value.permissionCatalogPath) + : null; + if (catalogPath) info(`permission catalog: ${catalogPath}`); if (opts.json) { // Additive on top of the raw plan/permissions (#24) — existing consumers of `plan`/`permissions` // are unaffected. See README "CI usage" for exactly what each summary field means. out({ - plan, - permissions: permItems, - summary: { - resources: summarize(plan), - drifted: plan.items.filter((i) => i.drift && i.drift.length > 0).length, - // Non-zero means the plan is PARTIAL: these resources could not be read, so their diff - // is missing rather than empty. A machine consumer must not treat the plan as complete - // while this is > 0 (#126) — `ct plan` also exits 1 in that case. - unreadable: plan.items.filter((i) => i.note === "fetch-failed").length, - permissions: { - toPut: permItems.reduce((n, i) => n + i.diff.toPut.length, 0), - toDelete: permItems.reduce((n, i) => n + i.diff.toDelete.length, 0), - preserved: permItems.reduce((n, i) => n + i.diff.preserved.length, 0), - }, - hasChanges, - }, + plan: value.plan, + permissions: value.permissions, + summary: value.summary, }); } else { // Under --env, surface the target env name + its CT version (per-env version gate, #22) so a // dev/prod version skew is visible before applying. No --env keeps the original header byte-identical. - if (cmdEnv.name) { + if (project.environment) { info( - `env: ${cmdEnv.name} · host: ${config.host} · ChurchTools ${client.version ?? "unknown"} · ` + - `config: ${configPath} · state host: ${state.host}`, + `env: ${project.environment} · host: ${project.host} · ChurchTools ${value.churchToolsVersion ?? "unknown"} · ` + + `config: ${project.configDisplayPath} · state host: ${value.stateHost}`, ); } else { - info(`config: ${configPath} · state host: ${state.host}`); + info(`config: ${project.configDisplayPath} · state host: ${value.stateHost}`); } - process.stdout.write(`${renderPlan(plan)}\n`); - if (permItems.length > 0) { - process.stdout.write(`\n${renderPermissionPlan(permItems)}\n`); + process.stdout.write(`${renderPlan(value.plan)}\n`); + if (value.permissions.length > 0) { + process.stdout.write(`\n${renderPermissionPlan(value.permissions)}\n`); } } // Permission catalog warnings (#25): stale-version / unknown-authId. Informational — they do // not make the plan incomplete (unlike fetchErrors), so they never set a failing exit code. - for (const w of permWarnings) warn(w); + for (const warning of result.warnings) warn(warning.message); - const allFetchErrors = [...fetchErrors, ...permFetchErrors]; - if (allFetchErrors.length > 0) { + if (!value.complete) { warn( - `Plan is INCOMPLETE — ${allFetchErrors.length} resource(s) could not be fetched; their diff is missing. Re-run to retry.`, + `Plan is INCOMPLETE — ${value.fetchErrors.length} resource(s) could not be fetched; their diff is missing. Re-run to retry.`, ); // An INCOMPLETE plan is always an error (1) — even under --detailed-exitcode, and even if // the (partial) plan has changes. Never demoted to 2: an incomplete diff cannot be trusted // enough to report "changes present" instead of "this run failed". process.exitCode = 1; - } else if (opts.detailedExitcode && hasChanges) { + } else if (opts.detailedExitcode && value.summary.hasChanges) { process.exitCode = 2; } }); diff --git a/tests/application/plan-operation.test.ts b/tests/application/plan-operation.test.ts new file mode 100644 index 0000000..e6505fd --- /dev/null +++ b/tests/application/plan-operation.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Plan } from "../../src/engine/types.js"; +import type { PermissionPlanItem } from "../../src/permissions/plan.js"; + +const resourcePlan: Plan = { + items: [ + { + type: "campus", + key: "mainz", + id: 0, + action: "update", + changes: [{ field: "name", from: "MZ", to: "Mainz", source: "config" }], + }, + ], +}; + +const permissionItems: PermissionPlanItem[] = [ + { + key: "team", + domainType: "group_role", + domainId: 7, + diff: { + toPut: [{ authId: 5, dataId: [], type: "grant" }], + toDelete: [], + preserved: [], + preservedUnknown: [], + }, + }, +]; + +vi.mock("../../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ + client: { get: vi.fn(), version: "3.140.0" }, + me: { id: 1 }, + })), +})); + +vi.mock("../../src/config/load.js", () => ({ + DEFAULT_CONFIG_PATH: "ct.config.ts", + resolveConfigPath: (explicit?: string) => explicit ?? "ct.config.ts", + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "." })), +})); + +vi.mock("../../src/engine/build.js", () => ({ + buildPlan: vi.fn(async () => ({ plan: resourcePlan, actual: new Map(), fetchErrors: [] })), +})); + +vi.mock("../../src/permissions/plan.js", () => ({ + buildPermissionPlan: vi.fn(async () => ({ + items: permissionItems, + fetchErrors: [], + warnings: ["catalog warning"], + })), +})); + +const { runPlan } = await import("../../src/application/operations/plan.js"); +const { planCommand } = await import("../../src/commands/plan.js"); +const { emptyState, saveState } = await import("../../src/state/state.js"); + +const host = "https://mychurch.church.tools"; +const statePath = join(tmpdir(), `ct-plan-operation-${process.pid}.json`); +const savedHost = process.env.CT_HOST; +let stdout = ""; +let stdoutSpy: { mockRestore: () => void }; + +beforeEach(async () => { + process.env.CT_HOST = host; + process.exitCode = 0; + await saveState(statePath, emptyState(host)); + stdout = ""; + stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => { + stdout += String(chunk); + return true; + }) as (typeof process.stdout)["write"]); +}); + +afterEach(async () => { + stdoutSpy.mockRestore(); + process.exitCode = 0; + if (savedHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = savedHost; + await rm(statePath, { force: true }); +}); + +describe("runPlan", () => { + it("is the canonical result projected unchanged by the CLI JSON adapter", async () => { + const events: string[] = []; + const direct = await runPlan( + { statePath }, + { + observer: { emit: (event) => events.push(event.type === "phase-started" ? event.phase : event.type) }, + }, + ); + + await planCommand().parseAsync(["--state", statePath, "--json"], { from: "user" }); + const cli = JSON.parse(stdout) as unknown; + + expect(cli).toEqual({ + plan: direct.value.plan, + permissions: direct.value.permissions, + summary: direct.value.summary, + }); + expect(direct).toMatchObject({ + operation: "plan", + project: { host, statePath }, + warnings: [{ code: "PERMISSION_CATALOG", message: "catalog warning" }], + value: { complete: true, churchToolsVersion: "3.140.0", stateHost: host }, + }); + expect(events).toEqual(["resolve-project", "load-project", "build-plan"]); + }); +}); diff --git a/tests/application/project.test.ts b/tests/application/project.test.ts index b131428..4a4ce7c 100644 --- a/tests/application/project.test.ts +++ b/tests/application/project.test.ts @@ -30,6 +30,8 @@ describe("resolveProject", () => { configPath: join(cwd, "ct.config.ts"), statePath: join(cwd, "ct-state.json"), environmentsPath: join(cwd, "ct.envs.json"), + configDisplayPath: "ct.config.ts", + stateDisplayPath: "ct-state.json", environment: null, protected: false, host: "https://stored.church.tools", @@ -51,6 +53,8 @@ describe("resolveProject", () => { expect(project.configPath).toBe(join(cwd, "explicit.config.ts")); expect(project.statePath).toBe(join(cwd, "explicit-state.json")); + expect(project.configDisplayPath).toBe("explicit.config.ts"); + expect(project.stateDisplayPath).toBe("explicit-state.json"); expect(project.host).toBe("https://env.church.tools"); }); From a209b8440c205a3a44054298058a5d6c9b793a70 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:15:57 +0200 Subject: [PATCH 04/15] refactor(apply): expose prepared application operation --- .../plans/2026-08-23-ui-core-projection.md | 16 +- src/application/operations/apply.ts | 306 ++++++++++++++++++ src/application/operations/index.ts | 1 + src/application/operations/plan.ts | 48 ++- src/application/prepared-operation-store.ts | 68 ++++ src/commands/apply.ts | 192 +++++------ tests/application/apply-operation.test.ts | 163 ++++++++++ tests/architecture-boundaries.test.ts | 4 - 8 files changed, 658 insertions(+), 140 deletions(-) create mode 100644 src/application/operations/apply.ts create mode 100644 src/application/prepared-operation-store.ts create mode 100644 tests/application/apply-operation.test.ts diff --git a/docs/superpowers/plans/2026-08-23-ui-core-projection.md b/docs/superpowers/plans/2026-08-23-ui-core-projection.md index a3d0eeb..22dcf1b 100644 --- a/docs/superpowers/plans/2026-08-23-ui-core-projection.md +++ b/docs/superpowers/plans/2026-08-23-ui-core-projection.md @@ -200,21 +200,21 @@ the complete plan without importing an engine, resolver, state store or ChurchTo - Create: `tests/application/apply-operation.test.ts` - Modify: existing apply and environment protection tests -- [ ] Implement `prepareApply(request)` using the same plan-building primitives as `runPlan`. +- [x] Implement `prepareApply(request)` using the same plan-building primitives as `runPlan`. Factor a private/shared plan builder rather than copy the orchestration. -- [ ] Return the rendered-independent proposal, exact prepared execution data, change count, +- [x] Return the rendered-independent proposal, exact prepared execution data, change count, warnings and a core-decided confirmation requirement (`yes` or exact environment name). -- [ ] Implement `executePreparedApply(prepared, proof)` so the core validates completeness, +- [x] Implement `executePreparedApply(prepared, proof)` so the core validates completeness, confirmation, expiry, state-file identity and mutation lock before any write. -- [ ] Keep backup-before-write, crash-safe state saves, dependency order, permission reconciliation +- [x] Keep backup-before-write, crash-safe state saves, dependency order, permission reconciliation and optional dynamic-group refresh in this operation. -- [ ] Convert informational milestones to optional `OperationEvent`s. Terminal rendering must remain +- [x] Convert informational milestones to optional `OperationEvent`s. Terminal rendering must remain byte-compatible where covered by tests. -- [ ] Reduce the Commander action to prepare, render, prompt, execute and map result/errors to exit +- [x] Reduce the Commander action to prepare, render, prompt, execute and map result/errors to exit status. -- [ ] Test that CLI and direct operation calls produce identical writes, backup behavior and +- [x] Test that CLI and direct operation calls produce identical writes, backup behavior and protected-environment refusal. -- [ ] Test that an expired/reused prepared operation and a changed state file are refused. +- [x] Test that an expired/reused prepared operation and a changed state file are refused. **Exit criterion:** There is no safety decision or mutation orchestration unique to the CLI. diff --git a/src/application/operations/apply.ts b/src/application/operations/apply.ts new file mode 100644 index 0000000..5924b1a --- /dev/null +++ b/src/application/operations/apply.ts @@ -0,0 +1,306 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { writeBackup } from "../../engine/backup.js"; +import { executePlan, type ExecuteResult } from "../../engine/execute.js"; +import { runPostApplyHooks } from "../../engine/synthetic.js"; +import { applyPermissionPlan, type PermissionApplyResult } from "../../permissions/apply.js"; +import { saveState } from "../../state/state.js"; +import { resolveWithEnv } from "../../util/resolve.js"; +import type { OperationResult } from "../contracts.js"; +import { CtApplicationError } from "../errors.js"; +import { InMemoryMutationLock, PreparedOperationStore } from "../prepared-operation-store.js"; +import { + noopObserver, + systemClock, + type Clock, + type MutationLock, + type OperationObserver, +} from "../ports.js"; +import { + buildPlanContext, + type BuiltPlanContext, + type PlanOperationDependencies, + type PlanRequest, + type PlanResult, +} from "./plan.js"; + +const PREPARED_APPLY_TTL_MS = 5 * 60 * 1000; + +export interface ApplyRequest extends PlanRequest { + backupDir?: string; + refresh?: boolean; +} + +export type ConfirmationRequirement = + { type: "none" } | { type: "yes" } | { type: "environment"; environment: string }; + +export type ConfirmationProof = { type: "yes" } | { type: "environment"; value: string }; + +export interface PreparedApply { + id: string; + plan: PlanResult; + changeCount: number; + confirmation: ConfirmationRequirement; + expiresAt: string; +} + +export interface ApplyValue { + backupPath: string | null; + resources: ExecuteResult; + permissions: PermissionApplyResult; + refreshed: boolean; + dynamicGroupKeys: string[]; +} + +export type ApplyResult = OperationResult; + +/** Internal prepared payload retained by the process-local store, never sent over HTTP. */ +export interface PreparedApplyExecution { + context: BuiltPlanContext; + stateFingerprint: string; + backupDir?: string; + refresh: boolean; + confirmation: ConfirmationRequirement; +} + +export interface ApplyOperationDependencies extends PlanOperationDependencies { + clock?: Clock; + observer?: OperationObserver; + store?: PreparedOperationStore; + lock?: MutationLock; + readStateFile?: (path: string) => Promise; + writeBackup?: typeof writeBackup; + executePlan?: typeof executePlan; + applyPermissionPlan?: typeof applyPermissionPlan; + runPostApplyHooks?: typeof runPostApplyHooks; + saveState?: typeof saveState; + env?: NodeJS.ProcessEnv; + preparedTtlMs?: number; +} + +const defaultStore = new PreparedOperationStore(); +const defaultLock = new InMemoryMutationLock(); + +function isNotFound(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +async function stateFingerprint( + path: string, + read: (path: string) => Promise = (value) => readFile(value, "utf8"), +): Promise { + try { + return createHash("sha256") + .update("present\0") + .update(await read(path)) + .digest("hex"); + } catch (error) { + if (isNotFound(error)) return createHash("sha256").update("missing").digest("hex"); + throw error; + } +} + +/** 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 resolveWithEnv(explicit, env.CT_BACKUP_DIR, join(dirname(statePath), "backups")); +} + +function confirmationFor(context: BuiltPlanContext, changeCount: number): ConfirmationRequirement { + if (changeCount === 0) return { type: "none" }; + if (context.result.project.protected) { + const environment = context.result.project.environment; + if (!environment) { + throw new CtApplicationError( + "PROTECTED_ENV_CONFIRMATION_REQUIRED", + "A protected project must name its environment before apply.", + ); + } + return { type: "environment", environment }; + } + return { type: "yes" }; +} + +function assertConfirmation(requirement: ConfirmationRequirement, proof?: ConfirmationProof): void { + if (requirement.type === "none") return; + if (requirement.type === "yes" && proof?.type === "yes") return; + if ( + requirement.type === "environment" && + proof?.type === "environment" && + proof.value === requirement.environment + ) { + return; + } + if (requirement.type === "environment") { + throw new CtApplicationError( + "PROTECTED_ENV_CONFIRMATION_REQUIRED", + `Protected environment "${requirement.environment}" was not confirmed.`, + { details: { environment: requirement.environment } }, + ); + } + throw new CtApplicationError("PLAN_CONFIRMATION_MISMATCH", "Apply confirmation was not provided."); +} + +/** Prepare the exact immutable proposal that a CLI or HTTP adapter asks a user to confirm. */ +export async function prepareApply( + request: ApplyRequest = {}, + dependencies: ApplyOperationDependencies = {}, +): Promise { + const context = await buildPlanContext(request, dependencies); + if (!context.result.value.complete) { + throw new CtApplicationError( + "PLAN_INCOMPLETE", + `Aborting: ${context.result.value.fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`, + { details: { fetchErrors: context.result.value.fetchErrors } }, + ); + } + + const summary = context.result.value.summary; + const changeCount = + summary.resources.create + + summary.resources.update + + summary.permissions.toPut + + summary.permissions.toDelete; + const confirmation = confirmationFor(context, changeCount); + const fingerprint = await stateFingerprint(context.result.project.statePath, dependencies.readStateFile); + const store = dependencies.store ?? defaultStore; + const stored = store.put( + { + context, + stateFingerprint: fingerprint, + backupDir: request.backupDir, + refresh: request.refresh ?? false, + confirmation, + }, + dependencies.preparedTtlMs ?? PREPARED_APPLY_TTL_MS, + ); + + return { + id: stored.id, + plan: context.result, + changeCount, + confirmation, + expiresAt: stored.expiresAt.toISOString(), + }; +} + +/** Validate and consume one prepared apply, then own every write from backup through refresh. */ +export async function executePreparedApply( + prepared: Pick, + proof?: ConfirmationProof, + dependencies: ApplyOperationDependencies = {}, +): Promise { + const store = dependencies.store ?? defaultStore; + const candidate = store.peek(prepared.id); + assertConfirmation(candidate.confirmation, proof); + const statePath = candidate.context.result.project.statePath; + const lock = dependencies.lock ?? defaultLock; + + return lock.runExclusive(statePath, async () => { + const stored = store.take(prepared.id); + const currentFingerprint = await stateFingerprint(statePath, dependencies.readStateFile); + if (currentFingerprint !== stored.stateFingerprint) { + throw new CtApplicationError( + "PLAN_CONFIRMATION_MISMATCH", + "The state file changed after this apply was prepared. Prepare and confirm a new plan.", + { details: { statePath } }, + ); + } + + const observer = dependencies.observer ?? noopObserver; + const { context } = stored; + const { project, warnings } = context.result; + const { plan, permissions } = context.result.value; + const dynamicGroupKeys = plan.items + .filter( + (item) => + item.action !== "no-op" && + item.action !== "delete" && + item.changes.some((change) => change.field === "dynamic"), + ) + .map((item) => item.key); + + if (candidate.confirmation.type === "none") { + return { + operation: "apply", + project, + warnings, + value: { + backupPath: null, + resources: { created: [], updated: [], skippedDeletes: [] }, + permissions: { granted: 0, deleted: 0, failed: [] }, + refreshed: false, + dynamicGroupKeys, + }, + }; + } + + observer.emit({ type: "phase-started", phase: "backup" }); + const backupPath = await (dependencies.writeBackup ?? writeBackup)( + resolveBackupDir(stored.backupDir, statePath, dependencies.env), + project.host, + context.actual, + (dependencies.clock ?? systemClock).now(), + ); + observer.emit({ type: "backup-written", path: backupPath }); + + observer.emit({ type: "phase-started", phase: "apply-resources" }); + const resources = await (dependencies.executePlan ?? executePlan)(plan, { + client: context.client, + state: context.state, + statePath, + save: dependencies.saveState ?? saveState, + }); + for (const key of resources.created) { + const item = plan.items.find((candidate) => candidate.key === key); + const id = context.state.resources[key]?.id; + if (item && id !== undefined) { + observer.emit({ type: "resource-created", resourceType: item.type, key, id }); + } + } + for (const key of resources.updated) { + const item = plan.items.find((candidate) => candidate.key === key); + const id = context.state.resources[key]?.id; + if (item && id !== undefined) { + observer.emit({ type: "resource-updated", resourceType: item.type, key, id }); + } + } + let permissionResult: PermissionApplyResult = { granted: 0, deleted: 0, failed: [] }; + let refreshed = false; + if (!resources.failed) { + observer.emit({ type: "phase-started", phase: "apply-permissions" }); + permissionResult = await (dependencies.applyPermissionPlan ?? applyPermissionPlan)( + permissions, + context.client, + context.state, + ); + if (permissionResult.failed.length === 0 && stored.refresh) { + observer.emit({ type: "phase-started", phase: "post-apply" }); + await (dependencies.runPostApplyHooks ?? runPostApplyHooks)(plan, context.state, context.client); + refreshed = true; + } + } + + return { + operation: "apply", + project, + warnings, + value: { + backupPath, + resources, + permissions: permissionResult, + refreshed, + dynamicGroupKeys, + }, + }; + }); +} diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index d3d197f..d0c462c 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -1 +1,2 @@ export * from "./plan.js"; +export * from "./apply.js"; diff --git a/src/application/operations/plan.ts b/src/application/operations/plan.ts index f862431..722aaca 100644 --- a/src/application/operations/plan.ts +++ b/src/application/operations/plan.ts @@ -6,7 +6,8 @@ import { summarize, type Plan, type PlanAction } from "../../engine/types.js"; import { CATALOG_DIR, loadHostCatalog } from "../../permissions/catalog-store.js"; import { buildPermissionPlan, type PermissionPlanItem } from "../../permissions/plan.js"; import { Resolver } from "../../resolve/resolver.js"; -import { loadState } from "../../state/state.js"; +import { loadState, type State } from "../../state/state.js"; +import type { CtClient } from "../../api/ctClient.js"; import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; import { noopObserver, type OperationObserver } from "../ports.js"; import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; @@ -53,6 +54,14 @@ export interface PlanOperationDependencies { observer?: OperationObserver; } +/** Internal execution context shared with prepared mutations; never serialize this object. */ +export interface BuiltPlanContext { + result: PlanResult; + client: CtClient; + state: State; + actual: Map>; +} + function summarizePlan(plan: Plan, permissions: PermissionPlanItem[]): PlanSummary { const hasResourceChanges = plan.items.some((item) => item.action !== "no-op"); const hasPermissionChanges = permissions.some( @@ -76,6 +85,14 @@ export async function runPlan( request: PlanRequest = {}, dependencies: PlanOperationDependencies = {}, ): Promise { + return (await buildPlanContext(request, dependencies)).result; +} + +/** Build once for both the read-only plan and the exact snapshot later consumed by apply. */ +export async function buildPlanContext( + request: PlanRequest = {}, + dependencies: PlanOperationDependencies = {}, +): Promise { const observer = dependencies.observer ?? noopObserver; observer.emit({ type: "phase-started", phase: "resolve-project" }); const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); @@ -118,18 +135,23 @@ export async function runPlan( })); return { - operation: "plan", - project, - warnings, - value: { - plan: resourceResult.plan, - permissions: permissionResult.items, - summary: summarizePlan(resourceResult.plan, permissionResult.items), - complete: fetchErrors.length === 0, - fetchErrors, - churchToolsVersion: client.version, - stateHost: state.host, - permissionCatalogPath: catalogPath, + client, + state, + actual: resourceResult.actual, + result: { + operation: "plan", + project, + warnings, + value: { + plan: resourceResult.plan, + permissions: permissionResult.items, + summary: summarizePlan(resourceResult.plan, permissionResult.items), + complete: fetchErrors.length === 0, + fetchErrors, + churchToolsVersion: client.version, + stateHost: state.host, + permissionCatalogPath: catalogPath, + }, }, }; } diff --git a/src/application/prepared-operation-store.ts b/src/application/prepared-operation-store.ts new file mode 100644 index 0000000..588882b --- /dev/null +++ b/src/application/prepared-operation-store.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; +import { CtApplicationError } from "./errors.js"; +import type { Clock, IdGenerator, MutationLock } from "./ports.js"; +import { systemClock } from "./ports.js"; + +interface StoredOperation { + value: T; + expiresAt: Date; + used: boolean; +} + +export class PreparedOperationStore { + private readonly operations = new Map>(); + + constructor( + private readonly clock: Clock = systemClock, + private readonly ids: IdGenerator = { nextId: () => randomUUID() }, + ) {} + + put(value: T, ttlMs: number): { id: string; expiresAt: Date } { + const id = this.ids.nextId(); + const expiresAt = new Date(this.clock.now().getTime() + ttlMs); + this.operations.set(id, { value, expiresAt, used: false }); + return { id, expiresAt }; + } + + peek(id: string): T { + const entry = this.getEntry(id); + return entry.value; + } + + take(id: string): T { + const entry = this.getEntry(id); + entry.used = true; + return entry.value; + } + + private getEntry(id: string): StoredOperation { + const entry = this.operations.get(id); + if (!entry || entry.used) { + throw new CtApplicationError( + "OPERATION_ALREADY_USED", + "Prepared operation is unknown or already used.", + ); + } + if (entry.expiresAt.getTime() <= this.clock.now().getTime()) { + throw new CtApplicationError("OPERATION_EXPIRED", "Prepared operation has expired. Prepare it again."); + } + return entry; + } +} + +/** Process-local, fail-fast lock. A later HTTP adapter can share this instance across requests. */ +export class InMemoryMutationLock implements MutationLock { + private readonly active = new Set(); + + async runExclusive(statePath: string, operation: () => Promise): Promise { + if (this.active.has(statePath)) { + throw new CtApplicationError("MUTATION_BUSY", `Another mutation is already using ${statePath}.`); + } + this.active.add(statePath); + try { + return await operation(); + } finally { + this.active.delete(statePath); + } + } +} diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 668ad59..c44a6d0 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -1,23 +1,15 @@ -import { dirname, join } from "node:path"; +import { relative } from "node:path"; import { Command } from "commander"; -import { authedSession } from "../api/session.js"; -import { resolveConfig } from "../config.js"; -import { prepareEnv } from "../env/context.js"; -import { loadState, saveState } from "../state/state.js"; -import { loadConfig, resolveConfigPath } from "../config/load.js"; -import { buildPlan } from "../engine/build.js"; -import { Resolver } from "../resolve/resolver.js"; -import { executePlan } from "../engine/execute.js"; -import { runPostApplyHooks } from "../engine/synthetic.js"; -import { writeBackup } from "../engine/backup.js"; +import { + executePreparedApply, + prepareApply, + resolveBackupDir, + type ConfirmationProof, +} from "../application/operations/apply.js"; +import { CtApplicationError } from "../application/errors.js"; import { renderPlan } from "../engine/render.js"; -import { summarize } from "../engine/types.js"; -import { buildPermissionPlan } from "../permissions/plan.js"; -import { loadHostCatalog } from "../permissions/catalog-store.js"; import { renderPermissionPlan } from "../permissions/render.js"; -import { applyPermissionPlan } from "../permissions/apply.js"; import { confirm, confirmEnv } from "../ui/prompt.js"; -import { resolveWithEnv } from "../util/resolve.js"; import { info, warn, success, error } from "../ui.js"; interface ApplyOptions { @@ -30,14 +22,8 @@ interface ApplyOptions { refresh?: 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 resolveWithEnv(explicit, env.CT_BACKUP_DIR, join(dirname(statePath), "backups")); -} +// Retain this command-module export for callers that used it before the application extraction. +export { resolveBackupDir }; export function applyCommand(): Command { return new Command("apply") @@ -53,134 +39,110 @@ export function applyCommand(): Command { "after a successful apply, POST /dynamicgroups/{id}/refresh for each changed dynamic group (per-group only)", ) .action(async (opts: ApplyOptions) => { - const cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const configPath = resolveConfigPath(opts.config); - const statePath = cmdEnv.statePath; - // A per-instance permission catalog this repo committed for THIS host wins over the bundled - // one (#105) — same precedence AND same ordering as `ct plan`, so the two never disagree about - // what a right is (config evaluation validates scope dimensions against the active catalog). - const hostCatalog = await loadHostCatalog(config.host); - if (hostCatalog) info(`permission catalog: ${hostCatalog}`); - const { resources: desired, permissions, configDir } = await loadConfig(configPath); - const state = await loadState(statePath, config.host); - - const { client } = await authedSession(); - // One shared resolver (#20) across both concurrent plans — see commands/plan.ts. - const resolver = new Resolver({ client, state, desired, host: config.host }); - // Independent fetches: the resource plan and the permission plan (whose instance-wide - // /permissions/ reads are slow) run concurrently rather than back-to-back. - const [ - { plan, actual, fetchErrors }, - { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }, - ] = await Promise.all([ - buildPlan(client, state, desired, { configDir, resolver }), - buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), - ]); - - // Permission catalog warnings (#25): stale-version / unknown-authId (untouched, never revoked). - for (const w of permWarnings) warn(w); + let prepared; + try { + prepared = await prepareApply({ + configPath: opts.config, + statePath: opts.state, + environment: opts.env, + backupDir: opts.backupDir, + refresh: opts.refresh, + }); + } catch (caught) { + if (caught instanceof CtApplicationError && caught.code === "PLAN_INCOMPLETE") { + error(caught.message); + process.exitCode = 1; + return; + } + throw caught; + } - const allFetchErrors = [...fetchErrors, ...permFetchErrors]; - if (allFetchErrors.length > 0) { - error( - `Aborting: ${allFetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`, - ); - process.exitCode = 1; - return; + const { project, value } = prepared.plan; + if (value.permissionCatalogPath) { + info(`permission catalog: ${relative(project.cwd, value.permissionCatalogPath)}`); } + for (const warning of prepared.plan.warnings) warn(warning.message); - process.stdout.write(`${renderPlan(plan)}\n`); - if (permItems.length > 0) { - process.stdout.write(`\n${renderPermissionPlan(permItems)}\n`); + process.stdout.write(`${renderPlan(value.plan)}\n`); + if (value.permissions.length > 0) { + process.stdout.write(`\n${renderPermissionPlan(value.permissions)}\n`); } - const deletes = plan.items.filter((i) => i.action === "delete"); + const deletes = value.plan.items.filter((item) => item.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}`); + for (const item of deletes) { + info(` ${item.type}.${item.key} (#${item.id}) — run: ct destroy --target ${item.key}`); } } - const s = summarize(plan); - const permChangeCount = permItems.reduce((n, i) => n + i.diff.toPut.length + i.diff.toDelete.length, 0); - const changeCount = s.create + s.update + permChangeCount; - if (changeCount === 0) { + if (prepared.changeCount === 0) { success("No changes to apply."); return; } - // Protected env (#22): typed confirmation of the env name is MANDATORY — --auto-approve does not - // bypass it. --confirm-env substitutes for the typed input in CI. Otherwise the normal - // y/N (skippable with --auto-approve) applies. - const ok = cmdEnv.protected - ? await confirmEnv(cmdEnv.name!, { confirmFlag: opts.confirmEnv }) - : await confirm(`Apply ${changeCount} change(s)?`, { assumeYes: opts.autoApprove }); - if (!ok) { + let proof: ConfirmationProof | undefined; + let confirmed = false; + if (prepared.confirmation.type === "environment") { + confirmed = await confirmEnv(prepared.confirmation.environment, { confirmFlag: opts.confirmEnv }); + if (confirmed) proof = { type: "environment", value: prepared.confirmation.environment }; + } else if (prepared.confirmation.type === "yes") { + confirmed = await confirm(`Apply ${prepared.changeCount} change(s)?`, { + assumeYes: opts.autoApprove, + }); + if (confirmed) proof = { type: "yes" }; + } else { + confirmed = true; + } + + if (!confirmed) { warn( - cmdEnv.protected - ? `Aborted — protected environment "${cmdEnv.name}" was not confirmed (no changes made).` + prepared.confirmation.type === "environment" + ? `Aborted — protected environment "${prepared.confirmation.environment}" was not confirmed (no changes made).` : "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) { + const result = await executePreparedApply(prepared, proof); + const applied = result.value; + if (applied.backupPath) info(`Backup written: ${applied.backupPath}`); + success( + `Applied: ${applied.resources.created.length} created, ${applied.resources.updated.length} updated.`, + ); + if (applied.resources.failed) { error( - `Stopped at ${result.failed.key}: ${result.failed.message}. State saved up to this point — re-run to resume.`, + `Stopped at ${applied.resources.failed.key}: ${applied.resources.failed.message}. State saved up to this point — re-run to resume.`, ); process.exitCode = 1; return; } - // Re-resolve scope dataIds against the POST-execute state (executePlan has upserted every - // created/recreated group's real id) so grants are never written with a stale/pending id. - const permResult = await applyPermissionPlan(permItems, client, state); - if (permResult.granted > 0 || permResult.deleted > 0) { - success(`Permissions applied: ${permResult.granted} granted, ${permResult.deleted} deleted.`); + if (applied.permissions.granted > 0 || applied.permissions.deleted > 0) { + success( + `Permissions applied: ${applied.permissions.granted} granted, ${applied.permissions.deleted} deleted.`, + ); } - if (permResult.failed.length > 0) { - // Mirror executePlan's resumable stance: report which tuples failed (not a raw stack) and - // exit non-zero. Grants are reconciled statelessly, so a plain re-run resumes idempotently. + if (applied.permissions.failed.length > 0) { error( - `${permResult.failed.length} permission write(s) failed — re-run to resume (grant reconciliation is idempotent):`, + `${applied.permissions.failed.length} permission write(s) failed — re-run to resume (grant reconciliation is idempotent):`, ); - for (const f of permResult.failed) { + for (const failure of applied.permissions.failed) { info( - ` ${f.method} ${f.path} (authId ${f.authId}${f.dataId.length ? ` dataId ${f.dataId.join(",")}` : ""}): ${f.message}`, + ` ${failure.method} ${failure.path} (authId ${failure.authId}${failure.dataId.length ? ` dataId ${failure.dataId.join(",")}` : ""}): ${failure.message}`, ); } process.exitCode = 1; return; } - if (opts.refresh) { - await runPostApplyHooks(plan, state, client); - } else { - // The auto-group model is the single most surprising thing about a green apply (#105): the - // ruleset is written and activated, but ChurchTools computes membership on its own schedule, - // so a freshly created auto-group is legitimately EMPTY right now. Saying so costs one line - // and removes the "did it work?" that otherwise follows every first apply. - const dynamicKeys = plan.items - .filter( - (i) => - i.action !== "no-op" && i.action !== "delete" && i.changes.some((c) => c.field === "dynamic"), - ) - .map((i) => i.key); - if (dynamicKeys.length > 0) { - info( - `${dynamicKeys.length} dynamic group(s) written and activated — ChurchTools materializes their ` + - `membership on its own schedule, so they may be empty for now. Force it with ` + - `\`ct refresh --group ${dynamicKeys[0]}\` (or re-run apply with --refresh).`, - ); - } + if (!opts.refresh && applied.dynamicGroupKeys.length > 0) { + info( + `${applied.dynamicGroupKeys.length} dynamic group(s) written and activated — ChurchTools materializes their ` + + `membership on its own schedule, so they may be empty for now. Force it with ` + + `\`ct refresh --group ${applied.dynamicGroupKeys[0]}\` (or re-run apply with --refresh).`, + ); } }); } diff --git a/tests/application/apply-operation.test.ts b/tests/application/apply-operation.test.ts new file mode 100644 index 0000000..65b272a --- /dev/null +++ b/tests/application/apply-operation.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CtApplicationError } from "../../src/application/errors.js"; +import { + executePreparedApply, + prepareApply, + type ApplyOperationDependencies, + type PreparedApplyExecution, +} from "../../src/application/operations/apply.js"; +import { PreparedOperationStore } from "../../src/application/prepared-operation-store.js"; +import type { Clock } from "../../src/application/ports.js"; +import type { Plan } from "../../src/engine/types.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; +const statePath = "/project/ct-state.prod.json"; +const resourcePlan: Plan = { + items: [ + { + type: "campus", + key: "mainz", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Mainz" }], + }, + ], +}; + +function harness(options: { protected?: boolean; environment?: string | null } = {}) { + let now = new Date("2026-08-25T20:00:00.000Z"); + let stateFile = "state-v1"; + const clock: Clock = { now: () => now }; + const store = new PreparedOperationStore(clock, { + nextId: () => "prepared-1", + }); + const order: string[] = []; + const execute = vi.fn(async () => { + order.push("execute"); + return { created: ["mainz"], updated: [], skippedDeletes: [] }; + }); + const backup = vi.fn(async () => { + order.push("backup"); + return "/project/backups/backup.json"; + }); + const client = { version: "3.140.0", get: vi.fn(), request: vi.fn() }; + const dependencies: ApplyOperationDependencies = { + clock, + store, + readStateFile: async () => stateFile, + resolveProject: vi.fn(async () => ({ + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath, + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "ct-state.prod.json", + environment: options.environment === undefined ? "prod" : options.environment, + protected: options.protected ?? true, + host, + })), + loadHostCatalog: vi.fn(async () => null), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + loadState: vi.fn(async () => emptyState(host)), + authedSession: vi.fn(async () => ({ + client, + me: { id: 1 }, + })) as unknown as ApplyOperationDependencies["authedSession"], + buildPlan: vi.fn(async () => ({ + plan: resourcePlan, + actual: new Map([["existing", { name: "Existing" }]]), + fetchErrors: [], + })), + buildPermissionPlan: vi.fn(async () => ({ items: [], fetchErrors: [], warnings: [] })), + writeBackup: backup, + executePlan: execute, + applyPermissionPlan: vi.fn(async () => ({ granted: 0, deleted: 0, failed: [] })), + }; + return { + dependencies, + execute, + backup, + order, + advance(ms: number) { + now = new Date(now.getTime() + ms); + }, + changeState(value: string) { + stateFile = value; + }, + }; +} + +async function expectCode(promise: Promise, code: CtApplicationError["code"]): Promise { + await expect(promise).rejects.toMatchObject({ name: "CtApplicationError", code }); +} + +describe("prepared apply operation", () => { + it("requires the exact protected environment and writes the backup before resources", async () => { + const test = harness(); + const prepared = await prepareApply({}, test.dependencies); + + expect(prepared).toMatchObject({ + id: "prepared-1", + changeCount: 1, + confirmation: { type: "environment", environment: "prod" }, + }); + await expectCode( + executePreparedApply(prepared, { type: "yes" }, test.dependencies), + "PROTECTED_ENV_CONFIRMATION_REQUIRED", + ); + expect(test.execute).not.toHaveBeenCalled(); + + const result = await executePreparedApply( + prepared, + { type: "environment", value: "prod" }, + test.dependencies, + ); + expect(test.order).toEqual(["backup", "execute"]); + expect(result).toMatchObject({ + operation: "apply", + value: { + backupPath: "/project/backups/backup.json", + resources: { created: ["mainz"] }, + }, + }); + }); + + it("refuses an expired prepared operation before backup or mutation", async () => { + const test = harness(); + const prepared = await prepareApply({}, { ...test.dependencies, preparedTtlMs: 100 }); + test.advance(100); + + await expectCode( + executePreparedApply(prepared, { type: "environment", value: "prod" }, test.dependencies), + "OPERATION_EXPIRED", + ); + expect(test.backup).not.toHaveBeenCalled(); + expect(test.execute).not.toHaveBeenCalled(); + }); + + it("is single-use", async () => { + const test = harness({ protected: false, environment: "dev" }); + const prepared = await prepareApply({}, test.dependencies); + await executePreparedApply(prepared, { type: "yes" }, test.dependencies); + + await expectCode( + executePreparedApply(prepared, { type: "yes" }, test.dependencies), + "OPERATION_ALREADY_USED", + ); + expect(test.execute).toHaveBeenCalledTimes(1); + }); + + it("refuses when the state file changed after prepare", async () => { + const test = harness({ protected: false, environment: "dev" }); + const prepared = await prepareApply({}, test.dependencies); + test.changeState("state-v2"); + + await expectCode( + executePreparedApply(prepared, { type: "yes" }, test.dependencies), + "PLAN_CONFIRMATION_MISMATCH", + ); + expect(test.backup).not.toHaveBeenCalled(); + expect(test.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts index 05eed9b..0eddf21 100644 --- a/tests/architecture-boundaries.test.ts +++ b/tests/architecture-boundaries.test.ts @@ -61,10 +61,6 @@ describe("application architecture boundaries", () => { [ "src/commands/adopt-group.ts:saveState", "src/commands/adopt.ts:saveState", - "src/commands/apply.ts:applyPermissionPlan", - "src/commands/apply.ts:executePlan", - "src/commands/apply.ts:saveState", - "src/commands/apply.ts:writeBackup", "src/commands/destroy.ts:saveState", "src/commands/destroy.ts:writeBackup", "src/commands/state.ts:saveState", From 0c37f0d871f108b52a07963dc4ede1a22c075432 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:22:22 +0200 Subject: [PATCH 05/15] refactor(coverage): expose shared application operation --- src/application/operations/coverage.ts | 116 +++++++++++++++++ src/application/operations/index.ts | 1 + src/commands/coverage.ts | 127 ++++--------------- tests/application/coverage-operation.test.ts | 93 ++++++++++++++ 4 files changed, 233 insertions(+), 104 deletions(-) create mode 100644 src/application/operations/coverage.ts create mode 100644 tests/application/coverage-operation.test.ts diff --git a/src/application/operations/coverage.ts b/src/application/operations/coverage.ts new file mode 100644 index 0000000..cbfd798 --- /dev/null +++ b/src/application/operations/coverage.ts @@ -0,0 +1,116 @@ +import { join } from "node:path"; +import type { CtClient } from "../../api/ctClient.js"; +import { parseDynamicGroupIds } from "../../api/dynamicGroups.js"; +import { authedSession, type AuthedSession } from "../../api/session.js"; +import { buildCoverageReport, decodeGroupsWithRoles, type CoverageReport } from "../../coverage/report.js"; +import { CATALOG_DIR, loadHostCatalog } from "../../permissions/catalog-store.js"; +import { fetchPermissionRows, type PermissionReader } from "../../permissions/fetch.js"; +import { slug } from "../../resources/registry.js"; +import { loadState, type State } from "../../state/state.js"; +import type { OperationResult, ProjectRequest } from "../contracts.js"; +import { noopObserver, type OperationObserver } from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; + +const GROUPS_WITH_ROLES = "/groups?include[]=roles"; + +export interface CoverageRequest extends ProjectRequest { + type?: string; + declarable?: boolean; + blocked?: boolean; +} + +export interface CoverageValue { + report: CoverageReport; + permissionCatalogPath: string | null; +} + +export type CoverageResult = OperationResult; + +export interface CoverageOperationDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadHostCatalog?: typeof loadHostCatalog; + loadState?: typeof loadState; + authedSession?: () => Promise; + collectCoverage?: typeof collectCoverage; + observer?: OperationObserver; +} + +/** Fetch the fixed, bounded set of host-wide reads needed by the pure coverage report builder. */ +export async function collectCoverage( + client: PermissionReader & Pick, + host: string, + state: State, +): Promise { + const [groupRows, groupTypeRows, dynamicRows, roleDefRows] = await Promise.all([ + client.getAll>(GROUPS_WITH_ROLES), + client.getAll>("/group/grouptypes"), + client.getAll("/dynamicgroups"), + client.getAll>("/group/roles"), + ]); + const groupRolePermissions = await fetchPermissionRows(client, "/permissions/group_role"); + + const groupTypeNames = new Map(); + for (const row of groupTypeRows.data) { + const id = Number(row.id); + if (Number.isFinite(id) && typeof row.name === "string") groupTypeNames.set(id, row.name); + } + const roleNamesById = new Map(); + for (const row of roleDefRows.data) { + const id = Number(row.id); + if (Number.isFinite(id) && typeof row.name === "string") roleNamesById.set(id, row.name); + } + + return buildCoverageReport({ + host, + state, + groups: decodeGroupsWithRoles(groupRows.data, roleNamesById), + groupTypeNames, + dynamicGroupIds: parseDynamicGroupIds(dynamicRows.data), + groupRolePermissions: Array.isArray(groupRolePermissions) ? groupRolePermissions : [], + }); +} + +/** Canonical coverage query shared by terminal and future HTTP projections. */ +export async function runCoverage( + request: CoverageRequest = {}, + dependencies: CoverageOperationDependencies = {}, +): Promise { + const observer = dependencies.observer ?? noopObserver; + observer.emit({ type: "phase-started", phase: "resolve-project" }); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + observer.emit({ type: "phase-started", phase: "load-project" }); + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const catalogPath = await (dependencies.loadHostCatalog ?? loadHostCatalog)( + project.host, + join(project.cwd, CATALOG_DIR), + ); + const { client } = await (dependencies.authedSession ?? authedSession)(); + observer.emit({ type: "phase-started", phase: "build-coverage" }); + const complete = await (dependencies.collectCoverage ?? collectCoverage)(client, project.host, state); + + let roleInstances = complete.roleInstances; + if (request.type) { + const wanted = request.type.trim(); + const typeIds = new Set( + complete.byType + .filter((item) => item.name === wanted || slug(item.name) === slug(wanted)) + .map((item) => item.groupTypeId), + ); + if (typeIds.size === 0) { + throw new Error( + `--type "${request.type}": no group type matches (checked by name and slug against this host).`, + ); + } + roleInstances = roleInstances.filter((item) => typeIds.has(item.groupTypeId)); + } + if (request.declarable) roleInstances = roleInstances.filter((item) => item.verdict.declarable); + if (request.blocked) roleInstances = roleInstances.filter((item) => !item.verdict.declarable); + + return { + operation: "coverage", + project, + warnings: [], + value: { report: { ...complete, roleInstances }, permissionCatalogPath: catalogPath }, + }; +} diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index d0c462c..95c94b7 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -1,2 +1,3 @@ export * from "./plan.js"; export * from "./apply.js"; +export * from "./coverage.js"; diff --git a/src/commands/coverage.ts b/src/commands/coverage.ts index 1fb6174..98d1b3c 100644 --- a/src/commands/coverage.ts +++ b/src/commands/coverage.ts @@ -1,29 +1,7 @@ -/** - * `ct coverage` (#103) — the audit "what exists on this host that I am not managing, and could I - * manage it?", built into the tool instead of hand-rolled per consumer repo. - * - * Reads only; writes nothing, touches no state. The joins it performs are the ones every repo was - * doing by hand (`?include[]=roles` + `/dynamicgroups` + `/permissions/group_role`, minus inherited - * rows); the verdict it computes — whether a role instance's grants are declarable — is the part only - * `ct` can produce, because it needs the scope-dimension knowledge that lives in this codebase. - */ +import { relative } from "node:path"; import { Command } from "commander"; -import { authedSession } from "../api/session.js"; -import type { CtClient } from "../api/ctClient.js"; -import { parseDynamicGroupIds } from "../api/dynamicGroups.js"; -import { resolveConfig } from "../config.js"; -import { prepareEnv } from "../env/context.js"; -import { - buildCoverageReport, - decodeGroupsWithRoles, - renderCoverage, - renderRoleInstances, - type CoverageReport, -} from "../coverage/report.js"; -import { fetchPermissionRows, type PermissionReader } from "../permissions/fetch.js"; -import { loadHostCatalog } from "../permissions/catalog-store.js"; -import { slug } from "../resources/registry.js"; -import { loadState, type State } from "../state/state.js"; +import { collectCoverage, runCoverage } from "../application/operations/coverage.js"; +import { renderCoverage, renderRoleInstances } from "../coverage/report.js"; import { info, out } from "../ui.js"; interface CoverageOptions { @@ -35,12 +13,8 @@ interface CoverageOptions { blocked?: boolean; } -/** - * `?include[]=roles` is the whole reason this command is cheap: without it, auditing role instances - * means one `/groups/{id}/roles` call per group (645 of them on eqrm prod). With it, the same data - * arrives in the paged group list. - */ -const GROUPS_WITH_ROLES = "/groups?include[]=roles"; +// Compatibility export for programmatic callers; orchestration now lives in the application layer. +export { collectCoverage }; export function coverageCommand(): Command { return new Command("coverage") @@ -52,87 +26,32 @@ export function coverageCommand(): Command { .option("--declarable", "list only role instances whose grants could be adopted today") .option("--blocked", "list only role instances blocked by an undeclarable scope dimension") .action(async (opts: CoverageOptions) => { - const cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const state = await loadState(cmdEnv.statePath, config.host); - // The declarability verdict is computed from the catalog's authIds and scope dimensions, so it - // must read the SAME catalog `ct plan` does for this host (#105) — otherwise a right the - // committed capture can name is reported here as "blocked by authId N" (failing a `--json` CI - // gate) while `ct plan` manages it without complaint. - const hostCatalog = await loadHostCatalog(config.host); - if (hostCatalog) info(`permission catalog: ${hostCatalog}`); - const { client } = await authedSession(); - - const report = await collectCoverage(client, config.host, state); - - let instances = report.roleInstances; - if (opts.type) { - const wanted = opts.type.trim(); - const typeIds = new Set( - report.byType - .filter((t) => t.name === wanted || slug(t.name) === slug(wanted)) - .map((t) => t.groupTypeId), - ); - if (typeIds.size === 0) { - throw new Error( - `--type "${opts.type}": no group type matches (checked by name and slug against this host).`, - ); - } - instances = instances.filter((r) => typeIds.has(r.groupTypeId)); + const result = await runCoverage({ + statePath: opts.state, + environment: opts.env, + type: opts.type, + declarable: opts.declarable, + blocked: opts.blocked, + }); + const { project, value } = result; + if (value.permissionCatalogPath) { + info(`permission catalog: ${relative(project.cwd, value.permissionCatalogPath)}`); } - if (opts.declarable) instances = instances.filter((r) => r.verdict.declarable); - if (opts.blocked) instances = instances.filter((r) => !r.verdict.declarable); - if (opts.json) { - out({ ...report, roleInstances: instances }); + out(value.report); return; } - if (cmdEnv.name) info(`env: ${cmdEnv.name} · host: ${config.host} · state: ${cmdEnv.statePath}`); - process.stdout.write(`${renderCoverage(report)}\n`); + if (project.environment) { + info(`env: ${project.environment} · host: ${project.host} · state: ${project.stateDisplayPath}`); + } + process.stdout.write(`${renderCoverage(value.report)}\n`); if (opts.type || opts.declarable || opts.blocked) { process.stdout.write( - instances.length > 0 ? `\n${renderRoleInstances(instances)}\n` : "\nNo role instances match.\n", + value.report.roleInstances.length > 0 + ? `\n${renderRoleInstances(value.report.roleInstances)}\n` + : "\nNo role instances match.\n", ); } }); } - -/** Fetch everything the report needs. Split out from the action so it is reusable and mockable. */ -export async function collectCoverage( - client: PermissionReader & Pick, - host: string, - state: State, -): Promise { - const [groupRows, groupTypeRows, dynamicRows, roleDefRows] = await Promise.all([ - client.getAll>(GROUPS_WITH_ROLES), - client.getAll>("/group/grouptypes"), - client.getAll("/dynamicgroups"), - client.getAll>("/group/roles"), - ]); - // Same guarded read the planner performs: one request while the endpoint stays un-paged, and a - // proper paging pass if it ever does paginate — never a silent first page (see permissions/fetch.ts). - const groupRolePermissions = await fetchPermissionRows(client, "/permissions/group_role"); - - const groupTypeNames = new Map(); - for (const row of groupTypeRows.data) { - const id = Number(row.id); - if (Number.isFinite(id) && typeof row.name === "string") groupTypeNames.set(id, row.name); - } - const roleNamesById = new Map(); - for (const row of roleDefRows.data) { - const id = Number(row.id); - if (Number.isFinite(id) && typeof row.name === "string") roleNamesById.set(id, row.name); - } - // `/dynamicgroups` returns BARE group ids, not objects — see api/dynamicGroups.ts (#113/#124). - const dynamicGroupIds = parseDynamicGroupIds(dynamicRows.data); - - return buildCoverageReport({ - host, - state, - groups: decodeGroupsWithRoles(groupRows.data, roleNamesById), - groupTypeNames, - dynamicGroupIds, - groupRolePermissions: Array.isArray(groupRolePermissions) ? groupRolePermissions : [], - }); -} diff --git a/tests/application/coverage-operation.test.ts b/tests/application/coverage-operation.test.ts new file mode 100644 index 0000000..b8b3efe --- /dev/null +++ b/tests/application/coverage-operation.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CoverageReport } from "../../src/coverage/report.js"; +import { + runCoverage, + type CoverageOperationDependencies, +} from "../../src/application/operations/coverage.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; +const report: CoverageReport = { + host, + groups: { total: 2, managed: 0, dynamic: 0, managedDynamic: 0 }, + grants: { + authored: 2, + roleInstances: 2, + declarable: 1, + blocked: 1, + blockingDimensions: ["cc_html_template"], + }, + byType: [{ groupTypeId: 7, name: "Local Lead", total: 2, managed: 0, dynamic: 0, unmanagedWithGrants: 2 }], + roleInstances: [ + { + domainId: 1, + groupId: 10, + groupName: "A", + groupTypeId: 7, + roleName: "Lead", + managedGroupKey: null, + verdict: { declarable: true, grantCount: 1, blockedBy: [], unknownAuthIds: [] }, + }, + { + domainId: 2, + groupId: 11, + groupName: "B", + groupTypeId: 7, + roleName: "Lead", + managedGroupKey: null, + verdict: { + declarable: false, + grantCount: 1, + blockedBy: ["cc_html_template"], + unknownAuthIds: [], + }, + }, + ], +}; + +function dependencies(events: string[]): CoverageOperationDependencies { + return { + observer: { + emit: (event) => events.push(event.type === "phase-started" ? event.phase : event.type), + }, + resolveProject: vi.fn(async () => ({ + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath: "/project/ct-state.dev.json", + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "ct-state.dev.json", + environment: "dev", + protected: false, + host, + })), + loadState: vi.fn(async () => emptyState(host)), + loadHostCatalog: vi.fn(async () => "/project/.ct/catalog.json"), + authedSession: vi.fn(async () => ({ + client: {}, + me: { id: 1 }, + })) as unknown as CoverageOperationDependencies["authedSession"], + collectCoverage: vi.fn(async () => report), + }; +} + +describe("runCoverage", () => { + it("returns one canonical filtered report and structured project metadata", async () => { + const events: string[] = []; + const result = await runCoverage({ type: "local_lead", blocked: true }, dependencies(events)); + + expect(result).toMatchObject({ + operation: "coverage", + project: { environment: "dev", host }, + value: { permissionCatalogPath: "/project/.ct/catalog.json" }, + }); + expect(result.value.report.roleInstances.map((item) => item.domainId)).toEqual([2]); + expect(events).toEqual(["resolve-project", "load-project", "build-coverage"]); + }); + + it("rejects an unknown group type before returning a misleading empty report", async () => { + await expect(runCoverage({ type: "unknown" }, dependencies([]))).rejects.toThrow( + '--type "unknown": no group type matches', + ); + }); +}); From d7ca4d965c314422c144665dcae76ba26e85a039 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:24:54 +0200 Subject: [PATCH 06/15] refactor(refresh): expose shared application operation --- src/application/operations/index.ts | 1 + src/application/operations/refresh.ts | 129 ++++++++++++++++++++ src/commands/refresh.ts | 120 ++++-------------- tests/application/refresh-operation.test.ts | 59 +++++++++ 4 files changed, 210 insertions(+), 99 deletions(-) create mode 100644 src/application/operations/refresh.ts create mode 100644 tests/application/refresh-operation.test.ts diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index 95c94b7..68762d2 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -1,3 +1,4 @@ export * from "./plan.js"; export * from "./apply.js"; export * from "./coverage.js"; +export * from "./refresh.js"; diff --git a/src/application/operations/refresh.ts b/src/application/operations/refresh.ts new file mode 100644 index 0000000..8c1718c --- /dev/null +++ b/src/application/operations/refresh.ts @@ -0,0 +1,129 @@ +import type { CtClient } from "../../api/ctClient.js"; +import { CtApiError } from "../../api/ctClient.js"; +import { fetchDynamicGroupIds } from "../../api/dynamicGroups.js"; +import { authedSession, type AuthedSession } from "../../api/session.js"; +import { assertNotPeople } from "../../engine/guard.js"; +import { loadState, type ManagedResource, type State } from "../../state/state.js"; +import type { OperationResult, ProjectRequest } from "../contracts.js"; +import { noopObserver, type OperationObserver } from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; + +export interface RefreshRequest extends ProjectRequest { + group?: string; + all?: boolean; +} + +export interface RefreshCounts { + created: number; + updated: number; + deleted: number; +} + +export interface RefreshOutcome { + key: string; + id: number; + counts: RefreshCounts | null; + error: string | null; +} + +export interface RefreshValue { + outcomes: RefreshOutcome[]; + failed: number; + fanOut: boolean; +} + +export type RefreshResult = OperationResult; + +export interface RefreshOperationDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadState?: typeof loadState; + authedSession?: () => Promise; + selectTargets?: typeof selectRefreshTargets; + observer?: OperationObserver; +} + +/** Select only managed dynamic groups; the host-wide refresh endpoint remains unreachable. */ +export async function selectRefreshTargets( + client: Pick, + state: State, + groupKey: string | undefined, +): Promise { + const dynamicIds = await fetchDynamicGroupIds(client); + if (groupKey !== undefined) { + const managed = state.resources[groupKey]; + if (!managed || managed.type !== "group") { + throw new Error( + `--group "${groupKey}" is not a managed group in this state file. Adopt or declare it first.`, + ); + } + if (!dynamicIds.has(managed.id)) { + throw new Error( + `--group "${groupKey}" (#${managed.id}) is not a dynamic group on this host — there is no ruleset to evaluate.`, + ); + } + return [managed]; + } + return Object.values(state.resources).filter( + (resource) => resource.type === "group" && dynamicIds.has(resource.id), + ); +} + +/** Canonical guarded refresh mutation used by CLI and future HTTP adapters. */ +export async function runRefresh( + request: RefreshRequest, + dependencies: RefreshOperationDependencies = {}, +): Promise { + if (!request.group && !request.all) { + throw new Error( + "Specify --group for one group, or --all to refresh every managed dynamic group. " + + "Refreshing recomputes membership, so the fan-out is never the default.", + ); + } + if (request.group && request.all) throw new Error("Specify only one of: --group, --all."); + + const observer = dependencies.observer ?? noopObserver; + observer.emit({ type: "phase-started", phase: "resolve-project" }); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const { client } = await (dependencies.authedSession ?? authedSession)(); + observer.emit({ type: "phase-started", phase: "select-refresh-targets" }); + const targets = await (dependencies.selectTargets ?? selectRefreshTargets)(client, state, request.group); + + const outcomes: RefreshOutcome[] = []; + observer.emit({ type: "phase-started", phase: "refresh-groups" }); + for (const target of targets) { + const path = `/dynamicgroups/${target.id}/refresh`; + assertNotPeople(path); + try { + const response = await client.request("POST", path); + outcomes.push({ key: target.key, id: target.id, counts: response?.[0] ?? null, error: null }); + } catch (caught) { + outcomes.push({ + key: target.key, + id: target.id, + counts: null, + error: caught instanceof CtApiError ? `HTTP ${caught.status}` : (caught as Error).message, + }); + } + } + + return { + operation: "refresh", + project, + warnings: + request.all && targets.length > 0 + ? [ + { + code: "REFRESH_FAN_OUT", + message: `Refreshing ${targets.length} managed dynamic group(s) — this recomputes membership.`, + }, + ] + : [], + value: { + outcomes, + failed: outcomes.filter((outcome) => outcome.error !== null).length, + fanOut: request.all ?? false, + }, + }; +} diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts index b942f47..7e8c402 100644 --- a/src/commands/refresh.ts +++ b/src/commands/refresh.ts @@ -1,26 +1,5 @@ -/** - * `ct refresh` (#105) — ask ChurchTools to materialize a dynamic group's membership NOW. - * - * `ct apply` writes the ruleset and flips the status; ChurchTools computes the membership on its own - * schedule. So a freshly created auto-group is legitimately EMPTY after a green apply, which reads as - * a failure to anyone who does not know the model. `ct apply --refresh` only covers dynamic groups - * CHANGED in that run, so it cannot re-evaluate an existing group and does nothing at all on a no-op - * plan — leaving no lever for "it's empty and I want to know whether the ruleset is wrong". - * - * Scope is deliberately per-group: `POST /dynamicgroups/{id}/refresh`. ChurchTools also exposes a - * legacy scheduler ping (`GET /?q=cron&standby=true`) that the admin UI's cron page hits, but that - * runs EVERY due scheduled job on the instance — far beyond auto-groups — so `ct` documents it (see - * docs/runbook-manual-surface.md) and never fires it. - */ import { Command } from "commander"; -import { authedSession } from "../api/session.js"; -import type { CtClient } from "../api/ctClient.js"; -import { CtApiError } from "../api/ctClient.js"; -import { fetchDynamicGroupIds } from "../api/dynamicGroups.js"; -import { resolveConfig } from "../config.js"; -import { prepareEnv } from "../env/context.js"; -import { assertNotPeople } from "../engine/guard.js"; -import { loadState, type ManagedResource, type State } from "../state/state.js"; +import { runRefresh, selectRefreshTargets } from "../application/operations/refresh.js"; import { error, info, success, warn } from "../ui.js"; interface RefreshOptions { @@ -30,12 +9,8 @@ interface RefreshOptions { all?: boolean; } -/** The per-group counts CT returns from POST /dynamicgroups/{id}/refresh. */ -interface RefreshResult { - created: number; - updated: number; - deleted: number; -} +// Compatibility export for callers that previously reused the command helper. +export { selectRefreshTargets }; export function refreshCommand(): Command { return new Command("refresh") @@ -47,81 +22,28 @@ export function refreshCommand(): Command { .option("--group ", "refresh this managed group only") .option("--all", "refresh every managed dynamic group (required to fan out — this changes membership)") .action(async (opts: RefreshOptions) => { - if (!opts.group && !opts.all) { - throw new Error( - "Specify --group for one group, or --all to refresh every managed dynamic group. " + - "Refreshing recomputes membership, so the fan-out is never the default.", - ); - } - if (opts.group && opts.all) { - throw new Error("Specify only one of: --group, --all."); - } - - const cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const state = await loadState(cmdEnv.statePath, config.host); - const { client } = await authedSession(); - - const targets = await selectTargets(client, state, opts.group); - if (targets.length === 0) { + const result = await runRefresh({ + statePath: opts.state, + environment: opts.env, + group: opts.group, + all: opts.all, + }); + for (const warning of result.warnings) warn(warning.message); + if (result.value.outcomes.length === 0) { info("No managed dynamic groups to refresh."); return; } - - let failed = 0; - for (const target of targets) { - const path = `/dynamicgroups/${target.id}/refresh`; - assertNotPeople(path); - try { - const res = await client.request("POST", path); - const r = res?.[0]; - success( - r - ? `refreshed ${target.key} (#${target.id}): +${r.created} ~${r.updated} -${r.deleted}` - : `refreshed ${target.key} (#${target.id})`, - ); - } catch (err) { - failed += 1; - error( - `Failed to refresh ${target.key} (#${target.id}): ${ - err instanceof CtApiError ? `HTTP ${err.status}` : (err as Error).message - }`, - ); + for (const outcome of result.value.outcomes) { + if (outcome.error) { + error(`Failed to refresh ${outcome.key} (#${outcome.id}): ${outcome.error}`); + continue; } + success( + outcome.counts + ? `refreshed ${outcome.key} (#${outcome.id}): +${outcome.counts.created} ~${outcome.counts.updated} -${outcome.counts.deleted}` + : `refreshed ${outcome.key} (#${outcome.id})`, + ); } - if (failed > 0) process.exitCode = 1; + if (result.value.failed > 0) process.exitCode = 1; }); } - -/** - * Which managed groups to refresh. Refuses a group that is not an auto-group on this host rather than - * POSTing to an endpoint that will 404 — "this group has no ruleset" is the answer the caller needs. - */ -async function selectTargets( - client: Pick, - state: State, - groupKey: string | undefined, -): Promise { - const dynamicIds = await fetchDynamicGroupIds(client); - - if (groupKey !== undefined) { - const managed = state.resources[groupKey]; - if (!managed || managed.type !== "group") { - throw new Error( - `--group "${groupKey}" is not a managed group in this state file. Adopt or declare it first.`, - ); - } - if (!dynamicIds.has(managed.id)) { - throw new Error( - `--group "${groupKey}" (#${managed.id}) is not a dynamic group on this host — there is no ruleset to evaluate.`, - ); - } - return [managed]; - } - - const all = Object.values(state.resources).filter((r) => r.type === "group" && dynamicIds.has(r.id)); - // Only ever the MANAGED ones: `ct` never touches a group the config does not own, and the - // all-groups /dynamicgroups/refresh endpoint (huge blast radius) is deliberately never called. - if (all.length > 0) warn(`Refreshing ${all.length} managed dynamic group(s) — this recomputes membership.`); - return all; -} diff --git a/tests/application/refresh-operation.test.ts b/tests/application/refresh-operation.test.ts new file mode 100644 index 0000000..488079e --- /dev/null +++ b/tests/application/refresh-operation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { runRefresh, type RefreshOperationDependencies } from "../../src/application/operations/refresh.js"; +import type { ManagedResource } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; +const targets: ManagedResource[] = [ + { type: "group", id: 10, key: "a", fields: {}, adoptedAt: "t", updatedAt: "t" }, + { type: "group", id: 11, key: "b", fields: {}, adoptedAt: "t", updatedAt: "t" }, +]; + +function dependencies(): RefreshOperationDependencies { + const request = vi + .fn() + .mockResolvedValueOnce([{ created: 2, updated: 1, deleted: 0 }]) + .mockRejectedValueOnce(new Error("boom")); + return { + resolveProject: vi.fn(async () => ({ + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath: "/project/state.json", + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "state.json", + environment: "dev", + protected: false, + host, + })), + loadState: vi.fn(async () => ({ version: 1 as const, host, resources: {} })), + authedSession: vi.fn(async () => ({ + client: { request }, + me: { id: 1 }, + })) as unknown as RefreshOperationDependencies["authedSession"], + selectTargets: vi.fn(async () => targets), + }; +} + +describe("runRefresh", () => { + it("keeps per-target success and failure structured while continuing the fan-out", async () => { + const result = await runRefresh({ all: true }, dependencies()); + expect(result.warnings).toEqual([ + { + code: "REFRESH_FAN_OUT", + message: "Refreshing 2 managed dynamic group(s) — this recomputes membership.", + }, + ]); + expect(result.value).toMatchObject({ + failed: 1, + fanOut: true, + outcomes: [ + { key: "a", counts: { created: 2, updated: 1, deleted: 0 }, error: null }, + { key: "b", counts: null, error: "boom" }, + ], + }); + }); + + it("requires an explicit single target or fan-out intent", async () => { + await expect(runRefresh({}, dependencies())).rejects.toThrow(/Specify --group .*--all/); + }); +}); From 206de5616c8b9d24a7b4754145fb48f14cb7ba4d Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:28:00 +0200 Subject: [PATCH 07/15] refactor(state): expose shared application operations --- src/application/operations/index.ts | 1 + src/application/operations/state.ts | 136 ++++++++++++++++++++ src/commands/state.ts | 144 +++------------------- tests/application/state-operation.test.ts | 72 +++++++++++ tests/architecture-boundaries.test.ts | 1 - 5 files changed, 229 insertions(+), 125 deletions(-) create mode 100644 src/application/operations/state.ts create mode 100644 tests/application/state-operation.test.ts diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index 68762d2..f6938a0 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -2,3 +2,4 @@ export * from "./plan.js"; export * from "./apply.js"; export * from "./coverage.js"; export * from "./refresh.js"; +export * from "./state.js"; diff --git a/src/application/operations/state.ts b/src/application/operations/state.ts new file mode 100644 index 0000000..4ec60a5 --- /dev/null +++ b/src/application/operations/state.ts @@ -0,0 +1,136 @@ +import { loadConfig } from "../../config/load.js"; +import { resourceType } from "../../resources/registry.js"; +import { collectRefs, isRef, type Ref } from "../../resolve/refs.js"; +import { loadState, saveState, type ManagedResource } from "../../state/state.js"; +import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; +import { InMemoryMutationLock } from "../prepared-operation-store.js"; +import type { MutationLock } from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; + +export interface StateOperationDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadState?: typeof loadState; + saveState?: typeof saveState; + loadConfig?: typeof loadConfig; + lock?: MutationLock; +} + +export type StateListResult = OperationResult<{ resources: ManagedResource[] }>; + +export interface StateRemoveRequest extends ProjectRequest { + type: string; + key: string; + force?: boolean; + dryRun?: boolean; +} + +export type StateRemoveResult = OperationResult<{ + entry: ManagedResource; + removed: boolean; + churchToolsContacted: false; +}>; + +const defaultLock = new InMemoryMutationLock(); + +export async function listState( + request: ProjectRequest = {}, + dependencies: StateOperationDependencies = {}, +): Promise { + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + return { + operation: "state", + project, + warnings: [], + value: { resources: Object.values(state.resources) }, + }; +} + +async function declaredKeys( + configPath: string, + dependencies: StateOperationDependencies, +): Promise> { + const { resources, permissions } = await (dependencies.loadConfig ?? loadConfig)(configPath); + const keys = new Set(resources.map((resource) => resource.key)); + const addRef = (ref: Ref): void => { + if (ref.kind === "group-role") keys.add(ref.group); + else if (ref.kind === "group-type-role") keys.add(ref.groupType); + else if (ref.kind === "group-member-field") keys.add(ref.group); + else keys.add(ref.key); + }; + for (const ref of collectRefs(permissions)) addRef(ref); + for (const permission of permissions) { + for (const grant of permission.grants) { + if (typeof grant === "string" || !Array.isArray(grant.scope)) continue; + for (const entry of grant.scope) { + if (typeof entry === "string" && entry.length > 0) keys.add(entry); + else if (entry !== null && typeof entry === "object" && !isRef(entry)) { + const values = Object.values(entry as Record); + if (values.length === 1 && typeof values[0] === "string" && values[0].length > 0) { + keys.add(values[0]); + } + } + } + } + } + return keys; +} + +export async function removeStateEntry( + request: StateRemoveRequest, + dependencies: StateOperationDependencies = {}, +): Promise { + resourceType(request.type); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const lock = dependencies.lock ?? defaultLock; + return lock.runExclusive(project.statePath, async () => { + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const entry = state.resources[request.key]; + if (!entry) { + throw new Error( + `No entry "${request.key}" in ${project.stateDisplayPath}. List them with \`ct state list\`.`, + ); + } + if (entry.type !== request.type) { + throw new Error( + `"${request.key}" in ${project.stateDisplayPath} is a ${entry.type} (#${entry.id}), not a ${request.type}. ` + + `Pass the right type, or list them with \`ct state list\`.`, + ); + } + + const warnings: CtWarning[] = []; + if (!request.force) { + try { + const declared = await declaredKeys(project.configPath, dependencies); + if (declared.has(request.key)) { + throw new Error( + `"${request.key}" is still declared in the config, so removing it from state would make the next ` + + `plan propose CREATING a resource that already exists on this host. Remove the ` + + `declaration first, or pass --force if you are deleting both in the same change.`, + ); + } + } catch (caught) { + if (caught instanceof Error && caught.message.includes("is still declared in the config")) + throw caught; + warnings.push({ + code: "CONFIG_UNREADABLE", + message: + `Could not read the config to check whether "${request.configPath ?? "the default config"}" still ` + + `declares this key (${caught instanceof Error ? caught.message : String(caught)}) — removing anyway.`, + }); + } + } + + if (!request.dryRun) { + delete state.resources[request.key]; + await (dependencies.saveState ?? saveState)(project.statePath, state); + } + return { + operation: "state", + project, + warnings, + value: { entry, removed: !request.dryRun, churchToolsContacted: false }, + }; + }); +} diff --git a/src/commands/state.ts b/src/commands/state.ts index 7683511..09634a1 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -1,10 +1,5 @@ import { Command } from "commander"; -import { resolveConfig } from "../config.js"; -import { loadConfig, resolveConfigPath } from "../config/load.js"; -import { prepareEnv } from "../env/context.js"; -import { loadState, saveState } from "../state/state.js"; -import { resourceType } from "../resources/registry.js"; -import { collectRefs, isRef, type Ref } from "../resolve/refs.js"; +import { listState, removeStateEntry } from "../application/operations/state.js"; import { info, out, success, warn } from "../ui.js"; interface StateOptions { @@ -27,23 +22,13 @@ export function stateCommand(): Command { .option("-s, --state ", "state file path (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .action(async (opts: StateOptions) => { - const cmdEnv = await prepareEnv(opts); - const statePath = cmdEnv.statePath; - const state = await loadState(statePath, (await resolveConfig()).host); - const resources = Object.values(state.resources); - info(`${resources.length} managed resource(s) in ${statePath} (host ${state.host}).`); - out(resources); + const result = await listState({ statePath: opts.state, environment: opts.env }); + info( + `${result.value.resources.length} managed resource(s) in ${result.project.stateDisplayPath} (host ${result.project.host}).`, + ); + out(result.value.resources); }); - // `ct state rm` — the missing inverse of `ct adopt` (#122). - // - // `ct adopt` writes an entry; nothing removed one. `ct destroy` is the opposite of what un-adopting - // means (it deletes the resource IN ChurchTools), so backing out an adoption meant hand-editing - // `ct-state..json` with a text editor or a `node -e` one-liner — the very file the tool - // insists is its own. Adopt-then-declare is the documented loop, so an adoption that turns out to - // be wrong is normal rather than exotic; leaving the entry in makes `plan` report a DESTROY for a - // resource where nothing is wrong, and the offline config-matches-state check cannot be satisfied - // without editing the file the check is checking. cmd .command("rm") .description("Un-adopt: remove a resource from the state file. Never touches ChurchTools.") @@ -55,50 +40,22 @@ export function stateCommand(): Command { .option("--force", "remove even though the key is still declared in the config") .option("--dry-run", "report what would be removed without writing") .action(async (type: string, key: string, opts: StateRmOptions) => { - // Validate the type against the registry first, so a typo is a clear error rather than a - // confusing "not in state" for an entry that could never have existed. - resourceType(type); - - const cmdEnv = await prepareEnv(opts); - const statePath = cmdEnv.statePath; - const state = await loadState(statePath, (await resolveConfig()).host); - - const entry = state.resources[key]; - if (!entry) { - throw new Error(`No entry "${key}" in ${statePath}. List them with \`ct state list\`.`); - } - if (entry.type !== type) { - throw new Error( - `"${key}" in ${statePath} is a ${entry.type} (#${entry.id}), not a ${type}. ` + - `Pass the right type, or list them with \`ct state list\`.`, - ); - } - - // Removing an entry the config still declares turns the next plan into a CREATE for a resource - // that already exists on the host — which then 400s on a duplicate name, or worse, succeeds and - // leaves a second copy. So it is refused by default; --force is there for the case where the - // declaration is being deleted in the same change. - if (!opts.force) { - const declared = await declaredKeys(opts.config); - if (declared?.has(key)) { - throw new Error( - `"${key}" is still declared in the config, so removing it from state would make the next ` + - `plan propose CREATING a resource that already exists on this host. Remove the ` + - `declaration first, or pass --force if you are deleting both in the same change.`, - ); - } - } - - if (opts.dryRun) { - info(`Would remove ${entry.type}.${key} (#${entry.id}) from ${statePath}.`); + const result = await removeStateEntry({ + type, + key, + statePath: opts.state, + environment: opts.env, + configPath: opts.config, + force: opts.force, + dryRun: opts.dryRun, + }); + for (const warning of result.warnings) warn(warning.message); + const entry = result.value.entry; + if (!result.value.removed) { + info(`Would remove ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`); return; } - - delete state.resources[key]; - await saveState(statePath, state); - success(`Removed ${entry.type}.${key} (#${entry.id}) from ${statePath}.`); - // The single most important thing to say: nothing happened in ChurchTools. Someone reaching for - // this command has usually just been told that `ct destroy` is the wrong tool. + success(`Removed ${entry.type}.${key} (#${entry.id}) from ${result.project.stateDisplayPath}.`); info( `ChurchTools was not contacted — #${entry.id} still exists there, now unmanaged. ` + `Re-adopt it with \`ct adopt ${type} ${entry.id}\`.`, @@ -107,64 +64,3 @@ export function stateCommand(): Command { return cmd; } - -/** - * The logical keys the config declares, or `undefined` when the config cannot be read. - * - * A missing/broken config must not block an un-adopt: backing out an adoption is exactly what you do - * when the config is mid-edit. So a load failure downgrades the guard to a warning rather than - * failing the command. - */ -/** - * Every logical key the config still names — as a declared resource, AND as a reference from a - * permission declaration. - * - * The permission half matters because a key can be referenced without being declared as a resource in - * the same breath: a `ct.groupRole({ group: "" })` domain, or a group-dimension - * `scope: [""]`. Removing such a key from state passes a resources-only guard and then hard-errors - * on the next `ct plan` in `resolveScope` ("does not resolve to a managed group") — which is precisely - * the broken-config outcome this guard exists to catch before the write, not after it. - * - * Embedded `{ __ctRef }` markers are collected structurally, so a new referencing position is covered - * the day it is added; compound refs contribute the key they actually name (`group-role` a group, - * `group-type-role` a group type). The scope sugar (`{ group: "x" }`) and the bare-string group key - * are NOT refs, so they are picked up explicitly — leniently, since a malformed entry is `ct plan`'s - * to report, and this guard must not turn it into a failure to read the config at all. - */ -async function declaredKeys(configOpt: string | undefined): Promise | undefined> { - try { - const { resources, permissions } = await loadConfig(resolveConfigPath(configOpt)); - const keys = new Set(resources.map((r) => r.key)); - const addRef = (r: Ref): void => { - if (r.kind === "group-role") keys.add(r.group); - else if (r.kind === "group-type-role") keys.add(r.groupType); - // A group-scoped member field (#135) is owned by a group, so the key it keeps alive is that - // group's — the field itself has no state entry of its own. - else if (r.kind === "group-member-field") keys.add(r.group); - else keys.add(r.key); - }; - for (const ref of collectRefs(permissions)) addRef(ref); - for (const p of permissions) { - for (const g of p.grants) { - if (typeof g === "string" || !Array.isArray(g.scope)) continue; - for (const entry of g.scope) { - // A bare string is a logical GROUP key; one-field sugar names a key on its dimension. - if (typeof entry === "string" && entry.length > 0) keys.add(entry); - else if (entry !== null && typeof entry === "object" && !isRef(entry)) { - const values = Object.values(entry as Record); - if (values.length === 1 && typeof values[0] === "string" && values[0].length > 0) { - keys.add(values[0]); - } - } - } - } - } - return keys; - } catch (err) { - warn( - `Could not read the config to check whether "${configOpt ?? "the default config"}" still ` + - `declares this key (${err instanceof Error ? err.message : String(err)}) — removing anyway.`, - ); - return undefined; - } -} diff --git a/tests/application/state-operation.test.ts b/tests/application/state-operation.test.ts new file mode 100644 index 0000000..1587e57 --- /dev/null +++ b/tests/application/state-operation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { listState, removeStateEntry } from "../../src/application/operations/state.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; + +function project() { + return { + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath: "/project/state.json", + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "state.json", + environment: "dev", + protected: false, + host, + }; +} + +describe("state operations", () => { + it("lists and removes through structured results without a ChurchTools dependency", async () => { + const state = emptyState(host); + state.resources.mainz = { + type: "campus", + id: 0, + key: "mainz", + fields: { name: "Mainz" }, + adoptedAt: "t", + updatedAt: "t", + }; + const saveState = vi.fn(); + const dependencies = { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState, + }; + + const listed = await listState({}, dependencies); + expect(listed.value.resources).toHaveLength(1); + const removed = await removeStateEntry({ type: "campus", key: "mainz" }, dependencies); + expect(removed.value).toMatchObject({ removed: true, churchToolsContacted: false }); + expect(saveState).toHaveBeenCalledOnce(); + expect(state.resources.mainz).toBeUndefined(); + }); + + it("keeps dry-run side-effect free", async () => { + const state = emptyState(host); + state.resources.mainz = { + type: "campus", + id: 0, + key: "mainz", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + const saveState = vi.fn(); + const result = await removeStateEntry( + { type: "campus", key: "mainz", dryRun: true }, + { + resolveProject: vi.fn(async () => project()), + loadState: vi.fn(async () => state), + loadConfig: vi.fn(async () => ({ resources: [], permissions: [], configDir: "/project" })), + saveState, + }, + ); + expect(result.value.removed).toBe(false); + expect(saveState).not.toHaveBeenCalled(); + expect(state.resources.mainz).toBeDefined(); + }); +}); diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts index 0eddf21..61f700f 100644 --- a/tests/architecture-boundaries.test.ts +++ b/tests/architecture-boundaries.test.ts @@ -63,7 +63,6 @@ describe("application architecture boundaries", () => { "src/commands/adopt.ts:saveState", "src/commands/destroy.ts:saveState", "src/commands/destroy.ts:writeBackup", - "src/commands/state.ts:saveState", ].sort(), ); }); From d011a78192173b349742e11d9267cd06db1da924 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:30:42 +0200 Subject: [PATCH 08/15] refactor(adopt): expose shared resource operation --- src/application/operations/adopt.ts | 120 ++++++++++++++++++++++ src/application/operations/index.ts | 1 + src/commands/adopt.ts | 87 +++++----------- tests/application/adopt-operation.test.ts | 53 ++++++++++ tests/architecture-boundaries.test.ts | 1 - 5 files changed, 200 insertions(+), 62 deletions(-) create mode 100644 src/application/operations/adopt.ts create mode 100644 tests/application/adopt-operation.test.ts diff --git a/src/application/operations/adopt.ts b/src/application/operations/adopt.ts new file mode 100644 index 0000000..352ee2c --- /dev/null +++ b/src/application/operations/adopt.ts @@ -0,0 +1,120 @@ +import { authedSession, type AuthedSession } from "../../api/session.js"; +import { configSnippet, resourceType } from "../../resources/registry.js"; +import { ReverseResolver } from "../../resolve/reverse.js"; +import { chooseAdoptKey, loadState, saveState, upsert, type UpsertAction } from "../../state/state.js"; +import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; +import { InMemoryMutationLock } from "../prepared-operation-store.js"; +import { systemClock, type Clock, type MutationLock } from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; + +export interface AdoptResourceRequest extends ProjectRequest { + type: string; + id: string | number; + key?: string; + rekey?: boolean; + dryRun?: boolean; +} + +export interface AdoptResourceValue { + type: string; + id: number; + key: string; + fields: Record; + config: string; + action: UpsertAction | null; + dryRun: boolean; +} + +export type AdoptResourceResult = OperationResult; + +type ReverseResolverLike = Pick; + +export interface AdoptOperationDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadState?: typeof loadState; + saveState?: typeof saveState; + authedSession?: () => Promise; + createReverseResolver?: (client: AuthedSession["client"]) => ReverseResolverLike; + clock?: Clock; + lock?: MutationLock; +} + +const defaultLock = new InMemoryMutationLock(); + +function parseId(raw: string | number): number { + const value = String(raw).trim(); + if (!/^\d+$/.test(value)) { + throw new Error(`Invalid id "${raw}" — expected a non-negative integer.`); + } + const id = Number.parseInt(value, 10); + if (!Number.isSafeInteger(id)) throw new Error(`Invalid id "${raw}" — expected a safe integer.`); + return id; +} + +/** Adopt one non-group resource; group bulk/capture and grants remain separate operations. */ +export async function runAdoptResource( + request: AdoptResourceRequest, + dependencies: AdoptOperationDependencies = {}, +): Promise { + const spec = resourceType(request.type); + const id = parseId(request.id); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const lock = dependencies.lock ?? defaultLock; + return lock.runExclusive(project.statePath, async () => { + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.host); + const { client } = await (dependencies.authedSession ?? authedSession)(); + const resource = spec.fetchOne + ? await spec.fetchOne(client, id) + : await client.get>(spec.itemPath(id)); + if (!resource) throw new Error(`No ${request.type} with id ${id} exists in ChurchTools.`); + + const choice = chooseAdoptKey(state, request.type, id, spec.deriveKey(resource), { + explicitKey: request.key, + rekey: request.rekey, + }); + if (!choice.key) throw new Error("Could not derive a logical key — pass --key explicitly."); + const warnings: CtWarning[] = []; + if (choice.wouldBecome) { + warnings.push({ + code: "ADOPT_KEY_PRESERVED", + message: + `${choice.key}: key would change to "${choice.wouldBecome}" (derived from the live name). ` + + `Keeping the adopted key. Pass --rekey to change it.`, + }); + } + const fields = spec.managedFields(resource); + const reverse = (dependencies.createReverseResolver ?? ((value) => new ReverseResolver(value)))(client); + const { fields: sugared, todos } = await reverse.sugarFields(fields); + const config = configSnippet(request.type, choice.key, sugared, { todos }); + let action: UpsertAction | null = null; + if (!request.dryRun) { + action = upsert( + state, + { type: request.type, id, key: choice.key, fields }, + (dependencies.clock ?? systemClock).now().toISOString(), + ); + await (dependencies.saveState ?? saveState)(project.statePath, state); + if (action === "updated") { + warnings.push({ + code: "ADOPT_ALREADY_MANAGED", + message: "This resource was already managed — its snapshot was refreshed.", + }); + } + } + return { + operation: "adopt", + project, + warnings, + value: { + type: request.type, + id, + key: choice.key, + fields, + config, + action, + dryRun: request.dryRun ?? false, + }, + }; + }); +} diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index f6938a0..d457c1a 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -3,3 +3,4 @@ export * from "./apply.js"; export * from "./coverage.js"; export * from "./refresh.js"; export * from "./state.js"; +export * from "./adopt.js"; diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts index 82b89a0..2146e8a 100644 --- a/src/commands/adopt.ts +++ b/src/commands/adopt.ts @@ -1,10 +1,5 @@ import { Command } from "commander"; -import { authedSession } from "../api/session.js"; -import { resolveConfig } from "../config.js"; -import { prepareEnv } from "../env/context.js"; -import { resourceType, configSnippet } from "../resources/registry.js"; -import { ReverseResolver } from "../resolve/reverse.js"; -import { chooseAdoptKey, loadState, saveState, upsert } from "../state/state.js"; +import { runAdoptResource } from "../application/operations/adopt.js"; import { success, info, warn, out } from "../ui.js"; import { adoptGrantsCommand } from "./adopt-grants.js"; import { adoptGroupCommand } from "./adopt-group.js"; @@ -32,66 +27,36 @@ export function adoptCommand(): Command { ) .option("--dry-run", "preview the config entry and state change without writing") .action(async (type: string, rawId: string, opts: AdoptOptions) => { - const spec = resourceType(type); - if (!/^\d+$/.test(rawId.trim())) { - throw new Error(`Invalid id "${rawId}" — expected a non-negative integer.`); - } - const id = Number.parseInt(rawId, 10); - - // Load + validate the state file (host guard included) BEFORE any network - // call, so a state file recorded against another instance never triggers a - // live authenticated request against the wrong ChurchTools host. - const cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const statePath = cmdEnv.statePath; - const state = await loadState(statePath, config.host); - - const { client } = await authedSession(); - // A type whose reads have no item path (#108: Bereiche — CT offers `/departments` only) reads - // through the spec's own `fetchOne`. Going via `itemPath` would 404 on every invocation. - const resource = spec.fetchOne - ? await spec.fetchOne(client, id) - : await client.get>(spec.itemPath(id)); - if (!resource) { - throw new Error(`No ${type} with id ${id} exists in ChurchTools.`); - } - - const derived = spec.deriveKey(resource); - // An already-managed resource keeps its adopted key unless --rekey says otherwise (#123). - const choice = chooseAdoptKey(state, type, id, derived, { - explicitKey: opts.key, + const result = await runAdoptResource({ + type, + id: rawId, + key: opts.key, + statePath: opts.state, + environment: opts.env, rekey: opts.rekey, + dryRun: opts.dryRun, }); - const key = choice.key; - if (!key) { - throw new Error("Could not derive a logical key — pass --key explicitly."); + const adopted = result.value; + for (const warning of result.warnings.filter((item) => item.code === "ADOPT_KEY_PRESERVED")) { + warn(warning.message); } - if (choice.wouldBecome) { - warn( - `${key}: key would change to "${choice.wouldBecome}" (derived from the live name). ` + - `Keeping the adopted key. Pass --rekey to change it.`, - ); - } - const fields = spec.managedFields(resource); - // Reverse-resolve numeric ids (campusId/groupTypeId/groupStatusId) to logical sugar so the - // emitted snippet is portable and human-readable; unresolved ids stay numeric + a TODO (#52). - const { fields: sugared, todos } = await new ReverseResolver(client).sugarFields(fields); - const snippet = configSnippet(type, key, sugared, { todos }); - - if (opts.dryRun) { - info(`Would adopt ${type} #${id} as "${key}". Generated config entry:`); - out({ key, type, id, fields, config: snippet }); + if (adopted.dryRun) { + info(`Would adopt ${type} #${adopted.id} as "${adopted.key}". Generated config entry:`); + out({ + key: adopted.key, + type, + id: adopted.id, + fields: adopted.fields, + config: adopted.config, + }); return; } - - const now = new Date().toISOString(); - const action = upsert(state, { type, id, key, fields }, now); - await saveState(statePath, state); - - success(`${action === "created" ? "Adopted" : "Updated"} ${type} #${id} as "${key}" → ${statePath}`); - info(`Config entry: ${snippet}`); - if (action === "updated") { - warn("This resource was already managed — its snapshot was refreshed."); + success( + `${adopted.action === "created" ? "Adopted" : "Updated"} ${type} #${adopted.id} as "${adopted.key}" → ${result.project.stateDisplayPath}`, + ); + info(`Config entry: ${adopted.config}`); + for (const warning of result.warnings.filter((item) => item.code !== "ADOPT_KEY_PRESERVED")) { + warn(warning.message); } }); diff --git a/tests/application/adopt-operation.test.ts b/tests/application/adopt-operation.test.ts new file mode 100644 index 0000000..df5983f --- /dev/null +++ b/tests/application/adopt-operation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { runAdoptResource, type AdoptOperationDependencies } from "../../src/application/operations/adopt.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; + +describe("runAdoptResource", () => { + it("returns a portable proposal and owns the state write", async () => { + const state = emptyState(host); + const saveState = vi.fn(); + const client = { get: vi.fn(async () => ({ id: 0, name: "Mainz", shorty: "MZ" })) }; + const dependencies: AdoptOperationDependencies = { + resolveProject: vi.fn(async () => ({ + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath: "/project/state.json", + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "state.json", + environment: "dev", + protected: false, + host, + })), + loadState: vi.fn(async () => state), + saveState, + authedSession: vi.fn(async () => ({ + client, + me: { id: 1 }, + })) as unknown as AdoptOperationDependencies["authedSession"], + createReverseResolver: () => ({ + sugarFields: vi.fn(async (fields) => ({ fields, todos: new Set() })), + }), + clock: { now: () => new Date("2026-08-25T20:00:00.000Z") }, + }; + + const result = await runAdoptResource({ type: "campus", id: "0" }, dependencies); + expect(result).toMatchObject({ + operation: "adopt", + value: { type: "campus", id: 0, key: "mainz", action: "created", dryRun: false }, + }); + expect(result.value.config).toContain('key: "mainz"'); + expect(saveState).toHaveBeenCalledOnce(); + expect(state.resources.mainz).toMatchObject({ id: 0, adoptedAt: "2026-08-25T20:00:00.000Z" }); + }); + + it("rejects an invalid id before project or network resolution", async () => { + const resolveProject = vi.fn(); + await expect(runAdoptResource({ type: "campus", id: "3abc" }, { resolveProject })).rejects.toThrow( + /expected a non-negative integer/, + ); + expect(resolveProject).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts index 61f700f..f9d9278 100644 --- a/tests/architecture-boundaries.test.ts +++ b/tests/architecture-boundaries.test.ts @@ -60,7 +60,6 @@ describe("application architecture boundaries", () => { expect(violations.sort()).toEqual( [ "src/commands/adopt-group.ts:saveState", - "src/commands/adopt.ts:saveState", "src/commands/destroy.ts:saveState", "src/commands/destroy.ts:writeBackup", ].sort(), From 67d30191202fd590621c0ced8a31d7b1538ce5b5 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:33:12 +0200 Subject: [PATCH 09/15] refactor(auth): expose shared status operation --- src/application/operations/auth.ts | 95 ++++++++++++++++++++++++ src/application/operations/index.ts | 1 + src/commands/auth.ts | 72 +++++------------- tests/application/auth-operation.test.ts | 60 +++++++++++++++ 4 files changed, 175 insertions(+), 53 deletions(-) create mode 100644 src/application/operations/auth.ts create mode 100644 tests/application/auth-operation.test.ts diff --git a/src/application/operations/auth.ts b/src/application/operations/auth.ts new file mode 100644 index 0000000..8c18d33 --- /dev/null +++ b/src/application/operations/auth.ts @@ -0,0 +1,95 @@ +import { resolve } from "node:path"; +import { authedSession, type AuthedSession } from "../../api/session.js"; +import type { WhoAmI } from "../../api/ctClient.js"; +import { checkAllEnvAuth, type EnvAuthStatus } from "../../auth/status.js"; +import { readToken } from "../../auth/tokenStore.js"; +import { loadEnvProfiles, resolveEnvsPath } from "../../env/envs.js"; +import { CtApplicationError } from "../errors.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; + +export interface AuthStatusRequest { + cwd?: string; + environment?: string; + all?: boolean; +} + +export interface AuthStatusResult { + operation: "auth"; + scope: "single" | "all"; + environment: string | null; + host: string | null; + identity: WhoAmI | null; + environments: EnvAuthStatus[]; + authenticated: boolean; + environmentsPath: string; +} + +export interface AuthStatusDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + readToken?: typeof readToken; + authedSession?: () => Promise; + loadEnvProfiles?: typeof loadEnvProfiles; + checkAllEnvAuth?: typeof checkAllEnvAuth; + env?: NodeJS.ProcessEnv; + cwd?: () => string; +} + +/** Return authentication identity and source metadata without ever returning a token. */ +export async function runAuthStatus( + request: AuthStatusRequest = {}, + dependencies: AuthStatusDependencies = {}, +): Promise { + if (request.all && request.environment) { + throw new Error("--all reports every environment; drop --env (or drop --all to check just one)."); + } + const env = dependencies.env ?? process.env; + const cwd = resolve(dependencies.cwd?.() ?? process.cwd(), request.cwd ?? "."); + const environmentsPath = resolve(cwd, resolveEnvsPath(undefined, env)); + if (request.all) { + const profiles = await (dependencies.loadEnvProfiles ?? loadEnvProfiles)(environmentsPath); + const environments = await (dependencies.checkAllEnvAuth ?? checkAllEnvAuth)(profiles, { env }); + return { + operation: "auth", + scope: "all", + environment: null, + host: null, + identity: null, + environments, + authenticated: environments.length > 0 && environments.every((status) => status.identity !== undefined), + environmentsPath, + }; + } + + let project; + try { + project = await (dependencies.resolveProject ?? resolveProject)( + { cwd, environment: request.environment }, + { ...dependencies.project, env }, + ); + } catch (cause) { + throw new CtApplicationError( + "AUTH_REQUIRED", + "Not logged in. Run `ct auth login --host --token `.", + { cause }, + ); + } + if (!(await (dependencies.readToken ?? readToken)(project.host))) { + throw new CtApplicationError( + "AUTH_REQUIRED", + `No token for ${project.host}. Run \`ct auth login --host ${project.host} --token \`.`, + { details: { host: project.host } }, + ); + } + const { me } = await (dependencies.authedSession ?? authedSession)(); + return { + operation: "auth", + scope: "single", + environment: project.environment, + host: project.host, + identity: me, + environments: [], + authenticated: true, + environmentsPath, + }; +} diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index d457c1a..f008848 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -4,3 +4,4 @@ export * from "./coverage.js"; export * from "./refresh.js"; export * from "./state.js"; export * from "./adopt.js"; +export * from "./auth.js"; diff --git a/src/commands/auth.ts b/src/commands/auth.ts index b4ae0d7..8e5b48a 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,19 +1,13 @@ import { Command } from "commander"; +import { runAuthStatus } from "../application/operations/auth.js"; import { CtClient } from "../api/ctClient.js"; -import { normalizeHost, resolveConfig } from "../config.js"; -import { - storeCredentials, - readToken, - clearCredentials, - isSecureStorageAvailable, -} from "../auth/tokenStore.js"; +import { normalizeHost } from "../config.js"; +import { storeCredentials, clearCredentials, isSecureStorageAvailable } from "../auth/tokenStore.js"; import { keychainSessionCache } from "../auth/sessionStore.js"; import { bootstrapLoginToken } from "../auth/login.js"; import { askVisible } from "../ui/prompt.js"; -import { checkAllEnvAuth, renderEnvAuth, allEnvsAuthenticated } from "../auth/status.js"; -import { authedSession } from "../api/session.js"; -import { prepareEnvHost } from "../env/context.js"; -import { loadEnvProfile, loadEnvProfiles, resolveEnvsPath } from "../env/envs.js"; +import { renderEnvAuth } from "../auth/status.js"; +import { loadEnvProfile, resolveEnvsPath } from "../env/envs.js"; import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "../api/version.js"; import { success, error, info, warn, out, formatError } from "../ui.js"; @@ -22,23 +16,6 @@ import { success, error, info, warn, out, formatError } from "../ui.js"; * token exactly as an `--env` command would. Exits non-zero when any environment * has no working token, so CI can gate on it. */ -async function reportAllEnvs(): Promise { - const envsPath = resolveEnvsPath(); - const profiles = await loadEnvProfiles(envsPath); - if (profiles.length === 0) { - error(`No environments defined in ${envsPath}.`); - process.exitCode = 1; - return; - } - const statuses = await checkAllEnvAuth(profiles); - for (const line of renderEnvAuth(statuses)) { - process.stdout.write(`${line}\n`); - } - if (!allEnvsAuthenticated(statuses)) { - process.exitCode = 1; - } -} - /** Verify a personal token, cache the resulting session, store it, and report the login. */ export async function verifyAndStoreLoginToken(rawHost: string, rawToken: string): Promise { const host = normalizeHost(rawHost.trim()); @@ -128,35 +105,24 @@ export function authCommand(): Command { .option("-e, --env ", "environment profile from ct.envs.json (targets that host)") .option("--all", "report every environment in ct.envs.json (read-only preflight)") .action(async (opts: { env?: string; all?: boolean }) => { - if (opts.all) { - if (opts.env) { - error("--all reports every environment; drop --env (or drop --all to check just one)."); - process.exitCode = 1; + try { + const result = await runAuthStatus({ environment: opts.env, all: opts.all }); + if (result.scope === "all") { + if (result.environments.length === 0) { + error(`No environments defined in ${result.environmentsPath}.`); + process.exitCode = 1; + return; + } + for (const line of renderEnvAuth(result.environments)) process.stdout.write(`${line}\n`); + if (!result.authenticated) process.exitCode = 1; return; } - await reportAllEnvs(); - return; - } - - // #22 wiring: point the unchanged host/token resolution at the env's instance. - await prepareEnvHost(opts); - let host: string; - try { - host = (await resolveConfig()).host; - } catch { - error("Not logged in. Run `ct auth login --host --token `."); + info(result.environment ? `${result.host} (env ${result.environment})` : result.host!); + out(result.identity); + } catch (caught) { + error(formatError(caught)); process.exitCode = 1; - return; - } - if (!(await readToken(host))) { - error(`No token for ${host}. Run \`ct auth login --host ${host} --token \`.`); - process.exitCode = 1; - return; } - const { me } = await authedSession(); - // The host goes to stderr so `ct auth status | jq` keeps seeing only the identity. - info(opts.env ? `${host} (env ${opts.env})` : host); - out(me); }); cmd diff --git a/tests/application/auth-operation.test.ts b/tests/application/auth-operation.test.ts new file mode 100644 index 0000000..7e23a02 --- /dev/null +++ b/tests/application/auth-operation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { runAuthStatus } from "../../src/application/operations/auth.js"; + +const host = "https://example.church.tools"; + +describe("runAuthStatus", () => { + it("returns a non-secret identity for one environment", async () => { + const result = await runAuthStatus( + { environment: "dev" }, + { + resolveProject: vi.fn(async () => ({ + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath: "/project/state.json", + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "state.json", + environment: "dev", + protected: false, + host, + })), + readToken: vi.fn(async () => "secret-that-must-not-be-returned"), + authedSession: vi.fn(async () => ({ + client: {}, + me: { id: 7, firstName: "Ada", lastName: "Lovelace" }, + })) as never, + }, + ); + expect(result).toMatchObject({ + operation: "auth", + scope: "single", + host, + identity: { id: 7, firstName: "Ada" }, + authenticated: true, + }); + expect(JSON.stringify(result)).not.toContain("secret-that-must-not-be-returned"); + }); + + it("returns every environment status without flattening token sources into secrets", async () => { + const statuses = [ + { + name: "dev", + host, + source: { kind: "stored" as const }, + identity: { id: 7 }, + }, + ]; + const result = await runAuthStatus( + { all: true }, + { + cwd: () => "/project", + loadEnvProfiles: vi.fn(async () => [ + { name: "dev", host, statePath: "state.json", protected: false }, + ]), + checkAllEnvAuth: vi.fn(async () => statuses), + }, + ); + expect(result).toMatchObject({ scope: "all", authenticated: true, environments: statuses }); + }); +}); From 27c9fe3af4a0d82e185351030fbac45cf527a499 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:37:02 +0200 Subject: [PATCH 10/15] refactor(adopt): expose shared group operation --- src/application/operations/adopt-group.ts | 567 +++++++++++++++++++++ src/application/operations/index.ts | 1 + src/commands/adopt-group.ts | 585 ++-------------------- tests/architecture-boundaries.test.ts | 6 +- 4 files changed, 609 insertions(+), 550 deletions(-) create mode 100644 src/application/operations/adopt-group.ts diff --git a/src/application/operations/adopt-group.ts b/src/application/operations/adopt-group.ts new file mode 100644 index 0000000..278ff46 --- /dev/null +++ b/src/application/operations/adopt-group.ts @@ -0,0 +1,567 @@ +/** + * `ct adopt group` — bulk/filtered group adoption + `--with-dynamic` ruleset capture (#51). + * + * A dedicated subcommand (mirroring `adopt-grants.ts`'s pattern) rather than an extension of the + * generic `ct adopt ` action: bulk selection (`--type`, `--children-of`) and dynamic + * ruleset capture (`--with-dynamic`) are group-specific concepts with no analog for the other + * adoptable types. Commander matches this named subcommand before falling through to the base + * action, so every `ct adopt group ...` invocation — a single id, a list of ids, or a filter — + * routes here (the base action never sees `type === "group"`). + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { authedSession } from "../../api/session.js"; +import { CtApiError, type CtClient } from "../../api/ctClient.js"; +import { normalizeRuleset } from "../../engine/dynamic.js"; +import { + groupScopedRows, + localKeyOf, + MEMBER_FIELD_PROPS, + memberFieldId, + memberFieldStateKey, + memberFieldsReadPath, +} from "../../engine/member-fields.js"; +import type { DynamicStatus } from "../../engine/types.js"; +import { RESOURCES, configSnippet, fromInformation, slug } from "../../resources/registry.js"; +import { ReverseResolver, type RoleCatalogEntry } from "../../resolve/reverse.js"; +import type { RefKind } from "../../resolve/refs.js"; +import { formatPortablizeWarnings, portablizeRuleset, scanUnportablized } from "../../config/query-refs.js"; +import { chooseAdoptKey, loadState, saveState, upsert, type State } from "../../state/state.js"; +import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; +import { InMemoryMutationLock } from "../prepared-operation-store.js"; +import { resolveProject } from "../project.js"; + +export interface AdoptGroupRequest extends ProjectRequest { + ids: string[]; + key?: string; + dryRun?: boolean; + groupType?: string; + childrenOf?: string; + withDynamic?: boolean; + /** Opt in to capturing the group's group-scoped member-field definitions (#135). Never the default. */ + withMemberFields?: boolean; + /** Opt in to changing an already-managed group's logical key (#123). Never the default. */ + rekey?: boolean; + /** Commander's negatable `--no-portable-rulesets`: true unless the flag was passed (#101). */ + portableRulesets?: boolean; + strictRulesets?: boolean; +} + +export interface AdoptedGroupValue extends ResolvedAdoption { + type: "group"; + action: "created" | "updated" | null; +} + +export type AdoptGroupsResult = OperationResult<{ + groups: AdoptedGroupValue[]; + configBlock: string | null; + noMatches: boolean; + dryRun: boolean; +}>; + +const mutationLock = new InMemoryMutationLock(); + +const GROUP_SPEC = RESOURCES.group!; + +interface ResolvedAdoption { + id: number; + key: string; + fields: Record; + snippet: string; +} + +function isNonNegativeInt(raw: string): boolean { + return /^\d+$/.test(raw.trim()); +} + +/** Resolve `--type`'s numeric group-type id or logical key against the live `/group/grouptypes` catalog. */ +async function resolveGroupTypeId(raw: string, client: Pick): Promise { + const trimmed = raw.trim(); + if (isNonNegativeInt(trimmed)) return Number.parseInt(trimmed, 10); + const rows = await client.get>>("/group/grouptypes"); + const list = Array.isArray(rows) ? rows : []; + const bySlug = list.filter((r) => typeof r.name === "string" && slug(r.name as string) === trimmed); + const candidates = bySlug.length > 0 ? bySlug : list.filter((r) => r.name === trimmed); + if (candidates.length === 0) { + throw new Error( + `--type "${raw}": no group type matches (checked /group/grouptypes by slug and exact name).`, + ); + } + if (candidates.length > 1) { + const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${String(c.id)})`).join(", "); + throw new Error(`--type "${raw}" is ambiguous: ${candidates.length} group types match — ${listed}.`); + } + return Number(candidates[0]!.id); +} + +/** Resolve `--children-of`'s numeric id, adopted-state logical key, or live group name to a group id. */ +async function resolveGroupId( + raw: string, + client: Pick, + state: State, +): Promise { + const trimmed = raw.trim(); + if (isNonNegativeInt(trimmed)) return Number.parseInt(trimmed, 10); + const managed = state.resources[trimmed]; + if (managed && managed.type === "group") return managed.id; + const page = await client.getAll>("/groups"); + const rows = page.data; + const bySlug = rows.filter((r) => typeof r.name === "string" && slug(r.name as string) === trimmed); + const candidates = bySlug.length > 0 ? bySlug : rows.filter((r) => r.name === trimmed); + if (candidates.length === 0) { + throw new Error( + `--children-of "${raw}": not adopted (no state entry) and no live group matches ` + + `(checked /groups by slug and exact name).`, + ); + } + if (candidates.length > 1) { + const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${String(c.id)})`).join(", "); + throw new Error( + `--children-of "${raw}" is ambiguous: ${candidates.length} live groups match — ${listed}.`, + ); + } + return Number(candidates[0]!.id); +} + +/** + * Resolve one `/groups/{id}/children` row to the group id it points at. + * + * CT answers this endpoint with either plain group rows (`{ id }`) or domain resources + * (`{ domainType: "group", domainIdentifier, apiUrl }`). For a domain resource the authoritative + * group id is `domainIdentifier` — a sibling `id`, if CT ever emits one, is the hierarchy edge's + * own id — so `domainIdentifier` is read first and `id` only backs it up. `apiUrl` is the last + * resort. `parentId` is threaded in purely so the error names the group that actually failed: + * `--children-of` walks whole subtrees, and "some group somewhere" is not actionable. + */ +function childId(raw: unknown, parentId: number): number { + let candidate: unknown = raw; + if (raw !== null && typeof raw === "object") { + const child = raw as Record; + candidate = child.domainIdentifier ?? child.id; + if (candidate == null && typeof child.apiUrl === "string") { + candidate = /\/groups\/(\d+)(?:[/?#]|$)/.exec(child.apiUrl)?.[1]; + } + } + + const id = + typeof candidate === "number" + ? candidate + : typeof candidate === "string" && /^\d+$/.test(candidate.trim()) + ? Number.parseInt(candidate, 10) + : Number.NaN; + if (!Number.isSafeInteger(id) || id < 0) { + const shape = + raw !== null && typeof raw === "object" + ? `{ ${Object.keys(raw as Record).join(", ")} }` + : JSON.stringify(raw); + throw new Error( + `GET /groups/${parentId}/children returned a child without a usable id (${shape}); ` + + `expected a number or an object with id, domainIdentifier, or apiUrl.`, + ); + } + return id; +} + +/** + * Recursively collect a group's full hierarchy subtree via `/groups/{id}/children`, in + * parent-before-child (pre-order) sequence, excluding the root itself. Guards against a cyclic + * hierarchy (a live-API bug, not a valid DAG state) with a `visited` set — never re-descends into + * an id already seen, so a back-reference to an ancestor cannot loop forever. + * + * `/groups/{id}/children` is a paginated list endpoint, so it is read with `getAll`, never a plain + * `get` (#101): a plain GET returns only CT's default first page, which would silently drop the + * tail of a wide Bereich and every subtree hanging off it. `getAll` also absorbs CT's inconsistent + * page shapes (bare array vs. `{ data: [...] }`) and an empty 204 body, which for a leaf group is + * simply "no children". + */ +async function collectSubtreeIds(rootId: number, client: Pick): Promise { + const visited = new Set([rootId]); + const order: number[] = []; + + async function walk(id: number): Promise { + const page = await client.getAll(`/groups/${id}/children`); + for (const c of page.data) { + const cid = childId(c, id); + if (visited.has(cid)) continue; + visited.add(cid); + order.push(cid); + await walk(cid); + } + } + + await walk(rootId); + return order; +} + +/** List every group id whose live `groupTypeId` (top-level or under `information`) matches. */ +async function collectByGroupType(groupTypeId: number, client: Pick): Promise { + const page = await client.getAll>("/groups"); + return page.data + .filter((row) => Number(fromInformation(row, "groupTypeId")) === groupTypeId) + .map((row) => Number(row.id)) + .filter((id) => Number.isFinite(id)) + .sort((a, b) => a - b); +} + +interface DynamicCapture { + status: DynamicStatus; + normalizedRuleset: Record; +} + +interface MemberFieldsCapture { + /** Portable declarations for config output; deliberately contain no ChurchTools ids. */ + declarations: Array>; + /** Instance-bound identity map stored only on the owning group's state entry. */ + ids: Record; +} + +/** + * Capture a group's group-scoped member-field DEFINITIONS as portable declarations (#135). + * + * Emits `{ key, ...managed properties }` per field and **never a ChurchTools id** — the field's + * identity in config is the group key plus its local key (`ojbp_2026_27_praktikum_1::wahl`), so the + * same blueprint applied to another group, or another host, mints its own fields rather than + * resolving against this host's numbering. The local key comes from CT's own `referenceName` + * (slugged), falling back to the slugged name for a field created in the ChurchTools UI. + * + * Opt-in only (`--with-member-fields`) — and that opt-in is TRANSITIONAL, not a statement that + * member fields are optional: they are a category-2 owned structural child, so the flip to + * default-on is a follow-up governed by the promotion policy in docs/adoption-contract.md (the flag + * survives as a no-op, and `--no-member-fields` ships in the same release as the flip). + */ +async function captureMemberFields( + id: number, + client: Pick, + warnings: CtWarning[], +): Promise { + let raw: unknown; + try { + raw = await client.get(memberFieldsReadPath(id)); + } catch (err) { + // A group whose member fields cannot be read is not a reason to abort a bulk adoption of a whole + // subtree — say so and adopt the group without them. That holds for EVERY failure, not just the + // 404: a 403 on one group the token may not read the fields of, or a transient 429, would + // otherwise abort `--children-of` partway with the earlier groups already written to state. + // Silence is the one thing that is not allowed here, because "no member fields" and "could not + // read them" produce the same config. + if (!(err instanceof CtApiError && err.status === 404)) { + warnings.push({ + code: "MEMBER_FIELDS_UNREADABLE", + message: + `group #${id}: member fields could not be read (${err instanceof Error ? err.message : String(err)}) — ` + + `adopted WITHOUT them. Re-run \`ct adopt group ${id} --with-member-fields\` once the read succeeds.`, + }); + } + return undefined; + } + const rows = groupScopedRows(raw); + const declarations: Array> = []; + const ids: Record = {}; + for (const row of rows) { + const localKey = localKeyOf(row); + const canonical = memberFieldStateKey(localKey); + const fieldId = memberFieldId(row); + if (!canonical) { + warnings.push({ + code: "MEMBER_FIELD_IDENTITY_MISSING", + message: + `group #${id}: a group-scoped member field has neither referenceName nor name — adopted ` + + `WITHOUT member fields. Give it a name in ChurchTools, then re-run ` + + `\`ct adopt group ${id} --with-member-fields\`.`, + }); + return undefined; + } + if (fieldId === undefined) { + warnings.push({ + code: "MEMBER_FIELD_ID_MISSING", + message: + `group #${id} member field "${localKey}": the live response contains no numeric field id — ` + + `adopted WITHOUT member fields. Re-run \`ct adopt group ${id} --with-member-fields\` once ` + + `the response carries ids.`, + }); + return undefined; + } + if (ids[canonical] !== undefined) { + warnings.push({ + code: "MEMBER_FIELD_IDENTITY_AMBIGUOUS", + message: + `group #${id}: multiple group-scoped member fields resolve to the local key "${canonical}" — ` + + `adopted WITHOUT member fields. Rename one in ChurchTools, then re-run ` + + `\`ct adopt group ${id} --with-member-fields\`.`, + }); + return undefined; + } + ids[canonical] = fieldId; + const declaration: Record = { key: localKeyOf(row) }; + for (const prop of MEMBER_FIELD_PROPS) { + if (row[prop] !== undefined) declaration[prop] = row[prop]; + } + declarations.push(declaration); + } + return { declarations, ids }; +} + +/** Fetch + normalize a group's ruleset and status. `undefined` (never throws) if the group isn't dynamic. */ +async function captureDynamic( + id: number, + client: Pick, +): Promise { + let raw: unknown; + try { + raw = await client.get(`/dynamicgroups/${id}/ruleset`); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) return undefined; // not a dynamic group — skip silently + throw err; + } + const normalizedRuleset = normalizeRuleset(raw); + const statusRes = await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${id}/status`); + const status = (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus; + return { status, normalizedRuleset }; +} + +export async function runAdoptGroups(opts: AdoptGroupRequest): Promise { + const ids = opts.ids; + const warnings: CtWarning[] = []; + const selectors = [ids.length > 0, Boolean(opts.groupType), Boolean(opts.childrenOf)].filter( + Boolean, + ).length; + if (selectors === 0) { + throw new Error("Specify group id(s), --type , or --children-of ."); + } + if (selectors > 1) { + throw new Error("Specify only one of: group id(s), --type, --children-of."); + } + for (const raw of ids) { + if (!isNonNegativeInt(raw)) { + throw new Error(`Invalid id "${raw}" — expected a non-negative integer.`); + } + } + if (opts.key && ids.length > 1) { + throw new Error("--key is only valid when adopting a single group."); + } + + const project = await resolveProject(opts); + return mutationLock.runExclusive(project.statePath, async () => { + const state = await loadState(project.statePath, project.host); + const { client } = await authedSession(); + + let resolvedIds: number[]; + if (ids.length > 0) { + resolvedIds = ids.map((raw) => Number.parseInt(raw, 10)); + } else if (opts.groupType) { + const groupTypeId = await resolveGroupTypeId(opts.groupType, client); + resolvedIds = await collectByGroupType(groupTypeId, client); + } else { + const rootId = await resolveGroupId(opts.childrenOf!, client, state); + resolvedIds = await collectSubtreeIds(rootId, client); + } + resolvedIds = [...new Set(resolvedIds)]; + + if (opts.key && resolvedIds.length !== 1) { + throw new Error(`--key is only valid when adopting a single group (resolved ${resolvedIds.length}).`); + } + if (resolvedIds.length === 0) { + return { + operation: "adopt", + project, + warnings, + value: { groups: [], configBlock: null, noMatches: true, dryRun: opts.dryRun ?? false }, + }; + } + + const now = new Date().toISOString(); + // One reverse resolver across the whole (possibly bulk) run — each master-data catalog is + // fetched at most once and reused for every group's numeric-id → logical-sugar rewrite (#52). + const reverse = new ReverseResolver(client); + const results: ResolvedAdoption[] = []; + const reports: Array<{ action: "created" | "updated"; id: number; key: string }> = []; + + // Portable-ruleset catalogs (#76): fetch the catalog-backed id→key maps ONCE (campus/group-type). + // The `group` map is state-derived and rebuilt per capture (it grows as this run adopts). Roles are + // NOT a simple id→key map — a `role.id`/`groupTypeRoleId` is group-type-scoped and role names are + // not globally unique, so we fetch the (groupTypeId, name) catalog plus the group-type id→key map + // and let portablizeRuleset emit (group-type, role-name) markers (fixes #86's `role-def` mapping). + // + // Portablization is ON by default since #101 (`--no-portable-rulesets` opts out): leaving a + // capture host-specific fails SILENTLY on the next host — CT does not validate the ids inside a + // ruleset, so the auto-group just collects the wrong people while `ct plan` stays green. + const portableCatalogMaps: Partial>> = {}; + let roleCatalog: Map | undefined; + let groupTypeIdToKey: Map | undefined; + if (opts.withDynamic && opts.portableRulesets !== false) { + portableCatalogMaps.campus = await reverse.idToKeyByKind("campus"); + portableCatalogMaps["group-type"] = await reverse.idToKeyByKind("group-type"); + groupTypeIdToKey = portableCatalogMaps["group-type"]; + roleCatalog = await reverse.roleGroupTypeCatalog(); + } + + for (const id of resolvedIds) { + const resource = await client.get>(GROUP_SPEC.itemPath(id)); + // An already-managed group keeps its adopted key unless --rekey says otherwise (#123). This + // is the mode that made the bug bite: the documented ruleset-refresh workflow passes a LIST + // of ids, which is exactly when `-k` is rejected, so there was no way to prevent the re-key. + // Because `relPath` below is built from `key`, preserving it also makes the refresh overwrite + // the ruleset file the config already points at instead of writing a second one. + const choice = chooseAdoptKey(state, "group", id, GROUP_SPEC.deriveKey(resource), { + explicitKey: resolvedIds.length === 1 ? opts.key : undefined, + rekey: opts.rekey, + }); + const key = choice.key; + if (!key) { + throw new Error(`Could not derive a logical key for group #${id} — pass --key explicitly.`); + } + if (choice.wouldBecome) { + warnings.push({ + code: "ADOPT_KEY_PRESERVED", + message: + `${key}: key would change to "${choice.wouldBecome}" (derived from the live name). ` + + `Keeping the adopted key. Pass --rekey to change it.`, + }); + } + const fields = GROUP_SPEC.managedFields(resource); + + // Reverse-resolve the group's numeric ids to logical sugar for the emitted snippet; the + // captured `dynamic` block (if any) is appended AFTER, so it is not treated as an id field. + const { fields: sugared, todos } = await reverse.sugarFields(fields); + const snippetFields: Record = sugared; + // Emitted BEFORE `dynamic` so the snippet reads in apply order — the fields a ruleset may + // reference are declared above the ruleset that references them (#135). + const memberFields = opts.withMemberFields + ? await captureMemberFields(id, client, warnings) + : undefined; + if (memberFields && memberFields.declarations.length > 0) { + snippetFields.memberFields = memberFields.declarations; + } + if (opts.withDynamic) { + const captured = await captureDynamic(id, client); + if (captured) { + const relPath = `rulesets/${key}.json`; + let rulesetToWrite = captured.normalizedRuleset; + if (opts.portableRulesets !== false) { + // Managed group ids come from state (no catalog for `group`), including any group this + // same run already adopted; the master-data kinds come from the catalog maps above. + const groupMap = new Map(); + // Managed role definitions, likewise from STATE rather than the live catalog (#125): + // state gives a per-host id under a shared logical key, which is what makes a + // duplicate role name fixable by adopting the role instead of renaming master data. + // (`ReverseResolver.idToKeyByKind("role-def")` keys by slug(name) off the catalog and + // is exactly the ambiguous mapping this replaces.) + const roleDefMap = new Map(); + for (const r of Object.values(state.resources)) { + if (r.type === "group") groupMap.set(r.id, r.key); + if (r.type === "group-role") roleDefMap.set(r.id, r.key); + } + const { ruleset, warnings: portableWarnings } = portablizeRuleset(captured.normalizedRuleset, { + idToKeyByKind: { ...portableCatalogMaps, group: groupMap, "role-def": roleDefMap }, + roleCatalog, + groupTypeIdToKey, + }); + rulesetToWrite = ruleset; + // Report every dimension left numeric, with its reason (#101). The old output said only + // "left N unmanaged id(s) numeric", which named neither the dimension nor the fix — so a + // capture that silently froze prod's ids into a cross-host file looked like a clean run. + if (portableWarnings.length > 0) { + const lines = formatPortablizeWarnings(portableWarnings); + if (opts.strictRulesets) { + throw new Error( + `--strict-rulesets: ${relPath} would contain ${portableWarnings.length} unportablized ` + + `(host-specific) id(s), so nothing was written:\n` + + lines.map((l) => ` ${l}`).join("\n"), + ); + } + warnings.push({ + code: "RULESET_NOT_PORTABLE", + message: + `${relPath} keeps ${portableWarnings.length} host-specific id(s) — NOT portable to another host:\n` + + lines.map((line) => ` ${line}`).join("\n"), + }); + } + } else { + // Verbatim capture (--no-portable-rulesets): every entity id in the file is this host's. + // Say so once per ruleset rather than let the opt-out quietly imply the ids are fine. + const left = scanUnportablized(captured.normalizedRuleset); + if (left.length > 0) { + if (opts.strictRulesets) { + throw new Error( + `--strict-rulesets with --no-portable-rulesets: ${relPath} would contain ` + + `${left.length} host-specific id(s) and nothing would rewrite them.`, + ); + } + warnings.push({ + code: "RULESET_NOT_PORTABLE", + message: + `${relPath} captured verbatim (--no-portable-rulesets): ${left.length} host-specific ` + + `id(s) kept as-is — NOT portable to another host.`, + }); + } + } + if (!opts.dryRun) { + await mkdir(join(project.cwd, "rulesets"), { recursive: true }); + await writeFile( + join(project.cwd, relPath), + `${JSON.stringify(rulesetToWrite, null, 2)}\n`, + "utf8", + ); + } + snippetFields.dynamic = { status: captured.status, ruleset: { ref: `./${relPath}` } }; + } + } + const snippet = configSnippet("group", key, snippetFields, { todos }); + + if (opts.dryRun) { + results.push({ id, key, fields, snippet }); + continue; + } + const action = upsert(state, { type: "group", id, key, fields }, now); + if (memberFields) { + const managed = state.resources[key]!; + if (Object.keys(memberFields.ids).length > 0) managed.memberFields = memberFields.ids; + else delete managed.memberFields; + } + results.push({ id, key, fields, snippet }); + reports.push({ action, id, key }); + } + + if (opts.dryRun) { + return { + operation: "adopt", + project, + warnings, + value: { + groups: results.map((result) => ({ ...result, type: "group", action: null })), + configBlock: null, + noMatches: false, + dryRun: true, + }, + }; + } + + await saveState(project.statePath, state); + if (reports.some((report) => report.action === "updated")) { + warnings.push({ + code: "ADOPT_ALREADY_MANAGED", + message: "This resource was already managed — its snapshot was refreshed.", + }); + } + + // Grouped, paste-ready config block: each snippet is now idiomatic multi-line TS (#52 item A), + // wrapped under a type comment header and ordered parents-before-children where hierarchy is + // known (--children-of's subtree walk). + const block = [`// group`, ...results.map((r) => r.snippet)].join("\n"); + return { + operation: "adopt", + project, + warnings, + value: { + groups: results.map((result) => ({ + ...result, + type: "group", + action: reports.find((report) => report.id === result.id)?.action ?? null, + })), + configBlock: block, + noMatches: false, + dryRun: false, + }, + }; + }); +} diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index f008848..ebc1539 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -5,3 +5,4 @@ export * from "./refresh.js"; export * from "./state.js"; export * from "./adopt.js"; export * from "./auth.js"; +export * from "./adopt-group.js"; diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index 0f082e2..d52ecc4 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -1,36 +1,6 @@ -/** - * `ct adopt group` — bulk/filtered group adoption + `--with-dynamic` ruleset capture (#51). - * - * A dedicated subcommand (mirroring `adopt-grants.ts`'s pattern) rather than an extension of the - * generic `ct adopt ` action: bulk selection (`--type`, `--children-of`) and dynamic - * ruleset capture (`--with-dynamic`) are group-specific concepts with no analog for the other - * adoptable types. Commander matches this named subcommand before falling through to the base - * action, so every `ct adopt group ...` invocation — a single id, a list of ids, or a filter — - * routes here (the base action never sees `type === "group"`). - */ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { Command } from "commander"; -import { authedSession } from "../api/session.js"; -import { CtApiError, type CtClient } from "../api/ctClient.js"; -import { resolveConfig } from "../config.js"; -import { prepareEnv } from "../env/context.js"; -import { normalizeRuleset } from "../engine/dynamic.js"; -import { - groupScopedRows, - localKeyOf, - MEMBER_FIELD_PROPS, - memberFieldId, - memberFieldStateKey, - memberFieldsReadPath, -} from "../engine/member-fields.js"; -import type { DynamicStatus } from "../engine/types.js"; -import { RESOURCES, configSnippet, fromInformation, slug } from "../resources/registry.js"; -import { ReverseResolver, type RoleCatalogEntry } from "../resolve/reverse.js"; -import type { RefKind } from "../resolve/refs.js"; -import { formatPortablizeWarnings, portablizeRuleset, scanUnportablized } from "../config/query-refs.js"; -import { chooseAdoptKey, loadState, saveState, upsert, type State } from "../state/state.js"; -import { success, info, warn, out, formatError } from "../ui.js"; +import { runAdoptGroups } from "../application/operations/adopt-group.js"; +import { info, out, success, warn } from "../ui.js"; interface AdoptGroupOptions { key?: string; @@ -40,267 +10,12 @@ interface AdoptGroupOptions { type?: string; childrenOf?: string; withDynamic?: boolean; - /** Opt in to capturing the group's group-scoped member-field definitions (#135). Never the default. */ withMemberFields?: boolean; - /** Opt in to changing an already-managed group's logical key (#123). Never the default. */ rekey?: boolean; - /** Commander's negatable `--no-portable-rulesets`: true unless the flag was passed (#101). */ portableRulesets?: boolean; strictRulesets?: boolean; } -const GROUP_SPEC = RESOURCES.group!; - -interface ResolvedAdoption { - id: number; - key: string; - fields: Record; - snippet: string; -} - -interface MemberFieldsCapture { - /** Portable declarations for config output; deliberately contain no ChurchTools ids. */ - declarations: Array>; - /** Instance-bound identity map stored only on the owning group's state entry. */ - ids: Record; -} - -function isNonNegativeInt(raw: string): boolean { - return /^\d+$/.test(raw.trim()); -} - -/** Resolve `--type`'s numeric group-type id or logical key against the live `/group/grouptypes` catalog. */ -async function resolveGroupTypeId(raw: string, client: Pick): Promise { - const trimmed = raw.trim(); - if (isNonNegativeInt(trimmed)) return Number.parseInt(trimmed, 10); - const rows = await client.get>>("/group/grouptypes"); - const list = Array.isArray(rows) ? rows : []; - const bySlug = list.filter((r) => typeof r.name === "string" && slug(r.name as string) === trimmed); - const candidates = bySlug.length > 0 ? bySlug : list.filter((r) => r.name === trimmed); - if (candidates.length === 0) { - throw new Error( - `--type "${raw}": no group type matches (checked /group/grouptypes by slug and exact name).`, - ); - } - if (candidates.length > 1) { - const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${String(c.id)})`).join(", "); - throw new Error(`--type "${raw}" is ambiguous: ${candidates.length} group types match — ${listed}.`); - } - return Number(candidates[0]!.id); -} - -/** Resolve `--children-of`'s numeric id, adopted-state logical key, or live group name to a group id. */ -async function resolveGroupId( - raw: string, - client: Pick, - state: State, -): Promise { - const trimmed = raw.trim(); - if (isNonNegativeInt(trimmed)) return Number.parseInt(trimmed, 10); - const managed = state.resources[trimmed]; - if (managed && managed.type === "group") return managed.id; - const page = await client.getAll>("/groups"); - const rows = page.data; - const bySlug = rows.filter((r) => typeof r.name === "string" && slug(r.name as string) === trimmed); - const candidates = bySlug.length > 0 ? bySlug : rows.filter((r) => r.name === trimmed); - if (candidates.length === 0) { - throw new Error( - `--children-of "${raw}": not adopted (no state entry) and no live group matches ` + - `(checked /groups by slug and exact name).`, - ); - } - if (candidates.length > 1) { - const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${String(c.id)})`).join(", "); - throw new Error( - `--children-of "${raw}" is ambiguous: ${candidates.length} live groups match — ${listed}.`, - ); - } - return Number(candidates[0]!.id); -} - -/** - * Resolve one `/groups/{id}/children` row to the group id it points at. - * - * CT answers this endpoint with either plain group rows (`{ id }`) or domain resources - * (`{ domainType: "group", domainIdentifier, apiUrl }`). For a domain resource the authoritative - * group id is `domainIdentifier` — a sibling `id`, if CT ever emits one, is the hierarchy edge's - * own id — so `domainIdentifier` is read first and `id` only backs it up. `apiUrl` is the last - * resort. `parentId` is threaded in purely so the error names the group that actually failed: - * `--children-of` walks whole subtrees, and "some group somewhere" is not actionable. - */ -function childId(raw: unknown, parentId: number): number { - let candidate: unknown = raw; - if (raw !== null && typeof raw === "object") { - const child = raw as Record; - candidate = child.domainIdentifier ?? child.id; - if (candidate == null && typeof child.apiUrl === "string") { - candidate = /\/groups\/(\d+)(?:[/?#]|$)/.exec(child.apiUrl)?.[1]; - } - } - - const id = - typeof candidate === "number" - ? candidate - : typeof candidate === "string" && /^\d+$/.test(candidate.trim()) - ? Number.parseInt(candidate, 10) - : Number.NaN; - if (!Number.isSafeInteger(id) || id < 0) { - const shape = - raw !== null && typeof raw === "object" - ? `{ ${Object.keys(raw as Record).join(", ")} }` - : JSON.stringify(raw); - throw new Error( - `GET /groups/${parentId}/children returned a child without a usable id (${shape}); ` + - `expected a number or an object with id, domainIdentifier, or apiUrl.`, - ); - } - return id; -} - -/** - * Recursively collect a group's full hierarchy subtree via `/groups/{id}/children`, in - * parent-before-child (pre-order) sequence, excluding the root itself. Guards against a cyclic - * hierarchy (a live-API bug, not a valid DAG state) with a `visited` set — never re-descends into - * an id already seen, so a back-reference to an ancestor cannot loop forever. - * - * `/groups/{id}/children` is a paginated list endpoint, so it is read with `getAll`, never a plain - * `get` (#101): a plain GET returns only CT's default first page, which would silently drop the - * tail of a wide Bereich and every subtree hanging off it. `getAll` also absorbs CT's inconsistent - * page shapes (bare array vs. `{ data: [...] }`) and an empty 204 body, which for a leaf group is - * simply "no children". - */ -async function collectSubtreeIds(rootId: number, client: Pick): Promise { - const visited = new Set([rootId]); - const order: number[] = []; - - async function walk(id: number): Promise { - const page = await client.getAll(`/groups/${id}/children`); - for (const c of page.data) { - const cid = childId(c, id); - if (visited.has(cid)) continue; - visited.add(cid); - order.push(cid); - await walk(cid); - } - } - - await walk(rootId); - return order; -} - -/** List every group id whose live `groupTypeId` (top-level or under `information`) matches. */ -async function collectByGroupType(groupTypeId: number, client: Pick): Promise { - const page = await client.getAll>("/groups"); - return page.data - .filter((row) => Number(fromInformation(row, "groupTypeId")) === groupTypeId) - .map((row) => Number(row.id)) - .filter((id) => Number.isFinite(id)) - .sort((a, b) => a - b); -} - -interface DynamicCapture { - status: DynamicStatus; - normalizedRuleset: Record; -} - -/** - * Capture a group's group-scoped member-field DEFINITIONS as portable declarations (#135). - * - * Emits `{ key, ...managed properties }` per field and **never a ChurchTools id** — the field's - * identity in config is the group key plus its local key (`ojbp_2026_27_praktikum_1::wahl`), so the - * same blueprint applied to another group, or another host, mints its own fields rather than - * resolving against this host's numbering. The local key comes from CT's own `referenceName` - * (slugged), falling back to the slugged name for a field created in the ChurchTools UI. - * - * Opt-in only (`--with-member-fields`) — and that opt-in is TRANSITIONAL, not a statement that - * member fields are optional: they are a category-2 owned structural child, so the flip to - * default-on is a follow-up governed by the promotion policy in docs/adoption-contract.md (the flag - * survives as a no-op, and `--no-member-fields` ships in the same release as the flip). - */ -async function captureMemberFields( - id: number, - client: Pick, -): Promise { - let raw: unknown; - try { - raw = await client.get(memberFieldsReadPath(id)); - } catch (err) { - // A group whose member fields cannot be read is not a reason to abort a bulk adoption of a whole - // subtree — say so and adopt the group without them. That holds for EVERY failure, not just the - // 404: a 403 on one group the token may not read the fields of, or a transient 429, would - // otherwise abort `--children-of` partway with the earlier groups already written to state. - // Silence is the one thing that is not allowed here, because "no member fields" and "could not - // read them" produce the same config. - if (!(err instanceof CtApiError && err.status === 404)) { - warn( - `group #${id}: member fields could not be read (${formatError(err)}) — adopted WITHOUT them. ` + - `Re-run \`ct adopt group ${id} --with-member-fields\` once the read succeeds.`, - ); - } - return undefined; - } - const rows = groupScopedRows(raw); - const declarations: Array> = []; - const ids: Record = {}; - for (const row of rows) { - const localKey = localKeyOf(row); - const canonical = memberFieldStateKey(localKey); - const fieldId = memberFieldId(row); - // Same rule as the read failure above: a group whose member fields cannot be captured CLEANLY - // is not a reason to abort a bulk adoption. `saveState` runs only after the whole `--children-of` - // loop, so throwing here would discard every group already processed in this run. - if (!canonical) { - warn( - `group #${id}: a group-scoped member field has neither referenceName nor name — adopted ` + - `WITHOUT member fields. Give it a name in ChurchTools, then re-run ` + - `\`ct adopt group ${id} --with-member-fields\`.`, - ); - return undefined; - } - if (fieldId === undefined) { - warn( - `group #${id} member field "${localKey}": the live response contains no numeric field id — ` + - `adopted WITHOUT member fields. Re-run \`ct adopt group ${id} --with-member-fields\` once ` + - `the response carries ids.`, - ); - return undefined; - } - if (ids[canonical] !== undefined) { - warn( - `group #${id}: multiple group-scoped member fields resolve to the local key "${canonical}" — ` + - `adopted WITHOUT member fields. Rename one in ChurchTools, then re-run ` + - `\`ct adopt group ${id} --with-member-fields\`.`, - ); - return undefined; - } - ids[canonical] = fieldId; - const declaration: Record = { key: localKey }; - for (const prop of MEMBER_FIELD_PROPS) { - if (row[prop] !== undefined) declaration[prop] = row[prop]; - } - declarations.push(declaration); - } - return { declarations, ids }; -} - -/** Fetch + normalize a group's ruleset and status. `undefined` (never throws) if the group isn't dynamic. */ -async function captureDynamic( - id: number, - client: Pick, -): Promise { - let raw: unknown; - try { - raw = await client.get(`/dynamicgroups/${id}/ruleset`); - } catch (err) { - if (err instanceof CtApiError && err.status === 404) return undefined; // not a dynamic group — skip silently - throw err; - } - const normalizedRuleset = normalizeRuleset(raw); - const statusRes = await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${id}/status`); - const status = (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus; - return { status, normalizedRuleset }; -} - export function adoptGroupCommand(): Command { return new Command("group") .description( @@ -311,278 +26,58 @@ export function adoptGroupCommand(): Command { .option("-k, --key ", "logical key (only valid when exactly one group is resolved)") .option("-s, --state ", "state file path (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .option( - "--rekey", - "let a re-adoption change an already-managed group's logical key to the derived one (#123)", - ) + .option("--rekey", "let a re-adoption change an already-managed group's logical key") .option("--dry-run", "preview the config entries and state changes without writing") - .option("--type ", "adopt every group of this group type (numeric id or logical key)") - .option( - "--children-of ", - "adopt a group's full hierarchy subtree (recursive; numeric id, adopted-state key, or live name)", - ) - .option( - "--with-dynamic", - "also capture each dynamic group's ruleset to rulesets/.json and emit the dynamic: block", - ) - .option( - "--with-member-fields", - "also capture each group's group-scoped member-field definitions and emit the memberFields: " + - "block (portable: no ChurchTools field ids are ever emitted). TRANSITIONAL: member fields " + - "are an owned part of a group, so this becomes the default in a later release (with a " + - "--no-member-fields escape hatch, and this flag kept working as a no-op) — see " + - "docs/adoption-contract.md", - ) - .option( - "--portable-rulesets", - "(deprecated — this is the default since #101) rewrite managed entity ids into portable logical refs", - ) - .option( - "--no-portable-rulesets", - "capture rulesets verbatim: keep this host's numeric entity ids instead of rewriting the managed " + - "ones into portable logical ref markers (#76/#101 — portablization is the default)", - ) - .option( - "--strict-rulesets", - "refuse to write a ruleset that still contains an unportablized (host-specific) id, instead of " + - "writing it with a warning (#101)", - ) + .option("--type ", "adopt every group of this group type") + .option("--children-of ", "adopt a group's full hierarchy subtree") + .option("--with-dynamic", "capture dynamic rulesets to rulesets/.json") + .option("--with-member-fields", "capture portable group-scoped member-field definitions") + .option("--portable-rulesets", "rewrite managed entity ids into portable logical refs") + .option("--no-portable-rulesets", "capture rulesets verbatim with host-specific numeric ids") + .option("--strict-rulesets", "refuse rulesets that retain a host-specific id") .action(async (ids: string[], _localOpts: AdoptGroupOptions, command: Command) => { - // `adopt` (the parent) also declares `-k/--key`, `-s/--state`, `-e/--env`, and `--dry-run` — - // for its own ` ` action. Commander does not merge same-named options declared on - // both a parent and a subcommand into either level's plain `.opts()` (each stays empty for - // that flag); only `optsWithGlobals()` walks the whole command chain and merges correctly. - // Read from there rather than the local `opts` parameter, so `ct adopt group ... --state - // ` / `--env ` (etc.) actually take effect. const opts = command.optsWithGlobals() as AdoptGroupOptions; - const selectors = [ids.length > 0, Boolean(opts.type), Boolean(opts.childrenOf)].filter(Boolean).length; - if (selectors === 0) { - throw new Error("Specify group id(s), --type , or --children-of ."); - } - if (selectors > 1) { - throw new Error("Specify only one of: group id(s), --type, --children-of."); - } - for (const raw of ids) { - if (!isNonNegativeInt(raw)) { - throw new Error(`Invalid id "${raw}" — expected a non-negative integer.`); - } - } - if (opts.key && ids.length > 1) { - throw new Error("--key is only valid when adopting a single group."); - } - - // Resolve the env FIRST — it wires the target host/token into the process env before - // resolveConfig — then load + validate the state file (host guard) BEFORE any network call, - // so a state file recorded against another instance never triggers a live request against - // the wrong host. - const cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const statePath = cmdEnv.statePath; - const state = await loadState(statePath, config.host); - - const { client } = await authedSession(); - - let resolvedIds: number[]; - if (ids.length > 0) { - resolvedIds = ids.map((raw) => Number.parseInt(raw, 10)); - } else if (opts.type) { - const groupTypeId = await resolveGroupTypeId(opts.type, client); - resolvedIds = await collectByGroupType(groupTypeId, client); - } else { - const rootId = await resolveGroupId(opts.childrenOf!, client, state); - resolvedIds = await collectSubtreeIds(rootId, client); - } - resolvedIds = [...new Set(resolvedIds)]; - - if (opts.key && resolvedIds.length !== 1) { - throw new Error(`--key is only valid when adopting a single group (resolved ${resolvedIds.length}).`); - } - if (resolvedIds.length === 0) { + const result = await runAdoptGroups({ + ids, + key: opts.key, + statePath: opts.state, + environment: opts.env, + dryRun: opts.dryRun, + groupType: opts.type, + childrenOf: opts.childrenOf, + withDynamic: opts.withDynamic, + withMemberFields: opts.withMemberFields, + rekey: opts.rekey, + portableRulesets: opts.portableRulesets, + strictRulesets: opts.strictRulesets, + }); + if (result.value.noMatches) { info("No groups matched — nothing to adopt."); return; } - - const now = new Date().toISOString(); - // One reverse resolver across the whole (possibly bulk) run — each master-data catalog is - // fetched at most once and reused for every group's numeric-id → logical-sugar rewrite (#52). - const reverse = new ReverseResolver(client); - const results: ResolvedAdoption[] = []; - const reports: Array<{ action: "created" | "updated"; id: number; key: string }> = []; - - // Portable-ruleset catalogs (#76): fetch the catalog-backed id→key maps ONCE (campus/group-type). - // The `group` map is state-derived and rebuilt per capture (it grows as this run adopts). Roles are - // NOT a simple id→key map — a `role.id`/`groupTypeRoleId` is group-type-scoped and role names are - // not globally unique, so we fetch the (groupTypeId, name) catalog plus the group-type id→key map - // and let portablizeRuleset emit (group-type, role-name) markers (fixes #86's `role-def` mapping). - // - // Portablization is ON by default since #101 (`--no-portable-rulesets` opts out): leaving a - // capture host-specific fails SILENTLY on the next host — CT does not validate the ids inside a - // ruleset, so the auto-group just collects the wrong people while `ct plan` stays green. - const portableCatalogMaps: Partial>> = {}; - let roleCatalog: Map | undefined; - let groupTypeIdToKey: Map | undefined; - if (opts.withDynamic && opts.portableRulesets !== false) { - portableCatalogMaps.campus = await reverse.idToKeyByKind("campus"); - portableCatalogMaps["group-type"] = await reverse.idToKeyByKind("group-type"); - groupTypeIdToKey = portableCatalogMaps["group-type"]; - roleCatalog = await reverse.roleGroupTypeCatalog(); - } - - for (const id of resolvedIds) { - const resource = await client.get>(GROUP_SPEC.itemPath(id)); - // An already-managed group keeps its adopted key unless --rekey says otherwise (#123). This - // is the mode that made the bug bite: the documented ruleset-refresh workflow passes a LIST - // of ids, which is exactly when `-k` is rejected, so there was no way to prevent the re-key. - // Because `relPath` below is built from `key`, preserving it also makes the refresh overwrite - // the ruleset file the config already points at instead of writing a second one. - const choice = chooseAdoptKey(state, "group", id, GROUP_SPEC.deriveKey(resource), { - explicitKey: resolvedIds.length === 1 ? opts.key : undefined, - rekey: opts.rekey, - }); - const key = choice.key; - if (!key) { - throw new Error(`Could not derive a logical key for group #${id} — pass --key explicitly.`); - } - if (choice.wouldBecome) { - warn( - `${key}: key would change to "${choice.wouldBecome}" (derived from the live name). ` + - `Keeping the adopted key. Pass --rekey to change it.`, - ); - } - const fields = GROUP_SPEC.managedFields(resource); - - // Reverse-resolve the group's numeric ids to logical sugar for the emitted snippet; the - // captured `dynamic` block (if any) is appended AFTER, so it is not treated as an id field. - const { fields: sugared, todos } = await reverse.sugarFields(fields); - const snippetFields: Record = sugared; - // Emitted BEFORE `dynamic` so the snippet reads in apply order — the fields a ruleset may - // reference are declared above the ruleset that references them (#135). - const memberFields = opts.withMemberFields ? await captureMemberFields(id, client) : undefined; - if (memberFields && memberFields.declarations.length > 0) { - snippetFields.memberFields = memberFields.declarations; - } - if (opts.withDynamic) { - const captured = await captureDynamic(id, client); - if (captured) { - const relPath = `rulesets/${key}.json`; - let rulesetToWrite = captured.normalizedRuleset; - if (opts.portableRulesets !== false) { - // Managed group ids come from state (no catalog for `group`), including any group this - // same run already adopted; the master-data kinds come from the catalog maps above. - const groupMap = new Map(); - // Managed role definitions, likewise from STATE rather than the live catalog (#125): - // state gives a per-host id under a shared logical key, which is what makes a - // duplicate role name fixable by adopting the role instead of renaming master data. - // (`ReverseResolver.idToKeyByKind("role-def")` keys by slug(name) off the catalog and - // is exactly the ambiguous mapping this replaces.) - const roleDefMap = new Map(); - for (const r of Object.values(state.resources)) { - if (r.type === "group") groupMap.set(r.id, r.key); - if (r.type === "group-role") roleDefMap.set(r.id, r.key); - } - const { ruleset, warnings } = portablizeRuleset(captured.normalizedRuleset, { - idToKeyByKind: { ...portableCatalogMaps, group: groupMap, "role-def": roleDefMap }, - roleCatalog, - groupTypeIdToKey, - }); - rulesetToWrite = ruleset; - // Report every dimension left numeric, with its reason (#101). The old output said only - // "left N unmanaged id(s) numeric", which named neither the dimension nor the fix — so a - // capture that silently froze prod's ids into a cross-host file looked like a clean run. - if (warnings.length > 0) { - const lines = formatPortablizeWarnings(warnings); - if (opts.strictRulesets) { - throw new Error( - `--strict-rulesets: ${relPath} would contain ${warnings.length} unportablized ` + - `(host-specific) id(s), so nothing was written:\n` + - lines.map((l) => ` ${l}`).join("\n"), - ); - } - warn( - `${relPath} keeps ${warnings.length} host-specific id(s) — NOT portable to another host:`, - ); - for (const line of lines) info(` ${line}`); - } - } else { - // Verbatim capture (--no-portable-rulesets): every entity id in the file is this host's. - // Say so once per ruleset rather than let the opt-out quietly imply the ids are fine. - const left = scanUnportablized(captured.normalizedRuleset); - if (left.length > 0) { - if (opts.strictRulesets) { - throw new Error( - `--strict-rulesets with --no-portable-rulesets: ${relPath} would contain ` + - `${left.length} host-specific id(s) and nothing would rewrite them.`, - ); - } - warn( - `${relPath} captured verbatim (--no-portable-rulesets): ${left.length} host-specific ` + - `id(s) kept as-is — NOT portable to another host.`, - ); - } - } - if (!opts.dryRun) { - await mkdir(join(process.cwd(), "rulesets"), { recursive: true }); - await writeFile( - join(process.cwd(), relPath), - `${JSON.stringify(rulesetToWrite, null, 2)}\n`, - "utf8", - ); - } - snippetFields.dynamic = { status: captured.status, ruleset: { ref: `./${relPath}` } }; - } - } - const snippet = configSnippet("group", key, snippetFields, { todos }); - - if (opts.dryRun) { - results.push({ id, key, fields, snippet }); - continue; - } - const action = upsert(state, { type: "group", id, key, fields }, now); - // An empty map is not the same as no map: `ct destroy --member-field` DELETES the key when - // the last id is forgotten (see destroy.ts), so writing `memberFields: {}` here would make - // a no-op re-adoption churn the state file against the two paths' shared contract. - if (memberFields) { - const managed = state.resources[key]!; - if (Object.keys(memberFields.ids).length > 0) managed.memberFields = memberFields.ids; - else delete managed.memberFields; - } - results.push({ id, key, fields, snippet }); - reports.push({ action, id, key }); - } - - if (opts.dryRun) { - const payload = results.map((r) => ({ - key: r.key, + for (const warning of result.warnings) warn(warning.message); + if (result.value.dryRun) { + const payload = result.value.groups.map((group) => ({ + key: group.key, type: "group", - id: r.id, - fields: r.fields, - config: r.snippet, + id: group.id, + fields: group.fields, + config: group.snippet, })); info( - results.length === 1 - ? `Would adopt group #${results[0]!.id} as "${results[0]!.key}". Generated config entry:` - : `Would adopt ${results.length} groups. Generated config entries:`, + payload.length === 1 + ? `Would adopt group #${payload[0]!.id} as "${payload[0]!.key}". Generated config entry:` + : `Would adopt ${payload.length} groups. Generated config entries:`, ); - out(results.length === 1 ? payload[0] : payload); + out(payload.length === 1 ? payload[0] : payload); return; } - - await saveState(statePath, state); - - for (const r of reports) { + for (const group of result.value.groups) { success( - `${r.action === "created" ? "Adopted" : "Updated"} group #${r.id} as "${r.key}" → ${statePath}`, + `${group.action === "created" ? "Adopted" : "Updated"} group #${group.id} as "${group.key}" → ${result.project.stateDisplayPath}`, ); - if (r.action === "updated") { - warn("This resource was already managed — its snapshot was refreshed."); - } } - - // Grouped, paste-ready config block: each snippet is now idiomatic multi-line TS (#52 item A), - // wrapped under a type comment header and ordered parents-before-children where hierarchy is - // known (--children-of's subtree walk). - info(results.length === 1 ? "Config entry:" : "Config entries (paste into your config):"); - const block = [`// group`, ...results.map((r) => r.snippet)].join("\n"); - process.stdout.write(`${block}\n`); + info(result.value.groups.length === 1 ? "Config entry:" : "Config entries (paste into your config):"); + process.stdout.write(`${result.value.configBlock}\n`); }); } diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts index f9d9278..13048b7 100644 --- a/tests/architecture-boundaries.test.ts +++ b/tests/architecture-boundaries.test.ts @@ -58,11 +58,7 @@ describe("application architecture boundaries", () => { // Tasks 2–4 remove this migration baseline as each command becomes a thin operation adapter. // Until then, an additional direct mutation import fails this test instead of expanding silently. expect(violations.sort()).toEqual( - [ - "src/commands/adopt-group.ts:saveState", - "src/commands/destroy.ts:saveState", - "src/commands/destroy.ts:writeBackup", - ].sort(), + ["src/commands/destroy.ts:saveState", "src/commands/destroy.ts:writeBackup"].sort(), ); }); }); From 4e28adceb21c3266f146a1bd316a3117bbc530ec Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:39:53 +0200 Subject: [PATCH 11/15] refactor(adopt): expose shared grants operation --- src/application/operations/adopt-grants.ts | 284 ++++++++++++++++++++ src/application/operations/index.ts | 1 + src/commands/adopt-grants.ts | 285 +++------------------ 3 files changed, 315 insertions(+), 255 deletions(-) create mode 100644 src/application/operations/adopt-grants.ts diff --git a/src/application/operations/adopt-grants.ts b/src/application/operations/adopt-grants.ts new file mode 100644 index 0000000..cad9af5 --- /dev/null +++ b/src/application/operations/adopt-grants.ts @@ -0,0 +1,284 @@ +import { appendFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { authedSession } from "../../api/session.js"; +import type { CtClient } from "../../api/ctClient.js"; +import { assertNotPeople } from "../../engine/guard.js"; +import { buildAdoptedGrants, type AdoptedGrantsBlock } from "../../permissions/adopt.js"; +import type { DomainType, RawPermission } from "../../permissions/grants.js"; +import { fetchPermissionRows, type PermissionReader } from "../../permissions/fetch.js"; +import { CATALOG_DIR, loadHostCatalog } from "../../permissions/catalog-store.js"; +import { declarability, decodeGroupsWithRoles, type RoleInstance } from "../../coverage/report.js"; +import { slug } from "../../resources/registry.js"; +import { loadState, type State } from "../../state/state.js"; +import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; +import { resolveProject } from "../project.js"; + +export interface AdoptGrantsRequest extends ProjectRequest { + domainType?: string; + domainId?: string; + group?: string; + allDeclarable?: boolean; + write?: string; +} + +export type AdoptGrantsResult = OperationResult<{ + blocks: AdoptedGrantsBlock[]; + text: string; + writtenPath: string | null; + permissionCatalogPath: string | null; + summary: string | null; +}>; + +interface BulkEmission { + blocks: AdoptedGrantsBlock[]; + warnings: CtWarning[]; + summary: string; +} + +/** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */ +function normalizeDomainType(raw: string): DomainType { + const t = raw.trim().replace(/-/g, "_"); + if (t === "group_role" || t === "group_type_role" || t === "status") return t; + throw new Error( + `Invalid domain type "${raw}" — expected "group_role", "group_type_role" or "status" (people domains are never managed).`, + ); +} + +/** + * `ct adopt grants` — read live permission rows and print paste-ready config blocks. Grants are NOT + * state-tracked, so this prints config only; it never writes the state file (contrast `ct adopt`). + * + * Single form (unchanged): `ct adopt grants group_role 44675`. + * + * Bulk forms (#104): adopting the declarable estate of a real instance meant 44 invocations and 44 + * manual pastes, each needing its `key` renamed and its emitted numeric `id:` swapped for the portable + * `group` + `role` pair — exactly the two edits a human forgets on the 30th paste. So: + * + * - `--group ` emits every role instance of one group, `--all-declarable` every declarable + * one on the host; + * - the portable `group` + `role` form is emitted by default whenever the group is managed, and the + * key is derived from (group key, role name) rather than being `group_role_44675`; + * - a block that would REVOKE live grants is never emitted silently in bulk — it is skipped and + * summarised, because the WARNING footer that protects the single form cannot protect a 44-block + * paste that nobody reads to the end. + */ +export async function runAdoptGrants(opts: AdoptGrantsRequest): Promise { + const bulk = opts.group !== undefined || opts.allDeclarable === true; + if (bulk && (opts.domainType !== undefined || opts.domainId !== undefined)) { + throw new Error( + "Specify either a pair or a bulk selector (--group / --all-declarable), not both.", + ); + } + if (opts.group !== undefined && opts.allDeclarable) { + throw new Error("Specify only one of: --group, --all-declarable."); + } + + // Load + validate the state file (host guard) BEFORE any network call, mirroring `ct adopt`, + // so a state file recorded against another instance never triggers a request to the wrong host. + const project = await resolveProject(opts); + const state = await loadState(project.statePath, project.host); + // Bulk selection runs the same declarability verdict as `ct coverage` (over the effective + // rather than the authored rows — see the call below), so it needs this host's catalog for + // the same reason (#105): under the bundled one, `--all-declarable` + // silently SKIPS role instances `ct plan` would manage, filed under an authId the active + // catalog can name perfectly well. + const hostCatalog = await loadHostCatalog(project.host, resolve(project.cwd, CATALOG_DIR)); + const { client } = await authedSession(); + + const bulkEmission = bulk ? await emitBulk(client, state, opts) : null; + const emitted = bulkEmission?.blocks ?? [await emitSingle(client, state, opts.domainType, opts.domainId)]; + + const text = `${emitted.map((e) => e.block).join("\n\n")}\n`; + let writtenPath: string | null = null; + if (opts.write) { + writtenPath = resolve(project.cwd, opts.write); + await appendFile(writtenPath, text, "utf8"); + } + const warnings = [...(bulkEmission?.warnings ?? [])]; + if (emitted.some((e) => e.omitted > 0)) { + warnings.push({ + code: "ADOPT_GRANT_OMITTED", + message: + "Any grant left as a WARNING/NOTE comment in the block is still LIVE on the instance but absent " + + "from the declaration — applying the block will REVOKE it. Resolve every comment first; `ct plan` " + + "is only a no-op once none remain.", + }); + } + return { + operation: "adopt", + project, + warnings, + value: { + blocks: emitted, + text, + writtenPath, + permissionCatalogPath: hostCatalog, + summary: bulkEmission?.summary ?? null, + }, + }; +} + +/** The original single-domain form: `ct adopt grants `. */ +async function emitSingle( + client: PermissionReader, + state: State, + rawType: string | undefined, + rawId: string | undefined, +): Promise { + if (rawType === undefined || rawId === undefined) { + throw new Error( + "Specify , or a bulk selector (--group / --all-declarable).", + ); + } + const domainType = normalizeDomainType(rawType); + if (!/^\d+$/.test(rawId.trim())) { + throw new Error(`Invalid domainId "${rawId}" — expected a non-negative integer.`); + } + const domainId = Number.parseInt(rawId, 10); + const path = `/permissions/${domainType}/${domainId}`; + assertNotPeople(path); // belt-and-suspenders: the domain-type guard already excludes people + const rows = await fetchPermissionRows(client, path); + return buildAdoptedGrants({ domainType, domainId, rows, state }); +} + +/** + * Bulk emission (#104). Selects role instances, then emits each one in the portable form. + * + * Blocks that would revoke live grants are dropped and summarised rather than printed: in bulk the + * per-block WARNING header stops being a safeguard and becomes noise the reader scrolls past. + */ +async function emitBulk( + client: PermissionReader & Pick, + state: State, + opts: AdoptGrantsRequest, +): Promise { + const [groupRows, roleDefRows] = await Promise.all([ + // `?include[]=roles` turns one role lookup per group into a handful of paged calls (#103). + client.getAll>("/groups?include[]=roles"), + client.getAll>("/group/roles"), + ]); + const roleNamesById = new Map(); + for (const r of roleDefRows.data) { + const id = Number(r.id); + if (Number.isFinite(id) && typeof r.name === "string") roleNamesById.set(id, r.name); + } + const groups = decodeGroupsWithRoles(groupRows.data, roleNamesById); + // Guarded read (see permissions/fetch.ts): a silent first page here would drop most role instances + // into the "no authored grants" bucket, which reads identically to a correct run. + const permissions = await fetchPermissionRows(client, "/permissions/group_role"); + const rowsByDomainId = new Map(); + for (const row of permissions) { + const list = rowsByDomainId.get(row.domainId); + if (list) list.push(row); + else rowsByDomainId.set(row.domainId, [row]); + } + + const managedKeyByGroupId = new Map(); + for (const r of Object.values(state.resources)) { + if (r.type === "group") managedKeyByGroupId.set(r.id, r.key); + } + + let candidates: RoleInstance[]; + if (opts.group !== undefined) { + const groupId = resolveGroupSelector(opts.group, groups, state); + candidates = groups.filter((g) => g.id === groupId).flatMap((g) => g.roles); + if (candidates.length === 0) { + throw new Error(`--group "${opts.group}" resolved to group #${groupId}, which has no role instances.`); + } + } else { + candidates = groups.flatMap((g) => g.roles); + } + + const blocks: AdoptedGrantsBlock[] = []; + const skippedUndeclarable: string[] = []; + const skippedWouldRevoke: string[] = []; + let skippedEmpty = 0; + + for (const role of candidates) { + const rows = rowsByDomainId.get(role.domainId) ?? []; + // Judged on the EFFECTIVE set, because that is what `buildAdoptedGrants` emits (#114/#119). An + // owned-rows verdict here would disagree with the emitter in both directions: it would skip a + // domain whose rights are all inherited on THIS host (18 of 63 group_role domains, measured) and + // leave them undeclared for the other one to revoke, and it would wave through an inherited grant + // on a dimension with no logical form, which the gate below exists to stop. + const verdict = declarability(rows, { scope: "effective" }); + if (verdict.grantCount === 0) { + skippedEmpty += 1; + continue; // nothing authored on this domain — an empty block is not worth a paste + } + const label = `${role.groupName} / ${role.roleName} (domainId ${role.domainId})`; + // Skipped in EVERY bulk mode, not just --all-declarable: a role instance with a grant on a + // dimension ct has no resource for can only be written as a host-specific number, and a bulk paste + // is exactly where that quietly becomes a cross-environment misgrant. The single form + // (`ct adopt grants group_role `) still emits it, deliberately, one domain at a time. + if (!verdict.declarable) { + skippedUndeclarable.push( + `${label}: blocked by ${[...verdict.blockedBy, ...verdict.unknownAuthIds.map((a) => `authId ${a}`)].join(", ")}`, + ); + continue; + } + const groupKey = managedKeyByGroupId.get(role.groupId); + const built = buildAdoptedGrants({ + domainType: "group_role", + domainId: role.domainId, + rows, + state, + domain: groupKey ? { group: groupKey, role: role.roleName } : undefined, + key: groupKey ? `${groupKey}_${slug(role.roleName)}` : undefined, + }); + if (built.omitted > 0) { + skippedWouldRevoke.push(`${label}: ${built.omitted} live grant(s) cannot be expressed as config`); + continue; + } + blocks.push(built); + } + + if (blocks.length === 0 && skippedWouldRevoke.length === 0 && skippedUndeclarable.length === 0) { + throw new Error("No role instance with authored grants matched — nothing to emit."); + } + + const summary = + `${blocks.length} block(s) emitted · ${skippedWouldRevoke.length} skipped (would revoke live grants) · ` + + `${skippedUndeclarable.length} skipped (not declarable) · ${skippedEmpty} skipped (no authored grants)`; + const warnings: CtWarning[] = []; + // Never a silent cap: what was NOT emitted is listed, so "44 blocks" can't quietly mean "44 of 59". + for (const line of skippedWouldRevoke) { + warnings.push({ + code: "ADOPT_GRANT_SKIPPED", + message: `skipped ${line} — adopt the missing scope target(s), then re-run for this domain`, + }); + } + for (const line of skippedUndeclarable) { + warnings.push({ + code: "ADOPT_GRANT_SKIPPED", + message: + `skipped ${line} — own the rest with \`preserveUnknown: []\` (#102), or emit it ` + + `deliberately with the single form`, + }); + } + return { blocks, warnings, summary }; +} + +/** Resolve `--group` to a live group id: numeric id, adopted-state logical key, or live name/slug. */ +function resolveGroupSelector( + raw: string, + groups: Array<{ id: number; name: string }>, + state: State, +): number { + const trimmed = raw.trim(); + if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); + const managed = state.resources[trimmed]; + if (managed && managed.type === "group") return managed.id; + const bySlug = groups.filter((g) => slug(g.name) === slug(trimmed)); + const candidates = bySlug.length > 0 ? bySlug : groups.filter((g) => g.name === trimmed); + if (candidates.length === 0) { + throw new Error( + `--group "${raw}": not adopted (no state entry) and no live group matches (checked by slug and exact name).`, + ); + } + if (candidates.length > 1) { + const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${c.id})`).join(", "); + throw new Error(`--group "${raw}" is ambiguous: ${candidates.length} live groups match — ${listed}.`); + } + return candidates[0]!.id; +} diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index ebc1539..4197110 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -6,3 +6,4 @@ export * from "./state.js"; export * from "./adopt.js"; export * from "./auth.js"; export * from "./adopt-group.js"; +export * from "./adopt-grants.js"; diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts index 48fc237..aee301e 100644 --- a/src/commands/adopt-grants.ts +++ b/src/commands/adopt-grants.ts @@ -1,17 +1,6 @@ -import { appendFile } from "node:fs/promises"; +import { relative } from "node:path"; import { Command } from "commander"; -import { authedSession } from "../api/session.js"; -import type { CtClient } from "../api/ctClient.js"; -import { resolveConfig } from "../config.js"; -import { prepareEnv } from "../env/context.js"; -import { assertNotPeople } from "../engine/guard.js"; -import { buildAdoptedGrants, type AdoptedGrantsBlock } from "../permissions/adopt.js"; -import type { DomainType, RawPermission } from "../permissions/grants.js"; -import { fetchPermissionRows, type PermissionReader } from "../permissions/fetch.js"; -import { loadHostCatalog } from "../permissions/catalog-store.js"; -import { declarability, decodeGroupsWithRoles, type RoleInstance } from "../coverage/report.js"; -import { slug } from "../resources/registry.js"; -import { loadState, type State } from "../state/state.js"; +import { runAdoptGrants } from "../application/operations/adopt-grants.js"; import { info, warn } from "../ui.js"; interface AdoptGrantsOptions { @@ -22,33 +11,6 @@ interface AdoptGrantsOptions { write?: string; } -/** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */ -function normalizeDomainType(raw: string): DomainType { - const t = raw.trim().replace(/-/g, "_"); - if (t === "group_role" || t === "group_type_role" || t === "status") return t; - throw new Error( - `Invalid domain type "${raw}" — expected "group_role", "group_type_role" or "status" (people domains are never managed).`, - ); -} - -/** - * `ct adopt grants` — read live permission rows and print paste-ready config blocks. Grants are NOT - * state-tracked, so this prints config only; it never writes the state file (contrast `ct adopt`). - * - * Single form (unchanged): `ct adopt grants group_role 44675`. - * - * Bulk forms (#104): adopting the declarable estate of a real instance meant 44 invocations and 44 - * manual pastes, each needing its `key` renamed and its emitted numeric `id:` swapped for the portable - * `group` + `role` pair — exactly the two edits a human forgets on the 30th paste. So: - * - * - `--group ` emits every role instance of one group, `--all-declarable` every declarable - * one on the host; - * - the portable `group` + `role` form is emitted by default whenever the group is managed, and the - * key is derived from (group key, role name) rather than being `group_role_44675`; - * - a block that would REVOKE live grants is never emitted silently in bulk — it is skipped and - * summarised, because the WARNING footer that protects the single form cannot protect a 44-block - * paste that nobody reads to the end. - */ export function adoptGrantsCommand(): Command { return new Command("grants") .description( @@ -57,231 +19,44 @@ export function adoptGrantsCommand(): Command { ) .argument("[domainType]", "group_role | group_type_role | status") .argument("[domainId]", "the domainId of the permission domain object") - .option( - "-s, --state ", - "state file path (or set CT_STATE) — used to resolve scope group ids to keys", - ) - .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .option("--group ", "bulk: every role instance of this group (managed key or numeric id)") - .option("--all-declarable", "bulk: every role instance on the host whose grants are declarable today") - .option("--write ", "append the emitted block(s) to this file instead of printing to stdout") + .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json") + .option("--group ", "bulk: every role instance of this group") + .option("--all-declarable", "bulk: every declarable role instance on the host") + .option("--write ", "append emitted blocks to this file instead of stdout") .action( async ( - rawType: string | undefined, - rawId: string | undefined, + domainType: string | undefined, + domainId: string | undefined, _localOpts: AdoptGrantsOptions, command: Command, ) => { - // `adopt` (the parent) also declares `-s/--state` and `-e/--env` for its own ` ` - // action. Commander does not merge a same-named parent+subcommand option into either level's - // plain `.opts()` (both come up empty for it); only `optsWithGlobals()` walks the whole - // command chain and merges correctly — read from there, not the local `opts` parameter (#51). const opts = command.optsWithGlobals() as AdoptGrantsOptions; - const bulk = opts.group !== undefined || opts.allDeclarable === true; - if (bulk && (rawType !== undefined || rawId !== undefined)) { - throw new Error( - "Specify either a pair or a bulk selector (--group / --all-declarable), not both.", - ); + const result = await runAdoptGrants({ + domainType, + domainId, + statePath: opts.state, + environment: opts.env, + group: opts.group, + allDeclarable: opts.allDeclarable, + write: opts.write, + }); + if (result.value.permissionCatalogPath) { + info(`permission catalog: ${relative(result.project.cwd, result.value.permissionCatalogPath)}`); } - if (opts.group !== undefined && opts.allDeclarable) { - throw new Error("Specify only one of: --group, --all-declarable."); - } - - // Load + validate the state file (host guard) BEFORE any network call, mirroring `ct adopt`, - // so a state file recorded against another instance never triggers a request to the wrong host. - const cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const statePath = cmdEnv.statePath; - const state = await loadState(statePath, config.host); - // Bulk selection runs the same declarability verdict as `ct coverage` (over the effective - // rather than the authored rows — see the call below), so it needs this host's catalog for - // the same reason (#105): under the bundled one, `--all-declarable` - // silently SKIPS role instances `ct plan` would manage, filed under an authId the active - // catalog can name perfectly well. - const hostCatalog = await loadHostCatalog(config.host); - if (hostCatalog) info(`permission catalog: ${hostCatalog}`); - const { client } = await authedSession(); - - const emitted = bulk - ? await emitBulk(client, state, opts) - : [await emitSingle(client, state, rawType, rawId)]; - - info(`Grants are not state-tracked — this prints config only and does NOT write ${statePath}.`); - const text = `${emitted.map((e) => e.block).join("\n\n")}\n`; - if (opts.write) { - await appendFile(opts.write, text, "utf8"); - info(`Appended ${emitted.length} block(s) to ${opts.write}. Run \`ct plan\` before applying.`); + if (result.value.summary) info(result.value.summary); + info( + `Grants are not state-tracked — this prints config only and does NOT write ${result.project.stateDisplayPath}.`, + ); + if (result.value.writtenPath) { + info( + `Appended ${result.value.blocks.length} block(s) to ${opts.write}. Run \`ct plan\` before applying.`, + ); } else { info("Paste the block(s) below into your config, then run `ct plan`:"); - process.stdout.write(text); - } - if (emitted.some((e) => e.omitted > 0)) { - warn( - "Any grant left as a WARNING/NOTE comment in the block is still LIVE on the instance but absent " + - "from the declaration — applying the block will REVOKE it. Resolve every comment first; `ct plan` " + - "is only a no-op once none remain.", - ); + process.stdout.write(result.value.text); } + for (const warning of result.warnings) warn(warning.message); }, ); } - -/** The original single-domain form: `ct adopt grants `. */ -async function emitSingle( - client: PermissionReader, - state: State, - rawType: string | undefined, - rawId: string | undefined, -): Promise { - if (rawType === undefined || rawId === undefined) { - throw new Error( - "Specify , or a bulk selector (--group / --all-declarable).", - ); - } - const domainType = normalizeDomainType(rawType); - if (!/^\d+$/.test(rawId.trim())) { - throw new Error(`Invalid domainId "${rawId}" — expected a non-negative integer.`); - } - const domainId = Number.parseInt(rawId, 10); - const path = `/permissions/${domainType}/${domainId}`; - assertNotPeople(path); // belt-and-suspenders: the domain-type guard already excludes people - const rows = await fetchPermissionRows(client, path); - return buildAdoptedGrants({ domainType, domainId, rows, state }); -} - -/** - * Bulk emission (#104). Selects role instances, then emits each one in the portable form. - * - * Blocks that would revoke live grants are dropped and summarised rather than printed: in bulk the - * per-block WARNING header stops being a safeguard and becomes noise the reader scrolls past. - */ -async function emitBulk( - client: PermissionReader & Pick, - state: State, - opts: AdoptGrantsOptions, -): Promise { - const [groupRows, roleDefRows] = await Promise.all([ - // `?include[]=roles` turns one role lookup per group into a handful of paged calls (#103). - client.getAll>("/groups?include[]=roles"), - client.getAll>("/group/roles"), - ]); - const roleNamesById = new Map(); - for (const r of roleDefRows.data) { - const id = Number(r.id); - if (Number.isFinite(id) && typeof r.name === "string") roleNamesById.set(id, r.name); - } - const groups = decodeGroupsWithRoles(groupRows.data, roleNamesById); - // Guarded read (see permissions/fetch.ts): a silent first page here would drop most role instances - // into the "no authored grants" bucket, which reads identically to a correct run. - const permissions = await fetchPermissionRows(client, "/permissions/group_role"); - const rowsByDomainId = new Map(); - for (const row of permissions) { - const list = rowsByDomainId.get(row.domainId); - if (list) list.push(row); - else rowsByDomainId.set(row.domainId, [row]); - } - - const managedKeyByGroupId = new Map(); - for (const r of Object.values(state.resources)) { - if (r.type === "group") managedKeyByGroupId.set(r.id, r.key); - } - - let candidates: RoleInstance[]; - if (opts.group !== undefined) { - const groupId = resolveGroupSelector(opts.group, groups, state); - candidates = groups.filter((g) => g.id === groupId).flatMap((g) => g.roles); - if (candidates.length === 0) { - throw new Error(`--group "${opts.group}" resolved to group #${groupId}, which has no role instances.`); - } - } else { - candidates = groups.flatMap((g) => g.roles); - } - - const blocks: AdoptedGrantsBlock[] = []; - const skippedUndeclarable: string[] = []; - const skippedWouldRevoke: string[] = []; - let skippedEmpty = 0; - - for (const role of candidates) { - const rows = rowsByDomainId.get(role.domainId) ?? []; - // Judged on the EFFECTIVE set, because that is what `buildAdoptedGrants` emits (#114/#119). An - // owned-rows verdict here would disagree with the emitter in both directions: it would skip a - // domain whose rights are all inherited on THIS host (18 of 63 group_role domains, measured) and - // leave them undeclared for the other one to revoke, and it would wave through an inherited grant - // on a dimension with no logical form, which the gate below exists to stop. - const verdict = declarability(rows, { scope: "effective" }); - if (verdict.grantCount === 0) { - skippedEmpty += 1; - continue; // nothing authored on this domain — an empty block is not worth a paste - } - const label = `${role.groupName} / ${role.roleName} (domainId ${role.domainId})`; - // Skipped in EVERY bulk mode, not just --all-declarable: a role instance with a grant on a - // dimension ct has no resource for can only be written as a host-specific number, and a bulk paste - // is exactly where that quietly becomes a cross-environment misgrant. The single form - // (`ct adopt grants group_role `) still emits it, deliberately, one domain at a time. - if (!verdict.declarable) { - skippedUndeclarable.push( - `${label}: blocked by ${[...verdict.blockedBy, ...verdict.unknownAuthIds.map((a) => `authId ${a}`)].join(", ")}`, - ); - continue; - } - const groupKey = managedKeyByGroupId.get(role.groupId); - const built = buildAdoptedGrants({ - domainType: "group_role", - domainId: role.domainId, - rows, - state, - domain: groupKey ? { group: groupKey, role: role.roleName } : undefined, - key: groupKey ? `${groupKey}_${slug(role.roleName)}` : undefined, - }); - if (built.omitted > 0) { - skippedWouldRevoke.push(`${label}: ${built.omitted} live grant(s) cannot be expressed as config`); - continue; - } - blocks.push(built); - } - - if (blocks.length === 0 && skippedWouldRevoke.length === 0 && skippedUndeclarable.length === 0) { - throw new Error("No role instance with authored grants matched — nothing to emit."); - } - - info( - `${blocks.length} block(s) emitted · ${skippedWouldRevoke.length} skipped (would revoke live grants) · ` + - `${skippedUndeclarable.length} skipped (not declarable) · ${skippedEmpty} skipped (no authored grants)`, - ); - // Never a silent cap: what was NOT emitted is listed, so "44 blocks" can't quietly mean "44 of 59". - for (const line of skippedWouldRevoke) { - warn(`skipped ${line} — adopt the missing scope target(s), then re-run for this domain`); - } - for (const line of skippedUndeclarable) { - warn( - `skipped ${line} — own the rest with \`preserveUnknown: []\` (#102), or emit it ` + - `deliberately with the single form`, - ); - } - return blocks; -} - -/** Resolve `--group` to a live group id: numeric id, adopted-state logical key, or live name/slug. */ -function resolveGroupSelector( - raw: string, - groups: Array<{ id: number; name: string }>, - state: State, -): number { - const trimmed = raw.trim(); - if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); - const managed = state.resources[trimmed]; - if (managed && managed.type === "group") return managed.id; - const bySlug = groups.filter((g) => slug(g.name) === slug(trimmed)); - const candidates = bySlug.length > 0 ? bySlug : groups.filter((g) => g.name === trimmed); - if (candidates.length === 0) { - throw new Error( - `--group "${raw}": not adopted (no state entry) and no live group matches (checked by slug and exact name).`, - ); - } - if (candidates.length > 1) { - const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${c.id})`).join(", "); - throw new Error(`--group "${raw}" is ambiguous: ${candidates.length} live groups match — ${listed}.`); - } - return candidates[0]!.id; -} From d8f4638b79f426c204490aea43e7a8ba43a591ac Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:47:33 +0200 Subject: [PATCH 12/15] refactor(destroy): expose prepared application operation --- src/application/contracts.ts | 1 + src/application/errors.ts | 1 + src/application/operations/destroy.ts | 693 ++++++++++++++++++++ src/application/operations/index.ts | 1 + src/commands/destroy.ts | 493 ++------------ tests/application/destroy-operation.test.ts | 124 ++++ tests/architecture-boundaries.test.ts | 6 +- tests/destroy.test.ts | 36 +- 8 files changed, 893 insertions(+), 462 deletions(-) create mode 100644 src/application/operations/destroy.ts create mode 100644 tests/application/destroy-operation.test.ts diff --git a/src/application/contracts.ts b/src/application/contracts.ts index 0715fe2..c632cb1 100644 --- a/src/application/contracts.ts +++ b/src/application/contracts.ts @@ -46,5 +46,6 @@ export type OperationEvent = | { type: "resource-reading"; resourceType: string; key: string } | { type: "resource-created"; resourceType: string; key: string; id: number } | { type: "resource-updated"; resourceType: string; key: string; id: number } + | { type: "resource-destroyed"; resourceType: string; key: string; id: number } | { type: "backup-written"; path: string } | { type: "warning"; warning: CtWarning }; diff --git a/src/application/errors.ts b/src/application/errors.ts index d5e3aa1..880b68c 100644 --- a/src/application/errors.ts +++ b/src/application/errors.ts @@ -7,6 +7,7 @@ export const APPLICATION_ERROR_CODES = [ "PROTECTED_ENV_CONFIRMATION_REQUIRED", "PLAN_CONFIRMATION_MISMATCH", "PREVENT_DESTROY", + "DESTROY_BACKUP_FAILED", "OPERATION_EXPIRED", "OPERATION_ALREADY_USED", "MUTATION_BUSY", diff --git a/src/application/operations/destroy.ts b/src/application/operations/destroy.ts new file mode 100644 index 0000000..51a5ee9 --- /dev/null +++ b/src/application/operations/destroy.ts @@ -0,0 +1,693 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { authedSession, type AuthedSession } from "../../api/session.js"; +import { CtApiError, type CtClient } from "../../api/ctClient.js"; +import { loadState, saveState, type State } from "../../state/state.js"; +import { RESOURCES, type CtWriteClient } from "../../resources/registry.js"; +import { assertNotPeople } from "../../engine/guard.js"; +import { orderKeys } from "../../engine/graph.js"; +import { fetchActual } from "../../engine/build.js"; +import { parentIdsByGroupId, managedParentKeys, type HierarchyEntry } from "../../engine/hierarchy.js"; +import type { DesiredResource } from "../../engine/types.js"; +import { writeBackup } from "../../engine/backup.js"; +import { + groupScopedRows, + memberFieldStateKey, + matchesLocalKey, + memberFieldItemPath, + memberFieldRowId, + memberFieldsReadPath, + parseMemberFieldIdentity, +} from "../../engine/member-fields.js"; +import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; +import { CtApplicationError } from "../errors.js"; +import { InMemoryMutationLock, PreparedOperationStore } from "../prepared-operation-store.js"; +import { + noopObserver, + systemClock, + type Clock, + type MutationLock, + type OperationObserver, +} from "../ports.js"; +import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; +import { resolveBackupDir, type ConfirmationProof, type ConfirmationRequirement } from "./apply.js"; + +const PREPARED_DESTROY_TTL_MS = 5 * 60 * 1000; + +function formatFailure(err: unknown): string { + if (err instanceof CtApiError) { + const body = + err.body === null || err.body === undefined + ? "" + : typeof err.body === "string" + ? err.body + : JSON.stringify(err.body); + return `${err.message} (HTTP ${err.status})${body ? `\n${body}` : ""}`; + } + return err instanceof Error ? err.message : String(err); +} + +export interface DestroyRequest extends ProjectRequest { + targets?: string[]; + /** Group-scoped member fields to delete, by their portable `::` identity (#135). */ + memberFields?: string[]; + backupDir?: string; +} + +export interface DestroyOutcome { + kind: "resource" | "member-field"; + key: string; + id: number | null; + status: "destroyed" | "already-absent" | "skipped" | "failed"; + message: string; +} + +export interface PreparedDestroy { + id: string; + project: OperationResult["project"]; + targets: string[]; + memberFields: string[]; + backupPath: string; + warnings: CtWarning[]; + confirmation: ConfirmationRequirement & { expected?: string }; + expiresAt: string; +} + +export type DestroyResult = OperationResult<{ + backupPath: string; + outcomes: DestroyOutcome[]; + complete: boolean; +}>; + +export interface PreparedDestroyExecution { + project: PreparedDestroy["project"]; + state: State; + client: CtClient; + ordered: string[]; + memberFieldTargets: MemberFieldTarget[]; + backupPath: string; + warnings: CtWarning[]; + confirmation: PreparedDestroy["confirmation"]; + stateFingerprint: string; +} + +export interface DestroyOperationDependencies { + project?: ProjectResolutionDependencies; + resolveProject?: typeof resolveProject; + loadState?: typeof loadState; + saveState?: typeof saveState; + authedSession?: () => Promise; + fetchActual?: typeof fetchActual; + writeBackup?: typeof writeBackup; + store?: PreparedOperationStore; + lock?: MutationLock; + observer?: OperationObserver; + clock?: Clock; + env?: NodeJS.ProcessEnv; + preparedTtlMs?: number; + readStateFile?: (path: string) => Promise; +} + +const defaultStore = new PreparedOperationStore(); +const defaultLock = new InMemoryMutationLock(); + +async function stateFingerprint( + path: string, + read: (path: string) => Promise = (value) => readFile(value, "utf8"), +): Promise { + return createHash("sha256") + .update(await read(path)) + .digest("hex"); +} + +/** 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 for destroy: highest tier first (leaves before their + * base metadata) and, within the group tier, a child before its parent. + * + * The state file carries no hierarchy edges (the synthetic `parents` field is + * stripped from snapshots — see execute.ts), so the caller passes `parentKeysByKey` + * discovered live from `/groups/hierarchies` (managed groups only). We reuse + * `orderKeys` — the very topological apply order plan uses — with those edges, + * then reverse it, so destroy is the exact inverse of apply and honours intra-tier + * parent edges. Pass an empty map (or omit) to fall back to tier-only ordering. + */ +export function orderDestroy( + state: State, + keys: string[], + parentKeysByKey: Map = new Map(), +): string[] { + const entries: DesiredResource[] = keys.map((key) => ({ + type: state.resources[key]!.type, + key, + fields: {}, + dependsOn: parentKeysByKey.get(key) ?? [], + })); + return orderKeys(entries).reverse(); +} + +/** + * Discover managed group→parent edges from the live `/groups/hierarchies`, so + * `orderDestroy` can put a child before its parent. Only the group targets need + * edges; every managed group is mapped id→key so a parent edge to a not-targeted + * managed group is still resolvable (harmless — `orderKeys` ignores deps outside + * the target set). Best-effort: a fetch failure warns and returns no edges, so + * ordering degrades to tier-only rather than aborting the destroy. + */ +async function fetchParentEdges( + client: Pick, + state: State, + keys: string[], + warnings: CtWarning[], +): Promise> { + const groupKeys = keys.filter((k) => state.resources[k]?.type === "group"); + if (groupKeys.length === 0) return new Map(); + const groupIdToKey = new Map(); + for (const m of Object.values(state.resources)) { + if (m.type === "group") groupIdToKey.set(m.id, m.key); + } + try { + const raw = await client.get("/groups/hierarchies"); + const parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); + const edges = new Map(); + for (const key of groupKeys) { + edges.set(key, managedParentKeys(parentIds.get(state.resources[key]!.id) ?? [], groupIdToKey)); + } + return edges; + } catch (err) { + warnings.push({ + code: "DESTROY_HIERARCHY_UNREADABLE", + message: `Failed to fetch group hierarchies for destroy ordering: ${formatFailure(err)}. Falling back to tier-only order.`, + }); + return new Map(); + } +} + +/** + * Type-level teardown warnings for the targets of this run (#99 review) — one line per target whose + * resource type declares a `destroyWarning`. A non-empty result also means `--force` must NOT skip + * the typed confirmation: these are the deletes whose blast radius leaves the managed surface (today: + * `person-status`, whose deletion mutates every person carrying it), so an unattended `--force` + * teardown is exactly the run that should stop and ask. + */ +/** One `--member-field` target, resolved against state. */ +export interface MemberFieldTarget { + /** The portable `::` identity, as typed. */ + identity: string; + groupKey: string; + fieldKey: string; + groupId: number; +} + +/** + * Resolve `--member-field ::` targets against the state file (#135). + * + * This is the EXPLICIT destructive operation a group member field can only ever be removed by. + * `apply` never deletes one — a field dropped from config produces no desired key at all, so the + * diff engine is structurally unable to propose it (see engine/synthetic.ts) — and `ct destroy + * --target` addresses whole managed resources, which a member field is not: it has no state entry + * of its own because it belongs to exactly one group. + * + * Guardrails are the group's: the owning group must be managed, and `preventDestroy` on it blocks + * its fields too — protecting a group protects what it owns. + */ +export function resolveMemberFieldTargets(state: State, raw: string[]): MemberFieldTarget[] { + const out: MemberFieldTarget[] = []; + for (const identity of parseTargets(raw)) { + const parsed = parseMemberFieldIdentity(identity); + if (!parsed) { + throw new Error( + `"${identity}" is not a group member field identity. Use "::" ` + + `(e.g. "ojbp_2026_27_praktikum_1::wahl").`, + ); + } + const managed = state.resources[parsed.group]; + if (!managed || managed.type !== "group") { + throw new Error( + `"${identity}": no managed group "${parsed.group}" in the state file. A member field can only ` + + `be destroyed through the group that owns it.`, + ); + } + if (managed.preventDestroy) { + throw new Error( + `preventDestroy is set (in state) for group "${parsed.group}", which owns "${identity}". ` + + `Clear the protection first — protecting a group protects its member fields too.`, + ); + } + out.push({ + identity, + groupKey: parsed.group, + fieldKey: parsed.field, + groupId: managed.id, + }); + } + return out; +} + +/** + * Delete each resolved member field, dropping its id from the owning group's state entry and saving + * after every success. A field that is already gone in ChurchTools (no live row, or a 404 on the + * DELETE) is success-with-note, mirroring `runDeleteLoop`; any other error stops the run with state + * saved up to that point. + * + * Returns structured outcomes and `complete:false` if any target failed, so the caller can hold back + * the group deletes that follow. That gate matters: the groups being destroyed are the very groups + * these fields belong to, so carrying on would delete a field the run just reported it could not delete. + */ +export async function runMemberFieldDeleteLoop(ctx: { + client: Pick; + state: State; + statePath: string; + targets: MemberFieldTarget[]; + save?: (path: string, state: State) => Promise; + observer?: OperationObserver; +}): Promise<{ outcomes: DestroyOutcome[]; complete: boolean }> { + const { client, state, statePath, targets } = ctx; + const save = ctx.save ?? saveState; + const outcomes: DestroyOutcome[] = []; + for (const target of targets) { + const forget = async (): Promise => { + const managed = state.resources[target.groupKey]; + // Slugged, exactly as the apply that wrote it keyed the entry (`memberFieldStateKey`) and as + // `matchesLocalKey` matched the live row — otherwise `--member-field g::Wahl` deletes the + // field in ChurchTools but leaves `memberFields.wahl` pointing at the id it just destroyed. + const stateKey = memberFieldStateKey(target.fieldKey); + if (managed?.memberFields && stateKey in managed.memberFields) { + const rest = { ...managed.memberFields }; + delete rest[stateKey]; + if (Object.keys(rest).length > 0) managed.memberFields = rest; + else delete managed.memberFields; + } + await save(statePath, state); + }; + let fieldId: number | undefined; + try { + const rows = groupScopedRows(await client.get(memberFieldsReadPath(target.groupId))); + const matches = rows.filter((row) => matchesLocalKey(row, target.fieldKey)); + if (matches.length > 1) { + outcomes.push({ + kind: "member-field", + key: target.identity, + id: null, + status: "failed", + message: + `${target.identity}: ${matches.length} member fields on group #${target.groupId} answer to ` + + `"${target.fieldKey}" — refusing to guess which one to delete. Rename one in ChurchTools first.`, + }); + continue; + } + fieldId = matches.length === 1 ? memberFieldRowId(matches[0]!) : undefined; + } catch (err) { + outcomes.push({ + kind: "member-field", + key: target.identity, + id: null, + status: "failed", + message: `Stopped at ${target.identity}: ${formatFailure(err)}. Nothing further was deleted.`, + }); + return { outcomes, complete: false }; + } + if (fieldId === undefined) { + await forget(); + outcomes.push({ + kind: "member-field", + key: target.identity, + id: null, + status: "already-absent", + message: `${target.identity} already absent in ChurchTools — nothing to delete`, + }); + continue; + } + const path = memberFieldItemPath(target.groupId, fieldId); + assertNotPeople(path); + try { + await client.request("DELETE", path); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) { + await forget(); + outcomes.push({ + kind: "member-field", + key: target.identity, + id: fieldId, + status: "already-absent", + message: `${target.identity} (#${fieldId}) already deleted in ChurchTools`, + }); + continue; + } + outcomes.push({ + kind: "member-field", + key: target.identity, + id: fieldId, + status: "failed", + message: `Stopped at ${target.identity}: ${formatFailure(err)}. State saved up to this point — re-run to resume.`, + }); + return { outcomes, complete: false }; + } + await forget(); + ctx.observer?.emit({ + type: "resource-destroyed", + resourceType: "group-member-field", + key: target.identity, + id: fieldId, + }); + outcomes.push({ + kind: "member-field", + key: target.identity, + id: fieldId, + status: "destroyed", + message: `Destroyed group member field ${target.identity} (#${fieldId})`, + }); + } + return { outcomes, complete: outcomes.every((outcome) => outcome.status !== "failed") }; +} + +export function destroyWarnings(state: State, keys: string[]): string[] { + const out: string[] = []; + for (const key of keys) { + const type = state.resources[key]?.type; + const warning = type ? RESOURCES[type]?.destroyWarning : undefined; + if (warning) out.push(`${type}.${key}: ${warning}`); + } + return out; +} + +export async function prepareDestroy( + request: DestroyRequest, + dependencies: DestroyOperationDependencies = {}, +): Promise { + const targets = parseTargets(request.targets ?? []); + const memberFieldArgs = parseTargets(request.memberFields ?? []); + if (targets.length === 0 && memberFieldArgs.length === 0) { + throw new Error("No --target or --member-field given. Destroy never deletes implicitly."); + } + + const observer = dependencies.observer ?? noopObserver; + observer.emit({ type: "phase-started", phase: "resolve-project" }); + const project = await (dependencies.resolveProject ?? resolveProject)(request, dependencies.project); + const state = await (dependencies.loadState ?? loadState)(project.statePath, project.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.`); + } + } + const blocked = targets.filter((key) => state.resources[key]!.preventDestroy); + if (blocked.length > 0) { + throw new CtApplicationError( + "PREVENT_DESTROY", + `preventDestroy is set (in state) for: ${blocked.join(", ")}. ` + + `Set preventDestroy:false in config and re-apply (or clear it in the state file) first.`, + { details: { targets: blocked } }, + ); + } + + const memberFieldTargets = resolveMemberFieldTargets(state, memberFieldArgs); + const { client } = await (dependencies.authedSession ?? authedSession)(); + const warnings: CtWarning[] = []; + const parentEdges = await fetchParentEdges(client, state, targets, warnings); + const ordered = orderDestroy(state, targets, parentEdges); + + observer.emit({ type: "phase-started", phase: "backup" }); + const { actual, fetchErrors } = await (dependencies.fetchActual ?? fetchActual)( + client, + ordered.map((key) => state.resources[key]!), + ); + if (fetchErrors.length > 0) { + throw new CtApplicationError( + "DESTROY_BACKUP_FAILED", + `Backup fetch failed for: ${fetchErrors.join("; ")}. Nothing was deleted — resolve the error (or wait out the outage) and re-run.`, + { details: { fetchErrors } }, + ); + } + for (const target of memberFieldTargets) { + try { + const rows = groupScopedRows(await client.get(memberFieldsReadPath(target.groupId))); + const match = rows.find((row) => matchesLocalKey(row, target.fieldKey)); + if (match) actual.set(target.identity, match); + } catch (err) { + throw new CtApplicationError( + "DESTROY_BACKUP_FAILED", + `Backup fetch failed for ${target.identity}: ${formatFailure(err)}. Nothing was deleted — resolve the error and re-run.`, + { cause: err, details: { target: target.identity } }, + ); + } + } + const backupPath = await (dependencies.writeBackup ?? writeBackup)( + resolveBackupDir(request.backupDir, project.statePath, dependencies.env), + project.host, + actual, + (dependencies.clock ?? systemClock).now(), + ); + observer.emit({ type: "backup-written", path: backupPath }); + + const risky = destroyWarnings(state, ordered); + warnings.push(...risky.map((message) => ({ code: "DESTROY_RISK", message: `RISK — ${message}` }))); + const expected = + targets.length === 1 && memberFieldTargets.length === 0 + ? targets[0]! + : targets.length === 0 && memberFieldTargets.length === 1 + ? memberFieldTargets[0]!.identity + : "destroy"; + const confirmation: PreparedDestroy["confirmation"] = project.protected + ? { type: "environment", environment: project.environment!, expected: project.environment! } + : { type: "yes", expected }; + const store = dependencies.store ?? defaultStore; + const fingerprint = await stateFingerprint(project.statePath, dependencies.readStateFile); + const stored = store.put( + { + project, + state, + client, + ordered, + memberFieldTargets, + backupPath, + warnings, + confirmation, + stateFingerprint: fingerprint, + }, + dependencies.preparedTtlMs ?? PREPARED_DESTROY_TTL_MS, + ); + return { + id: stored.id, + project, + targets: ordered, + memberFields: memberFieldTargets.map((target) => target.identity), + backupPath, + warnings, + confirmation, + expiresAt: stored.expiresAt.toISOString(), + }; +} + +function assertDestroyConfirmation( + requirement: PreparedDestroy["confirmation"], + proof: ConfirmationProof | undefined, +): void { + if (requirement.type === "yes" && proof?.type === "yes") return; + if ( + requirement.type === "environment" && + proof?.type === "environment" && + proof.value === requirement.environment + ) { + return; + } + if (requirement.type === "environment") { + throw new CtApplicationError( + "PROTECTED_ENV_CONFIRMATION_REQUIRED", + `Protected environment "${requirement.environment}" was not confirmed.`, + { details: { environment: requirement.environment } }, + ); + } + throw new CtApplicationError("PLAN_CONFIRMATION_MISMATCH", "Destroy confirmation was not provided."); +} + +export async function executePreparedDestroy( + prepared: Pick, + proof: ConfirmationProof | undefined, + dependencies: DestroyOperationDependencies = {}, +): Promise { + const store = dependencies.store ?? defaultStore; + const candidate = store.peek(prepared.id); + assertDestroyConfirmation(candidate.confirmation, proof); + const lock = dependencies.lock ?? defaultLock; + return lock.runExclusive(candidate.project.statePath, async () => { + const stored = store.take(prepared.id); + const currentFingerprint = await stateFingerprint(stored.project.statePath, dependencies.readStateFile); + if (currentFingerprint !== stored.stateFingerprint) { + throw new CtApplicationError( + "PLAN_CONFIRMATION_MISMATCH", + "The state file changed after this destroy was prepared. Prepare and confirm it again.", + { details: { statePath: stored.project.statePath } }, + ); + } + const outcomes: DestroyOutcome[] = []; + const observer = dependencies.observer ?? noopObserver; + if (stored.memberFieldTargets.length > 0) { + observer.emit({ type: "phase-started", phase: "destroy-member-fields" }); + const fields = await runMemberFieldDeleteLoop({ + client: stored.client, + state: stored.state, + statePath: stored.project.statePath, + targets: stored.memberFieldTargets, + save: dependencies.saveState, + observer, + }); + outcomes.push(...fields.outcomes); + if (!fields.complete) { + if (stored.ordered.length > 0) { + outcomes.push({ + kind: "resource", + key: stored.ordered.join(", "), + id: null, + status: "skipped", + message: `Not destroying ${stored.ordered.join(", ")} — a member field on it could not be deleted first.`, + }); + } + return { + operation: "destroy", + project: stored.project, + warnings: stored.warnings, + value: { backupPath: stored.backupPath, outcomes, complete: false }, + }; + } + } + if (stored.ordered.length > 0) { + observer.emit({ type: "phase-started", phase: "destroy-resources" }); + } + outcomes.push( + ...(await runDeleteLoop({ + client: stored.client, + state: stored.state, + statePath: stored.project.statePath, + ordered: stored.ordered, + save: dependencies.saveState, + observer, + })), + ); + return { + operation: "destroy", + project: stored.project, + warnings: stored.warnings, + value: { + backupPath: stored.backupPath, + outcomes, + complete: outcomes.every((outcome) => outcome.status !== "failed" && outcome.status !== "skipped"), + }, + }; + }); +} + +export interface DeleteLoopCtx { + client: Pick; + state: State; + statePath: string; + ordered: string[]; + /** Injection seam for tests; defaults to the real state writer. */ + save?: (path: string, state: State) => Promise; + observer?: OperationObserver; +} + +/** + * Delete each ordered target, removing it from state and saving after each success. + * + * A 404 means the target was already deleted in ChurchTools (e.g. by hand in the UI): + * treat it as success-with-note — drop the state entry, save, and continue to the next + * target. Any non-404 error stops the run with state saved up to that point, so a re-run + * can resume with the remaining targets. (Mirrors the backup loop's 404 tolerance.) + */ +export async function runDeleteLoop(ctx: DeleteLoopCtx): Promise { + const { client, state, statePath, ordered } = ctx; + const save = ctx.save ?? saveState; + const outcomes: DestroyOutcome[] = []; + for (const key of ordered) { + const managed = state.resources[key]!; + const spec = RESOURCES[managed.type]; + if (!spec) { + // Skipped, not destroyed: the resource is still in ChurchTools AND still in state, so the + // structured result must remain incomplete. + outcomes.push({ + kind: "resource", + key, + id: managed.id, + status: "skipped", + message: `No write spec for type "${managed.type}" — skipping ${key}.`, + }); + continue; + } + const path = spec.itemPath(managed.id); + assertNotPeople(path); + try { + if (spec.writer) { + // A type whose writes are not REST (#108: Bereiche). Without a `remove` it has no delete path + // at all — say so instead of issuing a DELETE the endpoint does not implement, which would + // 404/405 and read as "already deleted". + if (!spec.writer.remove) { + outcomes.push({ + kind: "resource", + key, + id: managed.id, + status: "skipped", + message: + `${managed.type}.${key} (#${managed.id}) cannot be deleted by \`ct\` — ChurchTools exposes ` + + `no delete for this type. Remove it in the ChurchTools admin UI, then re-run to drop it from state.`, + }); + continue; + } + await spec.writer.remove({ client: client as CtWriteClient, id: managed.id }); + } else { + await client.request("DELETE", path); + } + } catch (err) { + if (err instanceof CtApiError && err.status === 404) { + delete state.resources[key]; + await save(statePath, state); + outcomes.push({ + kind: "resource", + key, + id: managed.id, + status: "already-absent", + message: `${managed.type}.${key} (#${managed.id}) already deleted in ChurchTools — removed from state`, + }); + continue; + } + // Same formatter the top-level handler uses (#50) so a non-404 CtApiError's HTTP status + + // response body survive into the stop message (#71), not just the bare "... failed" text. + outcomes.push({ + kind: "resource", + key, + id: managed.id, + status: "failed", + message: `Stopped at ${key}: ${formatFailure(err)}. State saved up to this point — re-run with the remaining targets to resume.`, + }); + return outcomes; + } + delete state.resources[key]; + await save(statePath, state); + ctx.observer?.emit({ + type: "resource-destroyed", + resourceType: managed.type, + key, + id: managed.id, + }); + outcomes.push({ + kind: "resource", + key, + id: managed.id, + status: "destroyed", + message: `Destroyed ${managed.type}.${key} (#${managed.id})`, + }); + } + return outcomes; +} diff --git a/src/application/operations/index.ts b/src/application/operations/index.ts index 4197110..3a235dc 100644 --- a/src/application/operations/index.ts +++ b/src/application/operations/index.ts @@ -7,3 +7,4 @@ export * from "./adopt.js"; export * from "./auth.js"; export * from "./adopt-group.js"; export * from "./adopt-grants.js"; +export * from "./destroy.js"; diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 4400399..21759cc 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -1,32 +1,25 @@ import { Command } from "commander"; -import { authedSession } from "../api/session.js"; -import { CtApiError, type CtClient } from "../api/ctClient.js"; -import { resolveConfig } from "../config.js"; -import { prepareEnv } from "../env/context.js"; -import { loadState, saveState, type State } from "../state/state.js"; -import { RESOURCES, type CtWriteClient } from "../resources/registry.js"; -import { assertNotPeople } from "../engine/guard.js"; -import { orderKeys } from "../engine/graph.js"; -import { fetchActual } from "../engine/build.js"; -import { parentIdsByGroupId, managedParentKeys, type HierarchyEntry } from "../engine/hierarchy.js"; -import type { DesiredResource } from "../engine/types.js"; -import { writeBackup } from "../engine/backup.js"; +import { CtApplicationError } from "../application/errors.js"; import { - groupScopedRows, - memberFieldStateKey, - matchesLocalKey, - memberFieldItemPath, - memberFieldRowId, - memberFieldsReadPath, - parseMemberFieldIdentity, -} from "../engine/member-fields.js"; -import { resolveBackupDir } from "./apply.js"; -import { confirmTyped, confirmEnv } from "../ui/prompt.js"; -import { info, warn, success, error, formatError } from "../ui.js"; + executePreparedDestroy, + prepareDestroy, + type DestroyRequest, +} from "../application/operations/destroy.js"; +import type { ConfirmationProof } from "../application/operations/apply.js"; +import { confirmEnv, confirmTyped } from "../ui/prompt.js"; +import { error, info, success, warn } from "../ui.js"; + +export { + destroyWarnings, + orderDestroy, + parseTargets, + resolveMemberFieldTargets, + runDeleteLoop, + runMemberFieldDeleteLoop, +} from "../application/operations/destroy.js"; interface DestroyOptions { target?: string[]; - /** Group-scoped member fields to delete, by their portable `::` identity (#135). */ memberField?: string[]; state?: string; env?: string; @@ -35,233 +28,6 @@ interface DestroyOptions { 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 for destroy: highest tier first (leaves before their - * base metadata) and, within the group tier, a child before its parent. - * - * The state file carries no hierarchy edges (the synthetic `parents` field is - * stripped from snapshots — see execute.ts), so the caller passes `parentKeysByKey` - * discovered live from `/groups/hierarchies` (managed groups only). We reuse - * `orderKeys` — the very topological apply order plan uses — with those edges, - * then reverse it, so destroy is the exact inverse of apply and honours intra-tier - * parent edges. Pass an empty map (or omit) to fall back to tier-only ordering. - */ -export function orderDestroy( - state: State, - keys: string[], - parentKeysByKey: Map = new Map(), -): string[] { - const entries: DesiredResource[] = keys.map((key) => ({ - type: state.resources[key]!.type, - key, - fields: {}, - dependsOn: parentKeysByKey.get(key) ?? [], - })); - return orderKeys(entries).reverse(); -} - -/** - * Discover managed group→parent edges from the live `/groups/hierarchies`, so - * `orderDestroy` can put a child before its parent. Only the group targets need - * edges; every managed group is mapped id→key so a parent edge to a not-targeted - * managed group is still resolvable (harmless — `orderKeys` ignores deps outside - * the target set). Best-effort: a fetch failure warns and returns no edges, so - * ordering degrades to tier-only rather than aborting the destroy. - */ -async function fetchParentEdges( - client: Pick, - state: State, - keys: string[], -): Promise> { - const groupKeys = keys.filter((k) => state.resources[k]?.type === "group"); - if (groupKeys.length === 0) return new Map(); - const groupIdToKey = new Map(); - for (const m of Object.values(state.resources)) { - if (m.type === "group") groupIdToKey.set(m.id, m.key); - } - try { - const raw = await client.get("/groups/hierarchies"); - const parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []); - const edges = new Map(); - for (const key of groupKeys) { - edges.set(key, managedParentKeys(parentIds.get(state.resources[key]!.id) ?? [], groupIdToKey)); - } - return edges; - } catch (err) { - warn( - `Failed to fetch group hierarchies for destroy ordering: ${formatError(err)}. Falling back to tier-only order.`, - ); - return new Map(); - } -} - -/** - * Type-level teardown warnings for the targets of this run (#99 review) — one line per target whose - * resource type declares a `destroyWarning`. A non-empty result also means `--force` must NOT skip - * the typed confirmation: these are the deletes whose blast radius leaves the managed surface (today: - * `person-status`, whose deletion mutates every person carrying it), so an unattended `--force` - * teardown is exactly the run that should stop and ask. - */ -/** One `--member-field` target, resolved against state. */ -export interface MemberFieldTarget { - /** The portable `::` identity, as typed. */ - identity: string; - groupKey: string; - fieldKey: string; - groupId: number; -} - -/** - * Resolve `--member-field ::` targets against the state file (#135). - * - * This is the EXPLICIT destructive operation a group member field can only ever be removed by. - * `apply` never deletes one — a field dropped from config produces no desired key at all, so the - * diff engine is structurally unable to propose it (see engine/synthetic.ts) — and `ct destroy - * --target` addresses whole managed resources, which a member field is not: it has no state entry - * of its own because it belongs to exactly one group. - * - * Guardrails are the group's: the owning group must be managed, and `preventDestroy` on it blocks - * its fields too — protecting a group protects what it owns. - */ -export function resolveMemberFieldTargets(state: State, raw: string[]): MemberFieldTarget[] { - const out: MemberFieldTarget[] = []; - for (const identity of parseTargets(raw)) { - const parsed = parseMemberFieldIdentity(identity); - if (!parsed) { - throw new Error( - `"${identity}" is not a group member field identity. Use "::" ` + - `(e.g. "ojbp_2026_27_praktikum_1::wahl").`, - ); - } - const managed = state.resources[parsed.group]; - if (!managed || managed.type !== "group") { - throw new Error( - `"${identity}": no managed group "${parsed.group}" in the state file. A member field can only ` + - `be destroyed through the group that owns it.`, - ); - } - if (managed.preventDestroy) { - throw new Error( - `preventDestroy is set (in state) for group "${parsed.group}", which owns "${identity}". ` + - `Clear the protection first — protecting a group protects its member fields too.`, - ); - } - out.push({ - identity, - groupKey: parsed.group, - fieldKey: parsed.field, - groupId: managed.id, - }); - } - return out; -} - -/** - * Delete each resolved member field, dropping its id from the owning group's state entry and saving - * after every success. A field that is already gone in ChurchTools (no live row, or a 404 on the - * DELETE) is success-with-note, mirroring `runDeleteLoop`; any other error stops the run with state - * saved up to that point. - * - * Returns `false` if ANY target failed — including the ones that only skip ahead to the next target - * — so the caller can hold back the group deletes that follow. That gate matters: the groups being - * destroyed are the very groups these fields belong to, so carrying on would delete a field the run - * just reported it could not delete, and the printed "Nothing further was deleted" would be a lie. - */ -export async function runMemberFieldDeleteLoop(ctx: { - client: Pick; - state: State; - statePath: string; - targets: MemberFieldTarget[]; - save?: (path: string, state: State) => Promise; -}): Promise { - const { client, state, statePath, targets } = ctx; - const save = ctx.save ?? saveState; - let ok = true; - for (const target of targets) { - const forget = async (): Promise => { - const managed = state.resources[target.groupKey]; - // Slugged, exactly as the apply that wrote it keyed the entry (`memberFieldStateKey`) and as - // `matchesLocalKey` matched the live row — otherwise `--member-field g::Wahl` deletes the - // field in ChurchTools but leaves `memberFields.wahl` pointing at the id it just destroyed. - const stateKey = memberFieldStateKey(target.fieldKey); - if (managed?.memberFields && stateKey in managed.memberFields) { - const rest = { ...managed.memberFields }; - delete rest[stateKey]; - if (Object.keys(rest).length > 0) managed.memberFields = rest; - else delete managed.memberFields; - } - await save(statePath, state); - }; - let fieldId: number | undefined; - try { - const rows = groupScopedRows(await client.get(memberFieldsReadPath(target.groupId))); - const matches = rows.filter((row) => matchesLocalKey(row, target.fieldKey)); - if (matches.length > 1) { - error( - `${target.identity}: ${matches.length} member fields on group #${target.groupId} answer to ` + - `"${target.fieldKey}" — refusing to guess which one to delete. Rename one in ChurchTools first.`, - ); - process.exitCode = 1; - ok = false; - continue; - } - fieldId = matches.length === 1 ? memberFieldRowId(matches[0]!) : undefined; - } catch (err) { - error(`Stopped at ${target.identity}: ${formatError(err)}. Nothing further was deleted.`); - process.exitCode = 1; - return false; - } - if (fieldId === undefined) { - await forget(); - success(`${target.identity} already absent in ChurchTools — nothing to delete`); - continue; - } - const path = memberFieldItemPath(target.groupId, fieldId); - assertNotPeople(path); - try { - await client.request("DELETE", path); - } catch (err) { - if (err instanceof CtApiError && err.status === 404) { - await forget(); - success(`${target.identity} (#${fieldId}) already deleted in ChurchTools`); - continue; - } - error( - `Stopped at ${target.identity}: ${formatError(err)}. State saved up to this point — re-run to resume.`, - ); - process.exitCode = 1; - return false; - } - await forget(); - success(`Destroyed group member field ${target.identity} (#${fieldId})`); - } - return ok; -} - -export function destroyWarnings(state: State, keys: string[]): string[] { - const out: string[] = []; - for (const key of keys) { - const type = state.resources[key]?.type; - const warning = type ? RESOURCES[type]?.destroyWarning : undefined; - if (warning) out.push(`${type}.${key}: ${warning}`); - } - return out; -} - export function destroyCommand(): Command { return new Command("destroy") .description("Explicitly delete managed resources (protected; never implicit)") @@ -280,205 +46,62 @@ export function destroyCommand(): Command { "skip the typed confirmation (preventDestroy — and a type-level destroy warning, e.g. person-status — is still enforced)", ) .action(async (opts: DestroyOptions) => { - const targets = parseTargets(opts.target ?? []); - const memberFieldArgs = parseTargets(opts.memberField ?? []); - if (targets.length === 0 && memberFieldArgs.length === 0) { - throw new Error("No --target or --member-field given. Destroy never deletes implicitly."); - } - - const cmdEnv = await prepareEnv(opts); - const config = await resolveConfig(); - const statePath = cmdEnv.statePath; - 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: read from STATE, never the config. A resource dropped from config - // (the real destroy scenario) has lost its config flag, but its state entry still carries the - // protection apply mirrored there — so it survives the drop. destroy loads no config file at - // all, so a config eval error (e.g. a sibling still referencing the dropped target) can't - // block a teardown either (items 2 + 3). - const blocked = targets.filter((k) => state.resources[k]!.preventDestroy); - if (blocked.length > 0) { - throw new Error( - `preventDestroy is set (in state) for: ${blocked.join(", ")}. ` + - `Set preventDestroy:false in config and re-apply (or clear it in the state file) first.`, - ); - } - - // Member fields (#135) are resolved against state BEFORE any network call, so a malformed - // identity, an unmanaged group or a preventDestroy'd owner stops the run without touching CT. - const memberFieldTargets = resolveMemberFieldTargets(state, memberFieldArgs); - - const { client } = await authedSession(); - - const parentEdges = await fetchParentEdges(client, state, targets); - const ordered = orderDestroy(state, targets, parentEdges); - - // Backup: fetch each target's current actual values via the same fetchActual as plan/apply - // (404 → skip: already gone in CT, nothing to back up). A non-404 failure must ABORT before - // any DELETE — proceeding would irreversibly delete a target with no backup of its state. - const { actual, fetchErrors } = await fetchActual( - client, - ordered.map((k) => state.resources[k]!), - ); - if (fetchErrors.length > 0) { - error( - `Backup fetch failed for: ${fetchErrors.join("; ")}. ` + - `Nothing was deleted — resolve the error (or wait out the outage) and re-run.`, - ); - process.exitCode = 1; - return; - } - // Member-field definitions go into the SAME backup, under their portable identity, so an - // explicit teardown of one is as recoverable as any managed resource. - for (const target of memberFieldTargets) { - try { - const rows = groupScopedRows(await client.get(memberFieldsReadPath(target.groupId))); - const match = rows.find((row) => matchesLocalKey(row, target.fieldKey)); - if (match) actual.set(target.identity, match); - } catch (err) { - error( - `Backup fetch failed for ${target.identity}: ${formatError(err)}. Nothing was deleted — ` + - `resolve the error and re-run.`, - ); + const request: DestroyRequest = { + targets: opts.target, + memberFields: opts.memberField, + statePath: opts.state, + environment: opts.env, + backupDir: opts.backupDir, + }; + let prepared; + try { + prepared = await prepareDestroy(request); + } catch (caught) { + if (caught instanceof CtApplicationError && caught.code === "DESTROY_BACKUP_FAILED") { + error(caught.message); process.exitCode = 1; return; } + throw caught; } - const backupPath = await writeBackup(resolveBackupDir(opts.backupDir, statePath), config.host, actual); - info(`Backup written: ${backupPath}`); - warn(`About to DELETE: ${[...ordered, ...memberFieldTargets.map((t) => t.identity)].join(", ")}`); - // Type-level risk (see `destroyWarnings`): surfaced before the prompt, and it takes `--force` - // away for this run so the delete cannot go through unattended. - const risky = destroyWarnings(state, ordered); - for (const line of risky) { - warn(`RISK — ${line}`); - } - if (risky.length > 0 && opts.force) { + info(`Backup written: ${prepared.backupPath}`); + warn(`About to DELETE: ${[...prepared.targets, ...prepared.memberFields].join(", ")}`); + for (const warning of prepared.warnings) warn(warning.message); + const risky = prepared.warnings.some((warning) => warning.code === "DESTROY_RISK"); + if (risky && opts.force) { warn("--force does NOT skip confirmation for the target(s) above. Confirm interactively."); } - // Protected env (#22): typed confirmation of the env NAME is mandatory and --force does NOT bypass - // it (--confirm-env substitutes in CI). Otherwise the usual per-target typed confirmation. - const expected = - targets.length === 1 && memberFieldTargets.length === 0 - ? targets[0]! - : targets.length === 0 && memberFieldTargets.length === 1 - ? memberFieldTargets[0]!.identity - : "destroy"; - const ok = cmdEnv.protected - ? await confirmEnv(cmdEnv.name!, { confirmFlag: opts.confirmEnv }) - : await confirmTyped(expected, { force: opts.force && risky.length === 0 }); - if (!ok) { + + let proof: ConfirmationProof | undefined; + let confirmed = false; + if (prepared.confirmation.type === "environment") { + confirmed = await confirmEnv(prepared.confirmation.environment, { confirmFlag: opts.confirmEnv }); + if (confirmed) proof = { type: "environment", value: prepared.confirmation.environment }; + } else { + confirmed = await confirmTyped(prepared.confirmation.expected!, { + force: opts.force && !risky, + }); + if (confirmed) proof = { type: "yes" }; + } + if (!confirmed) { warn( - cmdEnv.protected - ? `Aborted — protected environment "${cmdEnv.name}" was not confirmed. Nothing deleted.` + prepared.confirmation.type === "environment" + ? `Aborted — protected environment "${prepared.confirmation.environment}" was not confirmed. Nothing deleted.` : "Aborted — nothing deleted.", ); process.exitCode = 1; return; } - // Member fields FIRST: they are owned by their group, so deleting the group would take them - // with it and the explicit per-field record would be lost. - if (memberFieldTargets.length > 0) { - const fieldsDone = await runMemberFieldDeleteLoop({ - client, - state, - statePath, - targets: memberFieldTargets, - }); - if (!fieldsDone) { - // A field that could not be deleted must not be deleted anyway as collateral of its - // owning group's destroy — and the user was just told nothing further would happen. - if (ordered.length > 0) { - error(`Not destroying ${ordered.join(", ")} — a member field on it could not be deleted first.`); - } - return; + const result = await executePreparedDestroy(prepared, proof); + for (const outcome of result.value.outcomes) { + if (outcome.status === "destroyed" || outcome.status === "already-absent") { + success(outcome.message); + } else { + error(outcome.message); } } - if (ordered.length > 0) { - await runDeleteLoop({ client, state, statePath, ordered }); - } + if (!result.value.complete) process.exitCode = 1; }); } - -export interface DeleteLoopCtx { - client: Pick; - state: State; - statePath: string; - ordered: string[]; - /** Injection seam for tests; defaults to the real state writer. */ - save?: (path: string, state: State) => Promise; -} - -/** - * Delete each ordered target, removing it from state and saving after each success. - * - * A 404 means the target was already deleted in ChurchTools (e.g. by hand in the UI): - * treat it as success-with-note — drop the state entry, save, and continue to the next - * target. Any non-404 error stops the run with state saved up to that point, so a re-run - * can resume with the remaining targets. (Mirrors the backup loop's 404 tolerance.) - */ -export async function runDeleteLoop(ctx: DeleteLoopCtx): Promise { - const { client, state, statePath, ordered } = ctx; - const save = ctx.save ?? saveState; - for (const key of ordered) { - const managed = state.resources[key]!; - const spec = RESOURCES[managed.type]; - if (!spec) { - // Skipped, not destroyed: the resource is still in ChurchTools AND still in state, so exit - // non-zero like every other failure below — a `ct destroy` that reports success while its - // targets survive is the one outcome a caller must never see. - error(`No write spec for type "${managed.type}" — skipping ${key}.`); - process.exitCode = 1; - continue; - } - const path = spec.itemPath(managed.id); - assertNotPeople(path); - try { - if (spec.writer) { - // A type whose writes are not REST (#108: Bereiche). Without a `remove` it has no delete path - // at all — say so instead of issuing a DELETE the endpoint does not implement, which would - // 404/405 and read as "already deleted". - if (!spec.writer.remove) { - error( - `${managed.type}.${key} (#${managed.id}) cannot be deleted by \`ct\` — ChurchTools exposes ` + - `no delete for this type. Remove it in the ChurchTools admin UI, then re-run to drop it ` + - `from state.`, - ); - // Still in ChurchTools and still in state — a skip, not a success. Exit non-zero. - process.exitCode = 1; - continue; - } - await spec.writer.remove({ client: client as CtWriteClient, id: managed.id }); - } else { - await client.request("DELETE", path); - } - } catch (err) { - if (err instanceof CtApiError && err.status === 404) { - delete state.resources[key]; - await save(statePath, state); - success( - `${managed.type}.${key} (#${managed.id}) already deleted in ChurchTools — removed from state`, - ); - continue; - } - // Same formatter the top-level handler uses (#50) so a non-404 CtApiError's HTTP status + - // response body survive into the stop message (#71), not just the bare "... failed" text. - error( - `Stopped at ${key}: ${formatError(err)}. State saved up to this point — re-run with the remaining targets to resume.`, - ); - process.exitCode = 1; - return; - } - delete state.resources[key]; - await save(statePath, state); - success(`Destroyed ${managed.type}.${key} (#${managed.id})`); - } -} diff --git a/tests/application/destroy-operation.test.ts b/tests/application/destroy-operation.test.ts new file mode 100644 index 0000000..6945cd3 --- /dev/null +++ b/tests/application/destroy-operation.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CtApplicationError } from "../../src/application/errors.js"; +import { + executePreparedDestroy, + prepareDestroy, + type DestroyOperationDependencies, + type PreparedDestroyExecution, +} from "../../src/application/operations/destroy.js"; +import { PreparedOperationStore } from "../../src/application/prepared-operation-store.js"; +import type { CtClient } from "../../src/api/ctClient.js"; +import { emptyState } from "../../src/state/state.js"; + +const host = "https://example.church.tools"; +const statePath = "/project/ct-state.prod.json"; + +function harness() { + let stateFile = "state-v1"; + const state = emptyState(host); + state.resources.area = { + type: "group", + id: 42, + key: "area", + fields: {}, + adoptedAt: "t", + updatedAt: "t", + }; + const request = vi.fn(async () => ({})); + const client = { + get: vi.fn(async () => []), + request, + } as unknown as CtClient; + const store = new PreparedOperationStore(undefined, { + nextId: () => "destroy-1", + }); + const events: string[] = []; + const dependencies: DestroyOperationDependencies = { + store, + readStateFile: async () => stateFile, + resolveProject: vi.fn(async () => ({ + cwd: "/project", + configPath: "/project/ct.config.ts", + statePath, + environmentsPath: "/project/ct.envs.json", + configDisplayPath: "ct.config.ts", + stateDisplayPath: "ct-state.prod.json", + environment: "prod", + protected: true, + host, + })), + loadState: vi.fn(async () => state), + authedSession: vi.fn(async () => ({ client, me: { id: 1 } })), + fetchActual: vi.fn(async () => ({ + actual: new Map([["area", { name: "Area" }]]), + fetchErrors: [], + unresolved: new Set(), + fetchFailed: new Map(), + })), + writeBackup: vi.fn(async () => "/project/backups/backup.json"), + saveState: vi.fn(async () => {}), + observer: { emit: (event) => events.push(event.type) }, + }; + return { + dependencies, + request, + events, + changeState(value: string) { + stateFile = value; + }, + }; +} + +async function expectCode(promise: Promise, code: CtApplicationError["code"]): Promise { + await expect(promise).rejects.toMatchObject({ name: "CtApplicationError", code }); +} + +describe("prepared destroy operation", () => { + it("exposes the exact proposal and requires the protected environment before deleting", async () => { + const test = harness(); + const prepared = await prepareDestroy({ targets: ["area"] }, test.dependencies); + + expect(prepared).toMatchObject({ + id: "destroy-1", + targets: ["area"], + memberFields: [], + backupPath: "/project/backups/backup.json", + confirmation: { type: "environment", environment: "prod" }, + }); + expect(test.request).not.toHaveBeenCalled(); + + await expectCode( + executePreparedDestroy(prepared, { type: "yes" }, test.dependencies), + "PROTECTED_ENV_CONFIRMATION_REQUIRED", + ); + expect(test.request).not.toHaveBeenCalled(); + + const result = await executePreparedDestroy( + prepared, + { type: "environment", value: "prod" }, + test.dependencies, + ); + expect(test.request).toHaveBeenCalledWith("DELETE", "/groups/42"); + expect(result).toMatchObject({ + operation: "destroy", + value: { + backupPath: "/project/backups/backup.json", + complete: true, + outcomes: [{ key: "area", id: 42, status: "destroyed" }], + }, + }); + expect(test.events).toContain("resource-destroyed"); + }); + + it("refuses a proposal after its state file changed", async () => { + const test = harness(); + const prepared = await prepareDestroy({ targets: ["area"] }, test.dependencies); + test.changeState("state-v2"); + + await expectCode( + executePreparedDestroy(prepared, { type: "environment", value: "prod" }, test.dependencies), + "PLAN_CONFIRMATION_MISMATCH", + ); + expect(test.request).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts index 13048b7..2bc6c83 100644 --- a/tests/architecture-boundaries.test.ts +++ b/tests/architecture-boundaries.test.ts @@ -55,10 +55,6 @@ describe("application architecture boundaries", () => { } } - // Tasks 2–4 remove this migration baseline as each command becomes a thin operation adapter. - // Until then, an additional direct mutation import fails this test instead of expanding silently. - expect(violations.sort()).toEqual( - ["src/commands/destroy.ts:saveState", "src/commands/destroy.ts:writeBackup"].sort(), - ); + expect(violations).toEqual([]); }); }); diff --git a/tests/destroy.test.ts b/tests/destroy.test.ts index b003b88..58d171f 100644 --- a/tests/destroy.test.ts +++ b/tests/destroy.test.ts @@ -100,9 +100,7 @@ describe("runDeleteLoop", () => { return {}; }); const save = vi.fn(async () => {}); - const prevExit = process.exitCode; - - await runDeleteLoop({ + const outcomes = await runDeleteLoop({ client: asClient(request), state, statePath: "s.json", @@ -114,8 +112,7 @@ describe("runDeleteLoop", () => { expect(state.resources.later).toBeDefined(); // never reached expect(request).toHaveBeenCalledTimes(1); // stopped at the first target expect(save).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - process.exitCode = prevExit; + expect(outcomes).toMatchObject([{ key: "boom", status: "failed" }]); }); it("exits NON-ZERO when a type has no delete path — the target survives, so it is not a success", async () => { @@ -128,25 +125,20 @@ describe("runDeleteLoop", () => { const state = stateWith({ key: "bereich", type: "department", id: 7 }); const request = vi.fn(async () => ({})); const save = vi.fn(async () => {}); - const prevExit = process.exitCode; - const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - try { - await runDeleteLoop({ + const outcomes = await runDeleteLoop({ client: asClient(request), state, statePath: "s.json", ordered: ["bereich"], save, }); - expect(process.exitCode).toBe(1); + expect(outcomes).toMatchObject([{ key: "bereich", status: "skipped" }]); expect(state.resources.bereich).toBeDefined(); // still managed — nothing was deleted expect(request).not.toHaveBeenCalled(); // and no DELETE was issued against a path CT lacks - expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain("cannot be deleted"); + expect(outcomes[0]!.message).toContain("cannot be deleted"); } finally { spec.writer!.remove = originalRemove; - errSpy.mockRestore(); - process.exitCode = prevExit; } }); @@ -158,16 +150,16 @@ describe("runDeleteLoop", () => { return {}; }); const save = vi.fn(async () => {}); - const prevExit = process.exitCode; - const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - - await runDeleteLoop({ client: asClient(request), state, statePath: "s.json", ordered: ["boom"], save }); + const outcomes = await runDeleteLoop({ + client: asClient(request), + state, + statePath: "s.json", + ordered: ["boom"], + save, + }); - const combined = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(combined).toContain("HTTP 403"); - expect(combined).toContain("no permission"); - errSpy.mockRestore(); - process.exitCode = prevExit; + expect(outcomes[0]!.message).toContain("HTTP 403"); + expect(outcomes[0]!.message).toContain("no permission"); }); }); From 8e59bcf42c8385e0b3eb93d91be65aab2dfd96ac Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Tue, 25 Aug 2026 22:49:50 +0200 Subject: [PATCH 13/15] refactor(auth): expose login and logout operations --- src/application/operations/auth.ts | 109 ++++++++++++++++++++++- src/commands/auth.ts | 46 ++++------ tests/application/auth-operation.test.ts | 57 +++++++++++- 3 files changed, 179 insertions(+), 33 deletions(-) diff --git a/src/application/operations/auth.ts b/src/application/operations/auth.ts index 8c18d33..692d05b 100644 --- a/src/application/operations/auth.ts +++ b/src/application/operations/auth.ts @@ -1,9 +1,17 @@ import { resolve } from "node:path"; import { authedSession, type AuthedSession } from "../../api/session.js"; -import type { WhoAmI } from "../../api/ctClient.js"; +import { CtClient, type WhoAmI } from "../../api/ctClient.js"; +import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "../../api/version.js"; import { checkAllEnvAuth, type EnvAuthStatus } from "../../auth/status.js"; -import { readToken } from "../../auth/tokenStore.js"; -import { loadEnvProfiles, resolveEnvsPath } from "../../env/envs.js"; +import { keychainSessionCache } from "../../auth/sessionStore.js"; +import { + clearCredentials, + readToken, + storeCredentials, + type ClearCredentialsResult, +} from "../../auth/tokenStore.js"; +import { normalizeHost } from "../../config.js"; +import { loadEnvProfile, loadEnvProfiles, resolveEnvsPath } from "../../env/envs.js"; import { CtApplicationError } from "../errors.js"; import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; @@ -35,6 +43,101 @@ export interface AuthStatusDependencies { cwd?: () => string; } +export interface AuthLoginRequest { + host: string; + token: string; +} + +export interface AuthLoginResult { + operation: "auth"; + action: "login"; + host: string; + identity: WhoAmI; + storage: string; + churchToolsVersion: string | null; + minimumVersion: string; + supportedVersion: boolean | null; +} + +export interface AuthLoginDependencies { + createClient?: (host: string) => Pick; + storeCredentials?: typeof storeCredentials; +} + +/** Verify and persist a personal token without returning the secret to either adapter. */ +export async function runAuthLogin( + request: AuthLoginRequest, + dependencies: AuthLoginDependencies = {}, +): Promise { + const host = normalizeHost(request.host.trim()); + const token = request.token.trim(); + if (!token) throw new Error("No token provided."); + const client = ( + dependencies.createClient ?? + ((resolvedHost) => new CtClient({ host: resolvedHost }, { sessionCache: keychainSessionCache() })) + )(host); + const identity = await client.authenticate(token, { fresh: true }); + const storage = await (dependencies.storeCredentials ?? storeCredentials)({ host, token }); + const info = await client.get("/info"); + const churchToolsVersion = info.version ?? null; + return { + operation: "auth", + action: "login", + host, + identity, + storage, + churchToolsVersion, + minimumVersion: MIN_CT_VERSION, + supportedVersion: churchToolsVersion ? meetsMinVersion(churchToolsVersion) : null, + }; +} + +export interface AuthLogoutRequest { + cwd?: string; + environment?: string; +} + +export interface AuthLogoutResult { + operation: "auth"; + action: "logout"; + environment: string | null; + host: string | null; + clearedDefault: boolean; +} + +export interface AuthLogoutDependencies { + env?: NodeJS.ProcessEnv; + cwd?: () => string; + loadEnvProfile?: typeof loadEnvProfile; + clearCredentials?: (host?: string) => Promise; +} + +/** Remove credentials for the default login or exactly one environment-bound host. */ +export async function runAuthLogout( + request: AuthLogoutRequest = {}, + dependencies: AuthLogoutDependencies = {}, +): Promise { + const clear = dependencies.clearCredentials ?? clearCredentials; + if (!request.environment) { + const { clearedDefault } = await clear(); + return { operation: "auth", action: "logout", environment: null, host: null, clearedDefault }; + } + const cwd = resolve(dependencies.cwd?.() ?? process.cwd(), request.cwd ?? "."); + const environmentsPath = resolve(cwd, resolveEnvsPath(undefined, dependencies.env ?? process.env)); + const profile = await (dependencies.loadEnvProfile ?? loadEnvProfile)( + request.environment, + environmentsPath, + ); + const { clearedDefault } = await clear(profile.host); + return { + operation: "auth", + action: "logout", + environment: profile.name, + host: profile.host, + clearedDefault, + }; +} + /** Return authentication identity and source metadata without ever returning a token. */ export async function runAuthStatus( request: AuthStatusRequest = {}, diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 8e5b48a..e3fb66b 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,14 +1,10 @@ import { Command } from "commander"; -import { runAuthStatus } from "../application/operations/auth.js"; -import { CtClient } from "../api/ctClient.js"; +import { runAuthLogin, runAuthLogout, runAuthStatus } from "../application/operations/auth.js"; import { normalizeHost } from "../config.js"; -import { storeCredentials, clearCredentials, isSecureStorageAvailable } from "../auth/tokenStore.js"; -import { keychainSessionCache } from "../auth/sessionStore.js"; +import { isSecureStorageAvailable } from "../auth/tokenStore.js"; import { bootstrapLoginToken } from "../auth/login.js"; import { askVisible } from "../ui/prompt.js"; import { renderEnvAuth } from "../auth/status.js"; -import { loadEnvProfile, resolveEnvsPath } from "../env/envs.js"; -import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "../api/version.js"; import { success, error, info, warn, out, formatError } from "../ui.js"; /** @@ -18,23 +14,17 @@ import { success, error, info, warn, out, formatError } from "../ui.js"; */ /** Verify a personal token, cache the resulting session, store it, and report the login. */ export async function verifyAndStoreLoginToken(rawHost: string, rawToken: string): Promise { - const host = normalizeHost(rawHost.trim()); - const token = rawToken.trim(); - if (!token) throw new Error("No token provided."); - - const client = new CtClient({ host }, { sessionCache: keychainSessionCache() }); - // A login must actually prove the token, never be answered from a cached session. - const me = await client.authenticate(token, { fresh: true }); - const location = await storeCredentials({ host, token }); - success(`Logged in to ${host} as ${me.firstName ?? ""} ${me.lastName ?? ""} (#${me.id})`.trim()); - info(`Host + token stored in ${location}.`); - - const ctInfo = await client.get("/info"); - if (ctInfo.version) { - if (meetsMinVersion(ctInfo.version)) { - info(`ChurchTools ${ctInfo.version} (≥ ${MIN_CT_VERSION} required).`); + const result = await runAuthLogin({ host: rawHost, token: rawToken }); + const me = result.identity; + success(`Logged in to ${result.host} as ${me.firstName ?? ""} ${me.lastName ?? ""} (#${me.id})`.trim()); + info(`Host + token stored in ${result.storage}.`); + if (result.churchToolsVersion) { + if (result.supportedVersion) { + info(`ChurchTools ${result.churchToolsVersion} (≥ ${result.minimumVersion} required).`); } else { - warn(`ChurchTools ${ctInfo.version} is below the required ${MIN_CT_VERSION} — plan/apply will refuse.`); + warn( + `ChurchTools ${result.churchToolsVersion} is below the required ${result.minimumVersion} — plan/apply will refuse.`, + ); } } } @@ -130,19 +120,17 @@ export function authCommand(): Command { .description("Remove the stored host + login token") .option("-e, --env ", "environment profile from ct.envs.json (log out of that host only)") .action(async (opts: { env?: string }) => { - if (!opts.env) { - await clearCredentials(); + const result = await runAuthLogout({ environment: opts.env }); + if (!result.environment) { success("Logged out — stored credentials removed."); return; } - const profile = await loadEnvProfile(opts.env, resolveEnvsPath()); - const { clearedDefault } = await clearCredentials(profile.host); - success(`Logged out of ${profile.host} (env ${profile.name}) — other hosts stay logged in.`); - if (clearedDefault) { + success(`Logged out of ${result.host} (env ${result.environment}) — other hosts stay logged in.`); + if (result.clearedDefault) { // The default blob held a copy of the very token just removed, so it went // with it — and with it the host that commands without --env fall back to. warn( - `${profile.host} was also the default login, so commands without --env now have no host. ` + + `${result.host} was also the default login, so commands without --env now have no host. ` + `Run \`ct auth login --host --token \` (or pass --env) to set one again.`, ); } diff --git a/tests/application/auth-operation.test.ts b/tests/application/auth-operation.test.ts index 7e23a02..88e8024 100644 --- a/tests/application/auth-operation.test.ts +++ b/tests/application/auth-operation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { runAuthStatus } from "../../src/application/operations/auth.js"; +import { runAuthLogin, runAuthLogout, runAuthStatus } from "../../src/application/operations/auth.js"; const host = "https://example.church.tools"; @@ -58,3 +58,58 @@ describe("runAuthStatus", () => { expect(result).toMatchObject({ scope: "all", authenticated: true, environments: statuses }); }); }); + +describe("auth mutations", () => { + it("verifies and stores a login token but never returns the secret", async () => { + const authenticate = vi.fn(async () => ({ id: 7, firstName: "Ada", lastName: "Lovelace" })); + const storeCredentials = vi.fn(async () => "test keychain"); + const result = await runAuthLogin( + { host: "https://example.church.tools/", token: " super-secret " }, + { + createClient: (() => ({ + authenticate, + get: vi.fn(async () => ({ version: "3.140.0" })), + })) as never, + storeCredentials, + }, + ); + + expect(authenticate).toHaveBeenCalledWith("super-secret", { fresh: true }); + expect(storeCredentials).toHaveBeenCalledWith({ host, token: "super-secret" }); + expect(result).toMatchObject({ + operation: "auth", + action: "login", + host, + identity: { id: 7 }, + storage: "test keychain", + churchToolsVersion: "3.140.0", + supportedVersion: true, + }); + expect(JSON.stringify(result)).not.toContain("super-secret"); + }); + + it("logs out exactly the host selected by an environment", async () => { + const clearCredentials = vi.fn(async () => ({ clearedDefault: true })); + const result = await runAuthLogout( + { cwd: "/project", environment: "prod" }, + { + env: {}, + cwd: () => "/ignored", + loadEnvProfile: vi.fn(async (_name, path) => { + expect(path).toBe("/project/ct.envs.json"); + return { name: "prod", host, statePath: "state.json", protected: true }; + }), + clearCredentials, + }, + ); + + expect(clearCredentials).toHaveBeenCalledWith(host); + expect(result).toEqual({ + operation: "auth", + action: "logout", + environment: "prod", + host, + clearedDefault: true, + }); + }); +}); From 6182666bb1a797f9a535abddffc4b28f76620119 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Wed, 26 Aug 2026 14:44:06 +0200 Subject: [PATCH 14/15] docs: track application operation sources --- docs/handbuch/dynamic-groups.md | 4 ++-- docs/handbuch/group-member-fields.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index b85b585..7813b55 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -5,8 +5,8 @@ sources: - src/config/query-refs.ts - src/engine/dynamic.ts - src/engine/synthetic.ts - - src/commands/adopt-group.ts -sources_hash: 02bcb67b91c897de + - src/application/operations/adopt-group.ts +sources_hash: 84a00fff3a2766c1 reviewed: 2026-08-17 --- diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index e4d8a11..004178c 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,12 +1,12 @@ --- -sources_hash: 00dca3c03dacc9ff +sources_hash: 9fdefc83f6524cca title: Group member fields sources: - src/engine/member-fields.ts - src/engine/synthetic.ts - src/config/context.ts - - src/commands/adopt-group.ts - - src/commands/destroy.ts + - src/application/operations/adopt-group.ts + - src/application/operations/destroy.ts reviewed: 2026-08-26 --- From 0616819204ef465b9328fa05c910386bb802978c Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Wed, 26 Aug 2026 20:09:18 +0200 Subject: [PATCH 15/15] fix(core): report progress as it happens and stop misreporting failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #156 found that the extraction was faithful, but two new behaviours regressed what the CLI tells an operator: Prepared operations carried a 5-minute TTL while the confirmation prompt blocks on stdin indefinitely, so reading a long diff for six minutes ended in `OPERATION_EXPIRED` with nothing applied — and, for destroy, with the backup already on disk and the target key already typed. The store now accepts "no expiry" and both CLI commands use it; the state fingerprint, not a timer, is what guards against a stale proposal. Diagnostics moved from inline printing to arrays drained after the operation resolved, which discards them exactly when they matter — when it throws. They are now emitted through the existing OperationObserver as they happen, and the commands print them live: - destroy records every completed delete before a later `save()` or guard throw can erase it (the run is irreversible); - `Backup written:` prints when the backup lands, not on success; - adopt warnings survive a `--strict-rulesets` abort partway through a subtree; - the `ct refresh --all` fan-out caution arrives before membership is recomputed, and each group is reported as it goes. Also: - `ct auth status` no longer rewrites every environment-profile error into "Not logged in"; only an unresolvable host does, via a typed MissingHostError. - `ct auth login` reports a stored, verified token as success when only the `/info` version check fails, instead of pure failure with credentials in the keychain. - `PLAN_INCOMPLETE` carries the catalog path and plan warnings so apply can still print them on the abort path. - destroy's local error formatter is gone; `formatError` moved to `src/api/format.ts` so the application layer and `src/ui.ts` share one renderer, keeping the #50 body truncation and the HTTP status on adopt's member-field warning. - the architecture rule now sees side-effect and dynamic imports, and pins the transitive paths from the application layer to the terminal presenter so the set can only shrink. Handbuch pages re-signed after re-reading them against the changed sources. Claude-Session: https://claude.ai/code/session_01RDZeavzUpPyvQp3EYdEDbR --- docs/handbuch/dynamic-groups.md | 2 +- docs/handbuch/group-member-fields.md | 2 +- src/api/format.ts | 35 +++++++++ src/application/contracts.ts | 16 +++- src/application/operations/adopt-grants.ts | 25 +++++-- src/application/operations/adopt-group.ts | 35 ++++++--- src/application/operations/apply.ts | 23 ++++-- src/application/operations/auth.ts | 23 +++++- src/application/operations/destroy.ts | 76 +++++++++++-------- src/application/operations/refresh.ts | 46 ++++++++---- src/application/prepared-operation-store.ts | 18 ++++- src/application/warnings.ts | 20 +++++ src/commands/adopt-grants.ts | 25 ++++--- src/commands/adopt-group.ts | 37 +++++---- src/commands/apply.ts | 33 +++++--- src/commands/auth.ts | 6 +- src/commands/destroy.ts | 18 ++--- src/commands/observer.ts | 33 ++++++++ src/commands/refresh.ts | 32 ++++---- src/config.ts | 20 ++++- src/ui.ts | 31 +------- tests/application/apply-operation.test.ts | 43 +++++++++++ tests/application/auth-operation.test.ts | 55 ++++++++++++++ tests/application/destroy-operation.test.ts | 69 ++++++++++++++++- tests/application/refresh-operation.test.ts | 33 ++++++++ tests/architecture-boundaries.test.ts | 83 ++++++++++++++++++++- 26 files changed, 657 insertions(+), 182 deletions(-) create mode 100644 src/api/format.ts create mode 100644 src/application/warnings.ts create mode 100644 src/commands/observer.ts diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 7813b55..0378413 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/application/operations/adopt-group.ts -sources_hash: 84a00fff3a2766c1 +sources_hash: 50b8ec500c69a6c5 reviewed: 2026-08-17 --- diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index 004178c..d2a164b 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: 9fdefc83f6524cca +sources_hash: 01cc4284717d4c2e title: Group member fields sources: - src/engine/member-fields.ts diff --git a/src/api/format.ts b/src/api/format.ts new file mode 100644 index 0000000..2ead2ef --- /dev/null +++ b/src/api/format.ts @@ -0,0 +1,35 @@ +/** + * Error rendering shared by every adapter and by the application layer. + * + * This lives outside `src/ui.ts` on purpose: the application layer must not import the terminal + * presenter, but it still has to produce the SAME error text — an HTTP status plus a truncated + * response body (#50, #71) — in the messages it returns to whichever adapter is listening. + */ +import { CtApiError } from "./ctClient.js"; + +/** Response bodies beyond this are truncated so a huge HTML/JSON dump doesn't flood the terminal. */ +const MAX_BODY_CHARS = 2000; + +function formatBody(body: unknown): string { + if (body === null || body === undefined) { + return ""; + } + const text = typeof body === "string" ? body : JSON.stringify(body, null, 2); + if (text.length > MAX_BODY_CHARS) { + return `${text.slice(0, MAX_BODY_CHARS)}\n… (truncated, ${text.length} chars total)`; + } + return text; +} + +/** + * Render a caught error. For {@link CtApiError} this surfaces the HTTP status + response body — + * without it, a failing `ct get raw` (or any API call) prints only "✗ GET ... failed" with no way + * to see what ChurchTools actually said (#50). + */ +export function formatError(err: unknown): string { + if (err instanceof CtApiError) { + const body = formatBody(err.body); + return `${err.message} (HTTP ${err.status})${body ? `\n${body}` : ""}`; + } + return err instanceof Error ? err.message : String(err); +} diff --git a/src/application/contracts.ts b/src/application/contracts.ts index c632cb1..38904ca 100644 --- a/src/application/contracts.ts +++ b/src/application/contracts.ts @@ -41,6 +41,19 @@ export interface OperationResult { warnings: CtWarning[]; } +/** + * One completed unit of work, reported the moment it happens. + * + * Every long-running operation emits these as it goes instead of only returning them at the end: + * a `ct destroy` that dies halfway through must still have said which resources it already + * deleted, and a fan-out caution has to reach the operator BEFORE the fan-out runs (#156 review). + */ +export interface OperationOutcomeEvent { + /** `ok` reads as a success line, `note` as neutral information, `failed` as an error. */ + status: "ok" | "note" | "failed"; + message: string; +} + export type OperationEvent = | { type: "phase-started"; phase: string } | { type: "resource-reading"; resourceType: string; key: string } @@ -48,4 +61,5 @@ export type OperationEvent = | { type: "resource-updated"; resourceType: string; key: string; id: number } | { type: "resource-destroyed"; resourceType: string; key: string; id: number } | { type: "backup-written"; path: string } - | { type: "warning"; warning: CtWarning }; + | { type: "warning"; warning: CtWarning } + | { type: "outcome"; outcome: OperationOutcomeEvent }; diff --git a/src/application/operations/adopt-grants.ts b/src/application/operations/adopt-grants.ts index cad9af5..3255a4f 100644 --- a/src/application/operations/adopt-grants.ts +++ b/src/application/operations/adopt-grants.ts @@ -12,6 +12,7 @@ import { slug } from "../../resources/registry.js"; import { loadState, type State } from "../../state/state.js"; import type { CtWarning, OperationResult, ProjectRequest } from "../contracts.js"; import { resolveProject } from "../project.js"; +import { noopObserver, type OperationObserver } from "../ports.js"; export interface AdoptGrantsRequest extends ProjectRequest { domainType?: string; @@ -62,7 +63,14 @@ function normalizeDomainType(raw: string): DomainType { * summarised, because the WARNING footer that protects the single form cannot protect a 44-block * paste that nobody reads to the end. */ -export async function runAdoptGrants(opts: AdoptGrantsRequest): Promise { +export interface AdoptGrantsDependencies { + observer?: OperationObserver; +} + +export async function runAdoptGrants( + opts: AdoptGrantsRequest, + dependencies: AdoptGrantsDependencies = {}, +): Promise { const bulk = opts.group !== undefined || opts.allDeclarable === true; if (bulk && (opts.domainType !== undefined || opts.domainId !== undefined)) { throw new Error( @@ -89,11 +97,8 @@ export async function runAdoptGrants(opts: AdoptGrantsRequest): Promise e.block).join("\n\n")}\n`; - let writtenPath: string | null = null; - if (opts.write) { - writtenPath = resolve(project.cwd, opts.write); - await appendFile(writtenPath, text, "utf8"); - } + // Assembled and reported BEFORE the append: a failing `--write` must not swallow the record of + // what the emission silently skipped (#156 review). const warnings = [...(bulkEmission?.warnings ?? [])]; if (emitted.some((e) => e.omitted > 0)) { warnings.push({ @@ -104,6 +109,14 @@ export async function runAdoptGrants(opts: AdoptGrantsRequest): Promise, - warnings: CtWarning[], + addWarning: WarningSink, ): Promise { let raw: unknown; try { @@ -245,10 +248,10 @@ async function captureMemberFields( // Silence is the one thing that is not allowed here, because "no member fields" and "could not // read them" produce the same config. if (!(err instanceof CtApiError && err.status === 404)) { - warnings.push({ + addWarning({ code: "MEMBER_FIELDS_UNREADABLE", message: - `group #${id}: member fields could not be read (${err instanceof Error ? err.message : String(err)}) — ` + + `group #${id}: member fields could not be read (${formatError(err)}) — ` + `adopted WITHOUT them. Re-run \`ct adopt group ${id} --with-member-fields\` once the read succeeds.`, }); } @@ -262,7 +265,7 @@ async function captureMemberFields( const canonical = memberFieldStateKey(localKey); const fieldId = memberFieldId(row); if (!canonical) { - warnings.push({ + addWarning({ code: "MEMBER_FIELD_IDENTITY_MISSING", message: `group #${id}: a group-scoped member field has neither referenceName nor name — adopted ` + @@ -272,7 +275,7 @@ async function captureMemberFields( return undefined; } if (fieldId === undefined) { - warnings.push({ + addWarning({ code: "MEMBER_FIELD_ID_MISSING", message: `group #${id} member field "${localKey}": the live response contains no numeric field id — ` + @@ -282,7 +285,7 @@ async function captureMemberFields( return undefined; } if (ids[canonical] !== undefined) { - warnings.push({ + addWarning({ code: "MEMBER_FIELD_IDENTITY_AMBIGUOUS", message: `group #${id}: multiple group-scoped member fields resolve to the local key "${canonical}" — ` + @@ -319,9 +322,17 @@ async function captureDynamic( return { status, normalizedRuleset }; } -export async function runAdoptGroups(opts: AdoptGroupRequest): Promise { +export interface AdoptGroupDependencies { + observer?: OperationObserver; +} + +export async function runAdoptGroups( + opts: AdoptGroupRequest, + dependencies: AdoptGroupDependencies = {}, +): Promise { const ids = opts.ids; const warnings: CtWarning[] = []; + const addWarning = warningSink(warnings, dependencies.observer ?? noopObserver); const selectors = [ids.length > 0, Boolean(opts.groupType), Boolean(opts.childrenOf)].filter( Boolean, ).length; @@ -411,7 +422,7 @@ export async function runAdoptGroups(opts: AdoptGroupRequest): Promise 0) { snippetFields.memberFields = memberFields.declarations; @@ -469,7 +480,7 @@ export async function runAdoptGroups(opts: AdoptGroupRequest): Promise ` ${l}`).join("\n"), ); } - warnings.push({ + addWarning({ code: "RULESET_NOT_PORTABLE", message: `${relPath} keeps ${portableWarnings.length} host-specific id(s) — NOT portable to another host:\n` + @@ -487,7 +498,7 @@ export async function runAdoptGroups(opts: AdoptGroupRequest): Promise report.action === "updated")) { - warnings.push({ + addWarning({ code: "ADOPT_ALREADY_MANAGED", message: "This resource was already managed — its snapshot was refreshed.", }); diff --git a/src/application/operations/apply.ts b/src/application/operations/apply.ts index 5924b1a..6b2be7b 100644 --- a/src/application/operations/apply.ts +++ b/src/application/operations/apply.ts @@ -42,7 +42,8 @@ export interface PreparedApply { plan: PlanResult; changeCount: number; confirmation: ConfirmationRequirement; - expiresAt: string; + /** `null` when the prepared operation has no wall-clock expiry (the CLI's own runs). */ + expiresAt: string | null; } export interface ApplyValue { @@ -76,7 +77,8 @@ export interface ApplyOperationDependencies extends PlanOperationDependencies { runPostApplyHooks?: typeof runPostApplyHooks; saveState?: typeof saveState; env?: NodeJS.ProcessEnv; - preparedTtlMs?: number; + /** `null` disables the wall-clock expiry entirely; omit for the default TTL. */ + preparedTtlMs?: number | null; } const defaultStore = new PreparedOperationStore(); @@ -160,7 +162,18 @@ export async function prepareApply( throw new CtApplicationError( "PLAN_INCOMPLETE", `Aborting: ${context.result.value.fetchErrors.length} resource(s) could not be fetched — the plan is incomplete. Re-run when resolved.`, - { details: { fetchErrors: context.result.value.fetchErrors } }, + { + details: { + fetchErrors: context.result.value.fetchErrors, + // An incomplete plan aborts before the plan (and therefore its diagnostics) is ever + // returned, so the catalog path and the plan warnings travel on the error itself. + // Without them a stale per-instance catalog (#25) goes unmentioned in exactly the run + // that ends in "could not be fetched" (#156 review). + cwd: context.result.project.cwd, + permissionCatalogPath: context.result.value.permissionCatalogPath ?? null, + warnings: context.result.warnings.map((warning) => warning.message), + }, + }, ); } @@ -181,7 +194,7 @@ export async function prepareApply( refresh: request.refresh ?? false, confirmation, }, - dependencies.preparedTtlMs ?? PREPARED_APPLY_TTL_MS, + dependencies.preparedTtlMs === undefined ? PREPARED_APPLY_TTL_MS : dependencies.preparedTtlMs, ); return { @@ -189,7 +202,7 @@ export async function prepareApply( plan: context.result, changeCount, confirmation, - expiresAt: stored.expiresAt.toISOString(), + expiresAt: stored.expiresAt === null ? null : stored.expiresAt.toISOString(), }; } diff --git a/src/application/operations/auth.ts b/src/application/operations/auth.ts index 692d05b..9589318 100644 --- a/src/application/operations/auth.ts +++ b/src/application/operations/auth.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { authedSession, type AuthedSession } from "../../api/session.js"; import { CtClient, type WhoAmI } from "../../api/ctClient.js"; +import { formatError } from "../../api/format.js"; import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "../../api/version.js"; import { checkAllEnvAuth, type EnvAuthStatus } from "../../auth/status.js"; import { keychainSessionCache } from "../../auth/sessionStore.js"; @@ -10,7 +11,7 @@ import { storeCredentials, type ClearCredentialsResult, } from "../../auth/tokenStore.js"; -import { normalizeHost } from "../../config.js"; +import { MissingHostError, normalizeHost } from "../../config.js"; import { loadEnvProfile, loadEnvProfiles, resolveEnvsPath } from "../../env/envs.js"; import { CtApplicationError } from "../errors.js"; import { resolveProject, type ProjectResolutionDependencies } from "../project.js"; @@ -55,6 +56,8 @@ export interface AuthLoginResult { identity: WhoAmI; storage: string; churchToolsVersion: string | null; + /** Set when the version could not be read; the credentials are stored either way. */ + versionCheckError: string | null; minimumVersion: string; supportedVersion: boolean | null; } @@ -78,8 +81,17 @@ export async function runAuthLogin( )(host); const identity = await client.authenticate(token, { fresh: true }); const storage = await (dependencies.storeCredentials ?? storeCredentials)({ host, token }); - const info = await client.get("/info"); - const churchToolsVersion = info.version ?? null; + // The token is verified and stored by this point, so the login HAS succeeded. A failing /info + // is a version check that could not run, not a failed login — throwing here reported pure + // failure to a user whose credentials were already in the keychain (#156 review). + let churchToolsVersion: string | null = null; + let versionCheckError: string | null = null; + try { + const info = await client.get("/info"); + churchToolsVersion = info.version ?? null; + } catch (caught) { + versionCheckError = formatError(caught); + } return { operation: "auth", action: "login", @@ -87,6 +99,7 @@ export async function runAuthLogin( identity, storage, churchToolsVersion, + versionCheckError, minimumVersion: MIN_CT_VERSION, supportedVersion: churchToolsVersion ? meetsMinVersion(churchToolsVersion) : null, }; @@ -171,6 +184,10 @@ export async function runAuthStatus( { ...dependencies.project, env }, ); } catch (cause) { + // ONLY an unresolvable host means "not logged in". A typo'd `--env`, a missing ct.envs.json + // or a malformed one must report itself — rewriting those sent a user who IS logged in to + // `ct auth login` (#156 review). + if (!(cause instanceof MissingHostError)) throw cause; throw new CtApplicationError( "AUTH_REQUIRED", "Not logged in. Run `ct auth login --host --token `.", diff --git a/src/application/operations/destroy.ts b/src/application/operations/destroy.ts index 51a5ee9..d1e3358 100644 --- a/src/application/operations/destroy.ts +++ b/src/application/operations/destroy.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { authedSession, type AuthedSession } from "../../api/session.js"; import { CtApiError, type CtClient } from "../../api/ctClient.js"; +import { formatError } from "../../api/format.js"; import { loadState, saveState, type State } from "../../state/state.js"; import { RESOURCES, type CtWriteClient } from "../../resources/registry.js"; import { assertNotPeople } from "../../engine/guard.js"; @@ -34,17 +35,26 @@ import { resolveBackupDir, type ConfirmationProof, type ConfirmationRequirement const PREPARED_DESTROY_TTL_MS = 5 * 60 * 1000; -function formatFailure(err: unknown): string { - if (err instanceof CtApiError) { - const body = - err.body === null || err.body === undefined - ? "" - : typeof err.body === "string" - ? err.body - : JSON.stringify(err.body); - return `${err.message} (HTTP ${err.status})${body ? `\n${body}` : ""}`; - } - return err instanceof Error ? err.message : String(err); +/** + * Record an outcome AND report it the moment it happens. + * + * Destroy is irreversible, so the list of what has already been deleted must never depend on the + * run reaching its `return`: a throw from `save()`, or from the `assertNotPeople` guard on a later + * target, used to discard the whole array and leave the operator with no record (#156 review). + */ +function record( + outcomes: DestroyOutcome[], + observer: OperationObserver | undefined, + outcome: DestroyOutcome, +): void { + outcomes.push(outcome); + observer?.emit({ + type: "outcome", + outcome: { + status: outcome.status === "destroyed" || outcome.status === "already-absent" ? "ok" : "failed", + message: outcome.message, + }, + }); } export interface DestroyRequest extends ProjectRequest { @@ -70,7 +80,8 @@ export interface PreparedDestroy { backupPath: string; warnings: CtWarning[]; confirmation: ConfirmationRequirement & { expected?: string }; - expiresAt: string; + /** `null` when the prepared operation has no wall-clock expiry (the CLI's own runs). */ + expiresAt: string | null; } export type DestroyResult = OperationResult<{ @@ -104,7 +115,8 @@ export interface DestroyOperationDependencies { observer?: OperationObserver; clock?: Clock; env?: NodeJS.ProcessEnv; - preparedTtlMs?: number; + /** `null` disables the wall-clock expiry entirely; omit for the default TTL. */ + preparedTtlMs?: number | null; readStateFile?: (path: string) => Promise; } @@ -190,7 +202,7 @@ async function fetchParentEdges( } catch (err) { warnings.push({ code: "DESTROY_HIERARCHY_UNREADABLE", - message: `Failed to fetch group hierarchies for destroy ordering: ${formatFailure(err)}. Falling back to tier-only order.`, + message: `Failed to fetch group hierarchies for destroy ordering: ${formatError(err)}. Falling back to tier-only order.`, }); return new Map(); } @@ -298,7 +310,7 @@ export async function runMemberFieldDeleteLoop(ctx: { const rows = groupScopedRows(await client.get(memberFieldsReadPath(target.groupId))); const matches = rows.filter((row) => matchesLocalKey(row, target.fieldKey)); if (matches.length > 1) { - outcomes.push({ + record(outcomes, ctx.observer, { kind: "member-field", key: target.identity, id: null, @@ -311,18 +323,18 @@ export async function runMemberFieldDeleteLoop(ctx: { } fieldId = matches.length === 1 ? memberFieldRowId(matches[0]!) : undefined; } catch (err) { - outcomes.push({ + record(outcomes, ctx.observer, { kind: "member-field", key: target.identity, id: null, status: "failed", - message: `Stopped at ${target.identity}: ${formatFailure(err)}. Nothing further was deleted.`, + message: `Stopped at ${target.identity}: ${formatError(err)}. Nothing further was deleted.`, }); return { outcomes, complete: false }; } if (fieldId === undefined) { await forget(); - outcomes.push({ + record(outcomes, ctx.observer, { kind: "member-field", key: target.identity, id: null, @@ -338,7 +350,7 @@ export async function runMemberFieldDeleteLoop(ctx: { } catch (err) { if (err instanceof CtApiError && err.status === 404) { await forget(); - outcomes.push({ + record(outcomes, ctx.observer, { kind: "member-field", key: target.identity, id: fieldId, @@ -347,12 +359,12 @@ export async function runMemberFieldDeleteLoop(ctx: { }); continue; } - outcomes.push({ + record(outcomes, ctx.observer, { kind: "member-field", key: target.identity, id: fieldId, status: "failed", - message: `Stopped at ${target.identity}: ${formatFailure(err)}. State saved up to this point — re-run to resume.`, + message: `Stopped at ${target.identity}: ${formatError(err)}. State saved up to this point — re-run to resume.`, }); return { outcomes, complete: false }; } @@ -363,7 +375,7 @@ export async function runMemberFieldDeleteLoop(ctx: { key: target.identity, id: fieldId, }); - outcomes.push({ + record(outcomes, ctx.observer, { kind: "member-field", key: target.identity, id: fieldId, @@ -439,7 +451,7 @@ export async function prepareDestroy( } catch (err) { throw new CtApplicationError( "DESTROY_BACKUP_FAILED", - `Backup fetch failed for ${target.identity}: ${formatFailure(err)}. Nothing was deleted — resolve the error and re-run.`, + `Backup fetch failed for ${target.identity}: ${formatError(err)}. Nothing was deleted — resolve the error and re-run.`, { cause: err, details: { target: target.identity } }, ); } @@ -477,7 +489,7 @@ export async function prepareDestroy( confirmation, stateFingerprint: fingerprint, }, - dependencies.preparedTtlMs ?? PREPARED_DESTROY_TTL_MS, + dependencies.preparedTtlMs === undefined ? PREPARED_DESTROY_TTL_MS : dependencies.preparedTtlMs, ); return { id: stored.id, @@ -487,7 +499,7 @@ export async function prepareDestroy( backupPath, warnings, confirmation, - expiresAt: stored.expiresAt.toISOString(), + expiresAt: stored.expiresAt === null ? null : stored.expiresAt.toISOString(), }; } @@ -547,7 +559,7 @@ export async function executePreparedDestroy( outcomes.push(...fields.outcomes); if (!fields.complete) { if (stored.ordered.length > 0) { - outcomes.push({ + record(outcomes, observer, { kind: "resource", key: stored.ordered.join(", "), id: null, @@ -617,7 +629,7 @@ export async function runDeleteLoop(ctx: DeleteLoopCtx): Promise 0 + ? [ + { + code: "REFRESH_FAN_OUT", + message: `Refreshing ${targets.length} managed dynamic group(s) — this recomputes membership.`, + }, + ] + : []; + for (const warning of warnings) observer.emit({ type: "warning", warning }); observer.emit({ type: "phase-started", phase: "refresh-groups" }); for (const target of targets) { const path = `/dynamicgroups/${target.id}/refresh`; assertNotPeople(path); try { const response = await client.request("POST", path); - outcomes.push({ key: target.key, id: target.id, counts: response?.[0] ?? null, error: null }); + const counts = response?.[0] ?? null; + outcomes.push({ key: target.key, id: target.id, counts, error: null }); + observer.emit({ + type: "outcome", + outcome: { + status: "ok", + message: counts + ? `refreshed ${target.key} (#${target.id}): +${counts.created} ~${counts.updated} -${counts.deleted}` + : `refreshed ${target.key} (#${target.id})`, + }, + }); } catch (caught) { - outcomes.push({ - key: target.key, - id: target.id, - counts: null, - error: caught instanceof CtApiError ? `HTTP ${caught.status}` : (caught as Error).message, + const message = caught instanceof CtApiError ? `HTTP ${caught.status}` : (caught as Error).message; + outcomes.push({ key: target.key, id: target.id, counts: null, error: message }); + observer.emit({ + type: "outcome", + outcome: { status: "failed", message: `Failed to refresh ${target.key} (#${target.id}): ${message}` }, }); } } @@ -111,15 +133,7 @@ export async function runRefresh( return { operation: "refresh", project, - warnings: - request.all && targets.length > 0 - ? [ - { - code: "REFRESH_FAN_OUT", - message: `Refreshing ${targets.length} managed dynamic group(s) — this recomputes membership.`, - }, - ] - : [], + warnings, value: { outcomes, failed: outcomes.filter((outcome) => outcome.error !== null).length, diff --git a/src/application/prepared-operation-store.ts b/src/application/prepared-operation-store.ts index 588882b..60a445f 100644 --- a/src/application/prepared-operation-store.ts +++ b/src/application/prepared-operation-store.ts @@ -5,7 +5,8 @@ import { systemClock } from "./ports.js"; interface StoredOperation { value: T; - expiresAt: Date; + /** `null` means the entry never expires on wall-clock time; see {@link PreparedOperationStore.put}. */ + expiresAt: Date | null; used: boolean; } @@ -17,9 +18,18 @@ export class PreparedOperationStore { private readonly ids: IdGenerator = { nextId: () => randomUUID() }, ) {} - put(value: T, ttlMs: number): { id: string; expiresAt: Date } { + /** + * Store one prepared operation. + * + * `ttlMs === null` stores it without a wall-clock expiry. That is the right choice whenever the + * confirming user is the very process that prepared it and the prompt blocks on stdin + * indefinitely — an operator reading a long diff for six minutes must not be told the plan + * expired (#156 review). What actually guards against a stale proposal is the state fingerprint + * checked at execute time, not the clock. + */ + put(value: T, ttlMs: number | null): { id: string; expiresAt: Date | null } { const id = this.ids.nextId(); - const expiresAt = new Date(this.clock.now().getTime() + ttlMs); + const expiresAt = ttlMs === null ? null : new Date(this.clock.now().getTime() + ttlMs); this.operations.set(id, { value, expiresAt, used: false }); return { id, expiresAt }; } @@ -43,7 +53,7 @@ export class PreparedOperationStore { "Prepared operation is unknown or already used.", ); } - if (entry.expiresAt.getTime() <= this.clock.now().getTime()) { + if (entry.expiresAt !== null && entry.expiresAt.getTime() <= this.clock.now().getTime()) { throw new CtApplicationError("OPERATION_EXPIRED", "Prepared operation has expired. Prepare it again."); } return entry; diff --git a/src/application/warnings.ts b/src/application/warnings.ts new file mode 100644 index 0000000..54a74f9 --- /dev/null +++ b/src/application/warnings.ts @@ -0,0 +1,20 @@ +import type { CtWarning } from "./contracts.js"; +import type { OperationObserver } from "./ports.js"; + +/** Collect a warning and report it in the same breath. */ +export type WarningSink = (warning: CtWarning) => void; + +/** + * Build a sink that appends to `warnings` AND emits the warning immediately. + * + * A warnings array that is only drained after the operation resolves is discarded whenever the + * operation throws — which is precisely when a half-finished adoption most needs to say what it + * silently left out (#156 review). Emitting as we go keeps the returned array intact for the + * structured result while making the operator hear about it either way. + */ +export function warningSink(warnings: CtWarning[], observer: OperationObserver): WarningSink { + return (warning) => { + warnings.push(warning); + observer.emit({ type: "warning", warning }); + }; +} diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts index aee301e..932711d 100644 --- a/src/commands/adopt-grants.ts +++ b/src/commands/adopt-grants.ts @@ -1,7 +1,8 @@ import { relative } from "node:path"; import { Command } from "commander"; import { runAdoptGrants } from "../application/operations/adopt-grants.js"; -import { info, warn } from "../ui.js"; +import { cliObserver } from "./observer.js"; +import { info } from "../ui.js"; interface AdoptGrantsOptions { state?: string; @@ -32,15 +33,18 @@ export function adoptGrantsCommand(): Command { command: Command, ) => { const opts = command.optsWithGlobals() as AdoptGrantsOptions; - const result = await runAdoptGrants({ - domainType, - domainId, - statePath: opts.state, - environment: opts.env, - group: opts.group, - allDeclarable: opts.allDeclarable, - write: opts.write, - }); + const result = await runAdoptGrants( + { + domainType, + domainId, + statePath: opts.state, + environment: opts.env, + group: opts.group, + allDeclarable: opts.allDeclarable, + write: opts.write, + }, + { observer: cliObserver() }, + ); if (result.value.permissionCatalogPath) { info(`permission catalog: ${relative(result.project.cwd, result.value.permissionCatalogPath)}`); } @@ -56,7 +60,6 @@ export function adoptGrantsCommand(): Command { info("Paste the block(s) below into your config, then run `ct plan`:"); process.stdout.write(result.value.text); } - for (const warning of result.warnings) warn(warning.message); }, ); } diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index d52ecc4..404ff64 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { runAdoptGroups } from "../application/operations/adopt-group.js"; -import { info, out, success, warn } from "../ui.js"; +import { cliObserver } from "./observer.js"; +import { info, out, success } from "../ui.js"; interface AdoptGroupOptions { key?: string; @@ -37,25 +38,29 @@ export function adoptGroupCommand(): Command { .option("--strict-rulesets", "refuse rulesets that retain a host-specific id") .action(async (ids: string[], _localOpts: AdoptGroupOptions, command: Command) => { const opts = command.optsWithGlobals() as AdoptGroupOptions; - const result = await runAdoptGroups({ - ids, - key: opts.key, - statePath: opts.state, - environment: opts.env, - dryRun: opts.dryRun, - groupType: opts.type, - childrenOf: opts.childrenOf, - withDynamic: opts.withDynamic, - withMemberFields: opts.withMemberFields, - rekey: opts.rekey, - portableRulesets: opts.portableRulesets, - strictRulesets: opts.strictRulesets, - }); + // Warnings print as they are produced: a `--strict-rulesets` throw halfway through a + // subtree used to take the record of every already-adopted group with it (#156 review). + const result = await runAdoptGroups( + { + ids, + key: opts.key, + statePath: opts.state, + environment: opts.env, + dryRun: opts.dryRun, + groupType: opts.type, + childrenOf: opts.childrenOf, + withDynamic: opts.withDynamic, + withMemberFields: opts.withMemberFields, + rekey: opts.rekey, + portableRulesets: opts.portableRulesets, + strictRulesets: opts.strictRulesets, + }, + { observer: cliObserver() }, + ); if (result.value.noMatches) { info("No groups matched — nothing to adopt."); return; } - for (const warning of result.warnings) warn(warning.message); if (result.value.dryRun) { const payload = result.value.groups.map((group) => ({ key: group.key, diff --git a/src/commands/apply.ts b/src/commands/apply.ts index c44a6d0..4fd4cfb 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -10,6 +10,7 @@ import { CtApplicationError } from "../application/errors.js"; import { renderPlan } from "../engine/render.js"; import { renderPermissionPlan } from "../permissions/render.js"; import { confirm, confirmEnv } from "../ui/prompt.js"; +import { cliObserver } from "./observer.js"; import { info, warn, success, error } from "../ui.js"; interface ApplyOptions { @@ -41,15 +42,28 @@ export function applyCommand(): Command { .action(async (opts: ApplyOptions) => { let prepared; try { - prepared = await prepareApply({ - configPath: opts.config, - statePath: opts.state, - environment: opts.env, - backupDir: opts.backupDir, - refresh: opts.refresh, - }); + prepared = await prepareApply( + { + configPath: opts.config, + statePath: opts.state, + environment: opts.env, + backupDir: opts.backupDir, + refresh: opts.refresh, + // No wall-clock expiry: the confirmation below blocks on stdin for as long as the + // operator needs to read the rendered diff (#156 review). Staleness is caught by the + // state fingerprint at execute time, not by a timer. + }, + { preparedTtlMs: null }, + ); } catch (caught) { if (caught instanceof CtApplicationError && caught.code === "PLAN_INCOMPLETE") { + const details = caught.details ?? {}; + const catalog = details.permissionCatalogPath; + const cwd = typeof details.cwd === "string" ? details.cwd : process.cwd(); + if (typeof catalog === "string") info(`permission catalog: ${relative(cwd, catalog)}`); + if (Array.isArray(details.warnings)) { + for (const warning of details.warnings) if (typeof warning === "string") warn(warning); + } error(caught.message); process.exitCode = 1; return; @@ -105,9 +119,10 @@ export function applyCommand(): Command { return; } - const result = await executePreparedApply(prepared, proof); + // The observer prints `Backup written: …` the moment the backup lands, so the path is on + // screen even when a later step throws — the exact case the backup exists for (#156 review). + const result = await executePreparedApply(prepared, proof, { observer: cliObserver() }); const applied = result.value; - if (applied.backupPath) info(`Backup written: ${applied.backupPath}`); success( `Applied: ${applied.resources.created.length} created, ${applied.resources.updated.length} updated.`, ); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index e3fb66b..9d1bad3 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -18,7 +18,11 @@ export async function verifyAndStoreLoginToken(rawHost: string, rawToken: string const me = result.identity; success(`Logged in to ${result.host} as ${me.firstName ?? ""} ${me.lastName ?? ""} (#${me.id})`.trim()); info(`Host + token stored in ${result.storage}.`); - if (result.churchToolsVersion) { + if (result.versionCheckError) { + warn( + `Could not read the ChurchTools version (${result.versionCheckError}) — the login itself is stored and verified.`, + ); + } else if (result.churchToolsVersion) { if (result.supportedVersion) { info(`ChurchTools ${result.churchToolsVersion} (≥ ${result.minimumVersion} required).`); } else { diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 21759cc..1892a8f 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -7,7 +7,8 @@ import { } from "../application/operations/destroy.js"; import type { ConfirmationProof } from "../application/operations/apply.js"; import { confirmEnv, confirmTyped } from "../ui/prompt.js"; -import { error, info, success, warn } from "../ui.js"; +import { cliObserver } from "./observer.js"; +import { error, info, warn } from "../ui.js"; export { destroyWarnings, @@ -55,7 +56,9 @@ export function destroyCommand(): Command { }; let prepared; try { - prepared = await prepareDestroy(request); + // No wall-clock expiry: the typed confirmation below blocks on stdin for as long as the + // operator needs, and the backup is already on disk by then (#156 review). + prepared = await prepareDestroy(request, { preparedTtlMs: null }); } catch (caught) { if (caught instanceof CtApplicationError && caught.code === "DESTROY_BACKUP_FAILED") { error(caught.message); @@ -94,14 +97,9 @@ export function destroyCommand(): Command { return; } - const result = await executePreparedDestroy(prepared, proof); - for (const outcome of result.value.outcomes) { - if (outcome.status === "destroyed" || outcome.status === "already-absent") { - success(outcome.message); - } else { - error(outcome.message); - } - } + // Every outcome is printed by the observer as it happens: a destroy that throws partway + // through must still have said which resources it already deleted (#156 review). + const result = await executePreparedDestroy(prepared, proof, { observer: cliObserver() }); if (!result.value.complete) process.exitCode = 1; }); } diff --git a/src/commands/observer.ts b/src/commands/observer.ts new file mode 100644 index 0000000..5fb428e --- /dev/null +++ b/src/commands/observer.ts @@ -0,0 +1,33 @@ +import type { OperationEvent } from "../application/contracts.js"; +import type { OperationObserver } from "../application/ports.js"; +import { error, info, success, warn } from "../ui.js"; + +/** + * The terminal adapter for operation events. + * + * Operations report progress as it happens rather than returning it in an array the command + * prints afterwards — an array is lost the moment the operation throws, which is exactly when a + * half-finished irreversible run most needs to say what it already did (#156 review). + * Phase and per-resource events stay silent here; commands that want them can compose their own. + */ +export function cliObserver(): OperationObserver { + return { + emit(event: OperationEvent): void { + switch (event.type) { + case "backup-written": + info(`Backup written: ${event.path}`); + return; + case "warning": + warn(event.warning.message); + return; + case "outcome": + if (event.outcome.status === "ok") success(event.outcome.message); + else if (event.outcome.status === "failed") error(event.outcome.message); + else info(event.outcome.message); + return; + default: + return; + } + }, + }; +} diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts index 7e8c402..4612411 100644 --- a/src/commands/refresh.ts +++ b/src/commands/refresh.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { runRefresh, selectRefreshTargets } from "../application/operations/refresh.js"; -import { error, info, success, warn } from "../ui.js"; +import { cliObserver } from "./observer.js"; +import { info } from "../ui.js"; interface RefreshOptions { state?: string; @@ -22,28 +23,21 @@ export function refreshCommand(): Command { .option("--group ", "refresh this managed group only") .option("--all", "refresh every managed dynamic group (required to fan out — this changes membership)") .action(async (opts: RefreshOptions) => { - const result = await runRefresh({ - statePath: opts.state, - environment: opts.env, - group: opts.group, - all: opts.all, - }); - for (const warning of result.warnings) warn(warning.message); + // The fan-out caution and every per-group line are printed by the observer while the run + // is still going, not after every membership has already been recomputed (#156 review). + const result = await runRefresh( + { + statePath: opts.state, + environment: opts.env, + group: opts.group, + all: opts.all, + }, + { observer: cliObserver() }, + ); if (result.value.outcomes.length === 0) { info("No managed dynamic groups to refresh."); return; } - for (const outcome of result.value.outcomes) { - if (outcome.error) { - error(`Failed to refresh ${outcome.key} (#${outcome.id}): ${outcome.error}`); - continue; - } - success( - outcome.counts - ? `refreshed ${outcome.key} (#${outcome.id}): +${outcome.counts.created} ~${outcome.counts.updated} -${outcome.counts.deleted}` - : `refreshed ${outcome.key} (#${outcome.id})`, - ); - } if (result.value.failed > 0) process.exitCode = 1; }); } diff --git a/src/config.ts b/src/config.ts index ffff560..9a290eb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,15 +25,29 @@ export function normalizeHost(host: string): string { * There is no default: with neither, this throws, directing the user to log in. * `readHost` is injectable so the resolution is testable without the Keychain. */ +/** + * No host could be resolved at all — the one failure that genuinely means "not logged in". + * + * Typed so callers can tell it apart from an environment-profile problem (a typo'd `--env`, a + * missing or malformed ct.envs.json), which must report itself rather than be rewritten into + * "Not logged in" for a user who is (#156 review). + */ +export class MissingHostError extends Error { + constructor() { + super( + "No ChurchTools host configured. Run `ct auth login --host --token ` (or set CT_HOST).", + ); + this.name = "MissingHostError"; + } +} + export async function resolveConfig( env: NodeJS.ProcessEnv = process.env, readHost: () => Promise = readStoredHost, ): Promise { const host = env.CT_HOST?.trim() || (await readHost()); if (!host) { - throw new Error( - "No ChurchTools host configured. Run `ct auth login --host --token ` (or set CT_HOST).", - ); + throw new MissingHostError(); } return { host: normalizeHost(host) }; } diff --git a/src/ui.ts b/src/ui.ts index fddf6bf..4b46c4b 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -2,35 +2,8 @@ * Tiny terminal output helpers. Kept dependency-light on purpose. */ import pc from "picocolors"; -import { CtApiError } from "./api/ctClient.js"; - -/** Response bodies beyond this are truncated so a huge HTML/JSON dump doesn't flood the terminal. */ -const MAX_BODY_CHARS = 2000; - -function formatBody(body: unknown): string { - if (body === null || body === undefined) { - return ""; - } - const text = typeof body === "string" ? body : JSON.stringify(body, null, 2); - if (text.length > MAX_BODY_CHARS) { - return `${text.slice(0, MAX_BODY_CHARS)}\n… (truncated, ${text.length} chars total)`; - } - return text; -} - -/** - * Render a caught error for the terminal. For {@link CtApiError} this surfaces - * the HTTP status + response body — without it, a failing `ct get raw` (or any - * API call) prints only "✗ GET ... failed" with no way to see what ChurchTools - * actually said (#50). - */ -export function formatError(err: unknown): string { - if (err instanceof CtApiError) { - const body = formatBody(err.body); - return `${err.message} (HTTP ${err.status})${body ? `\n${body}` : ""}`; - } - return err instanceof Error ? err.message : String(err); -} +// Shared with the application layer, which needs the same rendering but must not import this module. +export { formatError } from "./api/format.js"; export function info(message: string): void { process.stderr.write(`${message}\n`); diff --git a/tests/application/apply-operation.test.ts b/tests/application/apply-operation.test.ts index 65b272a..a69a633 100644 --- a/tests/application/apply-operation.test.ts +++ b/tests/application/apply-operation.test.ts @@ -136,6 +136,49 @@ describe("prepared apply operation", () => { expect(test.execute).not.toHaveBeenCalled(); }); + it("carries the catalog path and plan warnings on an incomplete plan", async () => { + const test = harness(); + const dependencies: ApplyOperationDependencies = { + ...test.dependencies, + loadHostCatalog: vi.fn(async () => "/project/.ct/catalog/example.json"), + buildPlan: vi.fn(async () => ({ + plan: resourcePlan, + actual: new Map(), + fetchErrors: ["group.broken: HTTP 500"], + })), + buildPermissionPlan: vi.fn(async () => ({ + items: [], + fetchErrors: [], + warnings: ["permission catalog is stale"], + })), + }; + // Aborting is right; aborting SILENTLY about a stale catalog is not — the adapter never sees + // the plan that carries these, so they travel on the error (#156 review). + await expect(prepareApply({}, dependencies)).rejects.toMatchObject({ + code: "PLAN_INCOMPLETE", + details: { + cwd: "/project", + permissionCatalogPath: "/project/.ct/catalog/example.json", + warnings: ["permission catalog is stale"], + }, + }); + }); + + it("does not expire a prepared apply the caller keeps across a blocking confirmation", async () => { + const test = harness(); + const prepared = await prepareApply({}, { ...test.dependencies, preparedTtlMs: null }); + expect(prepared.expiresAt).toBeNull(); + // Far longer than the default 5-minute TTL: an operator may read a long diff before typing y. + test.advance(60 * 60 * 1000); + + const result = await executePreparedApply( + prepared, + { type: "environment", value: "prod" }, + test.dependencies, + ); + expect(result.value.resources.created).toEqual(["mainz"]); + }); + it("is single-use", async () => { const test = harness({ protected: false, environment: "dev" }); const prepared = await prepareApply({}, test.dependencies); diff --git a/tests/application/auth-operation.test.ts b/tests/application/auth-operation.test.ts index 88e8024..e0709b6 100644 --- a/tests/application/auth-operation.test.ts +++ b/tests/application/auth-operation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { runAuthLogin, runAuthLogout, runAuthStatus } from "../../src/application/operations/auth.js"; +import { MissingHostError } from "../../src/config.js"; const host = "https://example.church.tools"; @@ -57,6 +58,34 @@ describe("runAuthStatus", () => { ); expect(result).toMatchObject({ scope: "all", authenticated: true, environments: statuses }); }); + + it("reports an environment-profile problem as itself, not as a missing login", async () => { + // A user who IS logged in, with a typo'd --env: "Not logged in. Run `ct auth login`" sent + // them to fix the wrong thing (#156 review). + await expect( + runAuthStatus( + { environment: "prd" }, + { + resolveProject: vi.fn(async () => { + throw new Error('Environment profile "prd" not found in ct.envs.json.'); + }), + }, + ), + ).rejects.toThrow('Environment profile "prd" not found'); + }); + + it("still reports a genuinely absent host as a missing login", async () => { + await expect( + runAuthStatus( + {}, + { + resolveProject: vi.fn(async () => { + throw new MissingHostError(); + }), + }, + ), + ).rejects.toMatchObject({ name: "CtApplicationError", code: "AUTH_REQUIRED" }); + }); }); describe("auth mutations", () => { @@ -88,6 +117,32 @@ describe("auth mutations", () => { expect(JSON.stringify(result)).not.toContain("super-secret"); }); + it("keeps a stored login a success when only the version check fails", async () => { + const storeCredentials = vi.fn(async () => "test keychain"); + const result = await runAuthLogin( + { host, token: "super-secret" }, + { + createClient: (() => ({ + authenticate: vi.fn(async () => ({ id: 7, firstName: "Ada", lastName: "Lovelace" })), + get: vi.fn(async () => { + throw new Error("502 Bad Gateway"); + }), + })) as never, + storeCredentials, + }, + ); + + // The token was verified and written before /info was ever called: reporting pure failure + // hid credentials that are now in the keychain (#156 review). + expect(storeCredentials).toHaveBeenCalled(); + expect(result).toMatchObject({ + storage: "test keychain", + churchToolsVersion: null, + versionCheckError: "502 Bad Gateway", + supportedVersion: null, + }); + }); + it("logs out exactly the host selected by an environment", async () => { const clearCredentials = vi.fn(async () => ({ clearedDefault: true })); const result = await runAuthLogout( diff --git a/tests/application/destroy-operation.test.ts b/tests/application/destroy-operation.test.ts index 6945cd3..a4e1d12 100644 --- a/tests/application/destroy-operation.test.ts +++ b/tests/application/destroy-operation.test.ts @@ -3,11 +3,12 @@ import type { CtApplicationError } from "../../src/application/errors.js"; import { executePreparedDestroy, prepareDestroy, + runDeleteLoop, type DestroyOperationDependencies, type PreparedDestroyExecution, } from "../../src/application/operations/destroy.js"; import { PreparedOperationStore } from "../../src/application/prepared-operation-store.js"; -import type { CtClient } from "../../src/api/ctClient.js"; +import { CtApiError, type CtClient } from "../../src/api/ctClient.js"; import { emptyState } from "../../src/state/state.js"; const host = "https://example.church.tools"; @@ -110,6 +111,72 @@ describe("prepared destroy operation", () => { expect(test.events).toContain("resource-destroyed"); }); + it("reports every completed delete before a mid-loop throw can discard them", async () => { + const state = emptyState(host); + for (const key of ["a", "b", "c"]) { + state.resources[key] = { type: "group", id: 1, key, fields: {}, adoptedAt: "t", updatedAt: "t" }; + } + const messages: string[] = []; + let saves = 0; + await expect( + runDeleteLoop({ + client: { request: vi.fn(async () => ({})) } as unknown as CtClient, + state, + statePath, + ordered: ["a", "b", "c"], + save: async () => { + saves += 1; + if (saves === 3) throw new Error("EACCES: state file is read-only"); + }, + observer: { + emit: (event) => { + if (event.type === "outcome") messages.push(event.outcome.message); + }, + }, + }), + ).rejects.toThrow("EACCES"); + // The array the old code returned is gone with the stack — these two deletes really happened + // in ChurchTools, and the operator has been told so. + expect(messages).toEqual(["Destroyed group.a (#1)", "Destroyed group.b (#1)"]); + }); + + it("keeps the HTTP status but truncates a huge body in the stop message", async () => { + const state = emptyState(host); + state.resources.a = { type: "group", id: 9, key: "a", fields: {}, adoptedAt: "t", updatedAt: "t" }; + const outcomes = await runDeleteLoop({ + client: { + request: vi.fn(async () => { + throw new CtApiError("DELETE /groups/9 failed", 502, "".repeat(2000)); + }), + } as unknown as CtClient, + state, + statePath, + ordered: ["a"], + save: async () => {}, + }); + const message = outcomes[0]!.message; + expect(message).toContain("(HTTP 502)"); + // The resume guidance must not be buried under a full HTML error page (#50). + expect(message).toContain("truncated"); + expect(message.length).toBeLessThan(2600); + }); + + it("does not expire a prepared destroy the caller keeps across a blocking confirmation", async () => { + const test = harness(); + const prepared = await prepareDestroy( + { targets: ["area"] }, + { ...test.dependencies, preparedTtlMs: null }, + ); + expect(prepared.expiresAt).toBeNull(); + + const result = await executePreparedDestroy( + prepared, + { type: "environment", value: "prod" }, + test.dependencies, + ); + expect(result.value.complete).toBe(true); + }); + it("refuses a proposal after its state file changed", async () => { const test = harness(); const prepared = await prepareDestroy({ targets: ["area"] }, test.dependencies); diff --git a/tests/application/refresh-operation.test.ts b/tests/application/refresh-operation.test.ts index 488079e..b4783e9 100644 --- a/tests/application/refresh-operation.test.ts +++ b/tests/application/refresh-operation.test.ts @@ -53,6 +53,39 @@ describe("runRefresh", () => { }); }); + it("cautions about the fan-out BEFORE any membership is recomputed", async () => { + const seen: string[] = []; + const deps = dependencies(); + const authed = deps.authedSession!; + await runRefresh( + { all: true }, + { + ...deps, + authedSession: (async () => { + const session = await authed(); + const inner = session.client.request; + return { + ...session, + client: { + ...session.client, + request: (...args: Parameters) => { + seen.push("post"); + return inner(...args); + }, + }, + }; + }) as typeof authed, + observer: { + emit: (event) => { + if (event.type === "warning") seen.push(`warning:${event.warning.code}`); + if (event.type === "outcome") seen.push(`outcome:${event.outcome.status}`); + }, + }, + }, + ); + expect(seen).toEqual(["warning:REFRESH_FAN_OUT", "post", "outcome:ok", "post", "outcome:failed"]); + }); + it("requires an explicit single target or fan-out intent", async () => { await expect(runRefresh({}, dependencies())).rejects.toThrow(/Specify --group .*--all/); }); diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts index 2bc6c83..83fa4c9 100644 --- a/tests/architecture-boundaries.test.ts +++ b/tests/architecture-boundaries.test.ts @@ -17,8 +17,52 @@ async function typescriptFiles(dir: string): Promise { return nested.flat(); } +/** + * Every module specifier a file pulls in: static `from "…"`, side-effect `import "…"`, and + * dynamic `import("…")`. The `from`-only version missed the latter two, so a boundary could be + * crossed by an `await import()` with the rule still green. + */ function imports(source: string): string[] { - return [...source.matchAll(/\bfrom\s+["']([^"']+)["']/g)].flatMap((match) => (match[1] ? [match[1]] : [])); + const patterns = [ + /\bfrom\s+["']([^"']+)["']/g, + /\bimport\s+["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + ]; + return patterns.flatMap((pattern) => [...source.matchAll(pattern)].flatMap((m) => (m[1] ? [m[1]] : []))); +} + +/** Resolve a relative `./x.js` specifier back to the `.ts` file it is compiled from. */ +async function resolveLocal(fromFile: string, specifier: string): Promise { + if (!specifier.startsWith(".")) return null; + const target = join(dirname(fromFile), specifier.replace(/\.js$/, ".ts")); + try { + await readFile(target, "utf8"); + return target; + } catch { + return null; + } +} + +/** Every `.ts` file reachable from `entries` by following relative imports. */ +async function reachableFrom(entries: string[]): Promise> { + const seen = new Set(entries); + const queue = [...entries]; + while (queue.length > 0) { + const file = queue.shift()!; + let source: string; + try { + source = await readFile(file, "utf8"); + } catch { + continue; + } + for (const specifier of imports(source)) { + const target = await resolveLocal(file, specifier); + if (!target || seen.has(target)) continue; + seen.add(target); + queue.push(target); + } + } + return seen; } describe("application architecture boundaries", () => { @@ -28,8 +72,9 @@ describe("application architecture boundaries", () => { for (const file of files) { const source = await readFile(file, "utf8"); for (const specifier of imports(source)) { + const bare = specifier.split("/")[0]; if ( - ["commander", "hono", "vue"].includes(specifier) || + ["commander", "hono", "vue"].includes(bare!) || /(^|\/)commands\//.test(specifier) || /(^|\/)server\//.test(specifier) || /(^|\/)web\//.test(specifier) || @@ -42,6 +87,40 @@ describe("application architecture boundaries", () => { expect(violations).toEqual([]); }); + /** + * The direct-import rule above only looks one hop deep, so it stayed green while the layer + * reached the terminal presenter transitively — which is how `warn()` inside plan building + * would write to a future server's stderr instead of returning a warning (#156 review). + * + * These modules print directly and are reachable from `src/application`. They pre-date the + * extraction, so they are pinned rather than asserted away: the list can only shrink, and a + * NEW module that both prints and is pulled into the application layer fails here. + */ + it("pins every module that prints and is reachable from the application layer", async () => { + const entries = await typescriptFiles(join(root, "src/application")); + const reachable = await reachableFrom(entries); + const printers: string[] = []; + for (const file of reachable) { + if (file.startsWith(join(root, "src/application"))) continue; + const source = await readFile(file, "utf8"); + for (const specifier of imports(source)) { + if ((await resolveLocal(file, specifier)) === join(root, "src/ui.ts")) { + printers.push(relative(root, file)); + break; + } + } + } + expect(printers.sort()).toEqual([ + "src/auth/status.ts", + "src/config/context.ts", + "src/engine/build.ts", + "src/engine/execute.ts", + "src/engine/synthetic.ts", + "src/permissions/apply.ts", + "src/permissions/masterdata.ts", + ]); + }); + it("pins the existing CLI mutation imports so no new adapter bypass is introduced", async () => { const files = await typescriptFiles(join(root, "src/commands")); const guarded = ["executePlan", "saveState", "writeBackup", "applyPermissionPlan"];