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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,30 @@ npm link # optional: puts `ct` on your PATH
```bash
export CT_HOST=https://eqrm.church.tools # default; override for a test instance

ct auth login --token <personal-login-token> # stored at ~/.config/ct-cli/credentials.json (0600)
ct auth login --token <personal-login-token> # stored in the macOS Keychain
ct auth status # who am I?

ct get campuses # JSON to stdout — pipe into jq
ct get groups
ct get group-types
ct get raw /groups/42 # arbitrary GET

ct plan | apply | adopt | destroy # not yet implemented — see phase issues
ct adopt campus 0 # bring an existing resource under management (→ state file)
ct state list # show the managed set
ct plan # diff the desired-state config against ChurchTools (read-only)
ct plan --json # the raw plan as JSON

ct apply | destroy # not yet implemented — see phase issues
```

The desired state lives in a config file (default `ct.config.ts`) that
default-exports a function receiving the DSL:

```ts
export default (ct) => {
ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" });
ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", parent: "mainz_area" });
};
```

Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"dependencies": {
"commander": "^12.1.0",
"jiti": "^2.4.0",
"openapi-fetch": "^0.13.0",
"picocolors": "^1.1.0"
},
Expand Down
1 change: 0 additions & 1 deletion src/commands/placeholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ interface Planned {
}

const PLANNED: Planned[] = [
{ name: "plan", description: "Show the diff between desired state and ChurchTools", issue: "Phase 3 (#5)" },
{ name: "apply", description: "Apply the plan (idempotent, in dependency order)", issue: "Phase 4 (#6)" },
{ name: "destroy", description: "Explicitly remove managed resources (protected)", issue: "Phase 4 (#6)" },
];
Expand Down
81 changes: 81 additions & 0 deletions src/commands/plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Command } from "commander";
import { authedSession } from "../api/session.js";
import { CtApiError } from "../api/ctClient.js";
import { resolveConfig } from "../config.js";
import { RESOURCES } from "../resources/registry.js";
import { loadState, resolveStatePath } from "../state/state.js";
import { loadConfig, resolveConfigPath } from "../config/load.js";
import { computePlan } from "../engine/plan.js";
import { renderPlan } from "../engine/render.js";
import { mapConcurrent } from "../util/concurrency.js";
import { info, warn, out } from "../ui.js";

interface PlanOptions {
config?: string;
state?: string;
json?: boolean;
}

/** How many managed resources to fetch from ChurchTools at once. */
const FETCH_CONCURRENCY = 8;

export function planCommand(): Command {
return new Command("plan")
.description("Show the diff between the desired-state config and ChurchTools (read-only)")
.option("-c, --config <path>", "config file (or set CT_CONFIG)")
.option("-s, --state <path>", "state file (or set CT_STATE)")
.option("--json", "emit the raw plan as JSON instead of the rendered diff")
.action(async (opts: PlanOptions) => {
const config = resolveConfig();
const configPath = resolveConfigPath(opts.config);
const desired = await loadConfig(configPath);
const state = await loadState(resolveStatePath(opts.state), config.host);
if (state.host !== config.host) {
throw new Error(`State host (${state.host}) does not match CT_HOST (${config.host}).`);
}

const { client } = await authedSession();
// Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0).
const actual = new Map<string, Record<string, unknown>>();
const unresolved = new Set<string>();
const fetchErrors: string[] = [];

await mapConcurrent(Object.values(state.resources), FETCH_CONCURRENCY, async (managed) => {
const spec = RESOURCES[managed.type];
if (!spec) {
unresolved.add(managed.key);
warn(
`No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`,
);
return;
}
try {
const raw = await client.get<Record<string, unknown>>(spec.itemPath(managed.id));
actual.set(managed.key, spec.managedFields(raw));
} catch (err) {
if (err instanceof CtApiError && err.status === 404) {
return; // vanished in CT — the plan will propose recreating (or pruning) it
}
// A read-only plan should not abort on one bad fetch: record it, keep going, flag the plan as partial.
const message = err instanceof Error ? err.message : String(err);
fetchErrors.push(`${managed.type}.${managed.key} (#${managed.id}): ${message}`);
warn(`Failed to fetch ${managed.type}.${managed.key} (#${managed.id}): ${message}`);
}
});

const plan = computePlan(desired, state, actual, { unresolved });
if (opts.json) {
out(plan);
} else {
info(`config: ${configPath} · state host: ${state.host}`);
process.stdout.write(`${renderPlan(plan)}\n`);
}

if (fetchErrors.length > 0) {
warn(
`Plan is INCOMPLETE — ${fetchErrors.length} resource(s) could not be fetched; their diff is missing. Re-run to retry.`,
);
process.exitCode = 1;
}
});
}
76 changes: 76 additions & 0 deletions src/config/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* The config DSL. A config file default-exports a function that receives this
* context and declares resources:
*
* export default (ct: ConfigContext) => {
* ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" });
* ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", parent: "mainz_area" });
* };
*
* The context is injected (no global state), so blueprints are just functions
* and loops, and the whole thing is trivially testable without file I/O.
*/
import type { DesiredResource } from "../engine/types.js";

export interface ResourceInput {
key: string;
parent?: string;
dependsOn?: string[];
[field: string]: unknown;
}

export interface ConfigContext {
campus(input: ResourceInput): void;
group(input: ResourceInput): void;
groupType(input: ResourceInput): void;
ageGroup(input: ResourceInput): void;
targetGroup(input: ResourceInput): void;
relationshipType(input: ResourceInput): void;
}

export type ConfigModule = (ct: ConfigContext) => void | Promise<void>;

function toDesired(type: string, input: ResourceInput): DesiredResource {
const { key, parent, dependsOn = [], ...fields } = input;
if (!key || typeof key !== "string") {
throw new Error(`${type} declaration is missing a string "key".`);
}
const edges = [...dependsOn];
if (parent) {
edges.push(parent);
}
return { type, key, fields, parent, dependsOn: edges };
}

export function createContext(): { ct: ConfigContext; resources: DesiredResource[] } {
const resources: DesiredResource[] = [];
const seen = new Set<string>();
const define =
(type: string) =>
(input: ResourceInput): void => {
const resource = toDesired(type, input);
if (seen.has(resource.key)) {
throw new Error(`Duplicate logical key "${resource.key}" in config.`);
}
seen.add(resource.key);
resources.push(resource);
};
// Every type emitted here MUST have an apply tier in engine/graph.ts TYPE_TIER
// (locked by tests/context.test.ts), else computePlan rejects it at plan time.
const ct: ConfigContext = {
campus: define("campus"),
group: define("group"),
groupType: define("group-type"),
ageGroup: define("age-group"),
targetGroup: define("target-group"),
relationshipType: define("relationship-type"),
};
return { ct, resources };
}

/** Run a loaded config module against a fresh context and collect its resources. */
export async function evaluateConfig(mod: ConfigModule): Promise<DesiredResource[]> {
const { ct, resources } = createContext();
await mod(ct);
return resources;
}
38 changes: 38 additions & 0 deletions src/config/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Load a desired-state config file (`.ts`, `.mjs`, or `.js`) at runtime.
*
* `jiti` transpiles TypeScript on the fly so the compiled CLI can import a
* user's `.ts` config directly. The config must default-export a
* {@link ConfigModule}; we run it against a fresh context and return the
* declared resources.
*/
import { access } from "node:fs/promises";
import { resolve } from "node:path";
import { createJiti } from "jiti";
import { evaluateConfig, type ConfigModule } from "./context.js";
import type { DesiredResource } from "../engine/types.js";

export const DEFAULT_CONFIG_PATH = "ct.config.ts";

export function resolveConfigPath(explicit?: string, env: NodeJS.ProcessEnv = process.env): string {
return explicit?.trim() || env.CT_CONFIG?.trim() || DEFAULT_CONFIG_PATH;
}

export async function loadConfig(path: string): Promise<DesiredResource[]> {
const resolved = resolve(path);
// Surface a friendly message rather than jiti's raw ERR_MODULE_NOT_FOUND stack.
try {
await access(resolved);
} catch {
throw new Error(
`Config file not found: ${path} (default: ${DEFAULT_CONFIG_PATH}). ` +
`Create it — it must default-export a function (ct) => { ... }.`,
);
}
const jiti = createJiti(import.meta.url, { moduleCache: false });
const mod = await jiti.import<ConfigModule>(resolved, { default: true });
if (typeof mod !== "function") {
throw new Error(`Config ${path} must default-export a function (ct) => { ... }.`);
}
return evaluateConfig(mod);
}
89 changes: 89 additions & 0 deletions src/engine/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Dependency ordering for apply/destroy.
*
* Apply order follows the ChurchTools dependency tiers: base metadata first,
* then groups, then the things that reference groups (hierarchy links, roles,
* permissions), then dynamic-group rulesets. Within a tier, explicit
* dependencies (a group's parent) are honoured via topological sort. Destroy
* runs in the exact reverse.
*/
import type { DesiredResource } from "./types.js";

/** Lower tier is applied first. Delete runs highest tier first. */
export const TYPE_TIER: Record<string, number> = {
campus: 0,
"group-type": 0,
"group-status": 0,
"age-group": 0,
"target-group": 0,
"relationship-type": 0,
group: 1,
"group-hierarchy": 2,
"group-role": 3,
permission: 4,
"dynamic-group": 5,
};

export function tierOf(type: string): number {
return TYPE_TIER[type] ?? 0;
}

/**
* Whether `type` has a declared apply tier. Every type the config DSL can emit
* MUST be here (locked by a test), so an unknown type is a registration bug —
* not something to silently order as tier 0.
*/
export function isKnownType(type: string): boolean {
return Object.prototype.hasOwnProperty.call(TYPE_TIER, type);
}

/**
* Return logical keys in apply order. Stable: ties break by tier, then by the
* original declaration order. Throws on a dependency cycle.
*/
export function orderKeys(resources: DesiredResource[]): string[] {
const byKey = new Map(resources.map((r, i) => [r.key, { r, i }]));
const indegree = new Map<string, number>(resources.map((r) => [r.key, 0]));
const successors = new Map<string, string[]>(resources.map((r) => [r.key, []]));

for (const r of resources) {
for (const dep of r.dependsOn) {
if (!byKey.has(dep)) {
continue; // dependency outside the managed set — nothing to order against
}
successors.get(dep)!.push(r.key);
indegree.set(r.key, (indegree.get(r.key) ?? 0) + 1);
}
}

const ready = resources.filter((r) => (indegree.get(r.key) ?? 0) === 0).map((r) => r.key);
const order: string[] = [];

const priority = (key: string): [number, number] => {
const entry = byKey.get(key)!;
return [tierOf(entry.r.type), entry.i];
};

while (ready.length > 0) {
ready.sort((a, b) => {
const [ta, ia] = priority(a);
const [tb, ib] = priority(b);
return ta - tb || ia - ib;
});
const key = ready.shift()!;
order.push(key);
for (const next of successors.get(key)!) {
const remaining = (indegree.get(next) ?? 0) - 1;
indegree.set(next, remaining);
if (remaining === 0) {
ready.push(next);
}
}
}

if (order.length !== resources.length) {
const cyclic = resources.map((r) => r.key).filter((k) => !order.includes(k));
throw new Error(`Dependency cycle among: ${cyclic.join(", ")}`);
}
return order;
}
Loading
Loading