diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1d3a761 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Keep text files LF in the working tree on every platform. The docs staleness gate +# hashes source-file bytes, so a CRLF checkout would otherwise produce different +# `sources_hash` values on Windows than the ones CI computes on Linux. +* text=auto eol=lf + +# Never touched by line-ending normalisation. +*.png binary +*.jpg binary +*.gif binary +*.ico binary diff --git a/.github/scripts/docs-staleness.mjs b/.github/scripts/docs-staleness.mjs new file mode 100644 index 0000000..8f22a88 --- /dev/null +++ b/.github/scripts/docs-staleness.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +// Staleness gate for docs/handbuch/ — the pages published into the Handbuch's +// "ChurchTools-Grundlagen" section. +// +// Each page declares the code it documents in `sources:` and signs it with +// `sources_hash`. Change a declared source without re-signing the page and this +// fails, so that someone re-reads the page against the new code. Bumping +// `reviewed:` alone does NOT satisfy the gate — that is the entire point. +// +// The signature is a sha256 over each resolved file's `\0\0`, +// files sorted by path, truncated to 16 hex chars. This is a port of +// `App\Services\Docs\SourcesHasher` from the estate-wide checker; it is inlined here +// because that implementation lives in a private repo and a public repo cannot call a +// reusable workflow out of one. Verified to reproduce the hashes the PHP signer wrote. +// +// node .github/scripts/docs-staleness.mjs # check (CI) +// node .github/scripts/docs-staleness.mjs --sign # re-sign every stale page + +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +const ROOT = process.cwd(); +const DOCS_DIR = "docs/handbuch"; +const SIGN = process.argv.includes("--sign"); + +/** + * Frontmatter fields this gate reads. The subset is deliberate — no YAML dependency. + * + * Strictness is asymmetric on purpose, because the two directions are not equally bad: + * + * - Inside `sources:`, ANY line not positively understood is an error, never a skip. + * A dropped entry means the gate passes while the docs are stale — exactly what it + * exists to prevent — and `--sign` would then bake the truncation in permanently. + * - Under any OTHER key, indented continuation lines are skipped. Those are foreign + * to this gate (`hide:` / `extra:` are ordinary mkdocs-material directives), and + * rejecting them would fail a page for frontmatter that is none of our business. + */ +function parseFrontmatter(text, page) { + // Tolerate CRLF so a Windows checkout does not fail every page; `.gitattributes` + // pins these files to LF, so this is a belt-and-braces path. + const normalized = text.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) throw new Error(`${page}: no frontmatter`); + // Search from 3, not 4: `---\n---\n` is empty-but-terminated frontmatter, and starting + // at 4 would step over its closing delimiter and misreport it as unterminated. + const end = normalized.indexOf("\n---\n", 3); + if (end === -1) throw new Error(`${page}: unterminated frontmatter`); + const body = normalized.slice(4, end + 1); + + const fm = { sources: [] }; + let inSources = false; + let inForeignBlock = false; + for (const [i, line] of body.split("\n").entries()) { + const where = `${page}:${i + 2}`; + if (!line.trim()) continue; + // A comment must not terminate a `sources:` block — that would silently truncate it. + if (/^\s*#/.test(line)) continue; + + if (inSources) { + const item = line.match(/^\s+-\s+(.*)$/); + if (item) { + fm.sources.push(item[1].trim().replace(/^["']|["']$/g, "")); + continue; + } + // Not a list item and not a comment: the block ended. Only a new top-level key + // may do that; anything else (a stray indent, a wrapped line) is an error below. + inSources = false; + } + + // A key we do not read opened a block list or nested mapping; its indented body is + // not ours to validate. `sources:` never lands here — it sets inSources instead. + if (inForeignBlock && /^\s/.test(line)) continue; + inForeignBlock = false; + + const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); + if (!kv) { + throw new Error( + `${where}: cannot parse frontmatter line: ${line.trim()}\n` + + ` (this gate reads flat \`key: value\` pairs plus a \`sources:\` block list)`, + ); + } + const [, key, rawValue] = kv; + const value = rawValue.trim().replace(/^["']|["']$/g, ""); + + // `>` / `|` block scalars would parse as the literal value ">" — truthy, and for + // sources_exempt_reason that silently drops the page from the gate. + if (/^[>|][0-9+-]*$/.test(value)) { + throw new Error(`${where}: YAML block scalars are not supported; put \`${key}\` on one line`); + } + + if (key === "sources") { + if (value === "[]") fm.sources = []; + else if (value === "") inSources = true; + else throw new Error(`${where}: \`sources:\` must be \`[]\` or a block list`); + continue; + } + // An empty value on a key we do not read opens a block we should ignore, not police. + if (value === "") inForeignBlock = true; + fm[key] = value; + } + return { fm, frontmatterEnd: end + 5 }; +} + +/** Walk a directory, returning repo-relative paths. */ +function walk(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (entry.isFile()) out.push(relative(ROOT, full).split(sep).join("/")); + } + return out; +} + +/** + * Resolve one `sources:` entry to repo-relative file paths. Supports a literal path, + * a TRAILING `**` prefix-walk (`src/permissions/**`) and `*` within a single segment. + * + * `**` anywhere but the end is rejected rather than approximated: the obvious reading + * of `src/**\/*.ts` is "the .ts files", but a prefix-walk returns every file under + * `src/`, and a bare `**` prefix would walk the repo root and pull in .git/ and + * node_modules/ — making the hash depend on untracked local state. + */ +function resolve(pattern) { + if (!pattern.includes("*")) { + try { + if (statSync(join(ROOT, pattern)).isFile()) return [pattern]; + return walk(join(ROOT, pattern)); + } catch { + throw new Error(`sources entry does not exist: ${pattern}`); + } + } + + const deep = pattern.indexOf("**"); + if (deep !== -1) { + if (!pattern.endsWith("/**") || pattern.indexOf("**") !== pattern.lastIndexOf("**")) { + throw new Error(`\`**\` is only supported as a trailing prefix-walk (\`dir/**\`): ${pattern}`); + } + const base = pattern.slice(0, deep).replace(/\/$/, ""); + if (!base) throw new Error(`\`**\` needs a directory prefix: ${pattern}`); + return walk(join(ROOT, base)); + } + + const rx = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$"); + const dir = pattern.slice(0, pattern.lastIndexOf("/")); + return walk(join(ROOT, dir)).filter((f) => rx.test(f)); +} + +function signature(sources) { + const files = [...new Set(sources.flatMap(resolve))].sort(); + if (!files.length) throw new Error("sources resolved to no files"); + const h = createHash("sha256"); + for (const f of files) { + h.update(f); + h.update("\0"); + h.update(readFileSync(join(ROOT, f))); + h.update("\0"); + } + return h.digest("hex").slice(0, 16); +} + +const pages = readdirSync(join(ROOT, DOCS_DIR)) + .filter((f) => f.endsWith(".md")) + .map((f) => `${DOCS_DIR}/${f}`) + .sort(); + +const stale = []; +const errors = []; + +for (const page of pages) { + const text = readFileSync(join(ROOT, page), "utf8"); + let parsed; + try { + parsed = parseFrontmatter(text, page); + } catch (e) { + errors.push(e.message); + continue; + } + const { fm } = parsed; + + if (fm.sources_exempt_reason) { + if (fm.sources.length) errors.push(`${page}: exempt pages must declare \`sources: []\``); + console.log(` exempt ${page} — ${fm.sources_exempt_reason}`); + continue; + } + + if (!fm.sources.length) { + errors.push(`${page}: no \`sources:\` and no \`sources_exempt_reason:\``); + continue; + } + + let actual; + try { + actual = signature(fm.sources); + } catch (e) { + errors.push(`${page}: ${e.message}`); + continue; + } + + if (actual === fm.sources_hash) { + console.log(` ok ${page}`); + continue; + } + + stale.push({ page, expected: fm.sources_hash ?? "(unsigned)", actual }); + if (SIGN) { + // Rewrite the whole line rather than string-matching the parsed value: the parser + // strips quotes, so `sources_hash: "abc"` would never match `sources_hash: abc` and + // the write would silently no-op while reporting success. + const signed = fm.sources_hash + ? text.replace(/^sources_hash:.*$/m, `sources_hash: ${actual}`) + : text.replace(/^---\n/, `---\nsources_hash: ${actual}\n`); + if (signed === text) { + errors.push(`${page}: could not write sources_hash — the frontmatter is not in the expected shape`); + continue; + } + writeFileSync(join(ROOT, page), signed); + console.log(` signed ${page} → ${actual}`); + } else { + console.log(` STALE ${page} — signed ${fm.sources_hash ?? "(unsigned)"}, now ${actual}`); + } +} + +if (errors.length) { + console.error("\nFrontmatter errors:"); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} + +if (stale.length && !SIGN) { + console.error( + `\n${stale.length} page(s) document code that changed since they were last read.\n` + + `Re-read each page against its sources, then re-sign:\n\n` + + ` node .github/scripts/docs-staleness.mjs --sign\n\n` + + `Bumping \`reviewed:\` alone does not satisfy this gate.`, + ); + process.exit(1); +} + +console.log(SIGN ? `\n${stale.length} page(s) re-signed.` : `\nAll ${pages.length} page(s) current.`); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc873b6..43f28bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +# Least privilege: this workflow only ever reads the checkout. Without an explicit +# block the token inherits the org default, which is often read-write. +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 724bc93..51e437e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,19 +1,23 @@ name: Docs # Two gates over docs/handbuch/ — the section published into the Handbuch's -# "ChurchTools-Grundlagen" (#89, publishing contract in eqrm/churchtools-connector#1114): +# "ChurchTools-Grundlagen" (#89): # # 1. `build` — mkdocs --strict, so a broken link or a page missing from the nav fails # the PR here rather than silently in the consuming site build. -# 2. `staleness` — the estate-wide reusable checker (eqrm/churchtools-connector#1120). -# It reads each page's `sources:` globs and fails a PR that changes documented code -# without re-signing the page. The single implementation lives in the connector repo -# on purpose: reimplementing it per language would give us four subtly different gates. +# 2. `staleness` — reads each page's `sources:` globs and fails a PR that changes +# documented code without re-signing the page. +# +# The staleness checker is vendored at .github/scripts/docs-staleness.mjs rather than +# called as the estate-wide reusable workflow: this repo is public and a public repo +# cannot call a reusable workflow out of a private one. It is a verified port of the +# canonical `App\Services\Docs\SourcesHasher` and reproduces the hashes that signer +# wrote, so pages stay signable from either side. # # Re-signing a page means recomputing `sources_hash` over its `sources:` — a sha256 over -# each resolved file's `\0\0`, sorted, truncated to 16 hex chars -# (see the connector's `App\Services\Docs\SourcesHasher`). Bumping `reviewed:` alone does -# NOT satisfy the gate; the point is that someone re-read the page against the new code. +# each resolved file's `\0\0`, sorted, truncated to 16 hex chars. +# Bumping `reviewed:` alone does NOT satisfy the gate; the point is that someone re-read +# the page against the new code. on: push: @@ -22,11 +26,13 @@ on: - "docs/handbuch/**" - "src/**" - ".github/workflows/docs.yml" + - ".github/scripts/docs-staleness.mjs" pull_request: paths: - "docs/handbuch/**" - "src/**" - ".github/workflows/docs.yml" + - ".github/scripts/docs-staleness.mjs" permissions: contents: read @@ -47,7 +53,11 @@ jobs: # `src/**` changed without the matching page re-signed → this fails. Signing is the # acknowledgement, not the file appearing in the diff. staleness: - uses: eqrm/churchtools-connector/.github/workflows/docs-staleness.yml@main - with: - docs-path: docs/handbuch - secrets: inherit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Check that documented sources are re-signed + run: node .github/scripts/docs-staleness.mjs diff --git a/.github/workflows/issue-intake.yml b/.github/workflows/issue-intake.yml index 6f12fda..6261ee4 100644 --- a/.github/workflows/issue-intake.yml +++ b/.github/workflows/issue-intake.yml @@ -18,7 +18,9 @@ permissions: jobs: triage: if: github.event.issue.milestone == null - runs-on: [self-hosted, eqrm-ci] + # GitHub-hosted on purpose: self-hosted runners on a public repo are a standing + # risk, and this job only shells out to `gh issue edit`. + runs-on: ubuntu-latest steps: - name: Mark as untriaged env: diff --git a/.gitignore b/.gitignore index 515e26f..83942b2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ dist/ .env.* !.env.example -# Local ChurchTools state / adopted config (belongs in eqrm/ct-structure, never here) +# Local ChurchTools state / adopted config (belongs in your config repo, never here) *.ctstate.json ct-state.json diff --git a/.prettierignore b/.prettierignore index b591866..818ce87 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ dist/ node_modules/ +site/ src/api/schema.d.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fc17be2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing + +This tool was built for one organisation's ChurchTools instance and then generalised, so +the most useful contributions are usually the ones that make it less specific to that +origin: another instance's API quirk, a field the engine does not manage yet, a +ChurchTools version where an endpoint behaves differently. + +## Getting set up + +```bash +npm ci +npm test # vitest — 570-odd unit tests, no network +npm run typecheck && npm run lint +npm run build # -> dist/index.js (the `ct` binary) +npm run dev -- get campuses # run from source against a live instance +``` + +Node ≥ 20; the repo pins 22 via `.nvmrc`. + +To run against a real instance, `ct auth login --host https://your.church.tools` stores +host + token in the **macOS Keychain**. There is no keychain backend on Linux or Windows — +export `CT_LOGINTOKEN` (and `CT_HOST`) instead. Never commit a token — see +[SECURITY.md](SECURITY.md). + +## Things worth knowing before you change engine code + +- **`plan` must stay honest.** A plan that under-reports a change is the worst bug this + tool can have. Anything touching diffing needs a test that pins the rendered plan. +- **A clean apply must round-trip to a no-op.** If applying a config and re-planning shows + a diff, the resource's `managedFields` are wrong — that is a bug, not a quirk. +- **People are out of scope, permanently.** `assertNotPeople` guards every write path. + PRs that manage people will be declined regardless of quality; it is a boundary, not a + gap. +- **Live-API tests are opt-in, and writes are triple-gated.** Reads need `CT_LIVE=1`. + Writes additionally need `CT_LIVE_WRITE=1` **and** `CT_LIVE_WRITE_HOST` set to exactly + the authenticated host — a deliberate barrier against a live write landing on prod. + They must target a dev instance and clean up after themselves. CI never runs them. + +## Documentation + +Pages under `docs/handbuch/` declare the code they document in `sources:` frontmatter and +are signed with `sources_hash`. Change a documented source and CI fails until the page is +re-read and re-signed: + +```bash +node .github/scripts/docs-staleness.mjs # what CI runs +node .github/scripts/docs-staleness.mjs --sign # after re-reading the page +``` + +## Commits and releases + +Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) — +`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`. This is load-bearing, not +cosmetic: every push to `main` runs semantic-release, which derives the version and cuts +the GitHub Release from the commit history. A `feat:` gets a minor, a `fix:` a patch, +`BREAKING CHANGE:` in the body a major. + +Open an issue before a large change so the design can be argued about before it is built. +Issues opened without a milestone are labelled `triage` automatically and picked up in the +weekly sweep; nothing is closed for being old. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..245b96c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Felix Kotschenreuther + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index caee814..9c6d1b8 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,11 @@ ct apply --env prod # protected env: type the env name to confirm | Repo | Contents | | --- | --- | | **`eqrm/ct-cli`** (this repo) | The tool: CLI, API client, plan/apply engine. Generic, reusable. | -| **`eqrm/ct-structure`** | Equippers' actual desired-state config + state files. Depends on this tool. | +| *your config repo* (private) | Your instance's desired-state config + state files. Depends on this tool. | -Like Terraform, the tool never lives in the same repo as the infra config. +Like Terraform, the tool never lives in the same repo as the infra config — the config +repo holds an organisation's actual structure and stays private. This repo is the tool +only; it carries no instance data. ## Documentation @@ -207,3 +209,18 @@ guardrails → blueprints) are complete. Phase 6 — reproducible instances, environments, GitOps — is tracked in [#19](https://github.com/eqrm/ct-cli/issues/19); the overall design lives in [#1](https://github.com/eqrm/ct-cli/issues/1). + +Built against a real ChurchTools instance, so a fair amount of the value here is the +written-down API archaeology — see [`docs/api-coverage.md`](docs/api-coverage.md) for +which endpoints actually support which verbs, and +[`docs/runbook-manual-surface.md`](docs/runbook-manual-surface.md) for what ChurchTools +gives no API for at all. + +## Contributing + +Issues and PRs are welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md). Security reports +go through [private vulnerability reporting](SECURITY.md), not a public issue. + +## License + +[MIT](LICENSE). Not affiliated with or endorsed by ChurchTools. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f6a3ccc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security policy + +## Reporting a vulnerability + +Please report security issues through GitHub's +[private vulnerability reporting](https://github.com/eqrm/ct-cli/security/advisories/new) +rather than a public issue. Expect an initial response within a week. + +## What this tool touches + +`ct` authenticates to a ChurchTools instance with a **personal login token** that carries +your own permissions, and it performs writes (`ct apply`, `ct destroy`). When assessing +impact, the things worth knowing: + +- **Tokens are never written to the repo.** They live in the **macOS Keychain** + (`ct auth login`, keyed per host) or in `CT_LOGINTOKEN` for CI. There is no keychain + backend on Linux or Windows — those platforms must use `CT_LOGINTOKEN`. `ct.envs.json` + stores only the _name_ of a token env var, never a value, which is why it is safe to + commit. +- **Storing a token exposes it briefly to `ps`.** `ct auth login` shells out to + `security add-generic-password -w `, so the value sits in that process's argv for + the duration of the call. On a shared machine, assume another local user could observe + it at that moment. +- **People are never _written_.** Persons and memberships are out of scope by design. The + load-bearing control is the resource registry: writes can only target the structural + resource kinds it defines, and there is no person kind. `assertNotPeople` sits on top of + every write path as a belt-and-braces second check. Reads are not guarded: + `ct get raw /persons` returns person records, because `ct get raw` is a deliberate + general-purpose API passthrough. The guarantee is that `ct` never creates, edits or + deletes people — not that it cannot display them. +- **Writes are opt-in and ordered.** `plan` is the default; `apply` needs explicit + confirmation, protected environments additionally require the env name to be typed (or + `--confirm-env`), and deletions never happen implicitly. +- **Grants are real permissions.** A config that declares permission grants can widen + access on the target instance. Review a `plan` before applying it, the same way you + would a Terraform plan. + +## Scope + +Vulnerabilities in this CLI are in scope. Vulnerabilities in ChurchTools itself are not — +report those to [ChurchTools](https://church.tools) directly. diff --git a/docs/README.md b/docs/README.md index f0b72a3..92d1566 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,9 +6,8 @@ Two audiences, kept apart on purpose (#89). The **generic ChurchTools reference**: how ChurchTools' own permission model, dynamic groups and field definitions behave, independent of any one instance. -These pages are published into the Handbuch's "ChurchTools-Grundlagen" section -(eqrm/churchtools-connector#1114), so they carry `sources:` frontmatter and are -gated by the estate-wide staleness checker. +These pages are published into a wider ChurchTools Handbuch, so they carry +`sources:` frontmatter and are gated by the staleness checker (#89). | Page | About | | --- | --- | @@ -36,10 +35,16 @@ fails the `Docs / staleness` check — the point being that someone re-read the page against the new code. Bumping `reviewed:` alone does **not** satisfy it. The signature is a sha256 over each resolved file's -`\0\0`, files sorted, truncated to 16 hex chars — -the canonical implementation is `App\Services\Docs\SourcesHasher` in -eqrm/churchtools-connector, and `php artisan docs:sign ` in a checkout of -that repo writes it. +`\0\0`, files sorted, truncated to 16 hex chars. +Check and re-sign locally: + +```bash +node .github/scripts/docs-staleness.mjs # what CI runs +node .github/scripts/docs-staleness.mjs --sign # re-sign, after re-reading +``` + +A page with no code behaviour to track declares `sources: []` plus a +`sources_exempt_reason:` instead. ## Everything else — developer- and operator-facing, unpublished diff --git a/docs/ci.md b/docs/ci.md index 8ad55b5..d6af451 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -20,7 +20,7 @@ Skip the keychain with two env vars (see [`src/auth/tokenStore.ts`](https://github.com/eqrm/ct-cli/blob/main/src/auth/tokenStore.ts)): ```bash -export CT_HOST=https://eqrm.church.tools +export CT_HOST=https://mychurch.church.tools export CT_LOGINTOKEN= ct plan ``` diff --git a/docs/environments.md b/docs/environments.md index b4f53e7..1997f38 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -1,6 +1,6 @@ # Environments (dev → prod promotion) -One config repo can drive several ChurchTools instances — e.g. an `eqrm-dev` +One config repo can drive several ChurchTools instances — e.g. a `dev` rehearsal env and the real `prod` — Terraform-workspace-style, with **no file edits** when switching. @@ -12,9 +12,9 @@ path; override with `CT_ENVS`): ```json { "environments": { - "dev": { "host": "https://eqrm-dev.church.tools" }, + "dev": { "host": "https://mychurch-dev.church.tools" }, "prod": { - "host": "https://eqrm.church.tools", + "host": "https://mychurch.church.tools", "state": "ct-state.prod.json", "protected": true, "tokenEnv": "CT_PROD_TOKEN" diff --git a/docs/handbuch/index.md b/docs/handbuch/index.md index 1347b3e..5a1c46c 100644 --- a/docs/handbuch/index.md +++ b/docs/handbuch/index.md @@ -22,9 +22,9 @@ each page marks explicitly what was **verified** and what is still an | Layer | Repo | Example | | --- | --- | --- | -| **Generic ChurchTools behaviour** | `eqrm/ct-cli` `docs/handbuch/` (these pages) | "A `group_type_role` grant reaches every group of that type" | -| **This instance's structure** | `eqrm/ct-structure` `docs/` | "Which Bereiche exist at Equippers and why" | -| **Connector-specific how-tos** | `eqrm/churchtools-connector` Handbuch | "How to run check-in" | +| **Generic ChurchTools behaviour** | `ct-cli` `docs/handbuch/` (these pages) | "A `group_type_role` grant reaches every group of that type" | +| **One instance's structure** | that organisation's private config repo, `docs/` | "Which Bereiche exist here and why" | +| **Tool-specific how-tos** | the relevant tool's own Handbuch | "How to run check-in" | ## The pages diff --git a/docs/handbuch/mkdocs.yml b/docs/handbuch/mkdocs.yml index 3e7ab0f..8fa7ae7 100644 --- a/docs/handbuch/mkdocs.yml +++ b/docs/handbuch/mkdocs.yml @@ -1,5 +1,4 @@ -# Mini config for ct-cli's section of the Handbuch (#89, publishing contract in -# eqrm/churchtools-connector#1114). +# Mini config for ct-cli's section of a wider ChurchTools Handbuch (#89). # # A page publishes only if it lives under docs/handbuch/ and is reachable from this # nav — everything else in docs/ (api-coverage.md, group-field-decisions.md, @@ -7,7 +6,7 @@ # developer- and operator-facing material, not reader-facing reference. # # These pages are the GENERIC ChurchTools reference — how ChurchTools' own group, -# permission and dynamic-group model actually works — as opposed to eqrm/ct-structure's +# permission and dynamic-group model actually works — as opposed to a config repo's # docs/, which describe one specific instance. That is why they belong in the Handbuch's # "ChurchTools-Grundlagen" section rather than in this repo's README. # @@ -17,8 +16,8 @@ # least match the page text while the mismatch stands. # # Built standalone via `mkdocs build -f docs/handbuch/mkdocs.yml --strict`; -# eqrm/churchtools-connector#1115 replaces that with an `!include` from the root config -# once mkdocs-monorepo-plugin exists. +# the consuming site replaces that with an `!include` from its root config once +# mkdocs-monorepo-plugin exists. site_name: ct-cli — ChurchTools-Grundlagen site_description: Generic ChurchTools reference — permissions, dynamic groups, field definitions, blueprints docs_dir: . diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 9e4e854..d10b596 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -5,8 +5,8 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 49ef267c4843eeb4 -reviewed: 2026-08-10 +sources_hash: ffcfe8dc0516ccf3 +reviewed: 2026-08-13 --- # Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`) diff --git a/docs/handbuch/requirements.txt b/docs/handbuch/requirements.txt index be750bc..6c64526 100644 --- a/docs/handbuch/requirements.txt +++ b/docs/handbuch/requirements.txt @@ -1,5 +1,5 @@ # Pinned toolchain for this section's standalone MkDocs build (`.github/workflows/docs.yml`). -# Kept in lockstep with eqrm/churchtools-connector's `requirements-docs.txt` so the section +# Kept in lockstep with the consuming Handbuch site's docs requirements so the section # renders identically here and in the site that consumes it — bump both together, and # deliberately (`pip index versions `), never via `pip install -U`. mkdocs==1.6.1 diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 0901903..56def4a 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -23,11 +23,10 @@ Every item below falls into exactly one of three buckets: | Meeting points (Treffpunkte) | A group's meeting-location master data | No endpoint at all — zero matches for `treffpunkt`/`meetingpoint` anywhere in the OpenAPI spec ([`docs/api-coverage.md`](api-coverage.md) #11) | Group admin → meeting point field on a group | No API verification possible; visually confirm in the UI. (Do not confuse with _meeting templates_ `/group/meetingtemplates` or _group meetings_ `/groups/{id}/meetings`, both full CRUD but different concepts — confirm with product if "meeting point" was meant to be one of those instead) | | Permission name↔authId catalog | The mapping from human-readable `module:right` names to the numeric `authId` the API actually writes | Not exposed by the REST API at all; only servable via the legacy `POST /index.php?q=churchauth/ajax&func=getMasterData` call ([`src/permissions/README.md`](../src/permissions/README.md)) | Permission editor (any role's right-picker enumerates the live set) | Regeneration procedure below (**Permission catalog lifecycle**) | -**Expected values for our instance(s):** left blank here deliberately — this +**Expected values for a given instance:** left blank here deliberately — this runbook is generic (part of `ct-cli`, the tool repo). The per-instance expected/desired values (which member statuses, which meeting points) belong -in `eqrm/ct-structure`'s own runbook once #23 creates that repo, following -this doc's structure. +in that instance's own config repo, in a runbook following this doc's structure. ## Not yet implemented — API supports it, `ct` doesn't drive it yet @@ -153,8 +152,8 @@ in where they'd otherwise be silently skipped: - The exact expected/desired **values** (which member statuses, which meeting points, which campus-per-group assignments) are instance-specific - and deliberately not enumerated here — they belong in `eqrm/ct-structure` - once #23 creates that repo. This runbook only tracks _what category_ of + and deliberately not enumerated here — they belong in the instance's own + private config repo. This runbook only tracks _what category_ of manual step exists and _how to verify_ it, not the target values for any particular ChurchTools instance. - "Meeting point" (Treffpunkt) has no confirmed CT concept mapping — `docs/api-coverage.md` diff --git a/eslint.config.js b/eslint.config.js index dbd55b8..f2d419f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,7 +2,9 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist/", "node_modules/", "src/api/schema.d.ts"] }, + // `site/` is the mkdocs build output (docs/README.md tells contributors to build it + // locally); without this, `npm run lint` drowns in minified vendor bundles. + { ignores: ["dist/", "node_modules/", "site/", "src/api/schema.d.ts"] }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/examples/ct.envs.json b/examples/ct.envs.json index d295a35..b2ca585 100644 --- a/examples/ct.envs.json +++ b/examples/ct.envs.json @@ -1,10 +1,10 @@ { "environments": { "dev": { - "host": "https://eqrm-dev.church.tools" + "host": "https://mychurch-dev.church.tools" }, "prod": { - "host": "https://eqrm.church.tools", + "host": "https://mychurch.church.tools", "state": "ct-state.prod.json", "protected": true, "tokenEnv": "CT_PROD_TOKEN" diff --git a/package.json b/package.json index 687b832..c37f8f7 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@eqrm/ct-cli", "version": "0.1.0", "description": "ChurchTools structure-as-code CLI — declarative plan/apply for the overarching, rights-bearing structure (campuses, groups, hierarchies, permissions, auto-groups). Never manages people.", + "license": "MIT", "type": "module", "repository": { "type": "git", diff --git a/src/config.ts b/src/config.ts index 035c03a..ffff560 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,7 +11,7 @@ import { readStoredHost } from "./auth/tokenStore.js"; export interface CtConfig { - /** Base host, e.g. `https://eqrm.church.tools` (no trailing slash, no `/api`). */ + /** Base host, e.g. `https://mychurch.church.tools` (no trailing slash, no `/api`). */ host: string; } diff --git a/src/env/envs.ts b/src/env/envs.ts index 5ac3841..9f8338c 100644 --- a/src/env/envs.ts +++ b/src/env/envs.ts @@ -10,8 +10,8 @@ * ```json * { * "environments": { - * "dev": { "host": "https://eqrm-dev.church.tools" }, - * "prod": { "host": "https://eqrm.church.tools", "protected": true, "tokenEnv": "CT_PROD_TOKEN" } + * "dev": { "host": "https://mychurch-dev.church.tools" }, + * "prod": { "host": "https://mychurch.church.tools", "protected": true, "tokenEnv": "CT_PROD_TOKEN" } * } * } * ``` diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index d075346..518b865 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -18,7 +18,7 @@ export type DomainType = "group_role" | "group_type_role" | "status"; // gated declaration/adoption/diff on the authId. That was too broad — it also excluded the // admin-authored `authId >= 10000` group-member rights (e.g. "Add group members" 10107) that CT DOES // let you write on a group_type_role domain and that Equippers prod actually curates. Verified live -// on eqrm prod `group_type_role/9`: 24 such rows carry `isInherited:false` and `modifiedPid` 1/3891. +// on prod `group_type_role/9`: 24 such rows carry `isInherited:false` and a real admin's `modifiedPid`. // The real, verified boundary is inheritance + system-baseline, which normalizeActual already // enforces on every path that reads actuals — the #65 no-op guarantee holds without the authId cutoff.) diff --git a/tests/adopt-grants-command.test.ts b/tests/adopt-grants-command.test.ts index 829d3e8..afb2f9a 100644 --- a/tests/adopt-grants-command.test.ts +++ b/tests/adopt-grants-command.test.ts @@ -16,7 +16,7 @@ vi.mock("../src/api/session.js", () => ({ const { adoptCommand } = await import("../src/commands/adopt.js"); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const originalHost = process.env.CT_HOST; async function run(args: string[]): Promise { diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index bbd269c..8f04353 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -113,7 +113,7 @@ const { adoptCommand } = await import("../src/commands/adopt.js"); const { loadState } = await import("../src/state/state.js"); const { loadConfig } = await import("../src/config/load.js"); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const originalHost = process.env.CT_HOST; const originalCwd = process.cwd(); diff --git a/tests/adopt.test.ts b/tests/adopt.test.ts index 14ba3bf..4c47173 100644 --- a/tests/adopt.test.ts +++ b/tests/adopt.test.ts @@ -14,7 +14,7 @@ const { adoptCommand } = await import("../src/commands/adopt.js"); const { loadState } = await import("../src/state/state.js"); const statePath = join(tmpdir(), `ct-cli-adopt-${process.pid}.json`); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; async function runAdopt(args: string[]): Promise { await adoptCommand().parseAsync(args, { from: "user" }); diff --git a/tests/apply-env-command.test.ts b/tests/apply-env-command.test.ts index 70a44c5..117aa0f 100644 --- a/tests/apply-env-command.test.ts +++ b/tests/apply-env-command.test.ts @@ -43,8 +43,8 @@ vi.mock("../src/engine/backup.js", () => ({ writeBackup: vi.fn(async () => "back const { applyCommand } = await import("../src/commands/apply.js"); const { saveState, emptyState } = await import("../src/state/state.js"); -const DEV = "https://eqrm-dev.church.tools"; -const PROD = "https://eqrm.church.tools"; +const DEV = "https://mychurch-dev.church.tools"; +const PROD = "https://mychurch.church.tools"; const envsPath = join(tmpdir(), `ct-cli-applyenv-envs-${process.pid}.json`); const devState = join(tmpdir(), `ct-cli-applyenv-dev-${process.pid}.json`); const prodState = join(tmpdir(), `ct-cli-applyenv-prod-${process.pid}.json`); diff --git a/tests/apply-refresh-command.test.ts b/tests/apply-refresh-command.test.ts index 05e859d..e1ebbf6 100644 --- a/tests/apply-refresh-command.test.ts +++ b/tests/apply-refresh-command.test.ts @@ -63,7 +63,7 @@ const { applyCommand } = await import("../src/commands/apply.js"); const { loadState, saveState, emptyState } = await import("../src/state/state.js"); const statePath = join(tmpdir(), `ct-cli-apply-refresh-${process.pid}.json`); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; async function runApply(args: string[]): Promise { await applyCommand().parseAsync(args, { from: "user" }); diff --git a/tests/ctClient.test.ts b/tests/ctClient.test.ts index 26e6f5b..a985fb6 100644 --- a/tests/ctClient.test.ts +++ b/tests/ctClient.test.ts @@ -18,12 +18,12 @@ describe("CtClient", () => { const fetchMock = vi .fn() .mockResolvedValueOnce( - jsonResponse({ data: { id: 7, firstName: "Ada" } }, { setCookie: "ChurchTools_ct_eqrm=abc; Path=/" }), + jsonResponse({ data: { id: 7, firstName: "Ada" } }, { setCookie: "ChurchTools_ct_mychurch=abc; Path=/" }), ) .mockResolvedValueOnce(jsonResponse({ data: "csrf-123" })); vi.stubGlobal("fetch", fetchMock); - const client = new CtClient({ host: "https://eqrm.church.tools" }); + const client = new CtClient({ host: "https://mychurch.church.tools" }); const me = await client.authenticate("tok"); expect(me).toEqual({ id: 7, firstName: "Ada" }); @@ -33,7 +33,7 @@ describe("CtClient", () => { }); it("refuses requests before authentication", async () => { - const client = new CtClient({ host: "https://eqrm.church.tools" }); + const client = new CtClient({ host: "https://mychurch.church.tools" }); await expect(client.get("/campuses")).rejects.toBeInstanceOf(CtApiError); }); @@ -45,7 +45,7 @@ describe("CtClient", () => { .mockResolvedValueOnce(jsonResponse({ data: { id: 99 } })); vi.stubGlobal("fetch", fetchMock); - const client = new CtClient({ host: "https://eqrm.church.tools" }); + const client = new CtClient({ host: "https://mychurch.church.tools" }); await client.authenticate("tok"); await client.request("POST", "/campuses", { name: "Mainz" }); @@ -61,7 +61,7 @@ describe("CtClient", () => { .mockResolvedValueOnce(jsonResponse({ data: { id: 1 } }, { setCookie: "s=1; Path=/" })) .mockResolvedValueOnce(jsonResponse({ data: "csrf" })); vi.stubGlobal("fetch", fetchMock); - const client = new CtClient({ host: "https://eqrm.church.tools" }); + const client = new CtClient({ host: "https://mychurch.church.tools" }); await client.authenticate("tok"); return { client, fetchMock }; } diff --git a/tests/destroy-command.test.ts b/tests/destroy-command.test.ts index 216703e..c919be8 100644 --- a/tests/destroy-command.test.ts +++ b/tests/destroy-command.test.ts @@ -44,7 +44,7 @@ const { destroyCommand } = await import("../src/commands/destroy.js"); const { saveState, loadState, emptyState } = await import("../src/state/state.js"); const statePath = join(tmpdir(), `ct-cli-destroy-cmd-${process.pid}.json`); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const originalHost = process.env.CT_HOST; function group(key: string, id: number, extra: Record = {}) { diff --git a/tests/destroy-env-command.test.ts b/tests/destroy-env-command.test.ts index 5f95fe5..b16915e 100644 --- a/tests/destroy-env-command.test.ts +++ b/tests/destroy-env-command.test.ts @@ -23,7 +23,7 @@ vi.mock("../src/engine/backup.js", () => ({ writeBackup: vi.fn(async () => "back const { destroyCommand } = await import("../src/commands/destroy.js"); const { saveState, loadState, emptyState } = await import("../src/state/state.js"); -const PROD = "https://eqrm.church.tools"; +const PROD = "https://mychurch.church.tools"; const envsPath = join(tmpdir(), `ct-cli-destroyenv-envs-${process.pid}.json`); const prodState = join(tmpdir(), `ct-cli-destroyenv-prod-${process.pid}.json`); const backupDir = join(tmpdir(), `ct-cli-destroyenv-backups-${process.pid}`); diff --git a/tests/env-context.test.ts b/tests/env-context.test.ts index 9a83701..2b5d949 100644 --- a/tests/env-context.test.ts +++ b/tests/env-context.test.ts @@ -11,9 +11,9 @@ async function writeEnvs(): Promise { envsPath, JSON.stringify({ environments: { - dev: { host: "https://eqrm-dev.church.tools" }, + dev: { host: "https://mychurch-dev.church.tools" }, prod: { - host: "https://eqrm.church.tools", + host: "https://mychurch.church.tools", state: "custom-prod-state.json", protected: true, tokenEnv: "CT_PROD_TOKEN", @@ -49,7 +49,7 @@ describe("prepareEnv with --env", () => { const env: NodeJS.ProcessEnv = { CT_ENVS: envsPath }; const result = await prepareEnv({ env: "dev" }, env); expect(result).toEqual({ name: "dev", protected: false, statePath: "ct-state.dev.json" }); - expect(env.CT_HOST).toBe("https://eqrm-dev.church.tools"); + expect(env.CT_HOST).toBe("https://mychurch-dev.church.tools"); }); it("uses the profile state override and surfaces the protected flag", async () => { @@ -61,7 +61,7 @@ describe("prepareEnv with --env", () => { protected: true, statePath: "custom-prod-state.json", }); - expect(env.CT_HOST).toBe("https://eqrm.church.tools"); + expect(env.CT_HOST).toBe("https://mychurch.church.tools"); }); it("copies the profile's tokenEnv value into CT_LOGINTOKEN (CI path) when present", async () => { diff --git a/tests/envs.test.ts b/tests/envs.test.ts index a6a611a..4a49c60 100644 --- a/tests/envs.test.ts +++ b/tests/envs.test.ts @@ -38,14 +38,14 @@ describe("loadEnvProfile", () => { it("resolves a named profile with a normalized host and defaulted state path", async () => { await writeEnvs({ environments: { - dev: { host: "https://eqrm-dev.church.tools/" }, - prod: { host: "https://eqrm.church.tools", state: "ct-state.prod.json", protected: true }, + dev: { host: "https://mychurch-dev.church.tools/" }, + prod: { host: "https://mychurch.church.tools", state: "ct-state.prod.json", protected: true }, }, }); const dev = await loadEnvProfile("dev", envsPath); expect(dev).toEqual({ name: "dev", - host: "https://eqrm-dev.church.tools", // trailing slash stripped + host: "https://mychurch-dev.church.tools", // trailing slash stripped statePath: "ct-state.dev.json", // convention default protected: false, tokenEnv: undefined, @@ -53,7 +53,7 @@ describe("loadEnvProfile", () => { const prod = await loadEnvProfile("prod", envsPath); expect(prod).toMatchObject({ name: "prod", - host: "https://eqrm.church.tools", + host: "https://mychurch.church.tools", statePath: "ct-state.prod.json", protected: true, }); @@ -61,7 +61,7 @@ describe("loadEnvProfile", () => { it("carries a tokenEnv reference when present", async () => { await writeEnvs({ - environments: { prod: { host: "https://eqrm.church.tools", tokenEnv: "CT_PROD_TOKEN" } }, + environments: { prod: { host: "https://mychurch.church.tools", tokenEnv: "CT_PROD_TOKEN" } }, }); expect((await loadEnvProfile("prod", envsPath)).tokenEnv).toBe("CT_PROD_TOKEN"); }); diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index 7c8b0e7..e9583dc 100644 --- a/tests/permission-adopt.test.ts +++ b/tests/permission-adopt.test.ts @@ -5,7 +5,7 @@ import { desiredTuples } from "../src/permissions/plan.js"; import type { Grant } from "../src/permissions/types.js"; import type { State } from "../src/state/state.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; /** A state file with one managed group "kids" at id 99. */ function stateWithKids(): State { @@ -244,9 +244,10 @@ describe("emitAdoptedGrants", () => { }); it("admin-authored member rights (authId >= 10000) are managed; system/inherited rows excluded (#65)", () => { - // eqrm prod, group_type_role 9 (/Struktur): admin-authored churchdb:+… MEMBER rights (authId >= + // prod, group_type_role 9 (/Struktur): admin-authored churchdb:+… MEMBER rights (authId >= // 10000, isInherited:false, modifiedPid != -1) that CT lets you write — verified live (24 such - // rows, modifiedPid 1/3891). They must be adopted as ACTIVE grants and round-trip to a no-op. + // rows, each with a real admin's modifiedPid). They must be adopted as ACTIVE grants and + // round-trip to a no-op. // The self-re-adding system baseline (modifiedPid === -1) and truly-inherited rows must be // EXCLUDED by normalizeActual and NEVER show up as `toDelete` (the #65 bug was "0 grant, 24 remove"). // The reconciliation boundary is inheritance + system-baseline, NOT the authId — hence the diff --git a/tests/permission-bootstrap.test.ts b/tests/permission-bootstrap.test.ts index 6ee8c2e..db6155a 100644 --- a/tests/permission-bootstrap.test.ts +++ b/tests/permission-bootstrap.test.ts @@ -15,7 +15,7 @@ import type { Plan, DesiredResource } from "../src/engine/types.js"; import type { DesiredPermission } from "../src/permissions/types.js"; import type { CtClient } from "../src/api/ctClient.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; // churchgroup:view group — authId 1104, scoped (scopeField cdb_gruppe). const scopedPerm: DesiredPermission = { diff --git a/tests/permission-pending-domain.test.ts b/tests/permission-pending-domain.test.ts index a67e9b4..7210498 100644 --- a/tests/permission-pending-domain.test.ts +++ b/tests/permission-pending-domain.test.ts @@ -18,7 +18,7 @@ import type { Plan, DesiredResource } from "../src/engine/types.js"; import type { DesiredPermission } from "../src/permissions/types.js"; import type { CtClient } from "../src/api/ctClient.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const STRUKTUR_TYPE_ID = 9; // The #23 config, in miniature: declare a group type AND a group_type_role permission domain that diff --git a/tests/plan-detailed-exitcode-command.test.ts b/tests/plan-detailed-exitcode-command.test.ts index 49c74ab..a551d4a 100644 --- a/tests/plan-detailed-exitcode-command.test.ts +++ b/tests/plan-detailed-exitcode-command.test.ts @@ -46,7 +46,7 @@ vi.mock("../src/permissions/plan.js", () => ({ const { planCommand } = await import("../src/commands/plan.js"); const { saveState, emptyState } = await import("../src/state/state.js"); -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const statePath = join(tmpdir(), `ct-cli-plan-detailed-exitcode-${process.pid}.json`); const originalHost = process.env.CT_HOST; diff --git a/tests/plan-env-command.test.ts b/tests/plan-env-command.test.ts index a16ce9e..24dd76e 100644 --- a/tests/plan-env-command.test.ts +++ b/tests/plan-env-command.test.ts @@ -16,8 +16,8 @@ import type { Plan } from "../src/engine/types.js"; const plan: Plan = { items: [] }; const versionByHost: Record = { - "https://eqrm-dev.church.tools": "3.100.0", - "https://eqrm.church.tools": "3.123.0", + "https://mychurch-dev.church.tools": "3.100.0", + "https://mychurch.church.tools": "3.123.0", }; // authedSession is mocked, but resolveConfig (real) has already read CT_HOST that prepareEnv wired, @@ -46,8 +46,8 @@ vi.mock("../src/permissions/plan.js", () => ({ const { planCommand } = await import("../src/commands/plan.js"); const { saveState, emptyState } = await import("../src/state/state.js"); -const DEV = "https://eqrm-dev.church.tools"; -const PROD = "https://eqrm.church.tools"; +const DEV = "https://mychurch-dev.church.tools"; +const PROD = "https://mychurch.church.tools"; const envsPath = join(tmpdir(), `ct-cli-planenv-envs-${process.pid}.json`); const devState = join(tmpdir(), `ct-cli-planenv-dev-${process.pid}.json`); const prodState = join(tmpdir(), `ct-cli-planenv-prod-${process.pid}.json`); diff --git a/tests/plan.test.ts b/tests/plan.test.ts index 7bfe94d..2f9f316 100644 --- a/tests/plan.test.ts +++ b/tests/plan.test.ts @@ -3,7 +3,7 @@ import { computePlan } from "../src/engine/plan.js"; import type { DesiredResource } from "../src/engine/types.js"; import type { State, ManagedResource } from "../src/state/state.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; function desired( key: string, diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 32abf7e..fa0c4ff 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -172,7 +172,7 @@ describe("write specs", () => { expect(RESOURCES["age-group"]?.itemPath(3)).toBe("/group/agegroups/3"); }); - // Field sets verified live against eqrm.church.tools (2026-07-08). + // Field sets verified live against mychurch.church.tools (2026-07-08). it("snapshots real relationship-type degree fields (degreeNameA/B, not degreeForward/Reverse)", () => { const raw = { name: "relationship.couple", diff --git a/tests/state.test.ts b/tests/state.test.ts index 9f3ca21..4aff229 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { emptyState, loadState, saveState, upsert, findByTypeId, isManaged } from "../src/state/state.js"; -const HOST = "https://eqrm.church.tools"; +const HOST = "https://mychurch.church.tools"; const NOW = "2026-07-07T00:00:00.000Z"; const LATER = "2026-07-08T00:00:00.000Z"; diff --git a/tests/tokenStore-multihost.test.ts b/tests/tokenStore-multihost.test.ts index c9ee69e..f6d11a9 100644 --- a/tests/tokenStore-multihost.test.ts +++ b/tests/tokenStore-multihost.test.ts @@ -14,8 +14,8 @@ import { resetKeychainCache, } from "../src/auth/tokenStore.js"; -const DEV = "https://eqrm-dev.church.tools"; -const PROD = "https://eqrm.church.tools"; +const DEV = "https://mychurch-dev.church.tools"; +const PROD = "https://mychurch.church.tools"; /** Model a keychain as account → stored blob; wire the `security` CLI mock to read/write it. */ function mockKeychain(store: Map): void {