Frozen. The TypeScript config DSL receives bugfixes only and is removed in ct-cli 5.0. Its successor is
terraform-provider-churchtools, an OpenTofu/Terraform provider that replaces the state file with tfstate and the logical-key resolver with native resource references. Its repository is not public yet; this note will link it at the provider's first release.Migrating needs no re-adoption for the resource types the provider already covers:
ct export tfgenerates HCL plusimportblocks from your existing state, so campuses, group types, departments, person statuses and comment viewers import at their current ids and plan clean. Your keys carry across (keys that are not valid HCL identifiers are relabelled, and the command reports every one). Config comments do not: the export reads state, and comments live in the TypeScript source. Groups, group roles, security levels, age groups, target groups and relationship types have no provider resource yet —ct export tfnames them and their counts, and they stay with ct until the provider covers them.
The desired state lives in a config file (default ct.config.ts) that
default-exports a function receiving the DSL:
export default (ct) => {
ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" });
// Reference master data BY NAME, not by hardcoded id: `groupType: "…"` resolves to the
// per-host group-type id at plan time, so this config is portable across instances (#20).
ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupType: "ministry_team" });
// Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared
// in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged
// groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.)
ct.group({
key: "mainz_kids_lead",
name: "Mainz · Kids Leitung",
groupType: "ministry_team",
parents: ["mainz_area"],
});
// Assign a group to a campus BY KEY: `campus: "mainz"` links to the campus above even though
// it is created in the same apply (its id is filled in at apply time). The numeric escape
// hatch still works — `campusId: 3` (or `campusId: null` to clear) targets an existing id.
ct.group({
key: "mainz_kids",
name: "Mainz · Kids",
groupType: "ministry_team",
campus: "mainz",
parents: ["mainz_kids_lead"],
});
};Every declaration carries a key — the logical name, unique across the whole
config. It is what the state file maps to a ChurchTools id, what other
declarations reference, and what shows up in ct plan output. ChurchTools ids
never appear in the config unless you deliberately put them there.
Logical fields (campus/groupType on a group, groupType on a permission)
and the inline ref.* helper compile to id-free sentinels that a per-host
resolver maps to real ChurchTools ids at plan time — sourced from resources this
tool manages, then from live master-data catalogs matched by name. So one config
file plans and applies unchanged against different instances (ids differ per
host).
An unresolvable name fails the plan with a clear error naming the reference and
where it was used. Raw numeric ids remain a valid escape hatch everywhere; see
examples/portable.config.ts
for a zero-numeric-id config.
Exception: groupStatusId (a group's lifecycle status) is numeric-only,
always. ChurchTools does expose a read catalog (GET /person/masterdata →
groupStatuses: 1 active, 2 pending, 3 archived, 4 finished), but ct does not resolve
it by name yet (#157) — so a status still cannot be resolved by name
(/group/memberstatus is a different dimension, member statuses; #67), so a
status: declaration fails fast at eval time rather than resolving against the
wrong dimension.
campusId is a managed group field: ct plan shows a campus assign/move/clear
as a normal field update, and ct adopt group <id> captures it. Which group
fields are managed vs. deliberately left to the CT UI is recorded in
group-field-decisions.md.
ChurchTools guards group creation by NAME, not by this tool's logical key —
POST /groups 400s (forbidden.duplicate.group) if a group with that name
already exists, even when the two are legitimately distinct (e.g. an archived and
an active "Kids Elternabend 2026" event signup). Opt in per-declaration to create
it anyway:
ct.group({ key: "kids_2026_b", name: "Kids Elternabend 2026", groupTypeId: 2, allowDuplicateName: true });allowDuplicateName sends CT's force: true on the CREATE request only — it is
never a managed field (not diffed, not in state, not touched on update, and
never adopted). Never set it as a default; it exists for the rare
intentional-duplicate case. If a create 400s on this guard without the flag set,
ct apply's stop message explains the likely cause (an unmanaged existing group
that should be adopted with ct adopt group <id> --key <key>) and the opt-in as
the alternative.
groupTypeId is managed, but changing it on an existing group is a migration,
not a field update. ChurchTools refuses the field on the ordinary group update
(HTTP 400 groupTypeId: validation.always.invalid) and exposes
POST /groups/{id}/grouptype instead, which takes a mapping from every role of the
old type onto a role of the new one. That mapping decides where existing
memberships land: whoever holds a role mapping to X holds X afterwards.
ct plan resolves the mapping up front and renders it, so the diff shows what the
apply will actually do:
~ group.youth_team (#42)
groupTypeId: 5 -> 4
via POST /groups/42/grouptype — role mapping (members follow their role):
Mitglied -> Mitglied (7 members, matched by name)
Leiter -> Leiter (2 members, matched by name)
Supporter -> Mitglied (0 members, matched by empty-role)
ct derives a mapping only where the answer cannot cost anyone their role:
| matched by | when |
|---|---|
name |
exactly one role of the target type carries the same name |
empty-role |
the old role holds no members here, so no membership can move |
declared |
you said so (below) — always wins |
A role that holds members and has no same-named target is a decision ct will not make for you. The plan refuses, naming the role, its member count and the target type's roles, and you answer it on the group:
ct.group({
key: "youth_team",
name: "Youth Team",
groupType: "merkmal",
roleMapping: { Supporter: "Mitglied" },
});roleMapping is old role name → new role name (so one config stays portable
across hosts), compared as slugs. Like allowDuplicateName it is never a managed
field: not diffed, not in state, never adopted, and read only when the type
actually changes.
Machine-readable output goes to stdout (pipe/jq it); human status lines go
to stderr. So ct plan --json | jq and ct get groups > groups.json are
always safe, whatever warnings the run prints.
- Permissions —
ct.groupRole,ct.groupTypeRole,ct.status - Dynamic groups — the
dynamicblock and the typed query DSL - Blueprints — one function per repeated structure, instantiated per campus
- Environments — one config, several instances