diff --git a/README.md b/README.md index 7c12c34..5a8e511 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,84 @@ Open a new shell, then try `ct sta`, `ct state rm ` or `ct plan --env ## 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` 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_LOGINTOKEN=... ct auth login --host https://example.church.tools +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 2e9460a..34216e2 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/auth.ts b/src/commands/auth.ts index 6cbb52d..b4ae0d7 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -39,6 +39,29 @@ async function reportAllEnvs(): Promise { } } +/** Verify a personal token, cache the resulting session, store it, and report the login. */ +export async function verifyAndStoreLoginToken(rawHost: string, rawToken: string): Promise { + const host = normalizeHost(rawHost.trim()); + const token = rawToken.trim(); + if (!token) throw new Error("No token provided."); + + const client = new CtClient({ host }, { sessionCache: keychainSessionCache() }); + // A login must actually prove the token, never be answered from a cached session. + const me = await client.authenticate(token, { fresh: true }); + const location = await storeCredentials({ host, token }); + success(`Logged in to ${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.`); + } + } +} + export function authCommand(): Command { const cmd = new Command("auth").description("Authenticate against ChurchTools"); @@ -96,24 +119,7 @@ export function authCommand(): Command { token = outcome.token; } - const client = new CtClient(config, { sessionCache: keychainSessionCache() }); - // `fresh`: a login must actually prove the token, never be answered from a - // cached session — but the session it buys is cached for the next command. - const me = await client.authenticate(token, { fresh: true }); - 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.`, - ); - } - } + await verifyAndStoreLoginToken(config.host, token); }); cmd diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..a801edc --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,91 @@ +import { createInterface } from "node:readline"; +import { Command } from "commander"; +import { bootstrapLoginToken } from "../auth/login.js"; +import { isSecureStorageAvailable } from "../auth/tokenStore.js"; +import { initializeConfigRepository } from "../init.js"; +import { error, formatError, info, success } from "../ui.js"; +import { verifyAndStoreLoginToken } from "./auth.js"; + +interface InitCommandOptions { + template?: string; + host?: string; + env?: string; + protected?: boolean; + 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 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, + }); + + success(`Initialized ct config repository in ${result.directory}`); + info(`Created: ${[...result.files, ...result.directories.map((name) => `${name}/`)].join(", ")}`); + if (!result.environment) { + info( + 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 && isSecureStorageAvailable()) { + // 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()) { + 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 \`${inspectCommand}\`.`, + ); + } + } + }); +} diff --git a/src/index.ts b/src/index.ts index 329d32a..ca1ecf2 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 { completionCommand } from "./commands/completion.js"; import { plannedCommands } from "./commands/placeholders.js"; import { isCompletionRequest, serveCompletionRequest } from "./completion/shell.js"; @@ -27,6 +28,7 @@ export function buildProgram(): Command { ) .version(versionLine(import.meta.url)); + 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..83e617b --- /dev/null +++ b/src/init.ts @@ -0,0 +1,319 @@ +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"; +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) => { + // Example: ct.campus({ key: "main", name: "Main Campus", shorty: "MAIN" }); +}; +`; + +const GITIGNORE_TEMPLATE = `# Local secrets +.env +.env.* + +# Generated local output +backups/ +node_modules/ +reference/ +reports/ +.DS_Store + +# 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; + isTTY?: boolean; + ask?: (question: string) => Promise; + runGitInit?: (directory: string) => Promise; +} + +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)) { + throw new Error( + `Invalid environment name "${name}". Use letters, numbers, dots, underscores or hyphens.`, + ); + } + return trimmed; +} + +function validateHost(host: string): { host: string; hostname: string } { + const normalized = normalizeHost(host.trim()); + let url: URL; + try { + 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.`); + } + if (url.username || url.password) { + throw new Error( + "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 }; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +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 }); +} + +/** + * 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 template = validateTemplate(options.template); + const rootFiles = template === "process" ? [...PROCESS_INIT_FILES] : [...INIT_FILES]; + + // 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(", ")}.`, + ); + } + + 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 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, 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."); + } + // 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); + } + 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 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 && !alreadyGit; + if (gitInitialized) { + await (options.runGitInit ?? defaultGitInit)(directory); + } + + return { + directory, + template, + files, + directories, + host, + hostname, + environment, + protected: host ? protectedEnvironment === true : undefined, + gitInitialized, + }; +} diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts index 8a19085..6f7119a 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; @@ -22,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. */ @@ -52,6 +39,38 @@ 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. + * + * `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; + 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/cli.test.ts b/tests/cli.test.ts index d76f97d..e3acc39 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -5,7 +5,7 @@ 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", "completion"]), + expect.arrayContaining(["init", "auth", "get", "adopt", "plan", "apply", "destroy", "completion"]), ); }); @@ -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-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 new file mode 100644 index 0000000..de6871d --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,311 @@ +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[] = []; + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "ct-init-")); + directories.push(directory); + return directory; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("initializeConfigRepository", () => { + it("keeps the standard init scaffold and environment defaults unchanged", 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"]); + 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({ + 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); + 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 () => { + 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("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"); + 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/, + ); + 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("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 = ""; + 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`"); + }); +}); 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..28a575c 100644 --- a/tests/tokenStore.test.ts +++ b/tests/tokenStore.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, afterEach } from "vitest"; -import { readToken, parseCredentials } from "../src/auth/tokenStore.js"; +import { readToken, parseCredentials, isSecureStorageAvailable } from "../src/auth/tokenStore.js"; const original = process.env.CT_LOGINTOKEN; +it("supports the macOS Keychain but not unsupported platform stores", () => { + expect(isSecureStorageAvailable()).toBe(process.platform === "darwin"); +}); + afterEach(() => { if (original === undefined) { delete process.env.CT_LOGINTOKEN;