From b0bc6c6e8afe475284f8c92680b1dcedae75682d Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 9 Jul 2026 13:50:33 +0200 Subject: [PATCH 1/3] feat(adopt): unknown-declaration-field warning (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive a per-resource-type allowlist from the registry's own managedFields (registry.knownFields) — never hand-copied — and warn (not throw) in the config DSL when a declaration carries a field the registry doesn't manage for that type, e.g. campus's vestigial `shortName` instead of the real `shorty`. The field still passes through unchanged; this only surfaces the mistake instead of leaving it silently un-diffed forever. File:line locations are #52's job, not built here. --- src/config/context.ts | 34 ++++++++++-- src/resources/registry.ts | 14 ++++- tests/context.test.ts | 105 ++++++++++++++++++++++++++++++-------- tests/registry.test.ts | 32 ++++++++++-- 4 files changed, 155 insertions(+), 30 deletions(-) diff --git a/src/config/context.ts b/src/config/context.ts index 9934685..34fb31d 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -15,6 +15,8 @@ import type { DesiredResource, DynamicSpec, DynamicStatus } from "../engine/type import type { DomainType } from "../permissions/grants.js"; import type { DesiredPermission, Grant } from "../permissions/types.js"; import { isRef, ref, refKey, type Ref } from "../resolve/refs.js"; +import { knownFields } from "../resources/registry.js"; +import { warn } from "../ui.js"; // Re-exported so a config file can pull the query DSL from the same module as // `ConfigContext`: `import { q, churchQuery } from "../../src/config/context.js"`. export { q, churchQuery } from "./query.js"; @@ -83,7 +85,9 @@ function domainKeyPart(domainId: number | Ref): string { function resolveDomainInput(domainType: DomainType, input: PermissionInput): number | Ref { const hasId = input.id !== undefined; const bothError = (logical: string): Error => - new Error(`${domainType} "${input.key}": declare either "id" (numeric) or ${logical} (logical), not both.`); + new Error( + `${domainType} "${input.key}": declare either "id" (numeric) or ${logical} (logical), not both.`, + ); if (domainType === "group_type_role") { if (input.groupType !== undefined) { if (hasId) throw bothError('"groupType"'); @@ -102,7 +106,9 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num } if (typeof input.id !== "number" || !Number.isFinite(input.id)) { const logical = domainType === "group_type_role" ? '"groupType"' : '"group" + "role"'; - throw new Error(`${domainType} "${input.key}": provide a numeric "id" (the domainId) or the logical ${logical} form.`); + throw new Error( + `${domainType} "${input.key}": provide a numeric "id" (the domainId) or the logical ${logical} form.`, + ); } return input.id; } @@ -148,7 +154,9 @@ function toDesired(type: string, input: ResourceInput): DesiredResource { } const value = fields[logical]; if (typeof value !== "string" || value.length === 0) { - throw new Error(`${type} "${key}": "${logical}" must be a non-empty string key (e.g. "${logical}: \\"mainz\\"").`); + throw new Error( + `${type} "${key}": "${logical}" must be a non-empty string key (e.g. "${logical}: \\"mainz\\"").`, + ); } fields[idField] = make(value); delete fields[logical]; @@ -164,6 +172,19 @@ function toDesired(type: string, input: ResourceInput): DesiredResource { `field, or ref.*).`, ); } + // Warn (never throw) on a declared field the registry does not manage for this type — e.g. a + // seeded config using campus's vestigial `shortName` instead of the real `shorty` (#51). The + // field still passes through into `fields` unchanged (unrecognised fields have always been sent + // as-is); this only surfaces the mistake instead of leaving it silently un-diffed forever. The + // allowlist comes from `knownFields` (the registry's own `managedFields`), never hand-copied, so + // it can't drift from what `adopt`/`plan`/`apply` actually read and write. Issue #52 will add + // file:line locations to this warning — not built here. + const allowed = knownFields(type); + for (const fieldKey of Object.keys(fields)) { + if (!allowed.has(fieldKey)) { + warn(`${type} "${key}": unknown field "${fieldKey}" (ignored)`); + } + } // `dynamic` is a synthetic field for auto-groups, handled separately from the plain diffed // field bag. Opt-in: `undefined` means "not a dynamic group" (mirrors `parents`). let dynamicSpec: DynamicSpec | undefined; @@ -253,11 +274,14 @@ export function createContext(): { if (typeof input.key !== "string" || !input.key) throw new Error(`${domainType} declaration missing a string "key".`); const domainId = resolveDomainInput(domainType, input); - if (!Array.isArray(input.grants)) throw new Error(`${domainType} "${input.key}": "grants" must be an array.`); + if (!Array.isArray(input.grants)) + throw new Error(`${domainType} "${input.key}": "grants" must be an array.`); for (const g of input.grants) { const right = typeof g === "string" ? g : g?.right; if (typeof right !== "string" || !right.includes(":")) - throw new Error(`${domainType} "${input.key}": each grant must be a "module:right" string or { right, scope }.`); + throw new Error( + `${domainType} "${input.key}": each grant must be a "module:right" string or { right, scope }.`, + ); if (typeof g === "object") { if (!Array.isArray(g.scope)) throw new Error(`${domainType} "${input.key}": scoped grant needs "scope": (string | number)[].`); diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 7be581f..f29664b 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -57,7 +57,7 @@ function str(resource: Record, key: string): string { } /** Read a field, preferring a nested `information` object but falling back to the top level. */ -function fromInformation(resource: Record, key: string): unknown { +export function fromInformation(resource: Record, key: string): unknown { const information = (resource.information as Record | undefined) ?? {}; return information[key] ?? resource[key]; } @@ -145,6 +145,18 @@ export function resourceType(type: string): AdoptableResource { return entry; } +/** + * The field names a declaration of `type` may manage — derived from the registry's own + * `managedFields`, not hand-copied, so this can never drift from what `adopt`/`plan`/`apply` + * actually read and write. `managedFields({})` still returns every key it would on a real + * resource: each key is written as an object-literal property (`{ name: r.name, ... }`), so it + * is present with value `undefined` even when the source object is empty — JS object literals + * always create the property, independent of the expression's runtime value. + */ +export function knownFields(type: string): Set { + return new Set(Object.keys(resourceType(type).managedFields({}))); +} + /** Camel-case a hyphenated type name: `group-type` → `groupType`. */ function camelCase(type: string): string { return type.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); diff --git a/tests/context.test.ts b/tests/context.test.ts index f19dac1..db2c501 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { createContext, evaluateConfig, type ConfigContext } from "../src/config/context.js"; import { isKnownType } from "../src/engine/graph.js"; import { RESOURCES } from "../src/resources/registry.js"; @@ -156,9 +156,10 @@ describe("config context", () => { const camel = (t: string): string => t.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); for (const [type, spec] of Object.entries(RESOURCES)) { const fn = spec.dslName ?? camel(type); - expect(typeof (ct as unknown as Record)[fn], `registry type "${type}" expects ct.${fn}()`).toBe( - "function", - ); + expect( + typeof (ct as unknown as Record)[fn], + `registry type "${type}" expects ct.${fn}()`, + ).toBe("function"); } }); @@ -167,7 +168,11 @@ describe("config context", () => { ct.roleDefinition({ key: "leiter", name: "Leiter", groupTypeId: 2 }); expect(permissions).toEqual([]); // roleDefinition is a resource, never a permission grant expect(resources).toEqual([ - expect.objectContaining({ type: "group-role", key: "leiter", fields: { name: "Leiter", groupTypeId: 2 } }), + expect.objectContaining({ + type: "group-role", + key: "leiter", + fields: { name: "Leiter", groupTypeId: 2 }, + }), ]); expect(isKnownType(resources[0]!.type)).toBe(true); // has an apply tier → plannable }); @@ -193,15 +198,18 @@ describe("dynamic block", () => { dynamic: { status: "manual", ruleset: { description: "x", method: "ChurchQuery", params: {} } }, }); const g = resources.find((r) => r.key === "all_mainz")!; - expect(g.dynamic).toEqual({ status: "manual", ruleset: { description: "x", method: "ChurchQuery", params: {} } }); + expect(g.dynamic).toEqual({ + status: "manual", + ruleset: { description: "x", method: "ChurchQuery", params: {} }, + }); expect(g.fields).not.toHaveProperty("dynamic"); // never a plain diffed field }); it("rejects an invalid status", async () => { const { ct } = createContext(); - expect(() => ct.group({ key: "g", name: "G", dynamic: { status: "bogus", ruleset: {} } as never })).toThrow( - /dynamic.*status/i, - ); + expect(() => + ct.group({ key: "g", name: "G", dynamic: { status: "bogus", ruleset: {} } as never }), + ).toThrow(/dynamic.*status/i); }); it("rejects dynamic on a non-group", async () => { @@ -213,8 +221,49 @@ describe("dynamic block", () => { it("rejects a null dynamic block with a clean error", async () => { const { ct } = createContext(); - expect(() => ct.group({ key: "g", name: "G", dynamic: null } as never)) - .toThrow(/dynamic/i); + expect(() => ct.group({ key: "g", name: "G", dynamic: null } as never)).toThrow(/dynamic/i); + }); +}); + +describe("unknown-field warning (#51)", () => { + it("warns naming the resource key and the unknown field, but still keeps it in fields", () => { + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const { ct, resources } = createContext(); + ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); // the seeded-config bug: shorty is the real field + expect(resources[0]?.fields).toEqual({ name: "Mainz", shortName: "MZ" }); + expect(spy).toHaveBeenCalledTimes(1); + expect(String(spy.mock.calls[0]![0])).toContain('campus "mainz": unknown field "shortName" (ignored)'); + } finally { + spy.mockRestore(); + } + }); + + it("does not warn for any recognised field, including sugared id fields and fields read via fromInformation", () => { + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const { ct } = createContext(); + ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); + ct.group({ key: "g", name: "G", groupTypeId: 2, groupStatusId: 1, campusId: 4 }); + ct.group({ key: "g2", name: "G2", campus: "mainz", groupType: "x", status: "active" }); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + + it("warns once per unknown field, naming each one", () => { + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const { ct } = createContext(); + ct.group({ key: "g", name: "G", bogus1: 1, bogus2: 2 } as never); + expect(spy).toHaveBeenCalledTimes(2); + const messages = spy.mock.calls.map((c) => String(c[0])); + expect(messages.some((m) => m.includes('unknown field "bogus1" (ignored)'))).toBe(true); + expect(messages.some((m) => m.includes('unknown field "bogus2" (ignored)'))).toBe(true); + } finally { + spy.mockRestore(); + } }); }); @@ -239,10 +288,11 @@ describe("preventDestroy lifecycle flag", () => { describe("permission declarations", () => { it("collects groupRole / groupTypeRole with validated grants", async () => { const mod = (ct: ConfigContext) => { - ct.groupTypeRole({ key: "leiter_tpl", id: 8, grants: [ - "churchgroup:view group", - { right: "churchdb:view group", scope: ["kids_area"] }, - ]}); + ct.groupTypeRole({ + key: "leiter_tpl", + id: 8, + grants: ["churchgroup:view group", { right: "churchdb:view group", scope: ["kids_area"] }], + }); ct.groupRole({ key: "kids_lead", id: 2882, grants: ["churchgroup:edit group members"] }); }; const { permissions } = await evaluateConfig(mod); // evaluateConfig now returns {resources, permissions} @@ -267,10 +317,14 @@ describe("permission declarations", () => { it("accepts a raw numeric scope entry alongside logical group keys (#49 escape hatch)", async () => { const { permissions } = await evaluateConfig((ct: ConfigContext) => { - ct.groupTypeRole({ key: "leiter_tpl", id: 8, grants: [ - { right: "churchdb:view comments", scope: [1, 2, 3] }, - { right: "churchdb:view group", scope: ["kids_area", 5] }, - ]}); + ct.groupTypeRole({ + key: "leiter_tpl", + id: 8, + grants: [ + { right: "churchdb:view comments", scope: [1, 2, 3] }, + { right: "churchdb:view group", scope: ["kids_area", 5] }, + ], + }); }); expect(permissions[0]!.grants).toEqual([ { right: "churchdb:view comments", scope: [1, 2, 3] }, @@ -281,7 +335,11 @@ describe("permission declarations", () => { it("rejects a scope array with a non-string/non-number entry", async () => { await expect( evaluateConfig((ct: ConfigContext) => - ct.groupTypeRole({ key: "x", id: 8, grants: [{ right: "churchdb:view comments", scope: [null] }] } as never), + ct.groupTypeRole({ + key: "x", + id: 8, + grants: [{ right: "churchdb:view comments", scope: [null] }], + } as never), ), ).rejects.toThrow(/scope/i); }); @@ -314,7 +372,12 @@ describe("permission declarations", () => { const { permissions } = await evaluateConfig((ct: ConfigContext) => ct.groupRole({ key: "p", group: "kids", role: "Leiter", grants: ["churchgroup:view group"] }), ); - expect(permissions[0]?.domainId).toEqual({ __ctRef: true, kind: "group-role", group: "kids", role: "Leiter" }); + expect(permissions[0]?.domainId).toEqual({ + __ctRef: true, + kind: "group-role", + group: "kids", + role: "Leiter", + }); }); it("rejects declaring both a numeric id and a logical domain form", async () => { diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 8bcd671..d82c6b5 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { slug, resourceType, configSnippet, RESOURCES } from "../src/resources/registry.js"; +import { slug, resourceType, configSnippet, knownFields, RESOURCES } from "../src/resources/registry.js"; import { loadConfig } from "../src/config/load.js"; describe("slug", () => { @@ -63,7 +63,8 @@ describe("resourceType", () => { }); // Mainz is campus id 0 — must survive the null-coalescing, not collapse to null. expect( - RESOURCES.group?.managedFields({ name: "Team", information: { groupTypeId: 2, campusId: 0 } })?.campusId, + RESOURCES.group?.managedFields({ name: "Team", information: { groupTypeId: 2, campusId: 0 } }) + ?.campusId, ).toBe(0); }); }); @@ -152,11 +153,36 @@ describe("write specs", () => { RESOURCES["age-group"]?.managedFields({ name: "NextGen", nameTranslated: "NextGen", sortKey: 8 }), ).toEqual({ name: "NextGen", nameTranslated: "NextGen", sortKey: 8 }); expect( - RESOURCES["group-role"]?.managedFields({ name: "Mitglied", nameTranslated: "Mitglied", groupTypeId: 2 }), + RESOURCES["group-role"]?.managedFields({ + name: "Mitglied", + nameTranslated: "Mitglied", + groupTypeId: 2, + }), ).toEqual({ name: "Mitglied", nameTranslated: "Mitglied", groupTypeId: 2 }); }); }); +describe("knownFields (#51)", () => { + it("derives the campus allowlist from managedFields — 'shorty', not the vestigial 'shortName'", () => { + expect(knownFields("campus")).toEqual(new Set(["name", "shorty"])); + }); + + it("derives the group allowlist, including fields read via fromInformation", () => { + expect(knownFields("group")).toEqual(new Set(["name", "groupTypeId", "groupStatusId", "campusId"])); + }); + + it("derives an allowlist for every registered resource type without throwing", () => { + for (const type of Object.keys(RESOURCES)) { + expect(() => knownFields(type)).not.toThrow(); + expect(knownFields(type).size).toBeGreaterThan(0); + } + }); + + it("throws the same 'Adoptable types' error as resourceType for an unknown type", () => { + expect(() => knownFields("widget")).toThrow(/Adoptable types/); + }); +}); + describe("configSnippet null omission", () => { it("omits null-valued fields — a campus-less group adopts without managing 'no campus'", () => { expect(configSnippet("group", "team", { name: "Team", groupTypeId: 2, campusId: null })).toBe( From fa5526b60645500f1a4686a904bbcba816f3f937 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 9 Jul 2026 13:50:41 +0200 Subject: [PATCH 2/3] fix(adopt): honour subcommand --state/--env instead of silently dropping them (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ct adopt`'s subcommands (grants, and the new group added in this PR) each redeclare -s/--state and -e/--env for their own --help text. Commander does not merge a same-named parent+subcommand option into either level's plain .opts() — both come up empty for it — so the subcommand silently fell back to the default state path / no env, ignoring the flag the user passed. Read merged options via command.optsWithGlobals() instead, which walks the whole command chain correctly. Adds a regression test proving --state is actually honoured by resolving a scoped grant's dataId via a non-default state file. --- src/commands/adopt-grants.ts | 16 +++++++++--- tests/adopt-grants-command.test.ts | 39 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts index f201923..b0f9a13 100644 --- a/src/commands/adopt-grants.ts +++ b/src/commands/adopt-grants.ts @@ -29,12 +29,22 @@ function normalizeDomainType(raw: string): DomainType { */ export function adoptGrantsCommand(): Command { return new Command("grants") - .description("Print a paste-ready grants config block from a live domain's permission rows (does not write state)") + .description( + "Print a paste-ready grants config block from a live domain's permission rows (does not write state)", + ) .argument("", "group_role | group_type_role") .argument("", "the domainId of the permission domain object") - .option("-s, --state ", "state file path (or set CT_STATE) — used to resolve scope group ids to keys") + .option( + "-s, --state ", + "state file path (or set CT_STATE) — used to resolve scope group ids to keys", + ) .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") - .action(async (rawType: string, rawId: string, opts: AdoptGrantsOptions) => { + .action(async (rawType: string, rawId: string, _localOpts: AdoptGrantsOptions, command: Command) => { + // `adopt` (the parent) also declares `-s/--state` and `-e/--env` for its own ` ` + // action. Commander does not merge a same-named parent+subcommand option into either level's + // plain `.opts()` (both come up empty for it); only `optsWithGlobals()` walks the whole + // command chain and merges correctly — read from there, not the local `opts` parameter (#51). + const opts = command.optsWithGlobals() as AdoptGrantsOptions; const domainType = normalizeDomainType(rawType); if (!/^\d+$/.test(rawId.trim())) { throw new Error(`Invalid domainId "${rawId}" — expected a non-negative integer.`); diff --git a/tests/adopt-grants-command.test.ts b/tests/adopt-grants-command.test.ts index 7bea655..829d3e8 100644 --- a/tests/adopt-grants-command.test.ts +++ b/tests/adopt-grants-command.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { RawPermission } from "../src/permissions/grants.js"; +import type { State } from "../src/state/state.js"; const rows: RawPermission[] = [ { authId: 1, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, @@ -71,4 +75,39 @@ describe("ct adopt grants", () => { await run(["campus", "0", "--dry-run"]); expect(getMock).toHaveBeenCalledWith("/campuses/0"); }); + + it("actually honours --state (regression, #51): resolves a scoped grant's dataId to the group's key from the given state file, not the default", async () => { + // Both `adopt` (the parent) and `grants` (this subcommand) declare `-s/--state` — a Commander + // option-name collision that used to silently swallow the value into neither level's `.opts()`, + // so `--state` was never actually honoured here (only ever exercised with the default/missing + // state file, which no test caught). Prove it now by giving a scoped grant a group that is ONLY + // resolvable via a real, non-default state file. + const dir = mkdtempSync(join(tmpdir(), "ct-adopt-grants-state-")); + const statePath = join(dir, "custom-state.json"); + const state: State = { + version: 1, + host: HOST, + resources: { kids: { type: "group", id: 7, key: "kids", fields: {}, adoptedAt: "t", updatedAt: "t" } }, + }; + writeFileSync(statePath, JSON.stringify(state)); + + getMock.mockResolvedValueOnce([ + { authId: 1104, dataId: 7, type: "grant", domainId: 42 }, // churchgroup:view group, scoped to group #7 + ] as never); + const writes: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((s) => { + writes.push(String(s)); + return true; + }); + try { + await run(["grants", "group_role", "42", "--state", statePath]); + } finally { + spy.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + } + // Resolved via the custom state file's "kids" key — proves --state was actually read, not + // silently ignored in favour of an empty default state (which would leave this as an "unmanaged" + // WARNING comment instead of a real scope entry). + expect(writes.join("")).toContain('scope: ["kids"]'); + }); }); From 0b65f574d6106cc35edfe493e44c0895a87fb738 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Thu, 9 Jul 2026 13:50:58 +0200 Subject: [PATCH 3/3] feat(adopt): bulk/filtered group adoption + --with-dynamic ruleset capture (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ct adopt group` as a dedicated subcommand (mirroring adopt-grants.ts) covering: - Multiple positional ids: `ct adopt group `. - `--type `: adopt every group of a group type, resolved as a numeric id or a logical key against the live /group/grouptypes catalog (client.getAll for listing, per #50). - `--children-of `: recursively adopt a group's full hierarchy subtree via /groups/{id}/children, parent-before-child, excluding the root; guards against a cyclic hierarchy with a visited-id set. - `--with-dynamic`: for each adopted group that IS a dynamic group (a 404 on the ruleset GET means "not dynamic" — skipped silently), capture its normalized ruleset (engine/dynamic.js's normalizeRuleset, the same normalizer plan/apply already use) to rulesets/.json and emit the `dynamic: { status, ruleset: { ref } }` block in the printed config snippet, so `ct plan` is a no-op once pasted (covered by an end-to-end test using buildPlan against the same mocked client). Output stays per-resource (state entries + config snippets), plus one grouped `// group` config block for bulk selections — configSnippet's own per-line FORMAT is untouched (#52 reworks that later). Selection filters compose with each other via mutual exclusivity (exactly one of: ids / --type / --children-of); duplicate resolved ids are deduped; --key is rejected whenever more than one group resolves. --- src/commands/adopt-group.ts | 314 ++++++++++++++++++++++++++++++ src/commands/adopt.ts | 5 + tests/adopt-group-command.test.ts | 312 +++++++++++++++++++++++++++++ 3 files changed, 631 insertions(+) create mode 100644 src/commands/adopt-group.ts create mode 100644 tests/adopt-group-command.test.ts diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts new file mode 100644 index 0000000..59622e8 --- /dev/null +++ b/src/commands/adopt-group.ts @@ -0,0 +1,314 @@ +/** + * `ct adopt group` — bulk/filtered group adoption + `--with-dynamic` ruleset capture (#51). + * + * A dedicated subcommand (mirroring `adopt-grants.ts`'s pattern) rather than an extension of the + * generic `ct adopt ` action: bulk selection (`--type`, `--children-of`) and dynamic + * ruleset capture (`--with-dynamic`) are group-specific concepts with no analog for the other + * adoptable types. Commander matches this named subcommand before falling through to the base + * action, so every `ct adopt group ...` invocation — a single id, a list of ids, or a filter — + * routes here (the base action never sees `type === "group"`). + */ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import { CtApiError, type CtClient } from "../api/ctClient.js"; +import { resolveConfig } from "../config.js"; +import { prepareEnv } from "../env/context.js"; +import { normalizeRuleset } from "../engine/dynamic.js"; +import type { DynamicStatus } from "../engine/types.js"; +import { RESOURCES, configSnippet, fromInformation, slug } from "../resources/registry.js"; +import { loadState, saveState, upsert, type State } from "../state/state.js"; +import { success, info, warn, out } from "../ui.js"; + +interface AdoptGroupOptions { + key?: string; + state?: string; + env?: string; + dryRun?: boolean; + type?: string; + childrenOf?: string; + withDynamic?: boolean; +} + +const GROUP_SPEC = RESOURCES.group!; + +interface ResolvedAdoption { + id: number; + key: string; + fields: Record; + snippet: string; +} + +function isNonNegativeInt(raw: string): boolean { + return /^\d+$/.test(raw.trim()); +} + +/** Resolve `--type`'s numeric group-type id or logical key against the live `/group/grouptypes` catalog. */ +async function resolveGroupTypeId(raw: string, client: Pick): Promise { + const trimmed = raw.trim(); + if (isNonNegativeInt(trimmed)) return Number.parseInt(trimmed, 10); + const rows = await client.get>>("/group/grouptypes"); + const list = Array.isArray(rows) ? rows : []; + const bySlug = list.filter((r) => typeof r.name === "string" && slug(r.name as string) === trimmed); + const candidates = bySlug.length > 0 ? bySlug : list.filter((r) => r.name === trimmed); + if (candidates.length === 0) { + throw new Error( + `--type "${raw}": no group type matches (checked /group/grouptypes by slug and exact name).`, + ); + } + if (candidates.length > 1) { + const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${String(c.id)})`).join(", "); + throw new Error(`--type "${raw}" is ambiguous: ${candidates.length} group types match — ${listed}.`); + } + return Number(candidates[0]!.id); +} + +/** Resolve `--children-of`'s numeric id, adopted-state logical key, or live group name to a group id. */ +async function resolveGroupId( + raw: string, + client: Pick, + state: State, +): Promise { + const trimmed = raw.trim(); + if (isNonNegativeInt(trimmed)) return Number.parseInt(trimmed, 10); + const managed = state.resources[trimmed]; + if (managed && managed.type === "group") return managed.id; + const page = await client.getAll>("/groups"); + const rows = page.data; + const bySlug = rows.filter((r) => typeof r.name === "string" && slug(r.name as string) === trimmed); + const candidates = bySlug.length > 0 ? bySlug : rows.filter((r) => r.name === trimmed); + if (candidates.length === 0) { + throw new Error( + `--children-of "${raw}": not adopted (no state entry) and no live group matches ` + + `(checked /groups by slug and exact name).`, + ); + } + if (candidates.length > 1) { + const listed = candidates.map((c) => `${JSON.stringify(c.name)} (#${String(c.id)})`).join(", "); + throw new Error( + `--children-of "${raw}" is ambiguous: ${candidates.length} live groups match — ${listed}.`, + ); + } + return Number(candidates[0]!.id); +} + +/** + * Recursively collect a group's full hierarchy subtree via `/groups/{id}/children`, in + * parent-before-child (pre-order) sequence, excluding the root itself. Guards against a cyclic + * hierarchy (a live-API bug, not a valid DAG state) with a `visited` set — never re-descends into + * an id already seen, so a back-reference to an ancestor cannot loop forever. + */ +async function collectSubtreeIds(rootId: number, client: Pick): Promise { + const visited = new Set([rootId]); + const order: number[] = []; + + async function walk(id: number): Promise { + let raw: unknown; + try { + raw = await client.get(`/groups/${id}/children`); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) return; // no children (leaf, or unknown group) + throw err; + } + const children = Array.isArray(raw) ? raw : []; + for (const c of children) { + const cid = typeof c === "number" ? c : Number((c as Record | null)?.id); + if (!Number.isFinite(cid) || visited.has(cid)) continue; + visited.add(cid); + order.push(cid); + await walk(cid); + } + } + + await walk(rootId); + return order; +} + +/** List every group id whose live `groupTypeId` (top-level or under `information`) matches. */ +async function collectByGroupType(groupTypeId: number, client: Pick): Promise { + const page = await client.getAll>("/groups"); + return page.data + .filter((row) => Number(fromInformation(row, "groupTypeId")) === groupTypeId) + .map((row) => Number(row.id)) + .filter((id) => Number.isFinite(id)) + .sort((a, b) => a - b); +} + +interface DynamicCapture { + status: DynamicStatus; + normalizedRuleset: Record; +} + +/** Fetch + normalize a group's ruleset and status. `undefined` (never throws) if the group isn't dynamic. */ +async function captureDynamic( + id: number, + client: Pick, +): Promise { + let raw: unknown; + try { + raw = await client.get(`/dynamicgroups/${id}/ruleset`); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) return undefined; // not a dynamic group — skip silently + throw err; + } + const normalizedRuleset = normalizeRuleset(raw); + const statusRes = await client.get<{ dynamicGroupStatus?: string }>(`/dynamicgroups/${id}/status`); + const status = (statusRes?.dynamicGroupStatus ?? "none") as DynamicStatus; + return { status, normalizedRuleset }; +} + +export function adoptGroupCommand(): Command { + return new Command("group") + .description( + "Adopt one or more groups: `ct adopt group `, or a filtered bulk form via " + + "--type / --children-of. See --with-dynamic to also capture a dynamic group's ruleset.", + ) + .argument("[ids...]", "one or more ChurchTools group ids") + .option("-k, --key ", "logical key (only valid when exactly one group is resolved)") + .option("-s, --state ", "state file path (or set CT_STATE)") + .option("-e, --env ", "environment profile from ct.envs.json (host + state + token)") + .option("--dry-run", "preview the config entries and state changes without writing") + .option("--type ", "adopt every group of this group type (numeric id or logical key)") + .option( + "--children-of ", + "adopt a group's full hierarchy subtree (recursive; numeric id, adopted-state key, or live name)", + ) + .option( + "--with-dynamic", + "also capture each dynamic group's ruleset to rulesets/.json and emit the dynamic: block", + ) + .action(async (ids: string[], _localOpts: AdoptGroupOptions, command: Command) => { + // `adopt` (the parent) also declares `-k/--key`, `-s/--state`, `-e/--env`, and `--dry-run` — + // for its own ` ` action. Commander does not merge same-named options declared on + // both a parent and a subcommand into either level's plain `.opts()` (each stays empty for + // that flag); only `optsWithGlobals()` walks the whole command chain and merges correctly. + // Read from there rather than the local `opts` parameter, so `ct adopt group ... --state + // ` / `--env ` (etc.) actually take effect. + const opts = command.optsWithGlobals() as AdoptGroupOptions; + const selectors = [ids.length > 0, Boolean(opts.type), Boolean(opts.childrenOf)].filter(Boolean).length; + if (selectors === 0) { + throw new Error("Specify group id(s), --type , or --children-of ."); + } + if (selectors > 1) { + throw new Error("Specify only one of: group id(s), --type, --children-of."); + } + for (const raw of ids) { + if (!isNonNegativeInt(raw)) { + throw new Error(`Invalid id "${raw}" — expected a non-negative integer.`); + } + } + if (opts.key && ids.length > 1) { + throw new Error("--key is only valid when adopting a single group."); + } + + // Resolve the env FIRST — it wires the target host/token into the process env before + // resolveConfig — then load + validate the state file (host guard) BEFORE any network call, + // so a state file recorded against another instance never triggers a live request against + // the wrong host. + const cmdEnv = await prepareEnv(opts); + const config = await resolveConfig(); + const statePath = cmdEnv.statePath; + const state = await loadState(statePath, config.host); + + const { client } = await authedSession(); + + let resolvedIds: number[]; + if (ids.length > 0) { + resolvedIds = ids.map((raw) => Number.parseInt(raw, 10)); + } else if (opts.type) { + const groupTypeId = await resolveGroupTypeId(opts.type, client); + resolvedIds = await collectByGroupType(groupTypeId, client); + } else { + const rootId = await resolveGroupId(opts.childrenOf!, client, state); + resolvedIds = await collectSubtreeIds(rootId, client); + } + resolvedIds = [...new Set(resolvedIds)]; + + if (opts.key && resolvedIds.length !== 1) { + throw new Error(`--key is only valid when adopting a single group (resolved ${resolvedIds.length}).`); + } + if (resolvedIds.length === 0) { + info("No groups matched — nothing to adopt."); + return; + } + + const now = new Date().toISOString(); + const results: ResolvedAdoption[] = []; + const reports: Array<{ action: "created" | "updated"; id: number; key: string }> = []; + + for (const id of resolvedIds) { + const resource = await client.get>(GROUP_SPEC.itemPath(id)); + const key = + (resolvedIds.length === 1 ? opts.key?.trim() : undefined) || GROUP_SPEC.deriveKey(resource); + if (!key) { + throw new Error(`Could not derive a logical key for group #${id} — pass --key explicitly.`); + } + const fields = GROUP_SPEC.managedFields(resource); + + let snippetFields: Record = fields; + if (opts.withDynamic) { + const captured = await captureDynamic(id, client); + if (captured) { + const relPath = `rulesets/${key}.json`; + if (!opts.dryRun) { + await mkdir(join(process.cwd(), "rulesets"), { recursive: true }); + await writeFile( + join(process.cwd(), relPath), + `${JSON.stringify(captured.normalizedRuleset, null, 2)}\n`, + "utf8", + ); + } + snippetFields = { + ...fields, + dynamic: { status: captured.status, ruleset: { ref: `./${relPath}` } }, + }; + } + } + const snippet = configSnippet("group", key, snippetFields); + + if (opts.dryRun) { + results.push({ id, key, fields, snippet }); + continue; + } + const action = upsert(state, { type: "group", id, key, fields }, now); + results.push({ id, key, fields, snippet }); + reports.push({ action, id, key }); + } + + if (opts.dryRun) { + const payload = results.map((r) => ({ + key: r.key, + type: "group", + id: r.id, + fields: r.fields, + config: r.snippet, + })); + info( + results.length === 1 + ? `Would adopt group #${results[0]!.id} as "${results[0]!.key}". Generated config entry:` + : `Would adopt ${results.length} groups. Generated config entries:`, + ); + out(results.length === 1 ? payload[0] : payload); + return; + } + + await saveState(statePath, state); + + for (const r of reports) { + success( + `${r.action === "created" ? "Adopted" : "Updated"} group #${r.id} as "${r.key}" → ${statePath}`, + ); + if (r.action === "updated") { + warn("This resource was already managed — its snapshot was refreshed."); + } + } + + // Grouped, paste-ready config block. configSnippet's per-line FORMAT is unchanged (#52 reworks + // that later) — this only wraps the group of lines under a type comment header, ordered + // parents-before-children where hierarchy is known (--children-of's subtree walk). + info(results.length === 1 ? "Config entry:" : "Config entries (paste into your config):"); + const block = [`// group`, ...results.map((r) => r.snippet)].join("\n"); + process.stdout.write(`${block}\n`); + }); +} diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts index 343426d..be38d23 100644 --- a/src/commands/adopt.ts +++ b/src/commands/adopt.ts @@ -6,6 +6,7 @@ import { resourceType, configSnippet } from "../resources/registry.js"; import { loadState, saveState, upsert } from "../state/state.js"; import { success, info, warn, out } from "../ui.js"; import { adoptGrantsCommand } from "./adopt-grants.js"; +import { adoptGroupCommand } from "./adopt-group.js"; interface AdoptOptions { key?: string; @@ -69,5 +70,9 @@ export function adoptCommand(): Command { // prints a config block only and never writes state. Commander matches the "grants" subcommand // name before falling through to the ` ` action above. cmd.addCommand(adoptGrantsCommand()); + // `ct adopt group ...` — bulk/filtered adoption (--type, --children-of, multiple ids) and + // --with-dynamic ruleset capture (#51). Named subcommand, so it also handles the plain single-id + // `ct adopt group ` case (Commander matches it before the generic ` ` action above). + cmd.addCommand(adoptGroupCommand()); return cmd; } diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts new file mode 100644 index 0000000..59265f2 --- /dev/null +++ b/tests/adopt-group-command.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CtApiError } from "../src/api/ctClient.js"; +import { buildPlan } from "../src/engine/build.js"; +import { createContext } from "../src/config/context.js"; +import type { CtClient } from "../src/api/ctClient.js"; + +/** + * A tiny in-memory ChurchTools double covering everything `ct adopt group` reads: + * - `/groups/{id}` (single fetch), `/groups` (getAll, for --type) + * - `/groups/{id}/children` (for --children-of; includes a cyclic pair to exercise the guard) + * - `/group/grouptypes` (for --type's logical-key resolution) + * - `/dynamicgroups/{id}/ruleset` + `/status` (for --with-dynamic; #31 is deliberately NOT dynamic) + */ +function makeClient() { + const groups: Record> = { + 10: { id: 10, name: "Area A", information: { groupTypeId: 5, groupStatusId: 1 } }, + 11: { id: 11, name: "Area B", information: { groupTypeId: 5, groupStatusId: 1 } }, + 20: { id: 20, name: "Other Type Group", information: { groupTypeId: 9, groupStatusId: 1 } }, + 40: { id: 40, name: "Root", information: { groupTypeId: 5, groupStatusId: 1 } }, + 41: { id: 41, name: "Child One", information: { groupTypeId: 5, groupStatusId: 1 } }, + 42: { id: 42, name: "Child Two", information: { groupTypeId: 5, groupStatusId: 1 } }, + 43: { id: 43, name: "Grandchild", information: { groupTypeId: 5, groupStatusId: 1 } }, + 50: { id: 50, name: "Cycle A", information: { groupTypeId: 5, groupStatusId: 1 } }, + 51: { id: 51, name: "Cycle B", information: { groupTypeId: 5, groupStatusId: 1 } }, + 30: { id: 30, name: "All Mainz", information: { groupTypeId: 5, groupStatusId: 1 } }, + 31: { id: 31, name: "Static Group", information: { groupTypeId: 5, groupStatusId: 1 } }, + }; + const children: Record = { + 40: [41, 42], + 41: [43], + 50: [51], // cyclic: 50 -> 51 -> 50 + 51: [50], + }; + const groupTypes = [ + { id: 5, name: "Team" }, + { id: 9, name: "Other Type" }, + ]; + const rulesets: Record> = { + 30: { description: "x", query: { "==": [{ var: "a" }, "1"] }, process: {} }, + }; + const statuses: Record = { 30: "active" }; + + const get = vi.fn(async (path: string): Promise => { + let m = /^\/groups\/(\d+)$/.exec(path); + if (m) { + const g = groups[Number(m[1])]; + if (!g) throw new CtApiError("not found", 404, null); + return g; + } + m = /^\/groups\/(\d+)\/children$/.exec(path); + if (m) return (children[Number(m[1])] ?? []).map((id) => ({ id })); + if (path === "/group/grouptypes") return groupTypes; + m = /^\/dynamicgroups\/(\d+)\/ruleset$/.exec(path); + if (m) { + const rs = rulesets[Number(m[1])]; + if (!rs) throw new CtApiError("not found", 404, null); + return rs; + } + m = /^\/dynamicgroups\/(\d+)\/status$/.exec(path); + if (m) return { dynamicGroupStatus: statuses[Number(m[1])] ?? "none" }; + throw new CtApiError(`unmocked GET ${path}`, 404, null); + }); + + const getAll = vi.fn(async (path: string) => { + if (path === "/groups") return { data: Object.values(groups) }; + throw new CtApiError(`unmocked getAll ${path}`, 404, null); + }); + + return { get, getAll }; +} + +let client = makeClient(); + +vi.mock("../src/api/session.js", () => ({ + authedSession: vi.fn(async () => ({ client, me: { id: 1 } })), +})); + +const { adoptCommand } = await import("../src/commands/adopt.js"); +const { loadState } = await import("../src/state/state.js"); + +const HOST = "https://eqrm.church.tools"; +const originalHost = process.env.CT_HOST; +const originalCwd = process.cwd(); + +let workDir: string; +let statePath: string; + +async function run(args: string[]): Promise { + await adoptCommand().parseAsync(args, { from: "user" }); +} + +beforeEach(() => { + client = makeClient(); + process.env.CT_HOST = HOST; + workDir = mkdtempSync(join(tmpdir(), "ct-adopt-group-")); + process.chdir(workDir); + statePath = join(workDir, "ct-state.json"); +}); + +afterEach(() => { + process.chdir(originalCwd); + rmSync(workDir, { recursive: true, force: true }); + if (originalHost === undefined) delete process.env.CT_HOST; + else process.env.CT_HOST = originalHost; +}); + +describe("ct adopt group — multi-id list form", () => { + it("adopts every listed group, in the given order, into one state file", async () => { + await run(["group", "10", "11", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect(state.resources.area_a).toMatchObject({ type: "group", id: 10 }); + expect(state.resources.area_b).toMatchObject({ type: "group", id: 11 }); + expect(Object.keys(state.resources)).toEqual(["area_a", "area_b"]); + }); + + it("prints a single grouped config block with a type comment header", async () => { + const writes: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((s) => { + writes.push(String(s)); + return true; + }); + try { + await run(["group", "10", "11", "--state", statePath]); + } finally { + spy.mockRestore(); + } + const block = writes.join(""); + expect(block).toContain("// group"); + expect(block).toContain('group({ key: "area_a"'); + expect(block).toContain('group({ key: "area_b"'); + // parents-before-children / declared order preserved: area_a's line precedes area_b's. + expect(block.indexOf('key: "area_a"')).toBeLessThan(block.indexOf('key: "area_b"')); + }); + + it("dedupes a repeated id", async () => { + await run(["group", "10", "10", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect(Object.keys(state.resources)).toEqual(["area_a"]); + }); + + it("--dry-run adopts nothing and writes no state file", async () => { + await run(["group", "10", "11", "--dry-run", "--state", statePath]); + await expect(readFile(statePath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects --key with more than one id, before any network call", async () => { + await expect(run(["group", "10", "11", "--key", "x", "--state", statePath])).rejects.toThrow( + /single group/, + ); + expect(client.get).not.toHaveBeenCalled(); + }); + + it("rejects an invalid id before any network call", async () => { + await expect(run(["group", "10", "abc", "--state", statePath])).rejects.toThrow( + /expected a non-negative integer/, + ); + expect(client.get).not.toHaveBeenCalled(); + }); + + it("still supports the plain single-id form exactly as before", async () => { + await run(["group", "10", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect(state.resources.area_a).toMatchObject({ + type: "group", + id: 10, + fields: { name: "Area A", groupTypeId: 5, groupStatusId: 1 }, + }); + }); +}); + +describe("ct adopt group --type", () => { + it("adopts every group of a numeric group-type id, and none of another type", async () => { + await run(["group", "--type", "5", "--state", statePath]); + const state = await loadState(statePath, HOST); + const types = Object.values(state.resources).map((r) => r.id); + expect(types).toEqual(expect.arrayContaining([10, 11, 40, 41, 42, 43, 50, 51, 30, 31])); + expect(types).not.toContain(20); // group type 9 — excluded + }); + + it("resolves a logical group-type key against /group/grouptypes", async () => { + await run(["group", "--type", "team", "--state", statePath]); + expect(client.getAll).toHaveBeenCalledWith("/groups"); + const state = await loadState(statePath, HOST); + expect(state.resources.area_a).toMatchObject({ id: 10 }); + expect(Object.values(state.resources).map((r) => r.id)).not.toContain(20); + }); + + it("rejects an unknown --type key", async () => { + await expect(run(["group", "--type", "nope", "--state", statePath])).rejects.toThrow( + /no group type matches/, + ); + }); + + it("rejects combining --type with explicit ids", async () => { + await expect(run(["group", "10", "--type", "5", "--state", statePath])).rejects.toThrow(/only one of/); + }); +}); + +describe("ct adopt group --children-of", () => { + it("recursively adopts the full subtree, parent before child, excluding the root itself", async () => { + await run(["group", "--children-of", "40", "--state", statePath]); + const state = await loadState(statePath, HOST); + const ids = Object.values(state.resources).map((r) => r.id); + expect(ids.sort((a, b) => a - b)).toEqual([41, 42, 43]); + expect(ids).not.toContain(40); // root itself is not re-adopted by --children-of + // 41 (child of root) must be adopted before 43 (child of 41). + const order = Object.values(state.resources).map((r) => r.id); + expect(order.indexOf(41)).toBeLessThan(order.indexOf(43)); + }); + + it("terminates on a cyclic hierarchy instead of looping forever (cycle guard)", async () => { + await run(["group", "--children-of", "50", "--state", statePath]); + const state = await loadState(statePath, HOST); + const ids = Object.values(state.resources).map((r) => r.id); + expect(ids).toEqual([51]); // 50 -> 51 -> 50: only 51 is a new descendant + }); + + it("resolves --children-of by an already-adopted state key", async () => { + await run(["group", "40", "--state", statePath]); // adopt the root first, under its derived key "root" + await run(["group", "--children-of", "root", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect( + Object.values(state.resources) + .map((r) => r.id) + .sort((a, b) => a - b), + ).toEqual([40, 41, 42, 43]); + }); + + it("reports an empty subtree without adopting anything", async () => { + await run(["group", "--children-of", "42", "--state", statePath]); // 42 has no children + const state = await loadState(statePath, HOST); + expect(Object.keys(state.resources)).toEqual([]); + }); +}); + +describe("ct adopt group --with-dynamic", () => { + it("captures a dynamic group's normalized ruleset to rulesets/.json and emits the dynamic block", async () => { + const writes: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((s) => { + writes.push(String(s)); + return true; + }); + try { + await run(["group", "30", "--with-dynamic", "--state", statePath]); + } finally { + spy.mockRestore(); + } + const rulesetPath = join(workDir, "rulesets", "all_mainz.json"); + const written = JSON.parse(await readFile(rulesetPath, "utf8")); + expect(written).toEqual({ description: "x", query: { "==": [{ var: "a" }, 1] }, process: {} }); // coerced "1" -> 1 + + const block = writes.join(""); + // configSnippet renders a nested-object field value via JSON.stringify (compact, quoted keys) — + // matches the existing tsObject behavior (see src/resources/registry.ts), unchanged by #51. + expect(block).toContain('dynamic: {"status":"active","ruleset":{"ref":"./rulesets/all_mainz.json"}}'); + + // The plain group fields (state snapshot) never carry "dynamic" — it's synthetic, not a managed field. + const state = await loadState(statePath, HOST); + expect(state.resources.all_mainz?.fields).not.toHaveProperty("dynamic"); + }); + + it("skips a non-dynamic group silently: no file, no dynamic block, no error", async () => { + await run(["group", "31", "--with-dynamic", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect(state.resources.static_group).toMatchObject({ id: 31 }); + await expect(readFile(join(workDir, "rulesets", "static_group.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("--dry-run --with-dynamic previews the dynamic block without writing the ruleset file", async () => { + await run(["group", "30", "--with-dynamic", "--dry-run", "--state", statePath]); + await expect(readFile(join(workDir, "rulesets", "all_mainz.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("works across a bulk selection: captures dynamic only for the groups that are actually dynamic", async () => { + await run(["group", "30", "31", "--with-dynamic", "--state", statePath]); + const rulesetPath = join(workDir, "rulesets", "all_mainz.json"); + await expect(readFile(rulesetPath, "utf8")).resolves.toBeTruthy(); + await expect(readFile(join(workDir, "rulesets", "static_group.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("end-to-end: adopting a dynamic group with --with-dynamic makes `ct plan` a no-op (#51 acceptance)", async () => { + await run(["group", "30", "--with-dynamic", "--state", statePath]); + const state = await loadState(statePath, HOST); + const managed = state.resources.all_mainz!; + + // Reconstruct the config a user would paste from the printed snippet: the plain group fields + // plus the emitted `dynamic` block, referencing the file `--with-dynamic` just wrote. + const { ct, resources } = createContext(); + ct.group({ + key: "all_mainz", + name: managed.fields.name as string, + groupTypeId: managed.fields.groupTypeId as number, + groupStatusId: managed.fields.groupStatusId as number, + dynamic: { status: "active", ruleset: { ref: "./rulesets/all_mainz.json" } }, + }); + + const { plan } = await buildPlan(client as unknown as Pick, state, resources, { + configDir: workDir, + }); + expect(plan.items.every((i) => i.action === "no-op")).toBe(true); + }); +});