diff --git a/README.md b/README.md index 62833af..718462b 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ ct coverage # what the instance has that the config does not m ct state list # what is managed ct state rm campus mainz # un-adopt: drop it from state. Never touches ChurchTools. ct plan # diff the config against ChurchTools (read-only) +ct plan --format markdown # plain-language review report (German by default) ct apply # create + update in dependency order (confirm + backup first) ct refresh --group # make ChurchTools re-evaluate one auto-group now ``` @@ -287,8 +288,8 @@ ct apply --env prod # protected env: type the env name to confirm - [**Environments**](docs/environments.md) — `ct.envs.json`, per-env state files, protected environments, the dev → prod promotion workflow. - [**CI usage**](docs/ci.md) — the auth model and token-from-secret setup, - `--detailed-exitcode`, the `--json` plan shape, drift-vs-config attribution, - and a copy-pasteable job that posts the plan as a PR comment. + `--detailed-exitcode`, Markdown/JSON plan projections, deterministic sidecar + names, drift-vs-config attribution, and copy-pasteable PR artifacts. ## Guardrails (by design) diff --git a/docs/ci.md b/docs/ci.md index 7f0583e..197d046 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -67,6 +67,79 @@ An INCOMPLETE plan is always exit `1`, even with `--detailed-exitcode` and even if the (partial) plan has changes: an incomplete diff can't be trusted enough to report "changes pending" instead of "this run failed". +## Plain-language review report: Markdown + +`ct plan --format markdown` renders the same resource and permission plan as a +self-contained Markdown document for people who do not need to understand the +terminal diff or raw JSON: + +```bash +ct plan --env prod --format markdown > plan-prod.md +``` + +The report starts with the target environment, host, ChurchTools version, +configuration and state host. It distinguishes creates, updates, drift, +delete candidates, permission grants/revocations, preserved grants and an +incomplete plan. A delete candidate is explicitly described as something +`ct apply` will **not** delete. Automatic groups receive a semantic summary; +all current and future resource types remain visible through a generic +technical appendix. + +German is the deterministic default. Select English explicitly when needed: + +```bash +ct plan --env prod --format markdown --locale en > plan-prod.md +``` + +The command computes the live plan only once even when several projections are +needed. Repeat `--format` and give one base name; `ct` adds a stable extension: + +```bash +ct plan --env prod \ + --format text \ + --format json \ + --format markdown \ + --output-base reports/plan-prod + +# reports/plan-prod.txt +# reports/plan-prod.json +# reports/plan-prod.md +``` + +For a single projection, `--output-base` follows the same convention. If the +base already ends in `.txt`, `.json`, `.md` or `.markdown`, that known extension +is replaced with the selected format's extension. Multiple formats without +`--output-base` are rejected so two documents can never be concatenated +ambiguously on stdout. + +`--json` remains the backward-compatible stdout alias for `--format json`. +Combining the alias with `--format` is rejected rather than guessing which +request wins. + +The Markdown renderer performs no additional ChurchTools request and does not +recalculate actions or safety policy. It consumes the same structured plan as +terminal and JSON output. Fields whose names look like credentials, tokens, +passwords or secrets are redacted in both the readable body and technical +fallback. + +To publish the report as a GitHub Actions artifact: + +```yaml +- name: Create review plans + env: + CT_LOGINTOKEN: ${{ secrets.CT_LOGINTOKEN }} + run: | + mkdir -p reports + ct plan --env prod --format json --format markdown --output-base reports/plan-prod + +- name: Upload plan reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: churchtools-plan + path: reports/plan-prod.* +``` + ## Machine-readable output: `--json` `ct plan --json` prints **only** the plan JSON to stdout — the env/host diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index b85b585..366746b 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/commands/adopt-group.ts -sources_hash: 02bcb67b91c897de +sources_hash: 437e71c4f4f8bf36 reviewed: 2026-08-17 --- diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index e4d8a11..f133122 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: 00dca3c03dacc9ff +sources_hash: a5335e766218b514 title: Group member fields sources: - src/engine/member-fields.ts diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 4f13434..53d454f 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -1,3 +1,5 @@ +import { writeFile } from "node:fs/promises"; +import { format as formatPath, parse as parsePath } from "node:path"; import { Command } from "commander"; import { authedSession } from "../api/session.js"; import { resolveConfig } from "../config.js"; @@ -7,20 +9,90 @@ import { loadConfig, resolveConfigPath } from "../config/load.js"; import { buildPlan } from "../engine/build.js"; import { Resolver } from "../resolve/resolver.js"; import { renderPlan } from "../engine/render.js"; +import { PLAN_MARKDOWN_LOCALES, renderPlanMarkdown, type PlanMarkdownLocale } from "../engine/markdown.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"; +import { info, warn } from "../ui.js"; + +export type PlanFormat = "text" | "json" | "markdown"; + +export interface PlanOutputTarget { + format: PlanFormat; + path?: string; +} interface PlanOptions { config?: string; state?: string; env?: string; json?: boolean; + format?: string[]; + outputBase?: string; + locale?: string; detailedExitcode?: boolean; } +function collectFormat(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function parseFormat(value: string): PlanFormat { + if (value === "text" || value === "json" || value === "markdown") return value; + throw new Error(`Unknown plan format "${value}". Use text, json, or markdown.`); +} + +export function parsePlanLocale(value: string | undefined): PlanMarkdownLocale { + const locale = value ?? "de-DE"; + if ((PLAN_MARKDOWN_LOCALES as readonly string[]).includes(locale)) return locale as PlanMarkdownLocale; + throw new Error(`Unknown plan locale "${locale}". Available locales: ${PLAN_MARKDOWN_LOCALES.join(", ")}.`); +} + +function planOutputPath(base: string, planFormat: PlanFormat): string { + const extension: Record = { text: ".txt", json: ".json", markdown: ".md" }; + const parsed = parsePath(base); + const knownExtension = [".txt", ".json", ".md", ".markdown"].includes(parsed.ext.toLowerCase()); + return formatPath({ + dir: parsed.dir, + name: parsed.name, + ext: knownExtension ? extension[planFormat] : `${parsed.ext}${extension[planFormat]}`, + }); +} + +/** Resolve output files before any ChurchTools request, so invalid combinations fail cheaply. */ +export function planOutputTargets( + opts: Pick, +): PlanOutputTarget[] { + const explicit = opts.format ?? []; + if (opts.json && explicit.length > 0) { + throw new Error("--json is an alias for --format json and cannot be combined with --format."); + } + if (opts.json && opts.outputBase) { + throw new Error( + "Use --format json with --output-base; the backward-compatible --json alias writes to stdout.", + ); + } + const selected = opts.json + ? ["json" as const] + : explicit.length > 0 + ? explicit.map(parseFormat) + : ["text" as const]; + const formats = [...new Set(selected)]; + if (opts.outputBase && explicit.length === 0) { + throw new Error("--output-base requires at least one explicit --format."); + } + if (formats.length > 1 && !opts.outputBase) { + throw new Error( + "Multiple --format values require --output-base so every projection has a distinct file.", + ); + } + return formats.map((planFormat) => ({ + format: planFormat, + path: opts.outputBase ? planOutputPath(opts.outputBase, planFormat) : undefined, + })); +} + export function planCommand(): Command { return new Command("plan") .description("Show the diff between the desired-state config and ChurchTools (read-only)") @@ -28,11 +100,21 @@ export function planCommand(): Command { .option("-s, --state ", "state file (or set CT_STATE)") .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") .option("--json", "emit the raw plan as JSON instead of the rendered diff") + .option( + "--format ", + "output format; repeat for multiple projections: text, json, markdown", + collectFormat, + [], + ) + .option("--output-base ", "write selected formats as .txt/.json/.md") + .option("--locale ", "Markdown language: de-DE or en", "de-DE") .option( "--detailed-exitcode", "Terraform-style exit code: 0 = no changes, 1 = error, 2 = changes pending (resource or permission)", ) .action(async (opts: PlanOptions) => { + const outputTargets = planOutputTargets(opts); + const locale = parsePlanLocale(opts.locale); // 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(); @@ -53,7 +135,7 @@ export function planCommand(): Command { const resolver = new Resolver({ client, state, desired, host: config.host }); // Independent fetches run concurrently (see commands/apply.ts). const [ - { plan, fetchErrors }, + { plan, fetchErrors, warnings: resourceWarnings = [] }, { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }, ] = await Promise.all([ buildPlan(client, state, desired, { configDir, resolver }), @@ -70,41 +152,54 @@ export function planCommand(): Command { ); const hasChanges = hasResourceChanges || hasPermissionChanges; - 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, + // Additive on top of the raw plan/permissions (#24) — existing consumers of `plan`/`permissions` + // are unaffected. Every projection below consumes this one computation. + const payload = { + 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), }, - }); - } 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) { - info( - `env: ${cmdEnv.name} · host: ${config.host} · ChurchTools ${client.version ?? "unknown"} · ` + - `config: ${configPath} · state host: ${state.host}`, - ); + hasChanges, + }, + }; + + const textHeader = cmdEnv.name + ? `env: ${cmdEnv.name} · host: ${config.host} · ChurchTools ${client.version ?? "unknown"} · ` + + `config: ${configPath} · state host: ${state.host}` + : `config: ${configPath} · state host: ${state.host}`; + const textBody = `${renderPlan(plan)}${permItems.length > 0 ? `\n\n${renderPermissionPlan(permItems)}` : ""}\n`; + for (const target of outputTargets) { + const content = + target.format === "json" + ? `${JSON.stringify(payload, null, 2)}\n` + : target.format === "markdown" + ? renderPlanMarkdown(plan, permItems, { + environment: cmdEnv.name, + host: config.host, + churchToolsVersion: client.version, + configPath, + stateHost: state.host, + locale, + warnings: [...resourceWarnings, ...permWarnings], + fetchErrors: [...fetchErrors, ...permFetchErrors], + }) + : textBody; + if (target.path) { + await writeFile(target.path, content, "utf8"); + info(`plan ${target.format}: ${target.path}`); } else { - info(`config: ${configPath} · state host: ${state.host}`); - } - process.stdout.write(`${renderPlan(plan)}\n`); - if (permItems.length > 0) { - process.stdout.write(`\n${renderPermissionPlan(permItems)}\n`); + if (target.format === "text") info(textHeader); + process.stdout.write(content); } } diff --git a/src/engine/build.ts b/src/engine/build.ts index f53bc30..91fd191 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -24,6 +24,8 @@ export interface BuildResult { plan: Plan; actual: Map>; fetchErrors: string[]; + /** Informational registry/portability warnings already printed to stderr. */ + warnings?: string[]; } export interface FetchActualResult { @@ -35,6 +37,7 @@ export interface FetchActualResult { fetchFailed: Map; /** Human-readable fetch-error lines (non-404), one per failed key. */ fetchErrors: string[]; + warnings?: string[]; } /** @@ -54,14 +57,15 @@ export async function fetchActual( const unresolved = new Set(); const fetchFailed = new Map(); const fetchErrors: string[] = []; + const warnings: string[] = []; await mapConcurrent(resources, FETCH_CONCURRENCY, async (managed) => { const spec = RESOURCES[managed.type]; if (!spec) { unresolved.add(managed.key); - warn( - `No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`, - ); + const warning = `No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`; + warnings.push(warning); + warn(warning); return; } try { @@ -88,7 +92,13 @@ export async function fetchActual( } }); - return { actual, unresolved, fetchFailed, fetchErrors }; + return { + actual, + unresolved, + fetchFailed, + fetchErrors, + ...(warnings.length > 0 ? { warnings } : {}), + }; } export interface BuildOptions { @@ -110,10 +120,13 @@ export async function buildPlan( opts: BuildOptions = {}, ): Promise { // Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0). - const { actual, unresolved, fetchFailed, fetchErrors } = await fetchActual( - client, - Object.values(state.resources), - ); + const { + actual, + unresolved, + fetchFailed, + fetchErrors, + warnings: fetchWarnings = [], + } = await fetchActual(client, Object.values(state.resources)); // Synthetic sub-resource fields (parents, dynamic, …) fold into the diff on both sides. const folded = await foldSynthetic({ client, state, desired, actual, configDir: opts.configDir }); @@ -149,5 +162,6 @@ export async function buildPlan( }); const plan = computePlan(ordered, state, actual, { unresolved, fetchFailed }); - return { plan, actual, fetchErrors }; + const warnings = [...fetchWarnings, ...(folded.warnings ?? [])]; + return { plan, actual, fetchErrors, ...(warnings.length > 0 ? { warnings } : {}) }; } diff --git a/src/engine/markdown.ts b/src/engine/markdown.ts new file mode 100644 index 0000000..d241b88 --- /dev/null +++ b/src/engine/markdown.ts @@ -0,0 +1,975 @@ +/** + * Plain-language Markdown projection of the same structured resource and + * permission plans used by terminal and JSON output. This module is deliberately + * pure: it performs no IO, fetches no ChurchTools data and never recomputes a + * plan action. + */ +import type { FieldChange, FieldChangeSource, Plan, PlanAction, PlanItem } from "./types.js"; +import type { PermissionPlanItem } from "../permissions/plan.js"; +import type { GrantTuple } from "../permissions/grants.js"; +import { CATALOG } from "../permissions/catalog.js"; +import { refLabel } from "../resolve/refs.js"; + +export const PLAN_MARKDOWN_LOCALES = ["de-DE", "en"] as const; +export type PlanMarkdownLocale = (typeof PLAN_MARKDOWN_LOCALES)[number]; + +export interface MarkdownPlanContext { + environment?: string | null; + host: string; + churchToolsVersion?: string | null; + configPath: string; + stateHost: string; + /** Injected by tests/automation when byte-identical output is required. */ + generatedAt?: Date; + locale?: PlanMarkdownLocale; + /** Informational catalog/portability warnings already produced by the shared planning pipeline. */ + warnings?: string[]; + /** Fetch failures make the report incomplete and block apply. */ + fetchErrors?: string[]; +} + +interface Copy { + title: string; + planOnly: string; + context: string; + environment: string; + host: string; + churchToolsVersion: string; + config: string; + stateHost: string; + generatedAt: string; + summary: string; + result: string; + count: string; + create: string; + update: string; + delete: string; + noop: string; + resourceTypes: string; + resourceType: string; + permissions: string; + grants: string; + revocations: string; + preserved: string; + changes: string; + creates: string; + updates: string; + deleteCandidates: string; + field: string; + before: string; + after: string; + reason: string; + configReason: string; + driftReason: string; + bothReason: string; + drift: string; + noChanges: string; + deleteSafety: string; + incompleteTitle: string; + incompleteText: string; + warnings: string; + review: string; + checklist: string[]; + appendix: string; + logicalKey: string; + id: string; + action: string; + note: string; + details: string; + unknown: string; + none: string; + pending: string; + domain: string; + right: string; + scope: string; + effect: string; + permissionPreserved: string; + automaticGroup: string; + status: string; + rules: string; +} + +const COPY: Record = { + "de-DE": { + title: "ChurchTools-Änderungsplan", + planOnly: + "Dieser Bericht beschreibt nur den Plan. Es wurde noch nichts in ChurchTools angelegt oder geändert.", + context: "Kontext", + environment: "Umgebung", + host: "ChurchTools-Instanz", + churchToolsVersion: "ChurchTools-Version", + config: "Konfiguration", + stateHost: "Instanz des State", + generatedAt: "Erstellt am", + summary: "Zusammenfassung", + result: "Ergebnis", + count: "Anzahl", + create: "Neu anlegen", + update: "Ändern", + delete: "Löschkandidaten", + noop: "Unverändert", + resourceTypes: "Ressourcen nach Typ", + resourceType: "Ressourcentyp", + permissions: "Berechtigungen", + grants: "Zu erteilen", + revocations: "Zu entziehen", + preserved: "Bewusst unangetastet", + changes: "Geplante Änderungen", + creates: "Neue Ressourcen", + updates: "Geänderte Ressourcen", + deleteCandidates: "Aus der Konfiguration entfernte Ressourcen", + field: "Feld", + before: "Bisher", + after: "Geplant", + reason: "Grund", + configReason: "Konfiguration wurde geändert", + driftReason: "Manuelle Änderung in ChurchTools wird zurückgeführt", + bothReason: "Konfiguration und ChurchTools wurden unabhängig geändert", + drift: "Erkannte manuelle Änderungen", + noChanges: + "Es sind keine Änderungen erforderlich. Der gewünschte Zustand stimmt mit ChurchTools überein.", + deleteSafety: + "`ct apply` löscht diese Ressourcen **nicht**. Eine tatsächliche Löschung ist nur über den separaten Befehl `ct destroy` möglich.", + incompleteTitle: "Unvollständiger Plan", + incompleteText: + "Mindestens eine Ressource konnte nicht gelesen werden. Der Bericht ist deshalb nicht vollständig und darf nicht als Freigabe für `apply` verwendet werden.", + warnings: "Warnungen", + review: "Prüfung vor dem Anwenden", + checklist: [ + "Stimmen Zielumgebung, Host und State-Datei überein?", + "Sind alle neuen und geänderten Ressourcen fachlich gewollt?", + "Sind Berechtigungsentzüge und Löschkandidaten ausdrücklich geprüft?", + "Ist der Plan vollständig und sind alle Warnungen geklärt?", + "Erst danach `ct apply -e ` ausführen.", + ], + appendix: "Technischer Anhang", + logicalKey: "Logischer Schlüssel", + id: "ChurchTools-ID", + action: "Aktion", + note: "Hinweis", + details: "Technische Details", + unknown: "unbekannt", + none: "keine", + pending: "wird beim Anwenden erzeugt", + domain: "Bereich", + right: "Recht", + scope: "Geltungsbereich", + effect: "Auswirkung", + permissionPreserved: "wird nicht verwaltet und bleibt erhalten", + automaticGroup: "Automatische Gruppe", + status: "Status", + rules: "Regeln", + }, + en: { + title: "ChurchTools change plan", + planOnly: "This report describes the plan only. Nothing has been created or changed in ChurchTools.", + context: "Context", + environment: "Environment", + host: "ChurchTools instance", + churchToolsVersion: "ChurchTools version", + config: "Configuration", + stateHost: "State instance", + generatedAt: "Generated at", + summary: "Summary", + result: "Result", + count: "Count", + create: "Create", + update: "Update", + delete: "Delete candidates", + noop: "Unchanged", + resourceTypes: "Resources by type", + resourceType: "Resource type", + permissions: "Permissions", + grants: "To grant", + revocations: "To revoke", + preserved: "Deliberately preserved", + changes: "Planned changes", + creates: "New resources", + updates: "Updated resources", + deleteCandidates: "Resources removed from configuration", + field: "Field", + before: "Before", + after: "Planned", + reason: "Reason", + configReason: "Configuration changed", + driftReason: "Manual ChurchTools change will be reverted", + bothReason: "Configuration and ChurchTools changed independently", + drift: "Detected manual changes", + noChanges: "No changes are required. Desired state matches ChurchTools.", + deleteSafety: + "`ct apply` does **not** delete these resources. Actual deletion is only available through the separate `ct destroy` command.", + incompleteTitle: "Incomplete plan", + incompleteText: + "At least one resource could not be read. This report is incomplete and must not be used to approve `apply`.", + warnings: "Warnings", + review: "Review before applying", + checklist: [ + "Do environment, host and state file identify the same target?", + "Are all new and updated resources intended?", + "Were permission revocations and delete candidates explicitly reviewed?", + "Is the plan complete and have all warnings been resolved?", + "Only then run `ct apply -e `.", + ], + appendix: "Technical appendix", + logicalKey: "Logical key", + id: "ChurchTools ID", + action: "Action", + note: "Note", + details: "Technical details", + unknown: "unknown", + none: "none", + pending: "created during apply", + domain: "Domain", + right: "Right", + scope: "Scope", + effect: "Effect", + permissionPreserved: "not managed and left untouched", + automaticGroup: "Automatic group", + status: "Status", + rules: "Rules", + }, +}; + +const TYPE_LABELS: Record> = { + "de-DE": { + campus: "Campus", + group: "Gruppe", + "group-type": "Gruppentyp", + "age-group": "Altersgruppe", + "target-group": "Zielgruppe", + "relationship-type": "Beziehungstyp", + "person-status": "Personenstatus", + department: "Bereich", + "security-level": "Sicherheitsstufe", + "group-role": "Gruppenrolle", + }, + en: { + campus: "Campus", + group: "Group", + "group-type": "Group type", + "age-group": "Age group", + "target-group": "Target group", + "relationship-type": "Relationship type", + "person-status": "Person status", + department: "Department", + "security-level": "Security level", + "group-role": "Group role", + }, +}; + +const FIELD_LABELS: Record> = { + "de-DE": { + name: "Name", + nameTranslated: "Übersetzter Name", + shorty: "Kurzname", + sortKey: "Sortierung", + campusId: "Campus", + groupTypeId: "Gruppentyp", + groupStatusId: "Gruppenstatus", + securityLevelId: "Sicherheitsstufe", + parents: "Übergeordnete Gruppen", + dynamic: "Automatische Gruppe", + type: "Typ", + isMember: "Mitgliedsstatus", + isSearchable: "Suchbar", + }, + en: { + name: "Name", + nameTranslated: "Translated name", + shorty: "Short name", + sortKey: "Sort order", + campusId: "Campus", + groupTypeId: "Group type", + groupStatusId: "Group status", + securityLevelId: "Security level", + parents: "Parent groups", + dynamic: "Automatic group", + type: "Type", + isMember: "Member status", + isSearchable: "Searchable", + }, +}; + +const ACTION_LABELS: Record> = { + "de-DE": { create: "anlegen", update: "ändern", delete: "Löschkandidat", "no-op": "unverändert" }, + en: { create: "create", update: "update", delete: "delete candidate", "no-op": "unchanged" }, +}; + +const DOMAIN_LABELS: Record> = { + "de-DE": { group_role: "Gruppenrolle", group_type_role: "Gruppentyp-Rolle", status: "Status" }, + en: { group_role: "Group role", group_type_role: "Group-type role", status: "Status" }, +}; + +const DYNAMIC_STATUS: Record> = { + "de-DE": { + active: "automatisch aktiv", + manual: "manuell auszuführen", + inactive: "pausiert", + none: "keine Automatik", + }, + en: { active: "active automatically", manual: "run manually", inactive: "paused", none: "no automation" }, +}; + +const MEMBER_FIELD_TYPE: Record> = { + "de-DE": { + checkbox: "Ja/Nein", + multiselect: "Mehrfachauswahl", + select: "Auswahl", + text: "Kurzer Text", + textarea: "Langer Text", + }, + en: { + checkbox: "Yes/No", + multiselect: "Multiple choice", + select: "Choice", + text: "Short text", + textarea: "Long text", + }, +}; + +const SECRET_KEY = /(?:token|secret|password|credential|authorization|cookie)/i; + +function sanitize(value: unknown, key = ""): unknown { + if (SECRET_KEY.test(key)) return "[REDACTED]"; + if (Array.isArray(value)) return value.map((entry) => sanitize(entry)); + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const childKey of Object.keys(value as Record).sort()) { + out[childKey] = sanitize((value as Record)[childKey], childKey); + } + return out; + } + return value; +} + +export function redactPlanReportSecrets(value: string): string { + return value + .replace(/([?&](?:login_)?token=)[^&#\s]+/gi, "$1[REDACTED]") + .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]") + .replace(/(https?:\/\/)[^/@\s]+@/gi, "$1[REDACTED]@"); +} + +function stableJson(value: unknown): string { + if (value === undefined) return "—"; + if (typeof value === "string") return value; + return JSON.stringify(sanitize(value)); +} + +/** Escape user-controlled text for both Markdown tables and prose. */ +export function escapeMarkdown(value: unknown): string { + return redactPlanReportSecrets(String(value ?? "")) + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(/([*_<>#])/g, "\\$1") + .replaceAll("[", "\\[") + .replaceAll("]", "\\]") + .replace(/`/g, "`") + .replace(/\r?\n/g, "
"); +} + +function typeLabel(type: string, locale: PlanMarkdownLocale): string { + return TYPE_LABELS[locale][type] ?? type; +} + +function fieldLabel(field: string, locale: PlanMarkdownLocale): string { + return FIELD_LABELS[locale][field] ?? field; +} + +function sourceLabel(source: FieldChangeSource | undefined, copy: Copy): string { + if (source === "drift") return copy.driftReason; + if (source === "config+drift") return copy.bothReason; + return copy.configReason; +} + +function pendingRefOf(value: unknown): Parameters[0] | null { + if (value !== null && typeof value === "object" && "__pendingRef" in value) { + return (value as { __pendingRef: Parameters[0] }).__pendingRef; + } + return null; +} + +function valueText(value: unknown, copy: Copy): string { + if (value === undefined) return copy.none; + const pending = pendingRefOf(value); + if (pending) return `${refLabel(pending)} (${copy.pending})`; + if (Array.isArray(value)) return value.length > 0 ? value.map((v) => stableJson(v)).join(", ") : copy.none; + return stableJson(value); +} + +function dynamicText(value: unknown, copy: Copy): string | null { + if (value === null || typeof value !== "object") return null; + const dynamic = value as Record; + if (!("status" in dynamic) && !("ruleset" in dynamic)) return null; + const ruleset = dynamic.ruleset; + let rules: string; + if (ruleset && typeof ruleset === "object" && "ref" in ruleset) { + rules = String((ruleset as Record).ref); + } else if (ruleset && typeof ruleset === "object") { + const count = Object.keys(ruleset as Record).length; + rules = `${count} ${copy.rules.toLocaleLowerCase()}`; + } else { + rules = copy.none; + } + return `${copy.automaticGroup}: ${copy.status} ${String(dynamic.status ?? copy.unknown)}, ${copy.rules} ${rules}`; +} + +function renderValue(value: unknown, field: string, copy: Copy): string { + if (SECRET_KEY.test(field)) return escapeMarkdown("[REDACTED]"); + const semantic = field === "dynamic" ? dynamicText(value, copy) : null; + return escapeMarkdown(semantic ?? valueText(value, copy)); +} + +function table(headers: string[], rows: string[][]): string[] { + return [ + `| ${headers.join(" | ")} |`, + `| ${headers.map(() => "---").join(" | ")} |`, + ...rows.map((row) => `| ${row.join(" | ")} |`), + ]; +} + +function resourceHeading(item: PlanItem, locale: PlanMarkdownLocale): string { + const name = item.displayName || item.key; + const technical = `${item.type}.${item.key}${item.id === null ? "" : ` · #${item.id}`}`; + return `### ${escapeMarkdown(name)} — ${escapeMarkdown(typeLabel(item.type, locale))} (${escapeMarkdown(technical)})`; +} + +function changeRows(changes: FieldChange[], locale: PlanMarkdownLocale, copy: Copy): string[][] { + return changes.map((change) => [ + escapeMarkdown(fieldLabel(change.field, locale)), + renderValue(change.from, change.field, copy), + renderValue(change.to, change.field, copy), + escapeMarkdown(sourceLabel(change.source, copy)), + ]); +} + +function changedFields(item: PlanItem): Record { + return Object.fromEntries(item.changes.map((change) => [change.field, change.to])); +} + +function itemNames(plan: Plan): Map { + return new Map(plan.items.map((item) => [item.key, item.displayName || item.key])); +} + +function findRulesetFilters(node: unknown, variable: string, found: unknown[] = []): unknown[] { + if (Array.isArray(node)) { + for (const value of node) findRulesetFilters(value, variable, found); + return found; + } + if (node === null || typeof node !== "object") return found; + for (const [operator, expression] of Object.entries(node as Record)) { + if ( + (operator === "==" || operator === "oneof") && + Array.isArray(expression) && + expression[0] !== null && + typeof expression[0] === "object" && + (expression[0] as Record).var === variable + ) { + found.push(expression[1]); + } + findRulesetFilters(expression, variable, found); + } + return found; +} + +function flattenUnique(values: unknown[]): unknown[] { + return [...new Set(values.flat(Number.POSITIVE_INFINITY).map((value) => stableJson(value)))].map( + (encoded) => { + try { + return JSON.parse(encoded) as unknown; + } catch { + return encoded; + } + }, + ); +} + +function dynamicDetails(dynamic: unknown, plan: Plan, locale: PlanMarkdownLocale): string[] { + if (dynamic === null || typeof dynamic !== "object") return []; + const value = dynamic as Record; + if (value.ruleset === null || typeof value.ruleset !== "object") return []; + const ruleset = value.ruleset as Record; + const names = itemNames(plan); + const groupNamesById = new Map( + plan.items + .filter((item) => item.type === "group" && item.id !== null) + .map((item) => [item.id as number, item.displayName || item.key]), + ); + const sourceIds = flattenUnique(findRulesetFilters(ruleset.query, "ctgroup.id")); + const sourceNames = flattenUnique(findRulesetFilters(ruleset.query, "ctgroup.name")); + const process = ruleset.process as Record | undefined; + const queryOnly = (process?.queryResultOnly as Record | undefined)?.none as + Record | undefined; + const groupAndQuery = (process?.groupAndQueryResult as Record | undefined)?.active as + Record | undefined; + const membership = (queryOnly?.handleMembership ?? groupAndQuery?.handleMembership) as + Record | undefined; + const lines: string[] = []; + if (sourceIds.length > 0) { + const resolved = sourceIds.map((entry) => { + if (typeof entry === "number") return groupNamesById.get(entry) ?? `ChurchTools group #${entry}`; + if (entry && typeof entry === "object") { + const pending = (entry as Record).__pendingRef ?? entry; + if (pending && typeof pending === "object") { + const key = (pending as Record).key; + if (typeof key === "string") return names.get(key) ?? key; + } + } + return stableJson(entry); + }); + lines.push( + locale === "de-DE" + ? `Quelle der Teilnehmenden: ${resolved.join(", ")}` + : `Participant source: ${resolved.join(", ")}`, + ); + } + if (sourceNames.length > 0) { + lines.push( + locale === "de-DE" + ? `Ausgewertete Gruppen: ${sourceNames.map(stableJson).join(", ")}` + : `Evaluated groups: ${sourceNames.map(stableJson).join(", ")}`, + ); + } + if (membership?.groupTypeRoleId !== undefined) { + lines.push( + locale === "de-DE" + ? `Neue Mitgliedschaft verwendet die aufgelöste Rollen-ID ${stableJson(membership.groupTypeRoleId)}.` + : `New membership uses resolved role ID ${stableJson(membership.groupTypeRoleId)}.`, + ); + } + if (membership?.groupMemberFields && typeof membership.groupMemberFields === "object") { + const assignments = Object.entries(membership.groupMemberFields as Record) + .map(([key, entry]) => `${key} = ${stableJson(entry)}`) + .join(", "); + lines.push( + locale === "de-DE" + ? `Automatisch gesetzte Gruppenfelder: ${assignments}` + : `Automatically assigned group fields: ${assignments}`, + ); + } + const groupOnly = (process?.groupOnly as Record | undefined)?.active as + Record | undefined; + const groupOnlyMembership = groupOnly?.handleMembership as Record | undefined; + if (groupOnlyMembership?.groupMemberStatus === "none") { + lines.push( + locale === "de-DE" + ? "Aktive Mitgliedschaften werden beendet, wenn eine Person die Auswahlbedingungen nicht mehr erfüllt." + : "Active memberships end when a person no longer matches the selection criteria.", + ); + } + return lines; +} + +function memberFieldsFor(group: PlanItem, plan: Plan): PlanItem[] { + // Only a group that is itself being created renders its member fields inline (see + // `renderGroupOverview`). For any other group the fields must stay in their own section, + // otherwise a new member field on an existing group is filtered out of the creates and + // rendered nowhere but the technical appendix. + if (group.type !== "group" || group.action !== "create") return []; + return plan.items.filter( + (item) => + item.action === "create" && + item.type === "group-member-field" && + (item.key === group.key || item.key.startsWith(`${group.key}::`)), + ); +} + +function renderGroupOverview(item: PlanItem, plan: Plan, locale: PlanMarkdownLocale, copy: Copy): string[] { + if (item.type !== "group" || item.action !== "create") return []; + const fields = changedFields(item); + const names = itemNames(plan); + const groupTypeNames = new Map( + plan.items + .filter((entry) => entry.type === "group-type" && entry.id !== null) + .map((entry) => [entry.id as number, entry.displayName || entry.key]), + ); + const parents = Array.isArray(fields.parents) + ? fields.parents.map((key) => names.get(String(key)) ?? String(key)) + : []; + const dynamic = fields.dynamic; + // `groupTypeId` is a pending ref whenever the group type is created in the same run + // (the bootstrap case). Resolving it as a number would render `#[object Object]`. + const groupTypeRef = pendingRefOf(fields.groupTypeId); + const groupTypeRefKey = groupTypeRef && "key" in groupTypeRef ? String(groupTypeRef.key) : null; + const groupType = groupTypeRef + ? ((groupTypeRefKey === null ? undefined : names.get(groupTypeRefKey)) ?? refLabel(groupTypeRef)) + : (groupTypeNames.get(Number(fields.groupTypeId)) ?? copy.unknown); + const groupTypeSuffix = groupTypeRef + ? ` (${copy.pending})` + : fields.groupTypeId === undefined + ? "" + : ` (#${escapeMarkdown(fields.groupTypeId)})`; + const ownedFields = memberFieldsFor(item, plan); + const lines = [ + `- ${locale === "de-DE" ? "Technischer Schlüssel" : "Technical key"}: \`${escapeMarkdown(item.key)}\``, + `- ${locale === "de-DE" ? "Gruppentyp" : "Group type"}: ${escapeMarkdown(groupType)}${groupTypeSuffix}`, + `- ${locale === "de-DE" ? "Gruppenstatus" : "Group status"}: ${escapeMarkdown(fields.groupStatusId ?? copy.unknown)}`, + `- ${locale === "de-DE" ? "Übergeordnete Gruppe(n)" : "Parent group(s)"}: ${escapeMarkdown(parents.length > 0 ? parents.join(", ") : copy.none)}`, + `- ${locale === "de-DE" ? "Neue Gruppenmitgliedsfelder" : "New group member fields"}: ${ownedFields.length}`, + `- ${copy.automaticGroup}: ${escapeMarkdown(DYNAMIC_STATUS[locale][String((dynamic as Record | undefined)?.status)] ?? copy.none)}`, + "", + ]; + const details = dynamicDetails(dynamic, plan, locale); + if (details.length > 0) { + lines.push(locale === "de-DE" ? "Was die Automatik macht:" : "What the automation does:", ""); + for (const detail of details) lines.push(`- ${escapeMarkdown(detail)}`); + lines.push(""); + } + if (ownedFields.length > 0) { + lines.push( + ...table( + [ + locale === "de-DE" ? "Feld" : "Field", + locale === "de-DE" ? "Technischer Name" : "Technical name", + locale === "de-DE" ? "Art" : "Type", + locale === "de-DE" ? "Standardwert" : "Default", + locale === "de-DE" ? "Auswahlwerte" : "Options", + locale === "de-DE" ? "Anmeldung" : "Registration", + locale === "de-DE" ? "Sicherheitsstufe" : "Security level", + ].map(escapeMarkdown), + ownedFields.map((field) => { + const value = changedFields(field); + const registration = value.useInRegistrationForm + ? value.requiredInRegistrationForm + ? locale === "de-DE" + ? "sichtbar, Pflichtfeld" + : "visible, required" + : locale === "de-DE" + ? "sichtbar, freiwillig" + : "visible, optional" + : locale === "de-DE" + ? "nicht im Formular" + : "not in form"; + const options = Array.isArray(value.options) + ? value.options.map((option) => stableJson(option)).join(", ") + : copy.none; + return [ + value.name, + value.referenceName, + MEMBER_FIELD_TYPE[locale][String(value.fieldTypeCode)] ?? value.fieldTypeCode, + value.defaultValue ?? copy.none, + options, + registration, + value.securityLevel ?? copy.unknown, + ].map(escapeMarkdown); + }), + ), + "", + ); + } + return lines; +} + +function renderResourceSection( + title: string, + items: PlanItem[], + plan: Plan, + locale: PlanMarkdownLocale, + copy: Copy, +): string[] { + if (items.length === 0) return []; + const lines = [`## ${title}`, ""]; + for (const item of items) { + lines.push(resourceHeading(item, locale), ""); + const overview = renderGroupOverview(item, plan, locale, copy); + lines.push(...overview); + if (item.note === "recreate") { + lines.push( + locale === "de-DE" + ? "Die Ressource fehlt in ChurchTools und wird mit einer neuen ID wieder angelegt." + : "The resource is missing in ChurchTools and will be recreated with a new ID.", + "", + ); + } + // Groups that rendered an overview already state their fields in prose; every other item — + // including a group *update* — still needs its before/after table (#155 review). + if (item.changes.length > 0 && overview.length === 0) { + lines.push( + ...table( + [copy.field, copy.before, copy.after, copy.reason].map(escapeMarkdown), + changeRows(item.changes, locale, copy), + ), + "", + ); + } + } + return lines; +} + +function catalogRight(authId: number): { technical: string; description: string } | null { + const found = Object.entries(CATALOG).find(([, entry]) => entry.authId === authId); + return found ? { technical: found[0], description: found[1].desc } : null; +} + +function tupleRight(tuple: GrantTuple): string { + const right = catalogRight(tuple.authId); + return right ? `${right.description} (${right.technical}, #${tuple.authId})` : `authId #${tuple.authId}`; +} + +function tupleScope(tuple: GrantTuple, copy: Copy): string { + if (tuple.pending && tuple.scopeKey) return `${tuple.scopeKey} (${copy.pending})`; + if (tuple.scopeKey) return tuple.scopeKey; + return tuple.dataId.length > 0 ? tuple.dataId.map((id) => `#${id}`).join(", ") : copy.none; +} + +function permissionDomain(item: PermissionPlanItem, locale: PlanMarkdownLocale, copy: Copy): string { + const label = DOMAIN_LABELS[locale][item.domainType] ?? item.domainType; + const id = item.pendingDomain ? `${refLabel(item.pendingDomain)} (${copy.pending})` : `#${item.domainId}`; + return `${label} ${item.key} (${id})`; +} + +function permissionCounts(items: PermissionPlanItem[]): { + grants: number; + revocations: number; + preserved: number; +} { + return { + grants: items.reduce((sum, item) => sum + item.diff.toPut.length, 0), + revocations: items.reduce((sum, item) => sum + item.diff.toDelete.length, 0), + preserved: items.reduce( + (sum, item) => sum + item.diff.preserved.length + item.diff.preservedUnknown.length, + 0, + ), + }; +} + +function renderPermissions(items: PermissionPlanItem[], locale: PlanMarkdownLocale, copy: Copy): string[] { + const visible = items.filter( + (item) => + item.diff.toPut.length + + item.diff.toDelete.length + + item.diff.preserved.length + + item.diff.preservedUnknown.length > + 0, + ); + if (visible.length === 0) return []; + const lines = [`## ${copy.permissions}`, ""]; + for (const item of visible) { + lines.push(`### ${escapeMarkdown(permissionDomain(item, locale, copy))}`, ""); + const rows: string[][] = []; + for (const tuple of item.diff.toPut) { + rows.push([ + escapeMarkdown(tupleRight(tuple)), + escapeMarkdown(tupleScope(tuple, copy)), + escapeMarkdown(copy.grants), + ]); + } + for (const tuple of item.diff.toDelete) { + rows.push([ + escapeMarkdown(tupleRight(tuple)), + escapeMarkdown(tupleScope(tuple, copy)), + escapeMarkdown(copy.revocations), + ]); + } + for (const tuple of [...item.diff.preservedUnknown, ...item.diff.preserved]) { + rows.push([ + escapeMarkdown(tupleRight(tuple)), + escapeMarkdown(tupleScope(tuple, copy)), + escapeMarkdown(copy.permissionPreserved), + ]); + } + lines.push(...table([copy.right, copy.scope, copy.effect].map(escapeMarkdown), rows), ""); + } + return lines; +} + +function renderDrift(plan: Plan, locale: PlanMarkdownLocale, copy: Copy): string[] { + const items = plan.items.filter((item) => (item.drift?.length ?? 0) > 0); + if (items.length === 0) return []; + const lines = [`## ${copy.drift}`, ""]; + for (const item of items) { + lines.push(resourceHeading(item, locale), ""); + lines.push( + ...table( + [copy.field, copy.before, copy.after].map(escapeMarkdown), + (item.drift ?? []).map((change) => [ + escapeMarkdown(fieldLabel(change.field, locale)), + renderValue(change.from, change.field, copy), + renderValue(change.to, change.field, copy), + ]), + ), + "", + ); + } + return lines; +} + +function renderAppendix(plan: Plan, locale: PlanMarkdownLocale, copy: Copy): string[] { + const rows = plan.items.map((item) => { + const safeChanges = (changes: FieldChange[] | undefined): FieldChange[] | undefined => + changes?.map((change) => + SECRET_KEY.test(change.field) + ? { ...change, from: change.from === undefined ? undefined : "[REDACTED]", to: "[REDACTED]" } + : change, + ); + const technical = { + changes: safeChanges(item.changes), + drift: safeChanges(item.drift), + preventDestroy: item.preventDestroy, + detail: item.detail, + }; + return [ + escapeMarkdown(typeLabel(item.type, locale)), + escapeMarkdown(item.key), + escapeMarkdown(item.id ?? "—"), + escapeMarkdown(ACTION_LABELS[locale][item.action]), + escapeMarkdown(item.note ?? "—"), + escapeMarkdown(stableJson(technical)), + ]; + }); + return [ + `## ${copy.appendix}`, + "", + ...table( + [copy.resourceType, copy.logicalKey, copy.id, copy.action, copy.note, copy.details].map(escapeMarkdown), + rows, + ), + "", + ]; +} + +export function renderPlanMarkdown( + plan: Plan, + permissions: PermissionPlanItem[], + context: MarkdownPlanContext, +): string { + const locale = context.locale ?? "de-DE"; + const copy = COPY[locale]; + const generatedAt = context.generatedAt ?? new Date(); + const counts = { create: 0, update: 0, delete: 0, "no-op": 0 } satisfies Record; + for (const item of plan.items) counts[item.action]++; + const perms = permissionCounts(permissions); + const incompleteItems = plan.items.filter((item) => item.note === "fetch-failed"); + const fetchErrors = context.fetchErrors ?? []; + const incomplete = incompleteItems.length > 0 || fetchErrors.length > 0; + const warnings = [...new Set((context.warnings ?? []).map(redactPlanReportSecrets))]; + + const lines: string[] = [ + `# ${copy.title}`, + "", + locale === "de-DE" + ? `Erstellt am ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "UTC" }).format(generatedAt)} UTC.` + : `Generated ${new Intl.DateTimeFormat("en", { dateStyle: "long", timeStyle: "short", timeZone: "UTC" }).format(generatedAt)} UTC.`, + "", + `> ${copy.planOnly}`, + "", + `## ${copy.context}`, + "", + ...table( + [copy.result, copy.details].map(escapeMarkdown), + [ + [copy.environment, context.environment ?? copy.none], + [copy.host, context.host], + [copy.churchToolsVersion, context.churchToolsVersion ?? copy.unknown], + [copy.config, context.configPath], + [copy.stateHost, context.stateHost], + ].map(([key, value]) => [escapeMarkdown(key), escapeMarkdown(value)]), + ), + "", + `## ${copy.summary}`, + "", + ]; + + const hasChanges = counts.create + counts.update + counts.delete + perms.grants + perms.revocations > 0; + lines.push( + `- ${copy.create}: ${counts.create}`, + `- ${copy.update}: ${counts.update}`, + `- ${copy.delete}: ${counts.delete}`, + `- ${copy.noop}: ${counts["no-op"]}`, + `- ${copy.grants}: ${perms.grants}`, + `- ${copy.revocations}: ${perms.revocations}`, + `- ${copy.preserved}: ${perms.preserved}`, + "", + ); + if (!hasChanges && !incomplete) { + lines.push(`**${copy.result}:** ${copy.noChanges}`, ""); + } else if (counts.update === 0 && counts.delete === 0 && perms.revocations === 0 && !incomplete) { + lines.push( + locale === "de-DE" + ? "**Ergebnis:** Der Plan ergänzt ausschließlich neue Ressourcen oder Berechtigungen. Bestehende Ressourcen werden nicht verändert oder gelöscht." + : "**Result:** The plan only adds resources or permissions. Existing resources are not changed or deleted.", + "", + ); + } + + const byType = new Map>(); + for (const item of plan.items) { + const current = byType.get(item.type) ?? { create: 0, update: 0, delete: 0, "no-op": 0 }; + current[item.action]++; + byType.set(item.type, current); + } + if (byType.size > 0) { + lines.push( + `### ${copy.resourceTypes}`, + "", + ...table( + [copy.resourceType, copy.create, copy.update, copy.delete, copy.noop].map(escapeMarkdown), + [...byType.entries()] + .sort(([a], [b]) => typeLabel(a, locale).localeCompare(typeLabel(b, locale), locale)) + .map(([type, value]) => [ + escapeMarkdown(typeLabel(type, locale)), + String(value.create), + String(value.update), + String(value.delete), + String(value["no-op"]), + ]), + ), + "", + ); + } + + lines.push( + ...renderResourceSection( + copy.creates, + plan.items.filter( + (item) => + item.action === "create" && + (item.type !== "group-member-field" || + !plan.items.some((group) => memberFieldsFor(group, plan).includes(item))), + ), + plan, + locale, + copy, + ), + ...renderResourceSection( + copy.updates, + plan.items.filter((item) => item.action === "update"), + plan, + locale, + copy, + ), + ); + + const deleteCandidates = plan.items.filter((item) => item.action === "delete"); + if (deleteCandidates.length > 0) { + lines.push(`## ${copy.deleteCandidates}`, "", copy.deleteSafety, ""); + for (const item of deleteCandidates) lines.push(resourceHeading(item, locale), ""); + } + + lines.push(...renderDrift(plan, locale, copy), ...renderPermissions(permissions, locale, copy)); + + if (incomplete) { + lines.push(`## ⚠️ ${copy.incompleteTitle}`, "", copy.incompleteText, ""); + const entries = [ + ...incompleteItems.map((item) => `${item.type}.${item.key}: ${item.detail ?? copy.unknown}`), + ...fetchErrors, + ]; + for (const entry of [...new Set(entries)]) + lines.push(`- ${escapeMarkdown(redactPlanReportSecrets(entry))}`); + lines.push(""); + } + + if (warnings.length > 0) { + lines.push(`## ${copy.warnings}`, ""); + for (const warning of warnings) lines.push(`- ${escapeMarkdown(warning)}`); + lines.push(""); + } + + lines.push(`## ${copy.review}`, ""); + for (const item of copy.checklist) lines.push(`- [ ] ${item}`); + lines.push("", ...renderAppendix(plan, locale, copy)); + + return `${lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd()}\n`; +} diff --git a/src/engine/plan.ts b/src/engine/plan.ts index faeed29..0517a5a 100644 --- a/src/engine/plan.ts +++ b/src/engine/plan.ts @@ -11,7 +11,13 @@ * types. Logical keys are globally unique in the state file. */ import type { State } from "../state/state.js"; -import type { DesiredResource, FieldChange, Plan, PlanItem } from "./types.js"; +import { + resourceDisplayName, + type DesiredResource, + type FieldChange, + type Plan, + type PlanItem, +} from "./types.js"; import { orderKeys, isKnownType } from "./graph.js"; import { RESOURCES } from "../resources/registry.js"; @@ -177,6 +183,7 @@ export function computePlan( creates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: null, action: "create", changes: attributeCreate(diffFields(d.fields, {})), @@ -196,6 +203,7 @@ export function computePlan( updates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: managed.id, action: "no-op", changes: [], @@ -209,6 +217,7 @@ export function computePlan( updates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: managed.id, action: "no-op", changes: [], @@ -223,6 +232,7 @@ export function computePlan( creates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: null, action: "create", changes: attributeCreate(diffFields(d.fields, {})), @@ -239,6 +249,7 @@ export function computePlan( updates.push({ type: d.type, key: d.key, + displayName: resourceDisplayName(d.fields, d.key), id: managed.id, action: changes.length > 0 ? "update" : "no-op", changes: attributeChanges(changes, managed.fields, driftedFields), @@ -257,6 +268,7 @@ export function computePlan( deletes.push({ type: managed.type, key: managed.key, + displayName: resourceDisplayName(managed.fields, managed.key), id: managed.id, action: "no-op", changes: [], @@ -269,6 +281,7 @@ export function computePlan( deletes.push({ type: managed.type, key: managed.key, + displayName: resourceDisplayName(managed.fields, managed.key), id: managed.id, action: "no-op", changes: [], @@ -283,6 +296,7 @@ export function computePlan( deletes.push({ type: managed.type, key: managed.key, + displayName: resourceDisplayName(managed.fields, managed.key), id: managed.id, action: "no-op", changes: [], @@ -290,7 +304,14 @@ export function computePlan( }); continue; } - deletes.push({ type: managed.type, key: managed.key, id: managed.id, action: "delete", changes: [] }); + deletes.push({ + type: managed.type, + key: managed.key, + displayName: resourceDisplayName(managed.fields, managed.key), + id: managed.id, + action: "delete", + changes: [], + }); } const rank = new Map(orderKeys(desired).map((key, i) => [key, i])); diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index c742d5f..6289f90 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -95,6 +95,8 @@ export interface SyntheticFoldResult { desired: DesiredResource[]; errors: string[]; unreadable?: string[]; + /** Informational safety/portability warnings to embed in non-terminal projections. */ + warnings?: string[]; } export interface SyntheticField { @@ -471,6 +473,7 @@ const dynamicField: SyntheticField = { const errors = perGroupOutcome.flatMap((o) => o.errors); const unreadable = perGroupOutcome.flatMap((o) => o.unreadable); const unreadableKeys = new Set(unreadable); + const warnings: string[] = []; const augmented = desired.map((d) => { if (d.type !== "group" || d.dynamic === undefined) return d; // Actual side unknown → leave the desired side unfolded so nothing diffs. `buildPlan` turns @@ -490,16 +493,18 @@ const dynamicField: SyntheticField = { // someone notices the membership. Warn, never fail: the numeric form stays a valid escape hatch. const unportable = scanUnportablized(resolvedRuleset); if (unportable.length > 0) { - warn( + const headline = `dynamic group "${d.key}": ruleset carries ${unportable.length} host-specific id(s) — ` + - `not portable to another instance:`, - ); - for (const line of formatPortablizeWarnings(unportable)) info(` ${line}`); + `not portable to another instance:`; + const details = formatPortablizeWarnings(unportable); + warnings.push(headline, ...details); + warn(headline); + for (const line of details) info(` ${line}`); } const dynamic = normalizeDynamic({ status: d.dynamic.status, ruleset: resolvedRuleset }); return { ...d, fields: { ...d.fields, dynamic } }; }); - return { desired: augmented, errors, unreadable }; + return { desired: augmented, errors, unreadable, ...(warnings.length > 0 ? { warnings } : {}) }; }, async apply({ client, id, change }) { const to = change.to as { status: DynamicStatus; ruleset: Record } | undefined; @@ -593,15 +598,17 @@ export async function runPostApplyHooks( /** Run every registered fold in order, threading the (immutably) augmented desired through each. */ export async function foldSynthetic( ctx: SyntheticFoldCtx, -): Promise<{ desired: DesiredResource[]; errors: string[]; unreadable: Set }> { +): Promise<{ desired: DesiredResource[]; errors: string[]; unreadable: Set; warnings?: string[] }> { let desired = ctx.desired; const errors: string[] = []; const unreadable = new Set(); + const warnings: string[] = []; for (const f of SYNTHETIC_FIELDS) { const res = await f.fold({ ...ctx, desired }); desired = res.desired; errors.push(...res.errors); + warnings.push(...(res.warnings ?? [])); for (const key of res.unreadable ?? []) unreadable.add(key); } - return { desired, errors, unreadable }; + return { desired, errors, unreadable, ...(warnings.length > 0 ? { warnings } : {}) }; } diff --git a/src/engine/types.ts b/src/engine/types.ts index 250a14a..9d2361e 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -84,6 +84,8 @@ export interface FieldChange { export interface PlanItem { type: string; key: string; + /** Best available human-facing name, derived once from the desired/state field bag. */ + displayName?: string; /** CT id when known (updates/deletes); null for creates. */ id: number | null; action: PlanAction; @@ -115,6 +117,15 @@ export interface PlanItem { allowDuplicateName?: boolean; } +/** Pick a stable human-facing label without coupling renderers to resource-specific branches. */ +export function resourceDisplayName(fields: Record, fallback: string): string { + for (const field of ["name", "nameTranslated", "shorty", "title", "label"]) { + const value = fields[field]; + if (typeof value === "string" && value.trim() !== "") return value.trim(); + } + return fallback; +} + export interface Plan { /** Items in execution order: creates/updates in dependency order, deletes in reverse. */ items: PlanItem[]; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 310fd44..19c6075 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -49,6 +49,12 @@ describe("ct program", () => { } }); + it("registers the plan format, locale and sidecar output options (#144)", () => { + const cmd = buildProgram().commands.find((candidate) => candidate.name() === "plan")!; + const options = cmd.options.map((option) => option.long); + expect(options).toEqual(expect.arrayContaining(["--format", "--output-base", "--locale", "--json"])); + }); + it("registers --env on auth status and auth logout (#117)", () => { const auth = buildProgram().commands.find((c) => c.name() === "auth")!; for (const name of ["status", "logout"]) { diff --git a/tests/plan-env-command.test.ts b/tests/plan-env-command.test.ts index 24dd76e..64b7d0d 100644 --- a/tests/plan-env-command.test.ts +++ b/tests/plan-env-command.test.ts @@ -8,7 +8,7 @@ * against a DIFFERENT host is rejected by the state host-check. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { writeFile, rm } from "node:fs/promises"; +import { readFile, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Plan } from "../src/engine/types.js"; @@ -44,6 +44,7 @@ vi.mock("../src/permissions/plan.js", () => ({ })); const { planCommand } = await import("../src/commands/plan.js"); +const { buildPlan } = await import("../src/engine/build.js"); const { saveState, emptyState } = await import("../src/state/state.js"); const DEV = "https://mychurch-dev.church.tools"; @@ -56,12 +57,15 @@ const saved = { host: process.env.CT_HOST, envs: process.env.CT_ENVS }; let stderr = ""; let stderrSpy: { mockRestore: () => void }; +let stdout = ""; +let stdoutSpy: { mockRestore: () => void }; async function runPlan(args: string[]): Promise { await planCommand().parseAsync(args, { from: "user" }); } beforeEach(async () => { + vi.mocked(buildPlan).mockClear(); delete process.env.CT_HOST; process.env.CT_ENVS = envsPath; await writeFile( @@ -81,10 +85,16 @@ beforeEach(async () => { stderr += String(chunk); return true; }) as (typeof process.stderr)["write"]); + stdout = ""; + stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => { + stdout += String(chunk); + return true; + }) as (typeof process.stdout)["write"]); }); afterEach(async () => { stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); if (saved.host === undefined) delete process.env.CT_HOST; else process.env.CT_HOST = saved.host; if (saved.envs === undefined) delete process.env.CT_ENVS; @@ -111,6 +121,45 @@ describe("ct plan --env", () => { expect(stderr).toContain(`state host: ${PROD}`); }); + it("renders the same env-bound plan as a self-contained Markdown report (#144)", async () => { + await runPlan(["--env", "dev", "--format", "markdown", "--locale", "de-DE"]); + expect(stdout).toContain("# ChurchTools-Änderungsplan"); + expect(stdout).toContain("> Dieser Bericht beschreibt nur den Plan."); + expect(stdout).toContain(`| ChurchTools-Instanz | ${DEV} |`); + expect(stdout).toContain("| Umgebung | dev |"); + expect(stdout).toContain("ChurchTools-Version | 3.100.0"); + expect(stdout).toContain("Es sind keine Änderungen erforderlich"); + }); + + it("writes text, JSON and Markdown sidecars after one plan computation (#144)", async () => { + const outputBase = join(tmpdir(), `ct-cli-planenv-report-${process.pid}`); + try { + await runPlan([ + "--env", + "dev", + "--format", + "text", + "--format", + "json", + "--format", + "markdown", + "--output-base", + outputBase, + ]); + expect(buildPlan).toHaveBeenCalledTimes(1); + expect(stdout).toBe(""); + expect(await readFile(`${outputBase}.txt`, "utf8")).toContain("No changes"); + expect(JSON.parse(await readFile(`${outputBase}.json`, "utf8"))).toMatchObject({ + summary: { hasChanges: false }, + }); + expect(await readFile(`${outputBase}.md`, "utf8")).toContain("# ChurchTools-Änderungsplan"); + } finally { + await Promise.all( + ["txt", "json", "md"].map((extension) => rm(`${outputBase}.${extension}`, { force: true })), + ); + } + }); + it("refuses when an env's state file was recorded against a different host (no cross-contamination)", async () => { // Point prod's profile at dev's state file: prod host vs a dev-bound state file must be rejected. await writeFile( diff --git a/tests/plan-markdown.test.ts b/tests/plan-markdown.test.ts new file mode 100644 index 0000000..1f3fdfa --- /dev/null +++ b/tests/plan-markdown.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from "vitest"; +import { planOutputTargets, parsePlanLocale } from "../src/commands/plan.js"; +import { escapeMarkdown, redactPlanReportSecrets, renderPlanMarkdown } from "../src/engine/markdown.js"; +import type { Plan } from "../src/engine/types.js"; +import type { PermissionPlanItem } from "../src/permissions/plan.js"; + +const context = { + environment: "test", + host: "https://example.church.tools", + churchToolsVersion: "3.135.2", + configPath: "ct.config.ts", + stateHost: "https://example.church.tools", + generatedAt: new Date("2026-08-24T12:34:00.000Z"), + locale: "de-DE" as const, +}; + +const permission = (overrides: Partial = {}): PermissionPlanItem => ({ + key: "team_lead", + domainType: "group_type_role", + domainId: 8, + diff: { + toPut: [{ authId: 1113, dataId: [], type: "grant" }], + toDelete: [{ authId: 1104, dataId: [42], type: "grant" }], + preserved: [{ authId: 100, dataId: [], type: "revoke" }], + preservedUnknown: [{ authId: 113, dataId: [7], type: "grant" }], + }, + ...overrides, +}); + +describe("plan output targets", () => { + it("keeps the default and --json stdout behavior backward compatible", () => { + expect(planOutputTargets({})).toEqual([{ format: "text", path: undefined }]); + expect(planOutputTargets({ json: true })).toEqual([{ format: "json", path: undefined }]); + }); + + it("derives deterministic sidecar names while computing the plan only once", () => { + expect( + planOutputTargets({ + format: ["text", "json", "markdown"], + outputBase: "reports/plan-prod", + }), + ).toEqual([ + { format: "text", path: "reports/plan-prod.txt" }, + { format: "json", path: "reports/plan-prod.json" }, + { format: "markdown", path: "reports/plan-prod.md" }, + ]); + expect(planOutputTargets({ format: ["markdown"], outputBase: "reports/plan.md" })).toEqual([ + { format: "markdown", path: "reports/plan.md" }, + ]); + }); + + it("rejects ambiguous or unsupported output selections before planning", () => { + expect(() => planOutputTargets({ json: true, format: ["markdown"] })).toThrow("cannot be combined"); + expect(() => planOutputTargets({ format: ["text", "markdown"] })).toThrow("require --output-base"); + expect(() => planOutputTargets({ outputBase: "plan" })).toThrow("requires"); + expect(() => planOutputTargets({ format: ["pdf"] })).toThrow("Unknown plan format"); + expect(() => parsePlanLocale("fr")).toThrow("Available locales"); + }); +}); + +describe("plain-language Markdown plan", () => { + it("renders the proven consumer-report structure for an unchanged plan", () => { + const output = renderPlanMarkdown({ items: [] }, [], context); + expect(output).toContain("# ChurchTools-Änderungsplan"); + expect(output).toContain("> Dieser Bericht beschreibt nur den Plan."); + expect(output).toContain("## Zusammenfassung"); + expect(output).toContain("**Ergebnis:** Es sind keine Änderungen erforderlich"); + expect(output).toContain("## Prüfung vor dem Anwenden"); + }); + + it("explains mixed resources, automation, member fields, drift, deletes and permissions", () => { + const plan: Plan = { + items: [ + { + type: "group-type", + key: "team", + displayName: "Team", + id: 2, + action: "no-op", + changes: [], + }, + { + type: "group", + key: "source", + displayName: "Quellgruppe", + id: 42, + action: "no-op", + changes: [], + }, + { + type: "group", + key: "new_team", + displayName: "Neues | Team", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Neues | Team", source: "config" }, + { field: "groupTypeId", from: undefined, to: 2, source: "config" }, + { field: "groupStatusId", from: undefined, to: 1, source: "config" }, + { field: "parents", from: undefined, to: ["source"], source: "config" }, + { + field: "dynamic", + from: undefined, + to: { + status: "active", + ruleset: { + query: { "==": [{ var: "ctgroup.id" }, 42] }, + process: { queryResultOnly: { none: { handleMembership: { groupTypeRoleId: 9 } } } }, + }, + }, + source: "config", + }, + ], + }, + { + type: "group-member-field", + key: "new_team::consent", + displayName: "Einwilligung", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Einwilligung", source: "config" }, + { field: "referenceName", from: undefined, to: "consent", source: "config" }, + { field: "fieldTypeCode", from: undefined, to: "checkbox", source: "config" }, + { field: "useInRegistrationForm", from: undefined, to: true, source: "config" }, + { field: "requiredInRegistrationForm", from: undefined, to: true, source: "config" }, + { field: "securityLevel", from: undefined, to: 2, source: "config" }, + ], + }, + { + type: "campus", + key: "mainz", + displayName: "Mainz", + id: 1, + action: "update", + changes: [{ field: "name", from: "MZ", to: "Mainz", source: "config+drift" }], + drift: [{ field: "name", from: "Mainz alt", to: "MZ" }], + }, + { + type: "future-type", + key: "old_entry", + displayName: "Alt `intern`", + id: 99, + action: "delete", + changes: [], + }, + ], + }; + + const output = renderPlanMarkdown(plan, [permission()], context); + expect(output).toContain("## Neue Ressourcen"); + expect(output).toContain("### Neues \\| Team"); + expect(output).toContain("Quelle der Teilnehmenden: Quellgruppe"); + expect(output).toContain("Neue Mitgliedschaft verwendet die aufgelöste Rollen-ID 9"); + expect(output).toContain("Einwilligung"); + expect(output).toContain("sichtbar, Pflichtfeld"); + expect(output).toContain("Konfiguration und ChurchTools wurden unabhängig geändert"); + expect(output).toContain("## Erkannte manuelle Änderungen"); + expect(output).toContain("`ct apply` löscht diese Ressourcen **nicht**"); + expect(output).toContain("## Berechtigungen"); + expect(output).toContain("Zu entziehen"); + expect(output).toContain("wird nicht verwaltet und bleibt erhalten"); + expect(output).toContain("future-type"); + expect(output).toContain("Alt `intern`"); + expect(output).not.toContain("[object Object]"); + }); + + it("marks incomplete plans as blocking and includes deduplicated warnings", () => { + const plan: Plan = { + items: [ + { + type: "group", + key: "broken", + displayName: "Nicht lesbar", + id: 7, + action: "no-op", + changes: [], + note: "fetch-failed", + detail: "HTTP 500", + }, + ], + }; + const output = renderPlanMarkdown(plan, [], { + ...context, + warnings: ["Katalog veraltet", "Katalog veraltet"], + fetchErrors: ["group.broken: Serverfehler"], + }); + expect(output).toContain("## ⚠️ Unvollständiger Plan"); + expect(output).toContain("darf nicht als Freigabe"); + expect(output.match(/Katalog veraltet/g)).toHaveLength(1); + }); + + it("escapes Markdown and redacts likely secrets in every fallback", () => { + expect(escapeMarkdown("A|B\n`x` #1")).toBe("A\\|B
`x` \\#1"); + expect(redactPlanReportSecrets("https://user:pass@example.test?a=1&login_token=secret")).toBe( + "https://[REDACTED]@example.test?a=1&login_token=[REDACTED]", + ); + const output = renderPlanMarkdown( + { + items: [ + { + type: "future", + key: "secret", + id: null, + action: "create", + changes: [ + { field: "apiToken", from: undefined, to: "must-not-leak" }, + { field: "payload", from: undefined, to: { password: "hidden", visible: "ok" } }, + ], + }, + ], + }, + [], + context, + ); + expect(output).not.toContain("must-not-leak"); + expect(output).not.toContain("hidden"); + expect(output).toContain("REDACTED"); + }); + + it("shows the before/after table for a group update instead of a bare heading", () => { + const plan: Plan = { + items: [ + { + type: "group", + key: "youth", + displayName: "Jugend", + id: 7, + action: "update", + changes: [{ field: "name", from: "Jugendkreis", to: "Jugend", source: "config" }], + }, + ], + }; + const output = renderPlanMarkdown(plan, [], context); + expect(output).toContain("## Geänderte Ressourcen"); + expect(output).toContain("### Jugend"); + expect(output).toContain("Jugendkreis"); + expect(output).toContain("| Feld |"); + }); + + it("renders a new member field on an existing group in its own section", () => { + const plan: Plan = { + items: [ + { type: "group", key: "youth", displayName: "Jugend", id: 7, action: "no-op", changes: [] }, + { + type: "group-member-field", + key: "youth::consent", + displayName: "Einwilligung", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Einwilligung", source: "config" }, + { field: "referenceName", from: undefined, to: "consent", source: "config" }, + ], + }, + ], + }; + const output = renderPlanMarkdown(plan, [], context); + expect(output).toContain("## Neue Ressourcen"); + expect(output).toContain("Einwilligung"); + }); + + it("names a group type that is created in the same run instead of printing the raw ref", () => { + const plan: Plan = { + items: [ + { type: "group-type", key: "team", displayName: "Team", id: null, action: "create", changes: [] }, + { + type: "group", + key: "new_team", + displayName: "Neues Team", + id: null, + action: "create", + changes: [ + { field: "name", from: undefined, to: "Neues Team", source: "config" }, + { + field: "groupTypeId", + from: undefined, + to: { __pendingRef: { kind: "group-type", key: "team" } }, + source: "config", + }, + ], + }, + ], + }; + const output = renderPlanMarkdown(plan, [], context); + expect(output).toContain("Gruppentyp: Team (wird beim Anwenden erzeugt)"); + expect(output).not.toContain("[object Object]"); + expect(output).not.toContain("unbekannt (#"); + }); + + it("is byte-identical for a fixed clock and supports English without changing plan semantics", () => { + const plan: Plan = { + items: [{ type: "campus", key: "mainz", displayName: "Mainz", id: 1, action: "no-op", changes: [] }], + }; + expect(renderPlanMarkdown(plan, [], context)).toBe(renderPlanMarkdown(plan, [], context)); + const english = renderPlanMarkdown(plan, [], { ...context, locale: "en" }); + expect(english).toContain("# ChurchTools change plan"); + expect(english).toContain("No changes are required"); + }); +}); diff --git a/tests/synthetic-dynamic.test.ts b/tests/synthetic-dynamic.test.ts index bb76c2d..267abdb 100644 --- a/tests/synthetic-dynamic.test.ts +++ b/tests/synthetic-dynamic.test.ts @@ -482,14 +482,16 @@ describe("dynamic synthetic field — un-portablized ruleset reporting (#101)", errs.push(String(s)); return true; }); + let result: Awaited["fold"]>> | undefined; try { - await dynamicField().fold({ client: getClient(client), state, desired, actual }); + result = await dynamicField().fold({ client: getClient(client), state, desired, actual }); } finally { spy.mockRestore(); } const out = errs.join(""); expect(out).toContain('dynamic group "g": ruleset carries 1 host-specific id(s)'); expect(out).toMatch(/ctgroup\.id: 1246 left numeric/); + expect(result?.warnings?.join("\n")).toContain("ctgroup.id: 1246 left numeric"); }); it("stays silent for a fully portable ruleset — the warning must mean something", async () => {