Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key> # make ChurchTools re-evaluate one auto-group now
```
Expand Down Expand Up @@ -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)

Expand Down
73 changes: 73 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/handbuch/dynamic-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
2 changes: 1 addition & 1 deletion docs/handbuch/group-member-fields.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sources_hash: 00dca3c03dacc9ff
sources_hash: a5335e766218b514
title: Group member fields
sources:
- src/engine/member-fields.ts
Expand Down
165 changes: 130 additions & 35 deletions src/commands/plan.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -7,32 +9,112 @@ 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<PlanFormat, string> = { 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<PlanOptions, "json" | "format" | "outputBase">,
): 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)")
.option("-c, --config <path>", "config file (or set CT_CONFIG)")
.option("-s, --state <path>", "state file (or set CT_STATE)")
.option("-e, --env <name>", "environment profile from ct.envs.json (host + state + token)")
.option("--json", "emit the raw plan as JSON instead of the rendered diff")
.option(
"--format <format>",
"output format; repeat for multiple projections: text, json, markdown",
collectFormat,
[],
)
.option("--output-base <path>", "write selected formats as <path>.txt/.json/.md")
.option("--locale <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();
Expand All @@ -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 }),
Expand All @@ -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);
}
}

Expand Down
Loading
Loading