From 1ce46d7951ed16fcda0874b1c4f12db01bf59739 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Sat, 22 Aug 2026 18:21:02 +0200 Subject: [PATCH 1/4] feat: add ct init scaffolding --- README.md | 20 ++++++ src/commands/init.ts | 53 ++++++++++++++ src/index.ts | 2 + src/init.ts | 162 +++++++++++++++++++++++++++++++++++++++++++ tests/cli.test.ts | 4 +- tests/init.test.ts | 105 ++++++++++++++++++++++++++++ 6 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 src/commands/init.ts create mode 100644 src/init.ts create mode 100644 tests/init.test.ts diff --git a/README.md b/README.md index 1b59c0d..8cd377d 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,26 @@ commands. ## First run +Create a config repository without having to assemble its files by hand: + +```bash +mkdir bgk-ct-config +cd bgk-ct-config +ct init +``` + +In an interactive terminal, `ct init` can collect the ChurchTools URL, the first environment name, +and whether to initialize Git. For scripts, pass the answers explicitly: + +```bash +ct init --host https://example.church.tools --env prod --git --yes +ct auth login --host https://example.church.tools --token ... +ct coverage --env prod +``` + +The command creates `ct.config.ts`, `ct.envs.json`, `.gitignore`, `config/`, and `blueprints/`. It +refuses to overwrite existing scaffold files. + ```bash # The host is captured at login and stored with the token; CT_HOST overrides it for CI. ct auth login --host https://mychurch.church.tools --token diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..6e316f5 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,53 @@ +import { createInterface } from "node:readline"; +import { Command } from "commander"; +import { initializeConfigRepository } from "../init.js"; +import { info, success } from "../ui.js"; + +interface InitCommandOptions { + host?: string; + env?: string; + git?: boolean; + yes?: boolean; +} + +function ask(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +export function initCommand(): Command { + return new Command("init") + .description("Initialize a new ct config repository") + .argument("[directory]", "target directory", ".") + .option("--host ", "ChurchTools URL for the first environment") + .option("--env ", "name of the first environment (default: prod)") + .option("--git", "initialize a Git repository") + .option("--no-git", "do not initialize a Git repository") + .option("-y, --yes", "accept defaults and do not prompt") + .action(async (directory: string, opts: InitCommandOptions) => { + const result = await initializeConfigRepository(directory, { + host: opts.host, + environment: opts.env, + git: opts.git, + yes: opts.yes, + ask, + }); + + success(`Initialized ct config repository in ${result.directory}`); + info(`Created: ${[...result.files, ...result.directories.map((name) => `${name}/`)].join(", ")}`); + if (!result.environment) { + info( + "Next: add an environment to ct.envs.json, then run `ct auth login --host --token `.", + ); + } else { + info( + `Next: run \`ct auth login --host ${result.host} --token \`, then \`ct coverage --env ${result.environment}\`.`, + ); + } + }); +} diff --git a/src/index.ts b/src/index.ts index eab581c..cf2574f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { refreshCommand } from "./commands/refresh.js"; import { planCommand } from "./commands/plan.js"; import { applyCommand } from "./commands/apply.js"; import { destroyCommand } from "./commands/destroy.js"; +import { initCommand } from "./commands/init.js"; import { plannedCommands } from "./commands/placeholders.js"; import { isMainModule } from "./isMain.js"; import { error, formatError } from "./ui.js"; @@ -24,6 +25,7 @@ export function buildProgram(): Command { ) .version("0.0.0"); + program.addCommand(initCommand()); program.addCommand(authCommand()); program.addCommand(getCommand()); program.addCommand(adoptCommand()); diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..d419a44 --- /dev/null +++ b/src/init.ts @@ -0,0 +1,162 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { access, mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { normalizeHost } from "./config.js"; + +const execFile = promisify(execFileCallback); + +export const INIT_FILES = ["ct.config.ts", "ct.envs.json", ".gitignore"] as const; +export const INIT_DIRECTORIES = ["config", "blueprints"] as const; + +const CONFIG_TEMPLATE = `/** Desired ChurchTools structure. Add declarations inside this function. */ +export default (ct) => { + // Example: ct.campus({ key: "main", name: "Main Campus", shorty: "MAIN" }); +}; +`; + +const GITIGNORE_TEMPLATE = `# Local secrets +.env +.env.* + +# Generated local output +node_modules/ +reference/ +reports/ +.DS_Store + +# ct-state*.json files intentionally stay tracked: they record what ct manages. +`; + +export interface InitOptions { + host?: string; + environment?: string; + git?: boolean; + /** Skip all questions. Useful for scripts and tests. */ + yes?: boolean; + isTTY?: boolean; + ask?: (question: string) => Promise; + runGitInit?: (directory: string) => Promise; +} + +export interface InitResult { + directory: string; + files: string[]; + directories: string[]; + host?: string; + environment?: string; + gitInitialized: boolean; +} + +function validateEnvironment(name: string): string { + const trimmed = name.trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) { + throw new Error( + `Invalid environment name "${name}". Use letters, numbers, dots, underscores or hyphens.`, + ); + } + return trimmed; +} + +function validateHost(host: string): string { + const trimmed = normalizeHost(host.trim()); + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error(`Invalid ChurchTools URL "${host}". Expected e.g. https://example.church.tools.`); + } + if ((url.protocol !== "https:" && url.protocol !== "http:") || !url.hostname) { + throw new Error(`Invalid ChurchTools URL "${host}". Expected an http(s) URL.`); + } + return trimmed; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function envsTemplate(host?: string, environment?: string): string { + const environments: Record = {}; + if (host && environment) environments[environment] = { host }; + return `${JSON.stringify({ environments }, null, 2)}\n`; +} + +async function defaultGitInit(directory: string): Promise { + await execFile("git", ["init"], { cwd: directory }); +} + +/** + * Scaffold a config repository without touching ChurchTools or credentials. + * Existing scaffold files are rejected in a preflight check and never overwritten. + */ +export async function initializeConfigRepository( + targetDirectory: string, + options: InitOptions = {}, +): Promise { + const directory = resolve(targetDirectory); + + const conflicts: string[] = []; + for (const file of INIT_FILES) { + if (await pathExists(resolve(directory, file))) conflicts.push(file); + } + if (conflicts.length > 0) { + throw new Error( + `Cannot initialize ${directory}: refusing to overwrite existing ${conflicts.join(", ")}.`, + ); + } + + const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY); + const interactive = isTTY && !options.yes && options.ask !== undefined; + const ask = options.ask; + + let host = options.host?.trim(); + let environment = options.environment?.trim(); + let initializeGit = options.git; + + if (interactive && !host) host = (await ask!("ChurchTools URL (leave empty to configure later): ")).trim(); + if (host) { + host = validateHost(host); + if (!environment && interactive) { + environment = (await ask!("First environment name [prod]: ")).trim() || "prod"; + } + environment = validateEnvironment(environment || "prod"); + } else if (environment) { + throw new Error("--env requires --host so the generated environment profile is usable."); + } + if (interactive && initializeGit === undefined) { + const answer = (await ask!("Initialize a Git repository? [y/N] ")).trim(); + initializeGit = /^y(es)?$/i.test(answer); + } + initializeGit ??= false; + + await mkdir(directory, { recursive: true }); + for (const name of INIT_DIRECTORIES) await mkdir(resolve(directory, name), { recursive: true }); + await Promise.all([ + writeFile(resolve(directory, "ct.config.ts"), CONFIG_TEMPLATE, { encoding: "utf8", flag: "wx" }), + writeFile(resolve(directory, "ct.envs.json"), envsTemplate(host, environment), { + encoding: "utf8", + flag: "wx", + }), + writeFile(resolve(directory, ".gitignore"), GITIGNORE_TEMPLATE, { encoding: "utf8", flag: "wx" }), + ]); + + const gitInitialized = initializeGit && !(await pathExists(resolve(directory, ".git"))); + if (gitInitialized) { + await (options.runGitInit ?? defaultGitInit)(directory); + } + + return { + directory, + files: [...INIT_FILES], + directories: [...INIT_DIRECTORIES], + host, + environment, + gitInitialized, + }; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 7917e96..f5710b9 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -4,7 +4,9 @@ import { buildProgram } from "../src/index.js"; describe("ct program", () => { it("registers the core command surface", () => { const names = buildProgram().commands.map((c) => c.name()); - expect(names).toEqual(expect.arrayContaining(["auth", "get", "adopt", "plan", "apply", "destroy"])); + expect(names).toEqual( + expect.arrayContaining(["init", "auth", "get", "adopt", "plan", "apply", "destroy"]), + ); }); it("exposes auth subcommands", () => { diff --git a/tests/init.test.ts b/tests/init.test.ts new file mode 100644 index 0000000..da81867 --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,105 @@ +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { initializeConfigRepository } from "../src/init.js"; +import { loadEnvProfile } from "../src/env/envs.js"; +import { loadConfig } from "../src/config/load.js"; + +const directories: string[] = []; + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "ct-init-")); + directories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("initializeConfigRepository", () => { + it("creates a minimal config repository with a usable environment", async () => { + const directory = await temporaryDirectory(); + + const result = await initializeConfigRepository(directory, { + host: "https://example.church.tools/", + environment: "prod", + git: false, + yes: true, + }); + + expect(result.files).toEqual(["ct.config.ts", "ct.envs.json", ".gitignore"]); + await expect(access(join(directory, "config"))).resolves.toBeUndefined(); + await expect(access(join(directory, "blueprints"))).resolves.toBeUndefined(); + expect(await loadEnvProfile("prod", join(directory, "ct.envs.json"))).toMatchObject({ + host: "https://example.church.tools", + name: "prod", + statePath: "ct-state.prod.json", + }); + await expect(loadConfig(join(directory, "ct.config.ts"))).resolves.toMatchObject({ + resources: [], + permissions: [], + }); + expect(await readFile(join(directory, ".gitignore"), "utf8")).not.toMatch(/^ct-state/m); + }); + + it("creates an empty, valid environments map when no host is supplied", async () => { + const directory = await temporaryDirectory(); + await initializeConfigRepository(directory, { yes: true }); + + expect(JSON.parse(await readFile(join(directory, "ct.envs.json"), "utf8"))).toEqual({ + environments: {}, + }); + }); + + it("collects optional answers interactively", async () => { + const directory = await temporaryDirectory(); + const answers = ["https://example.church.tools", "dev", "yes"]; + const gitInit = vi.fn(async () => undefined); + + const result = await initializeConfigRepository(directory, { + isTTY: true, + ask: async () => answers.shift()!, + runGitInit: gitInit, + }); + + expect(result.environment).toBe("dev"); + expect(result.gitInitialized).toBe(true); + expect(gitInit).toHaveBeenCalledWith(directory); + }); + + it("refuses to overwrite existing scaffold files before writing anything", async () => { + const directory = await temporaryDirectory(); + await writeFile(join(directory, "ct.envs.json"), "keep me", "utf8"); + + await expect(initializeConfigRepository(directory, { yes: true })).rejects.toThrow( + /refusing to overwrite existing ct\.envs\.json/, + ); + await expect(readFile(join(directory, "ct.config.ts"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(directory, "ct.envs.json"), "utf8")).toBe("keep me"); + }); + + it("checks for conflicts before asking questions", async () => { + const directory = await temporaryDirectory(); + await writeFile(join(directory, "ct.config.ts"), "keep me", "utf8"); + const ask = vi.fn(async () => ""); + + await expect(initializeConfigRepository(directory, { isTTY: true, ask })).rejects.toThrow( + /refusing to overwrite/, + ); + expect(ask).not.toHaveBeenCalled(); + }); + + it("validates host and environment options", async () => { + const directory = await temporaryDirectory(); + await expect(initializeConfigRepository(directory, { host: "not a url", yes: true })).rejects.toThrow( + /Invalid ChurchTools URL/, + ); + await expect(initializeConfigRepository(directory, { environment: "prod", yes: true })).rejects.toThrow( + /--env requires --host/, + ); + }); +}); From 57a8ba4b9e10445ae32eb5b19b426b8539b87188 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Sat, 22 Aug 2026 19:36:44 +0200 Subject: [PATCH 2/4] feat: add secure login to ct init --- README.md | 10 ++++--- src/auth/login.ts | 25 ++++++++++++++++++ src/auth/tokenStore.ts | 10 +++---- src/commands/auth.ts | 57 ++++++++++++++++++++++++---------------- src/commands/init.ts | 27 ++++++++++++++++--- src/ui/prompt.ts | 27 +++++++++++++++++++ tests/prompt.test.ts | 21 ++++++++++++++- tests/tokenStore.test.ts | 8 +++++- 8 files changed, 149 insertions(+), 36 deletions(-) create mode 100644 src/auth/login.ts diff --git a/README.md b/README.md index 8cd377d..a059c61 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,16 @@ cd bgk-ct-config ct init ``` -In an interactive terminal, `ct init` can collect the ChurchTools URL, the first environment name, -and whether to initialize Git. For scripts, pass the answers explicitly: +In an interactive terminal, `ct init` collects the ChurchTools URL, the first environment name, +whether to initialize Git, and optionally a personal login token. The token input is hidden; when +provided on macOS, it is verified immediately and stored in the Keychain. On platforms without +supported secure credential storage, `ct init` does not request a token and explains how to use +`CT_HOST` and `CT_LOGINTOKEN` instead. For scripts, pass the non-secret answers explicitly and log +in through environment variables: ```bash ct init --host https://example.church.tools --env prod --git --yes -ct auth login --host https://example.church.tools --token ... +CT_LOGINTOKEN=... ct auth login --host https://example.church.tools ct coverage --env prod ``` diff --git a/src/auth/login.ts b/src/auth/login.ts new file mode 100644 index 0000000..046d0e8 --- /dev/null +++ b/src/auth/login.ts @@ -0,0 +1,25 @@ +import { CtClient, type WhoAmI } from "../api/ctClient.js"; +import { type CtInfo } from "../api/version.js"; +import { normalizeHost } from "../config.js"; +import { storeCredentials } from "./tokenStore.js"; + +export interface LoginResult { + host: string; + me: WhoAmI; + location: string; + info: CtInfo; +} + +/** Verify a personal login token and persist it using the platform credential store. */ +export async function loginWithToken(rawHost: string, rawToken: string): Promise { + const host = normalizeHost(rawHost.trim()); + const token = rawToken.trim(); + if (!host) throw new Error("No host provided."); + if (!token) throw new Error("No token provided."); + + const client = new CtClient({ host }); + const me = await client.authenticate(token); + const location = await storeCredentials({ host, token }); + const info = await client.get("/info"); + return { host, me, location, info }; +} diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index 8a92e78..63554c6 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -37,8 +37,8 @@ export interface Credentials { token: string; } -function isMac(): boolean { - return platform() === "darwin"; +export function supportsCredentialStorage(currentPlatform: NodeJS.Platform = platform()): boolean { + return currentPlatform === "darwin"; } /** Parse the stored blob. Returns null for anything that is not a well-formed {host, token}. */ @@ -117,7 +117,7 @@ async function keychainDelete(account: string): Promise { * with no `--env` — keeps working, and this login becomes the default). */ export async function storeCredentials(creds: Credentials): Promise { - if (!isMac()) { + if (!supportsCredentialStorage()) { throw new Error( "Credential storage requires the macOS Keychain. On other platforms, set CT_HOST and CT_LOGINTOKEN instead.", ); @@ -136,7 +136,7 @@ export async function storeCredentials(creds: Credentials): Promise { * another). With no `host`, return the default / last-login blob (single-host path). */ export async function readCredentials(host?: string): Promise { - if (!isMac()) { + if (!supportsCredentialStorage()) { return null; } if (host === undefined) { @@ -174,7 +174,7 @@ export async function readStoredHost(): Promise { * OTHER hosts are left in place; re-login overwrites them.) */ export async function clearCredentials(): Promise { - if (isMac()) { + if (supportsCredentialStorage()) { const current = await readCredentials(); // default blob → its host's per-host account if (current) { await keychainDelete(current.host); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 454d0de..e33a4eb 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,11 +1,27 @@ import { Command } from "commander"; -import { CtClient } from "../api/ctClient.js"; -import { normalizeHost } from "../config.js"; -import { storeCredentials, readToken, clearCredentials } from "../auth/tokenStore.js"; +import { readToken, clearCredentials, supportsCredentialStorage } from "../auth/tokenStore.js"; +import { loginWithToken, type LoginResult } from "../auth/login.js"; import { authedSession } from "../api/session.js"; -import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "../api/version.js"; +import { meetsMinVersion, MIN_CT_VERSION } from "../api/version.js"; +import { askSecret } from "../ui/prompt.js"; import { success, error, info, warn, out } from "../ui.js"; +export function reportLogin(result: LoginResult): void { + const { host, me, location } = result; + success(`Logged in to ${host} as ${me.firstName ?? ""} ${me.lastName ?? ""} (#${me.id})`.trim()); + info(`Host + token stored in ${location}.`); + + if (result.info.version) { + if (meetsMinVersion(result.info.version)) { + info(`ChurchTools ${result.info.version} (≥ ${MIN_CT_VERSION} required).`); + } else { + warn( + `ChurchTools ${result.info.version} is below the required ${MIN_CT_VERSION} — plan/apply will refuse.`, + ); + } + } +} + export function authCommand(): Command { const cmd = new Command("auth").description("Authenticate against ChurchTools"); @@ -15,35 +31,30 @@ export function authCommand(): Command { .option("-H, --host ", "ChurchTools host, e.g. https://mychurch.church.tools (or set CT_HOST)") .option("-t, --token ", "personal login token (or set CT_LOGINTOKEN)") .action(async (opts: { host?: string; token?: string }) => { + if (!supportsCredentialStorage()) { + error( + "Secure credential storage is currently available only through the macOS Keychain. " + + "On this platform, set CT_HOST and CT_LOGINTOKEN in your environment instead.", + ); + process.exitCode = 1; + return; + } const rawHost = opts.host?.trim() || process.env.CT_HOST?.trim(); if (!rawHost) { error("No host provided. Pass --host or set CT_HOST."); process.exitCode = 1; return; } - const token = opts.token?.trim() || process.env.CT_LOGINTOKEN?.trim(); + let token = opts.token?.trim() || process.env.CT_LOGINTOKEN?.trim(); + if (!token && process.stdin.isTTY) { + token = (await askSecret("Personal login token (input hidden): ")).trim(); + } if (!token) { - error("No token provided. Pass --token or set CT_LOGINTOKEN."); + error("No token provided. Enter it interactively, pass --token , or set CT_LOGINTOKEN."); process.exitCode = 1; return; } - const config = { host: normalizeHost(rawHost) }; - const client = new CtClient(config); - const me = await client.authenticate(token); - const location = await storeCredentials({ host: config.host, token }); - success(`Logged in to ${config.host} as ${me.firstName ?? ""} ${me.lastName ?? ""} (#${me.id})`.trim()); - info(`Host + token stored in ${location}.`); - - const ctInfo = await client.get("/info"); - if (ctInfo.version) { - if (meetsMinVersion(ctInfo.version)) { - info(`ChurchTools ${ctInfo.version} (≥ ${MIN_CT_VERSION} required).`); - } else { - warn( - `ChurchTools ${ctInfo.version} is below the required ${MIN_CT_VERSION} — plan/apply will refuse.`, - ); - } - } + reportLogin(await loginWithToken(rawHost, token)); }); cmd diff --git a/src/commands/init.ts b/src/commands/init.ts index 6e316f5..a75c370 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,11 @@ import { createInterface } from "node:readline"; import { Command } from "commander"; +import { loginWithToken } from "../auth/login.js"; +import { supportsCredentialStorage } from "../auth/tokenStore.js"; import { initializeConfigRepository } from "../init.js"; +import { askSecret } from "../ui/prompt.js"; import { info, success } from "../ui.js"; +import { reportLogin } from "./auth.js"; interface InitCommandOptions { host?: string; @@ -45,9 +49,26 @@ export function initCommand(): Command { "Next: add an environment to ct.envs.json, then run `ct auth login --host --token `.", ); } else { - info( - `Next: run \`ct auth login --host ${result.host} --token \`, then \`ct coverage --env ${result.environment}\`.`, - ); + if (process.stdin.isTTY && !opts.yes && supportsCredentialStorage()) { + const token = ( + await askSecret("Personal login token (input hidden; leave empty to log in later): ") + ).trim(); + if (token) { + reportLogin(await loginWithToken(result.host!, token)); + info(`Next: run \`ct coverage --env ${result.environment}\`.`); + return; + } + } + if (supportsCredentialStorage()) { + info( + `Next: run \`ct auth login --host ${result.host}\`, then \`ct coverage --env ${result.environment}\`.`, + ); + } else { + info( + `Next: set \`CT_HOST=${result.host}\` and \`CT_LOGINTOKEN\` in your environment, ` + + `then run \`ct coverage --env ${result.environment}\`.`, + ); + } } }); } diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts index 6930ea6..d91961b 100644 --- a/src/ui/prompt.ts +++ b/src/ui/prompt.ts @@ -4,6 +4,7 @@ * the process's TTY state and a readline question on stdin. */ import { createInterface } from "node:readline"; +import { Writable } from "node:stream"; export interface PromptOptions { isTTY?: boolean; @@ -24,6 +25,32 @@ function ttyState(opts: PromptOptions): boolean { return opts.isTTY ?? Boolean(process.stdin.isTTY); } +export interface SecretPromptOptions { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +} + +/** Read a secret from the terminal without echoing its characters. */ +export function askSecret(message: string, opts: SecretPromptOptions = {}): Promise { + const input = opts.input ?? process.stdin; + const output = opts.output ?? process.stderr; + const mutedOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); + const rl = createInterface({ input, output: mutedOutput, terminal: true }); + + output.write(message); + return new Promise((resolve) => { + rl.question("", (answer) => { + rl.close(); + output.write("\n"); + resolve(answer); + }); + }); +} + /** Yes/No confirmation. `assumeYes` skips the prompt (for `-y`/CI). */ export async function confirm( message: string, diff --git a/tests/prompt.test.ts b/tests/prompt.test.ts index 24d2857..7a44844 100644 --- a/tests/prompt.test.ts +++ b/tests/prompt.test.ts @@ -1,5 +1,24 @@ +import { PassThrough } from "node:stream"; import { describe, it, expect } from "vitest"; -import { confirm, confirmTyped, confirmEnv } from "../src/ui/prompt.js"; +import { askSecret, confirm, confirmTyped, confirmEnv } from "../src/ui/prompt.js"; + +describe("askSecret", () => { + it("returns the secret without echoing it", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let displayed = ""; + output.on("data", (chunk) => { + displayed += chunk.toString(); + }); + + const answer = askSecret("Token: ", { input, output }); + input.end("super-secret\n"); + + await expect(answer).resolves.toBe("super-secret"); + expect(displayed).toBe("Token: \n"); + expect(displayed).not.toContain("super-secret"); + }); +}); describe("confirm", () => { it("short-circuits true with assumeYes", async () => { diff --git a/tests/tokenStore.test.ts b/tests/tokenStore.test.ts index bde893e..4fd51da 100644 --- a/tests/tokenStore.test.ts +++ b/tests/tokenStore.test.ts @@ -1,8 +1,14 @@ import { describe, it, expect, afterEach } from "vitest"; -import { readToken, parseCredentials } from "../src/auth/tokenStore.js"; +import { readToken, parseCredentials, supportsCredentialStorage } from "../src/auth/tokenStore.js"; const original = process.env.CT_LOGINTOKEN; +it("supports the macOS Keychain but not unsupported platform stores", () => { + expect(supportsCredentialStorage("darwin")).toBe(true); + expect(supportsCredentialStorage("linux")).toBe(false); + expect(supportsCredentialStorage("win32")).toBe(false); +}); + afterEach(() => { if (original === undefined) { delete process.env.CT_LOGINTOKEN; From 58156363a19a61a59c97f3a81040391c9284d04c Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Mon, 24 Aug 2026 12:57:21 +0200 Subject: [PATCH 3/4] feat(init): add portable process workspace template --- README.md | 54 +++++++++++++ docs/environments.md | 26 ++++++ src/commands/init.ts | 24 ++++-- src/init.ts | 187 ++++++++++++++++++++++++++++++++++++++----- tests/cli.test.ts | 7 ++ tests/init.test.ts | 167 +++++++++++++++++++++++++++++++++++++- 6 files changed, 434 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index a059c61..8680401 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,60 @@ ct coverage --env prod The command creates `ct.config.ts`, `ct.envs.json`, `.gitignore`, `config/`, and `blueprints/`. It refuses to overwrite existing scaffold files. +### Portable process workspace + +Use the opt-in `process` template when one reusable ChurchTools process should live below +`processes//` in an existing repository and target one or more instances. Git remains opt-in; +pass `--no-git` when the parent repository already owns version control: + +```bash +ct init processes/example-process \ + --template process \ + --host https://example.church.tools \ + --env prod \ + --protected \ + --no-git \ + --yes +``` + +The process template creates: + +```text +processes/example-process/ +├── ct.config.ts +├── ct.envs.json +├── .gitignore +├── README.md +├── blueprint/ +├── configs/ +└── instances/ + └── example.church.tools/ + ├── ct-state.example.church.tools.json + ├── backups/ + ├── reference/ + └── reports/ +``` + +`ct.config.ts` at the process root is the normal entry point, so no `-c` is needed. The generated +environment binds the normalized host to the state below its matching hostname directory; the empty +state is created immediately and no ambiguous `ct-state.json` is generated. Portable definitions +stay in `blueprint/`, while `configs/` is reserved for exceptional entry points such as a staged +bootstrap of an empty instance. + +Run commands from the process directory and select the target explicitly: + +```bash +ct plan -e prod +ct apply -e prod +ct plan -c configs/.ts -e prod +``` + +Do not omit `-e`: enforcing that rule automatically whenever `ct.envs.json` exists is a separate +engine-wide safety change. Until then, an invocation without `-e` still selects the backward- +compatible single-instance mode. The scaffold contains no credentials or live ChurchTools IDs; +reports, backups and captured reference output are kept below their host-specific instance and +ignored by default, while the host-bound state remains trackable. + ```bash # The host is captured at login and stored with the token; CT_HOST overrides it for CI. ct auth login --host https://mychurch.church.tools --token diff --git a/docs/environments.md b/docs/environments.md index 1997f38..b6b62f0 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -35,6 +35,32 @@ Each profile is a `(host, state file, token reference)` triple: token (for CI); never a literal secret, so the file is safe to commit. - **`protected`** — see the guardrail below. +### Process workspaces + +`ct init --template process --host --env ` creates a process-oriented +workspace whose environment uses an explicit hostname-bound state path: + +```json +{ + "environments": { + "prod": { + "host": "https://example.church.tools", + "state": "instances/example.church.tools/ct-state.example.church.tools.json", + "protected": true + } + } +} +``` + +The corresponding empty state is written immediately with the same normalized host. This makes the +instance binding reviewable from the first commit and avoids creating a hostless `ct-state.json`. +Additional instances follow the same invariant: hostname directory, state filename, state content +and environment host must all identify the same ChurchTools instance. + +Run `plan` and `apply` from the process directory with an explicit environment, for example +`ct plan -e prod`. The existence of `ct.envs.json` does not yet make `--env` mandatory: changing that +single-instance fallback is a separate, engine-wide safety decision rather than scaffold behavior. + ## Using them Every state/host-touching command takes `--env ` (`-e`): diff --git a/src/commands/init.ts b/src/commands/init.ts index a75c370..ce2bd70 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -8,8 +8,10 @@ import { info, success } from "../ui.js"; import { reportLogin } from "./auth.js"; interface InitCommandOptions { + template?: string; host?: string; env?: string; + protected?: boolean; git?: boolean; yes?: boolean; } @@ -26,17 +28,21 @@ function ask(question: string): Promise { export function initCommand(): Command { return new Command("init") - .description("Initialize a new ct config repository") + .description("Initialize a new ct config repository or portable process workspace") .argument("[directory]", "target directory", ".") + .option("--template ", "scaffold template: standard or process", "standard") .option("--host ", "ChurchTools URL for the first environment") .option("--env ", "name of the first environment (default: prod)") + .option("--protected", "mark the first environment as protected") .option("--git", "initialize a Git repository") .option("--no-git", "do not initialize a Git repository") .option("-y, --yes", "accept defaults and do not prompt") .action(async (directory: string, opts: InitCommandOptions) => { const result = await initializeConfigRepository(directory, { + template: opts.template, host: opts.host, environment: opts.env, + protected: opts.protected, git: opts.git, yes: opts.yes, ask, @@ -46,27 +52,31 @@ export function initCommand(): Command { info(`Created: ${[...result.files, ...result.directories.map((name) => `${name}/`)].join(", ")}`); if (!result.environment) { info( - "Next: add an environment to ct.envs.json, then run `ct auth login --host --token `.", + result.template === "process" + ? "Next: add a host-bound environment to ct.envs.json, then run `ct plan -e `." + : "Next: add an environment to ct.envs.json, then run `ct auth login --host --token `.", ); } else { + const inspectCommand = + result.template === "process" + ? `ct plan -e ${result.environment}` + : `ct coverage --env ${result.environment}`; if (process.stdin.isTTY && !opts.yes && supportsCredentialStorage()) { const token = ( await askSecret("Personal login token (input hidden; leave empty to log in later): ") ).trim(); if (token) { reportLogin(await loginWithToken(result.host!, token)); - info(`Next: run \`ct coverage --env ${result.environment}\`.`); + info(`Next: run \`${inspectCommand}\`.`); return; } } if (supportsCredentialStorage()) { - info( - `Next: run \`ct auth login --host ${result.host}\`, then \`ct coverage --env ${result.environment}\`.`, - ); + info(`Next: run \`ct auth login --host ${result.host}\`, then \`${inspectCommand}\`.`); } else { info( `Next: set \`CT_HOST=${result.host}\` and \`CT_LOGINTOKEN\` in your environment, ` + - `then run \`ct coverage --env ${result.environment}\`.`, + `then run \`${inspectCommand}\`.`, ); } } diff --git a/src/init.ts b/src/init.ts index d419a44..72c84e5 100644 --- a/src/init.ts +++ b/src/init.ts @@ -3,11 +3,16 @@ import { access, mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { promisify } from "node:util"; import { normalizeHost } from "./config.js"; +import { emptyState } from "./state/state.js"; const execFile = promisify(execFileCallback); export const INIT_FILES = ["ct.config.ts", "ct.envs.json", ".gitignore"] as const; export const INIT_DIRECTORIES = ["config", "blueprints"] as const; +export const PROCESS_INIT_FILES = ["ct.config.ts", "ct.envs.json", ".gitignore", "README.md"] as const; +export const PROCESS_INIT_DIRECTORIES = ["blueprint", "configs", "instances"] as const; + +export type InitTemplate = "standard" | "process"; const CONFIG_TEMPLATE = `/** Desired ChurchTools structure. Add declarations inside this function. */ export default (ct) => { @@ -28,9 +33,25 @@ reports/ # ct-state*.json files intentionally stay tracked: they record what ct manages. `; +const PROCESS_GITIGNORE_TEMPLATE = `# Local secrets +.env +.env.* + +# Generated local output belongs to one ChurchTools instance and is not committed. +instances/*/backups/ +instances/*/reference/ +instances/*/reports/ +node_modules/ +.DS_Store + +# Host-bound ct-state*.json files intentionally stay tracked: they record what ct manages. +`; + export interface InitOptions { + template?: string; host?: string; environment?: string; + protected?: boolean; git?: boolean; /** Skip all questions. Useful for scripts and tests. */ yes?: boolean; @@ -41,13 +62,24 @@ export interface InitOptions { export interface InitResult { directory: string; + template: InitTemplate; files: string[]; directories: string[]; host?: string; + hostname?: string; environment?: string; + protected?: boolean; gitInitialized: boolean; } +function validateTemplate(template: string | undefined): InitTemplate { + const value = template?.trim() || "standard"; + if (value !== "standard" && value !== "process") { + throw new Error(`Unknown init template "${value}". Available templates: standard, process.`); + } + return value; +} + function validateEnvironment(name: string): string { const trimmed = name.trim(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) { @@ -58,18 +90,23 @@ function validateEnvironment(name: string): string { return trimmed; } -function validateHost(host: string): string { - const trimmed = normalizeHost(host.trim()); +function validateHost(host: string): { host: string; hostname: string } { + const normalized = normalizeHost(host.trim()); let url: URL; try { - url = new URL(trimmed); + url = new URL(normalized); } catch { throw new Error(`Invalid ChurchTools URL "${host}". Expected e.g. https://example.church.tools.`); } if ((url.protocol !== "https:" && url.protocol !== "http:") || !url.hostname) { throw new Error(`Invalid ChurchTools URL "${host}". Expected an http(s) URL.`); } - return trimmed; + if (url.username || url.password) { + throw new Error( + "ChurchTools URL must not contain credentials. Tokens are never written to the scaffold.", + ); + } + return { host: normalizeHost(url.toString()), hostname: url.hostname }; } async function pathExists(path: string): Promise { @@ -81,12 +118,65 @@ async function pathExists(path: string): Promise { } } -function envsTemplate(host?: string, environment?: string): string { - const environments: Record = {}; - if (host && environment) environments[environment] = { host }; +interface EnvironmentTemplateOptions { + host?: string; + environment?: string; + statePath?: string; + protected?: boolean; + explicitProcessBinding?: boolean; +} + +function envsTemplate(options: EnvironmentTemplateOptions): string { + const environments: Record = {}; + if (options.host && options.environment) { + const profile: { host: string; state?: string; protected?: boolean } = { host: options.host }; + if (options.statePath) profile.state = options.statePath; + if (options.explicitProcessBinding || options.protected !== undefined) { + profile.protected = options.protected === true; + } + environments[options.environment] = profile; + } return `${JSON.stringify({ environments }, null, 2)}\n`; } +function processReadme(environment?: string): string { + const env = environment ?? ""; + return `# ChurchTools process + +This directory contains one portable ChurchTools process. Run all \`ct\` commands from this +directory so the root-level \`ct.config.ts\` and \`ct.envs.json\` are selected automatically. + +## Standard workflow + +\`\`\`bash +ct plan -e ${env} +ct apply -e ${env} +\`\`\` + +Always pass \`-e ${env}\`. Until the separate engine-wide environment guard is implemented, omitting +\`-e\` selects ct-cli's single-instance fallback instead of this process's explicit host/state binding. + +Portable process definitions belong in \`blueprint/\`. Keep instance-specific state, reports, +backups and captured reference data below \`instances//\`. Never store login tokens in +this repository. + +Exceptional entry points, such as a staged bootstrap for an empty instance, belong in \`configs/\` +and are selected explicitly: + +\`\`\`bash +ct plan -c configs/.ts -e ${env} +\`\`\` +`; +} + +async function existingPaths(directory: string, paths: readonly string[]): Promise { + const conflicts: string[] = []; + for (const path of paths) { + if (await pathExists(resolve(directory, path))) conflicts.push(path); + } + return conflicts; +} + async function defaultGitInit(directory: string): Promise { await execFile("git", ["init"], { cwd: directory }); } @@ -100,11 +190,12 @@ export async function initializeConfigRepository( options: InitOptions = {}, ): Promise { const directory = resolve(targetDirectory); + const template = validateTemplate(options.template); + const rootFiles = template === "process" ? [...PROCESS_INIT_FILES] : [...INIT_FILES]; - const conflicts: string[] = []; - for (const file of INIT_FILES) { - if (await pathExists(resolve(directory, file))) conflicts.push(file); - } + // Check every fixed root file before asking questions. Host-derived paths are checked after the + // host prompt, still before mkdir/writeFile, so every refusal is free of partial scaffold writes. + const conflicts = await existingPaths(directory, rootFiles); if (conflicts.length > 0) { throw new Error( `Cannot initialize ${directory}: refusing to overwrite existing ${conflicts.join(", ")}.`, @@ -116,18 +207,26 @@ export async function initializeConfigRepository( const ask = options.ask; let host = options.host?.trim(); + let hostname: string | undefined; let environment = options.environment?.trim(); + let protectedEnvironment = options.protected; let initializeGit = options.git; if (interactive && !host) host = (await ask!("ChurchTools URL (leave empty to configure later): ")).trim(); if (host) { - host = validateHost(host); + ({ host, hostname } = validateHost(host)); if (!environment && interactive) { environment = (await ask!("First environment name [prod]: ")).trim() || "prod"; } environment = validateEnvironment(environment || "prod"); + if (template === "process" && interactive && protectedEnvironment === undefined) { + const answer = (await ask!("Protect this environment? [y/N] ")).trim(); + protectedEnvironment = /^y(es)?$/i.test(answer); + } } else if (environment) { throw new Error("--env requires --host so the generated environment profile is usable."); + } else if (protectedEnvironment !== undefined) { + throw new Error("--protected requires --host so the generated environment profile is usable."); } if (interactive && initializeGit === undefined) { const answer = (await ask!("Initialize a Git repository? [y/N] ")).trim(); @@ -135,16 +234,57 @@ export async function initializeConfigRepository( } initializeGit ??= false; + const statePath = + template === "process" && host && hostname + ? `instances/${hostname}/ct-state.${hostname}.json` + : undefined; + const files = [...rootFiles, ...(statePath ? [statePath] : [])]; + const derivedConflicts = await existingPaths(directory, files); + if (derivedConflicts.length > 0) { + throw new Error( + `Cannot initialize ${directory}: refusing to overwrite existing ${derivedConflicts.join(", ")}.`, + ); + } + + const directories = + template === "process" + ? [ + ...PROCESS_INIT_DIRECTORIES, + ...(hostname + ? [ + `instances/${hostname}/backups`, + `instances/${hostname}/reference`, + `instances/${hostname}/reports`, + ] + : []), + ] + : [...INIT_DIRECTORIES]; + await mkdir(directory, { recursive: true }); - for (const name of INIT_DIRECTORIES) await mkdir(resolve(directory, name), { recursive: true }); - await Promise.all([ - writeFile(resolve(directory, "ct.config.ts"), CONFIG_TEMPLATE, { encoding: "utf8", flag: "wx" }), - writeFile(resolve(directory, "ct.envs.json"), envsTemplate(host, environment), { - encoding: "utf8", - flag: "wx", - }), - writeFile(resolve(directory, ".gitignore"), GITIGNORE_TEMPLATE, { encoding: "utf8", flag: "wx" }), + for (const name of directories) await mkdir(resolve(directory, name), { recursive: true }); + + const contents = new Map([ + ["ct.config.ts", CONFIG_TEMPLATE], + [ + "ct.envs.json", + envsTemplate({ + host, + environment, + statePath, + protected: protectedEnvironment, + explicitProcessBinding: template === "process", + }), + ], + [".gitignore", template === "process" ? PROCESS_GITIGNORE_TEMPLATE : GITIGNORE_TEMPLATE], ]); + if (template === "process") contents.set("README.md", processReadme(environment)); + if (statePath && host) contents.set(statePath, `${JSON.stringify(emptyState(host), null, 2)}\n`); + + await Promise.all( + [...contents].map(([path, content]) => + writeFile(resolve(directory, path), content, { encoding: "utf8", flag: "wx" }), + ), + ); const gitInitialized = initializeGit && !(await pathExists(resolve(directory, ".git"))); if (gitInitialized) { @@ -153,10 +293,13 @@ export async function initializeConfigRepository( return { directory, - files: [...INIT_FILES], - directories: [...INIT_DIRECTORIES], + template, + files, + directories, host, + hostname, environment, + protected: host ? protectedEnvironment === true : undefined, gitInitialized, }; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index f5710b9..fc5595e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -20,6 +20,13 @@ describe("ct program", () => { expect(cmd.options.some((o) => o.long === "--auto-approve")).toBe(true); }); + it("registers the process init template and environment protection options", () => { + const cmd = buildProgram().commands.find((c) => c.name() === "init")!; + expect(cmd.options.some((o) => o.long === "--template")).toBe(true); + expect(cmd.options.some((o) => o.long === "--protected")).toBe(true); + expect(cmd.options.some((o) => o.long === "--no-git")).toBe(true); + }); + it("registers destroy with a required --target", () => { const cmd = buildProgram().commands.find((c) => c.name() === "destroy")!; expect(cmd.options.some((o) => o.long === "--target")).toBe(true); diff --git a/tests/init.test.ts b/tests/init.test.ts index da81867..a03b9f5 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -1,10 +1,12 @@ -import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { initializeConfigRepository } from "../src/init.js"; +import { initCommand } from "../src/commands/init.js"; import { loadEnvProfile } from "../src/env/envs.js"; import { loadConfig } from "../src/config/load.js"; +import { loadState } from "../src/state/state.js"; const directories: string[] = []; @@ -15,13 +17,14 @@ async function temporaryDirectory(): Promise { } afterEach(async () => { + vi.restoreAllMocks(); await Promise.all( directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), ); }); describe("initializeConfigRepository", () => { - it("creates a minimal config repository with a usable environment", async () => { + it("keeps the standard init scaffold and environment defaults unchanged", async () => { const directory = await temporaryDirectory(); const result = await initializeConfigRepository(directory, { @@ -32,6 +35,8 @@ describe("initializeConfigRepository", () => { }); expect(result.files).toEqual(["ct.config.ts", "ct.envs.json", ".gitignore"]); + expect(result.template).toBe("standard"); + expect(result.directories).toEqual(["config", "blueprints"]); await expect(access(join(directory, "config"))).resolves.toBeUndefined(); await expect(access(join(directory, "blueprints"))).resolves.toBeUndefined(); expect(await loadEnvProfile("prod", join(directory, "ct.envs.json"))).toMatchObject({ @@ -44,6 +49,109 @@ describe("initializeConfigRepository", () => { permissions: [], }); expect(await readFile(join(directory, ".gitignore"), "utf8")).not.toMatch(/^ct-state/m); + await expect(access(join(directory, "README.md"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(join(directory, "instances"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("creates a portable process quickstart with a root config and host-bound instance", async () => { + const directory = await temporaryDirectory(); + + const result = await initializeConfigRepository(directory, { + template: "process", + host: "https://Example.Church.Tools/", + environment: "prod", + protected: true, + git: false, + yes: true, + }); + + const statePath = "instances/example.church.tools/ct-state.example.church.tools.json"; + expect(result).toMatchObject({ + template: "process", + host: "https://example.church.tools", + hostname: "example.church.tools", + environment: "prod", + protected: true, + gitInitialized: false, + }); + expect(result.files).toEqual(["ct.config.ts", "ct.envs.json", ".gitignore", "README.md", statePath]); + expect(result.directories).toEqual([ + "blueprint", + "configs", + "instances", + "instances/example.church.tools/backups", + "instances/example.church.tools/reference", + "instances/example.church.tools/reports", + ]); + + await expect(loadConfig(join(directory, "ct.config.ts"))).resolves.toMatchObject({ + resources: [], + permissions: [], + }); + await expect(loadEnvProfile("prod", join(directory, "ct.envs.json"))).resolves.toEqual({ + name: "prod", + host: "https://example.church.tools", + statePath, + protected: true, + }); + await expect(loadState(join(directory, statePath), "https://example.church.tools")).resolves.toEqual({ + version: 1, + host: "https://example.church.tools", + resources: {}, + }); + await expect(access(join(directory, "ct-state.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("creates a process inside an existing Git repository without nesting .git under --no-git", async () => { + const repository = await temporaryDirectory(); + await mkdir(join(repository, ".git")); + const directory = join(repository, "processes", "example-process"); + + const result = await initializeConfigRepository(directory, { + template: "process", + host: "https://example.church.tools", + environment: "test", + git: false, + yes: true, + }); + + expect(result.gitInitialized).toBe(false); + await expect(access(join(repository, ".git"))).resolves.toBeUndefined(); + await expect(access(join(directory, ".git"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("writes no secrets and gives explicit environment next steps", async () => { + const directory = await temporaryDirectory(); + await initializeConfigRepository(directory, { + template: "process", + host: "https://example.church.tools", + environment: "test", + yes: true, + }); + + const envs = JSON.parse(await readFile(join(directory, "ct.envs.json"), "utf8")) as { + environments: Record>; + }; + expect(envs.environments.test).toEqual({ + host: "https://example.church.tools", + state: "instances/example.church.tools/ct-state.example.church.tools.json", + protected: false, + }); + expect(envs.environments.test).not.toHaveProperty("token"); + expect(envs.environments.test).not.toHaveProperty("tokenEnv"); + + const readme = await readFile(join(directory, "README.md"), "utf8"); + expect(readme).toContain("ct plan -e test"); + expect(readme).toContain("ct apply -e test"); + expect(readme).toContain("ct plan -c configs/.ts -e test"); + expect(readme).toContain("Always pass `-e test`"); + expect(readme).not.toContain("OJBP"); + + const gitignore = await readFile(join(directory, ".gitignore"), "utf8"); + expect(gitignore).toContain("instances/*/reports/"); + expect(gitignore).toContain("instances/*/backups/"); + expect(gitignore).toContain("instances/*/reference/"); + expect(gitignore).not.toMatch(/^ct-state/m); }); it("creates an empty, valid environments map when no host is supplied", async () => { @@ -82,6 +190,22 @@ describe("initializeConfigRepository", () => { expect(await readFile(join(directory, "ct.envs.json"), "utf8")).toBe("keep me"); }); + it("refuses a process-specific conflict without partially writing the scaffold", async () => { + const directory = await temporaryDirectory(); + await writeFile(join(directory, "README.md"), "keep me", "utf8"); + + await expect( + initializeConfigRepository(directory, { + template: "process", + host: "https://example.church.tools", + yes: true, + }), + ).rejects.toThrow(/refusing to overwrite existing README\.md/); + expect(await readFile(join(directory, "README.md"), "utf8")).toBe("keep me"); + await expect(readFile(join(directory, "ct.config.ts"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(join(directory, "blueprint"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("checks for conflicts before asking questions", async () => { const directory = await temporaryDirectory(); await writeFile(join(directory, "ct.config.ts"), "keep me", "utf8"); @@ -101,5 +225,44 @@ describe("initializeConfigRepository", () => { await expect(initializeConfigRepository(directory, { environment: "prod", yes: true })).rejects.toThrow( /--env requires --host/, ); + await expect(initializeConfigRepository(directory, { protected: true, yes: true })).rejects.toThrow( + /--protected requires --host/, + ); + await expect( + initializeConfigRepository(directory, { + host: "https://user:secret@example.church.tools", + yes: true, + }), + ).rejects.toThrow(/must not contain credentials/); + await expect(initializeConfigRepository(directory, { template: "unknown", yes: true })).rejects.toThrow( + /Available templates: standard, process/, + ); + }); + + it("prints non-interactive process next steps with an explicit environment", async () => { + const directory = await temporaryDirectory(); + let stderr = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + + await initCommand().parseAsync( + [ + directory, + "--template", + "process", + "--host", + "https://example.church.tools", + "--env", + "test", + "--no-git", + "--yes", + ], + { from: "user" }, + ); + + expect(stderr).toContain("ct plan -e test"); + expect(stderr).not.toContain("ct plan`"); }); }); From 4f9247d66c5c49a33791200bc924f6bb9142a41c Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 25 Aug 2026 18:03:48 +0200 Subject: [PATCH 4/4] fix(init): harden the scaffold, host check and login fall-through Review follow-ups on #131: - `ct init` no longer exits 1 when the guided login fails after the scaffold is written. The failure is reported and the command falls through to the `ct auth login` hint, as `ct auth login` does; a re-run would otherwise only hit "refusing to overwrite". - The standard .gitignore now ignores `backups/`. `ct apply` writes backups next to the state file, which the standard scaffold puts at the repository root, so every apply left a dump of the live instance staged for the next `git add .`. The process template already did. - `validateHost` rejects a URL carrying a query or fragment. A URL copied out of the browser address bar was accepted verbatim and every request became `.../#/churchdb/api/...`. A path is left alone: it is how a sub-path installation is addressed. - The "Initialize a Git repository?" question is skipped in a directory that already is one, instead of collecting an answer that was then silently dropped. - `askHidden` delegates to `askSecret`, leaving one no-echo code path instead of two divergent ones, only one of which was covered. Claude-Session: https://claude.ai/code/session_01SaAzHDgDPSvLnfkKcnDj37 --- src/commands/init.ts | 22 ++++++--- src/init.ts | 18 ++++++- src/ui/prompt.ts | 28 ++++------- tests/init-command-login.test.ts | 84 ++++++++++++++++++++++++++++++++ tests/init.test.ts | 43 ++++++++++++++++ 5 files changed, 169 insertions(+), 26 deletions(-) create mode 100644 tests/init-command-login.test.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index 394c9d2..a801edc 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -3,7 +3,7 @@ import { Command } from "commander"; import { bootstrapLoginToken } from "../auth/login.js"; import { isSecureStorageAvailable } from "../auth/tokenStore.js"; import { initializeConfigRepository } from "../init.js"; -import { info, success } from "../ui.js"; +import { error, formatError, info, success } from "../ui.js"; import { verifyAndStoreLoginToken } from "./auth.js"; interface InitCommandOptions { @@ -61,11 +61,21 @@ export function initCommand(): Command { ? `ct plan -e ${result.environment}` : `ct coverage --env ${result.environment}`; if (process.stdin.isTTY && !opts.yes && isSecureStorageAvailable()) { - const outcome = await bootstrapLoginToken(result.host!); - if (outcome.kind === "token") { - await verifyAndStoreLoginToken(result.host!, outcome.token); - info(`Next: run \`${inspectCommand}\`.`); - return; + // The scaffold is already on disk. A failed or abandoned login must + // not fail `ct init`: re-running it would only hit "refusing to + // overwrite existing ct.config.ts". Fall through to the login hint, + // the way `ct auth login` reports and stops (#138). + try { + const outcome = await bootstrapLoginToken(result.host!); + if (outcome.kind === "token") { + await verifyAndStoreLoginToken(result.host!, outcome.token); + info(`Next: run \`${inspectCommand}\`.`); + return; + } + } catch (err) { + // formatError never sees a secret: LoginError carries a status and + // a redacted message, never the request body that was sent. + error(formatError(err)); } } if (isSecureStorageAvailable()) { diff --git a/src/init.ts b/src/init.ts index 72c84e5..83e617b 100644 --- a/src/init.ts +++ b/src/init.ts @@ -25,6 +25,7 @@ const GITIGNORE_TEMPLATE = `# Local secrets .env.* # Generated local output +backups/ node_modules/ reference/ reports/ @@ -106,6 +107,16 @@ function validateHost(host: string): { host: string; hostname: string } { "ChurchTools URL must not contain credentials. Tokens are never written to the scaffold.", ); } + // A URL copied out of the browser address bar carries the SPA's query and + // fragment (".../?q=churchdb#/churchdb"). Every request is built as + // `${host}/api/...`, so those would survive into every call. A path is left + // alone: it is how a sub-path installation is addressed. + if (url.search || url.hash) { + throw new Error( + `Invalid ChurchTools URL "${host}". Drop the "?"/"#" part and pass the base URL, ` + + `e.g. ${url.protocol}//${url.host}${url.pathname === "/" ? "" : url.pathname}.`, + ); + } return { host: normalizeHost(url.toString()), hostname: url.hostname }; } @@ -228,7 +239,10 @@ export async function initializeConfigRepository( } else if (protectedEnvironment !== undefined) { throw new Error("--protected requires --host so the generated environment profile is usable."); } - if (interactive && initializeGit === undefined) { + // Asking about `git init` in a directory that is already a repository would + // collect an answer this function then silently drops below. + const alreadyGit = await pathExists(resolve(directory, ".git")); + if (interactive && initializeGit === undefined && !alreadyGit) { const answer = (await ask!("Initialize a Git repository? [y/N] ")).trim(); initializeGit = /^y(es)?$/i.test(answer); } @@ -286,7 +300,7 @@ export async function initializeConfigRepository( ), ); - const gitInitialized = initializeGit && !(await pathExists(resolve(directory, ".git"))); + const gitInitialized = initializeGit && !alreadyGit; if (gitInitialized) { await (options.runGitInit ?? defaultGitInit)(directory); } diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts index a0b05a0..6f7119a 100644 --- a/src/ui/prompt.ts +++ b/src/ui/prompt.ts @@ -23,25 +23,11 @@ function realAsk(question: string): Promise { /** * Ask a question whose answer must never appear on screen — a password, a TOTP - * code, a login token (#138). - * - * `readline` echoes typed characters through its private `_writeToOutput`. The - * prompt itself is written synchronously by `rl.question`, so muting straight - * after it lets the label through and swallows every keystroke after it. The - * trailing newline is written by hand, since the muted Enter no longer produces - * one. + * code, a login token (#138). Thin alias for `askSecret` below, so there is a + * single no-echo code path to reason about. */ export function askHidden(question: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true }); - const muteable = rl as unknown as { _writeToOutput: (chunk: string) => void }; - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - process.stderr.write("\n"); - resolve(answer); - }); - muteable._writeToOutput = () => {}; - }); + return askSecret(question); } /** Ask a question whose answer is not secret and may echo normally. */ @@ -58,7 +44,13 @@ export interface SecretPromptOptions { output?: NodeJS.WritableStream; } -/** Read a secret from the terminal without echoing its characters. */ +/** + * Read a secret from the terminal without echoing its characters. + * + * `readline` echoes typed characters to its own output, so it is handed a sink + * that discards everything; the prompt label and the closing newline are + * written to the real output by hand. + */ export function askSecret(message: string, opts: SecretPromptOptions = {}): Promise { const input = opts.input ?? process.stdin; const output = opts.output ?? process.stderr; diff --git a/tests/init-command-login.test.ts b/tests/init-command-login.test.ts new file mode 100644 index 0000000..6551bdc --- /dev/null +++ b/tests/init-command-login.test.ts @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LoginError } from "../src/auth/login.js"; +import type * as LoginModule from "../src/auth/login.js"; +import type * as TokenStoreModule from "../src/auth/tokenStore.js"; + +const bootstrapLoginToken = vi.fn(); + +vi.mock("../src/auth/login.js", async (importOriginal) => ({ + ...(await importOriginal()), + bootstrapLoginToken: (...args: unknown[]) => bootstrapLoginToken(...args), +})); +vi.mock("../src/auth/tokenStore.js", async (importOriginal) => ({ + ...(await importOriginal()), + isSecureStorageAvailable: () => true, +})); + +const { initCommand } = await import("../src/commands/init.js"); + +const directories: string[] = []; +let stderr = ""; +let isTTY: boolean | undefined; + +beforeEach(async () => { + stderr = ""; + isTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + bootstrapLoginToken.mockReset(); + Object.defineProperty(process.stdin, "isTTY", { value: isTTY, configurable: true }); + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "ct-init-login-")); + directories.push(directory); + return directory; +} + +describe("ct init login bootstrap (#131)", () => { + it("keeps the scaffold and reports when the guided login fails", async () => { + const directory = await temporaryDirectory(); + bootstrapLoginToken.mockRejectedValue(new LoginError("No password given.", 0)); + + await initCommand().parseAsync( + [directory, "--host", "https://example.church.tools", "--env", "prod", "--no-git"], + { + from: "user", + }, + ); + + // The scaffold is written and stays written: re-running `ct init` here + // would only hit "refusing to overwrite existing ct.config.ts". + expect(await readFile(join(directory, "ct.config.ts"), "utf8")).toContain("export default"); + expect(process.exitCode ?? 0).toBe(0); + expect(stderr).toContain("No password given."); + expect(stderr).toContain("ct auth login --host https://example.church.tools"); + }); + + it("falls through to the login hint when the user skips the guided login", async () => { + const directory = await temporaryDirectory(); + bootstrapLoginToken.mockResolvedValue({ kind: "skipped", hint: "ct auth login" }); + + await initCommand().parseAsync( + [directory, "--host", "https://example.church.tools", "--env", "prod", "--no-git"], + { + from: "user", + }, + ); + + expect(stderr).toContain("ct auth login --host https://example.church.tools"); + }); +}); diff --git a/tests/init.test.ts b/tests/init.test.ts index a03b9f5..de6871d 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -239,6 +239,49 @@ describe("initializeConfigRepository", () => { ); }); + it("rejects a URL copied out of the browser address bar", async () => { + const directory = await temporaryDirectory(); + await expect( + initializeConfigRepository(directory, { + host: "https://example.church.tools/?q=churchdb#/churchdb", + yes: true, + }), + ).rejects.toThrow(/Drop the "\?"\/"#" part/); + await expect(access(join(directory, "ct.envs.json"))).rejects.toMatchObject({ code: "ENOENT" }); + + // A path is a sub-path installation, not junk, and stays intact. + const result = await initializeConfigRepository(directory, { + host: "https://example.church.tools/churchtools", + yes: true, + }); + expect(result.host).toBe("https://example.church.tools/churchtools"); + }); + + it("does not ask about git in a directory that is already a repository", async () => { + const directory = await temporaryDirectory(); + await mkdir(join(directory, ".git"), { recursive: true }); + const ask = vi.fn((question: string) => Promise.resolve(question.startsWith("Initialize") ? "y" : "")); + const gitInit = vi.fn(async () => undefined); + + const result = await initializeConfigRepository(directory, { isTTY: true, ask, runGitInit: gitInit }); + + expect(ask.mock.calls.flat()).not.toContain("Initialize a Git repository? [y/N] "); + expect(result.gitInitialized).toBe(false); + expect(gitInit).not.toHaveBeenCalled(); + }); + + it("gitignores the default backup directory of the standard scaffold", async () => { + const directory = await temporaryDirectory(); + await initializeConfigRepository(directory, { + host: "https://example.church.tools", + yes: true, + }); + + // `ct apply` writes backups next to the state file, which the standard + // scaffold puts at the repository root. + expect((await readFile(join(directory, ".gitignore"), "utf8")).split("\n")).toContain("backups/"); + }); + it("prints non-interactive process next steps with an explicit environment", async () => { const directory = await temporaryDirectory(); let stderr = "";