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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dist/

# Local ChurchTools state / adopted config (belongs in eqrm/ct-structure, never here)
*.ctstate.json
ct-state.json

# Generated typed client (regenerated from the live OpenAPI spec)
src/api/schema.d.ts
Expand Down
62 changes: 62 additions & 0 deletions src/commands/adopt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Command } from "commander";
import { authedSession } from "../api/session.js";
import { resolveConfig } from "../config.js";
import { resourceType, configSnippet } from "../resources/registry.js";
import { loadState, saveState, resolveStatePath, upsert } from "../state/state.js";
import { success, info, warn, out } from "../ui.js";

interface AdoptOptions {
key?: string;
state?: string;
dryRun?: boolean;
}

export function adoptCommand(): Command {
return new Command("adopt")
.description("Put one existing ChurchTools resource under management (adds it to the state file)")
.argument("<type>", "resource type, e.g. campus | group | group-type")
.argument("<id>", "ChurchTools id of the resource")
.option("-k, --key <key>", "logical key (defaults to a slug of the resource name)")
.option("-s, --state <path>", "state file path (or set CT_STATE)")
.option("--dry-run", "preview the config entry and state change without writing")
.action(async (type: string, rawId: string, opts: AdoptOptions) => {
const spec = resourceType(type);
if (!/^\d+$/.test(rawId.trim())) {
throw new Error(`Invalid id "${rawId}" — expected a non-negative integer.`);
}
const id = Number.parseInt(rawId, 10);

// Load + validate the state file (host guard included) BEFORE any network
// call, so a state file recorded against another instance never triggers a
// live authenticated request against the wrong ChurchTools host.
const config = resolveConfig();
const statePath = resolveStatePath(opts.state);
const state = await loadState(statePath, config.host);

const { client } = await authedSession();
const resource = await client.get<Record<string, unknown>>(spec.itemPath(id));

const key = opts.key?.trim() || spec.deriveKey(resource);
if (!key) {
throw new Error("Could not derive a logical key — pass --key explicitly.");
}
const fields = spec.managedFields(resource);
const snippet = configSnippet(type, key, fields);

if (opts.dryRun) {
info(`Would adopt ${type} #${id} as "${key}". Generated config entry:`);
out({ key, type, id, fields, config: snippet });
return;
}

const now = new Date().toISOString();
const action = upsert(state, { type, id, key, fields }, now);
await saveState(statePath, state);

success(`${action === "created" ? "Adopted" : "Updated"} ${type} #${id} as "${key}" → ${statePath}`);
info(`Config entry: ${snippet}`);
if (action === "updated") {
warn("This resource was already managed — its snapshot was refreshed.");
}
});
}
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: "adopt", description: "Put one existing resource under management", issue: "Phase 2 (#4)" },
{ 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
26 changes: 26 additions & 0 deletions src/commands/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Command } from "commander";
import { resolveConfig } from "../config.js";
import { loadState, resolveStatePath } from "../state/state.js";
import { info, out } from "../ui.js";

interface StateOptions {
state?: string;
}

export function stateCommand(): Command {
const cmd = new Command("state").description("Inspect the managed-resource state file");

cmd
.command("list")
.description("List every resource under management (JSON to stdout)")
.option("-s, --state <path>", "state file path (or set CT_STATE)")
.action(async (opts: StateOptions) => {
const statePath = resolveStatePath(opts.state);
const state = await loadState(statePath, resolveConfig().host);
const resources = Object.values(state.resources);
info(`${resources.length} managed resource(s) in ${statePath} (host ${state.host}).`);
out(resources);
});

return cmd;
}
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
import { Command } from "commander";
import { authCommand } from "./commands/auth.js";
import { getCommand } from "./commands/get.js";
import { adoptCommand } from "./commands/adopt.js";
import { stateCommand } from "./commands/state.js";
import { plannedCommands } from "./commands/placeholders.js";
import { isMainModule } from "./isMain.js";
import { error } from "./ui.js";

export function buildProgram(): Command {
Expand All @@ -17,6 +20,8 @@ export function buildProgram(): Command {

program.addCommand(authCommand());
program.addCommand(getCommand());
program.addCommand(adoptCommand());
program.addCommand(stateCommand());
for (const cmd of plannedCommands()) {
program.addCommand(cmd);
}
Expand All @@ -35,6 +40,6 @@ async function main(): Promise<void> {
}

/** Only run when invoked as the binary — importing this module (tests) must not parse argv. */
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
if (isMainModule(process.argv[1], import.meta.url)) {
void main();
}
32 changes: 32 additions & 0 deletions src/isMain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Whether this module is the process entrypoint (i.e. run as the `ct` binary),
* as opposed to being imported (e.g. by tests).
*
* The naive `import.meta.url === "file://" + process.argv[1]` check breaks when
* the binary is invoked through a symlink — npm link, a global install, or a
* Homebrew shim all leave `argv[1]` as the symlink path while `import.meta.url`
* is the module's realpath. Resolving both to their realpath makes the
* comparison robust; each side falls back to its raw path if resolution fails,
* so an unresolvable argv1 still gets compared against the module's realpath.
*/
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";

export function isMainModule(
argv1: string | undefined,
metaUrl: string,
realpath: (p: string) => string = realpathSync,
): boolean {
if (!argv1) {
return false;
}
const modulePath = fileURLToPath(metaUrl);
const resolve = (p: string): string => {
try {
return realpath(p);
} catch {
return p;
}
};
return resolve(argv1) === resolve(modulePath);
}
93 changes: 93 additions & 0 deletions src/resources/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Registry of adoptable ChurchTools resource types.
*
* Each entry knows how to fetch one resource by id, derive a stable logical key,
* snapshot the fields we manage, and render a config snippet (the TS-as-code
* form the Phase 3 engine will consume). Paths come from the Phase 0 coverage
* matrix (docs/api-coverage.md). Adding a type = adding an entry here.
*/

export interface AdoptableResource {
/** GET path for a single resource by id. */
itemPath: (id: number) => string;
/** Stable logical key derived from the fetched resource. */
deriveKey: (resource: Record<string, unknown>) => string;
/** The subset of fields we manage — the desired-state baseline. */
managedFields: (resource: Record<string, unknown>) => Record<string, unknown>;
}

/** Build an `itemPath` from a collection path, so each entry names its path once. */
const item = (collectionPath: string) => (id: number) => `${collectionPath}/${id}`;

/**
* kebab/underscore slug: "Kids Leitung" → "kids_leitung", "Zürich" → "zurich".
* NFKD splits accented letters into base + combining mark; we drop the marks so
* German names (ü/ö/ä/…) slug to their base letters rather than gaining a `_`.
*/
export function slug(value: string): string {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}

function str(resource: Record<string, unknown>, key: string): string {
const value = resource[key];
return typeof value === "string" ? value : "";
}

/** Read a field, preferring a nested `information` object but falling back to the top level. */
function fromInformation(resource: Record<string, unknown>, key: string): unknown {
const information = (resource.information as Record<string, unknown> | undefined) ?? {};
return information[key] ?? resource[key];
}

export const RESOURCES: Record<string, AdoptableResource> = {
campus: {
itemPath: item("/campuses"),
deriveKey: (r) => slug(str(r, "shortName") || str(r, "name")),
managedFields: (r) => ({ name: r.name, shortName: r.shortName }),
},
group: {
itemPath: item("/groups"),
deriveKey: (r) => slug(str(r, "name")),
managedFields: (r) => ({
name: r.name,
groupTypeId: fromInformation(r, "groupTypeId"),
groupStatusId: fromInformation(r, "groupStatusId"),
}),
},
"group-type": {
itemPath: item("/group/grouptypes"),
deriveKey: (r) => slug(str(r, "name")),
managedFields: (r) => ({ name: r.name, nameTranslated: r.nameTranslated }),
},
};

export function resourceType(type: string): AdoptableResource {
const entry = RESOURCES[type];
if (!entry) {
const known = Object.keys(RESOURCES).join(", ");
throw new Error(`Unknown resource type "${type}". Adoptable types: ${known}.`);
}
return entry;
}

/** Render a config entry as a TS-as-code call, e.g. `campus({ key: "mainz", name: "Mainz" })`. */
export function configSnippet(type: string, key: string, fields: Record<string, unknown>): string {
const fn = type.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
return `${fn}(${tsObject({ key, ...fields })});`;
}

function tsObject(obj: Record<string, unknown>): string {
const parts = Object.entries(obj)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${isIdentifier(k) ? k : JSON.stringify(k)}: ${JSON.stringify(v)}`);
return `{ ${parts.join(", ")} }`;
}

function isIdentifier(key: string): boolean {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key);
}
Loading
Loading