diff --git a/.env.example b/.env.example index 9f724f3..0fb5ac0 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ -# ChurchTools host (no trailing slash, no /api suffix). Defaults to prod. -CT_HOST=https://eqrm.church.tools +# ChurchTools host (no trailing slash, no /api suffix). No hardcoded default: +# the host is normally captured at `ct auth login --host ` and stored with +# the token. Set this to OVERRIDE the stored host (CI / one-off / test instance). +CT_HOST=https://mychurch.church.tools # Personal login token (ChurchTools → user settings). Prefer `ct auth login`, -# which stores it in ~/.config/ct-cli/credentials.json (mode 0600). This env -# var is for CI / one-off use and takes precedence over the stored token. +# which stores host + token together in the macOS Keychain. This env var is for +# CI / one-off use and takes precedence over the stored token. CT_LOGINTOKEN= diff --git a/README.md b/README.md index 362c573..82eb9f7 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ npm link # optional: puts `ct` on your PATH ## Usage ```bash -export CT_HOST=https://eqrm.church.tools # default; override for a test instance - -ct auth login --token # stored in the macOS Keychain +# The host is captured at login (stored with the token). No hardcoded default — +# CT_HOST overrides the stored host for CI / one-off use. +ct auth login --host https://mychurch.church.tools --token ct auth status # who am I? ct get campuses # JSON to stdout — pipe into jq diff --git a/src/api/ctClient.ts b/src/api/ctClient.ts index d6a135f..09a2f97 100644 --- a/src/api/ctClient.ts +++ b/src/api/ctClient.ts @@ -13,7 +13,7 @@ * client is generated (`npm run generate:client`), the hand-written `request` * here can be swapped for `openapi-fetch` while keeping this class's surface. */ -import { resolveConfig, type CtConfig } from "../config.js"; +import { type CtConfig } from "../config.js"; import { fetchWithRetry } from "./http.js"; export interface WhoAmI { @@ -40,7 +40,7 @@ export class CtClient { private cookie: string | null = null; private csrfToken: string | null = null; - constructor(private readonly config: CtConfig = resolveConfig()) {} + constructor(private readonly config: CtConfig) {} get host(): string { return this.config.host; diff --git a/src/api/session.ts b/src/api/session.ts index ebd2c8f..074d01c 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -12,10 +12,10 @@ export interface AuthedSession { } export async function authedSession(): Promise { - const config = resolveConfig(); + const config = await resolveConfig(); const token = await readToken(); if (!token) { - throw new Error("Not logged in. Run `ct auth login --token ` first."); + throw new Error("Not logged in. Run `ct auth login --host --token ` first."); } const client = new CtClient(config); const me = await client.authenticate(token); diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index 812d500..8127fd7 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -1,15 +1,17 @@ /** - * Persistence for the personal ChurchTools login token. + * Persistence for the ChurchTools credentials: the instance **host** and the + * personal **login token**, stored together so a token is always bound to the + * instance it authenticates against. * - * A single token is stored in the macOS Keychain (via the `security` CLI). - * There is no file fallback: on CI or non-macOS hosts, supply the token through - * the `CT_LOGINTOKEN` environment variable instead. + * They live in the macOS Keychain (via the `security` CLI) as a single JSON + * blob. There is no file fallback: on CI or non-macOS hosts, supply the host and + * token through the `CT_HOST` / `CT_LOGINTOKEN` environment variables instead. * - * Read precedence: - * 1. `CT_LOGINTOKEN` environment variable (CI / one-off use) - * 2. macOS Keychain + * Read precedence is applied by the callers: + * - token: `CT_LOGINTOKEN` env → stored credentials + * - host: `CT_HOST` env → stored credentials (see config.ts) * - * Note: `security ... -w ` passes the token as an argv, briefly visible + * Note: `security ... -w ` passes the value as an argv, briefly visible * to `ps`. Acceptable for a local developer CLI; the value never touches git. */ import { platform } from "node:os"; @@ -18,13 +20,38 @@ import { promisify } from "node:util"; const run = promisify(execFile); const KEYCHAIN_SERVICE = "ct-cli"; -const KEYCHAIN_ACCOUNT = "login-token"; +const KEYCHAIN_ACCOUNT = "credentials"; +/** Pre-host account name; a bare token used to live here. Cleared on logout so no secret is orphaned. */ +const LEGACY_KEYCHAIN_ACCOUNT = "login-token"; + +export interface Credentials { + host: string; + token: string; +} function isMac(): boolean { return platform() === "darwin"; } -async function keychainSet(token: string): Promise { +/** Parse the stored blob. Returns null for anything that is not a well-formed {host, token}. */ +export function parseCredentials(raw: string): Credentials | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; // legacy bare-token value or corruption — require a fresh `auth login` + } + if (typeof parsed !== "object" || parsed === null) { + return null; + } + const { host, token } = parsed as Record; + if (typeof host !== "string" || host === "" || typeof token !== "string" || token === "") { + return null; + } + return { host, token }; +} + +async function keychainSet(value: string): Promise { await run("security", [ "add-generic-password", "-U", @@ -33,7 +60,7 @@ async function keychainSet(token: string): Promise { "-a", KEYCHAIN_ACCOUNT, "-w", - token, + value, ]); } @@ -53,38 +80,52 @@ async function keychainGet(): Promise { } } -async function keychainDelete(): Promise { +async function keychainDelete(account: string): Promise { try { - await run("security", ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT]); + await run("security", ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account]); } catch { /* not present — nothing to delete */ } } -/** Persist the token in the macOS Keychain; returns a human-readable location. */ -export async function storeToken(token: string): Promise { +/** Persist host + token in the macOS Keychain; returns a human-readable location. */ +export async function storeCredentials(creds: Credentials): Promise { if (!isMac()) { throw new Error( - "Token storage requires the macOS Keychain. On other platforms, set CT_LOGINTOKEN instead.", + "Credential storage requires the macOS Keychain. On other platforms, set CT_HOST and CT_LOGINTOKEN instead.", ); } - await keychainSet(token); + await keychainSet(JSON.stringify(creds)); return `macOS Keychain (service "${KEYCHAIN_SERVICE}", account "${KEYCHAIN_ACCOUNT}")`; } +/** The stored credentials, or null when nothing valid is stored. */ +export async function readCredentials(): Promise { + if (!isMac()) { + return null; + } + const raw = await keychainGet(); + return raw ? parseCredentials(raw) : null; +} + +/** The login token: `CT_LOGINTOKEN` env wins, else the stored credentials. */ export async function readToken(): Promise { const fromEnv = process.env.CT_LOGINTOKEN?.trim(); if (fromEnv) { return fromEnv; } - if (isMac()) { - return keychainGet(); - } - return null; + return (await readCredentials())?.token ?? null; +} + +/** The stored instance host (no env fallback — env precedence lives in resolveConfig). */ +export async function readStoredHost(): Promise { + return (await readCredentials())?.host ?? null; } -export async function clearToken(): Promise { +export async function clearCredentials(): Promise { if (isMac()) { - await keychainDelete(); + await keychainDelete(KEYCHAIN_ACCOUNT); + // Also drop the pre-host bare-token entry so an upgrade doesn't leave a secret behind. + await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT); } } diff --git a/src/commands/adopt.ts b/src/commands/adopt.ts index 1034e22..21dfc47 100644 --- a/src/commands/adopt.ts +++ b/src/commands/adopt.ts @@ -29,7 +29,7 @@ export function adoptCommand(): Command { // Load + validate the state file (host guard included) BEFORE any network // call, so a state file recorded against another instance never triggers a // live authenticated request against the wrong ChurchTools host. - const config = resolveConfig(); + const config = await resolveConfig(); const statePath = resolveStatePath(opts.state); const state = await loadState(statePath, config.host); diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 6a1d500..2b70417 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -36,7 +36,7 @@ export function applyCommand(): Command { .option("--backup-dir ", "directory for the pre-apply backup (or set CT_BACKUP_DIR)") .option("-y, --auto-approve", "skip the confirmation prompt") .action(async (opts: ApplyOptions) => { - const config = resolveConfig(); + const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); const statePath = resolveStatePath(opts.state); const desired = await loadConfig(configPath); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 6e1aac1..454d0de 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { CtClient } from "../api/ctClient.js"; -import { resolveConfig } from "../config.js"; -import { storeToken, readToken, clearToken } from "../auth/tokenStore.js"; +import { normalizeHost } from "../config.js"; +import { storeCredentials, readToken, clearCredentials } from "../auth/tokenStore.js"; import { authedSession } from "../api/session.js"; import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "../api/version.js"; import { success, error, info, warn, out } from "../ui.js"; @@ -11,21 +11,28 @@ export function authCommand(): Command { cmd .command("login") - .description("Store and verify a personal ChurchTools login token") + .description("Store and verify a ChurchTools host + personal login token") + .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: { token?: string }) => { - const config = resolveConfig(); + .action(async (opts: { host?: string; token?: string }) => { + 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(); if (!token) { error("No token provided. 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 storeToken(token); + const location = await storeCredentials({ host: config.host, token }); success(`Logged in to ${config.host} as ${me.firstName ?? ""} ${me.lastName ?? ""} (#${me.id})`.trim()); - info(`Token stored in ${location}.`); + info(`Host + token stored in ${location}.`); const ctInfo = await client.get("/info"); if (ctInfo.version) { @@ -44,7 +51,7 @@ export function authCommand(): Command { .description("Show the currently authenticated user") .action(async () => { if (!(await readToken())) { - error("Not logged in. Run `ct auth login --token `."); + error("Not logged in. Run `ct auth login --host --token `."); process.exitCode = 1; return; } @@ -54,10 +61,10 @@ export function authCommand(): Command { cmd .command("logout") - .description("Remove the stored login token") + .description("Remove the stored host + login token") .action(async () => { - await clearToken(); - success("Logged out — stored token removed."); + await clearCredentials(); + success("Logged out — stored credentials removed."); }); return cmd; diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 952e3c6..4b68913 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -53,7 +53,7 @@ export function destroyCommand(): Command { throw new Error("No --target given. Destroy never deletes implicitly."); } - const config = resolveConfig(); + const config = await resolveConfig(); const statePath = resolveStatePath(opts.state); const state = await loadState(statePath, config.host); diff --git a/src/commands/plan.ts b/src/commands/plan.ts index b5bd9f3..27e8d6e 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -20,7 +20,7 @@ export function planCommand(): Command { .option("-s, --state ", "state file (or set CT_STATE)") .option("--json", "emit the raw plan as JSON instead of the rendered diff") .action(async (opts: PlanOptions) => { - const config = resolveConfig(); + const config = await resolveConfig(); const configPath = resolveConfigPath(opts.config); const desired = await loadConfig(configPath); const state = await loadState(resolveStatePath(opts.state), config.host); diff --git a/src/commands/state.ts b/src/commands/state.ts index 14cc4c7..fc23309 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -16,7 +16,7 @@ export function stateCommand(): Command { .option("-s, --state ", "state file path (or set CT_STATE)") .action(async (opts: StateOptions) => { const statePath = resolveStatePath(opts.state); - const state = await loadState(statePath, resolveConfig().host); + const state = await loadState(statePath, (await resolveConfig()).host); const resources = Object.values(state.resources); info(`${resources.length} managed resource(s) in ${statePath} (host ${state.host}).`); out(resources); diff --git a/src/config.ts b/src/config.ts index e4357f3..035c03a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,24 +1,39 @@ /** * Runtime configuration for the CLI. * - * The ChurchTools host is resolved from the environment so the same binary can - * point at prod, a test instance, or a black-hole host. It never carries a - * trailing slash and never includes the `/api` suffix — callers add path - * segments themselves. + * The ChurchTools host is bound to the login: it is captured at `ct auth login` + * (stored in the Keychain with the token) and resolved from there, so the same + * binary can target prod, a test instance, or any instance without a hardcoded + * default. `CT_HOST` overrides the stored value for CI / one-off use. The host + * never carries a trailing slash and never includes the `/api` suffix — callers + * add path segments themselves. */ - -const DEFAULT_HOST = "https://eqrm.church.tools"; +import { readStoredHost } from "./auth/tokenStore.js"; export interface CtConfig { /** Base host, e.g. `https://eqrm.church.tools` (no trailing slash, no `/api`). */ host: string; } -function stripTrailingSlash(value: string): string { - return value.replace(/\/+$/, ""); +/** Strip trailing slashes so `host + "/api" + path` is always well-formed. */ +export function normalizeHost(host: string): string { + return host.replace(/\/+$/, ""); } -export function resolveConfig(env: NodeJS.ProcessEnv = process.env): CtConfig { - const host = env.CT_HOST?.trim() || DEFAULT_HOST; - return { host: stripTrailingSlash(host) }; +/** + * Resolve the ChurchTools host. Precedence: `CT_HOST` env → stored login host. + * There is no default: with neither, this throws, directing the user to log in. + * `readHost` is injectable so the resolution is testable without the Keychain. + */ +export async function resolveConfig( + env: NodeJS.ProcessEnv = process.env, + readHost: () => Promise = readStoredHost, +): Promise { + const host = env.CT_HOST?.trim() || (await readHost()); + if (!host) { + throw new Error( + "No ChurchTools host configured. Run `ct auth login --host --token ` (or set CT_HOST).", + ); + } + return { host: normalizeHost(host) }; } diff --git a/tests/adopt.test.ts b/tests/adopt.test.ts index 584d32f..222e7dc 100644 --- a/tests/adopt.test.ts +++ b/tests/adopt.test.ts @@ -20,11 +20,19 @@ async function runAdopt(args: string[]): Promise { await adoptCommand().parseAsync(args, { from: "user" }); } +const originalHost = process.env.CT_HOST; + beforeEach(() => { getMock.mockClear(); + process.env.CT_HOST = HOST; // host is now resolved from env/stored login, not a hardcoded default }); afterEach(async () => { + if (originalHost === undefined) { + delete process.env.CT_HOST; + } else { + process.env.CT_HOST = originalHost; + } await rm(statePath, { force: true }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..a211eed --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { resolveConfig, normalizeHost } from "../src/config.js"; + +const noStoredHost = async () => null; + +describe("normalizeHost", () => { + it("strips trailing slashes", () => { + expect(normalizeHost("https://x.church.tools/")).toBe("https://x.church.tools"); + expect(normalizeHost("https://x.church.tools///")).toBe("https://x.church.tools"); + }); +}); + +describe("resolveConfig", () => { + it("prefers CT_HOST env over the stored host", async () => { + const config = await resolveConfig({ CT_HOST: "https://env.church.tools" }, async () => "https://stored.church.tools"); + expect(config.host).toBe("https://env.church.tools"); + }); + + it("falls back to the stored login host when no env is set", async () => { + const config = await resolveConfig({}, async () => "https://stored.church.tools/"); + expect(config.host).toBe("https://stored.church.tools"); + }); + + it("throws with a login hint when neither env nor a stored host is available", async () => { + await expect(resolveConfig({}, noStoredHost)).rejects.toThrow(/ct auth login --host/); + }); +}); diff --git a/tests/tokenStore.test.ts b/tests/tokenStore.test.ts index 61711c0..bde893e 100644 --- a/tests/tokenStore.test.ts +++ b/tests/tokenStore.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from "vitest"; -import { readToken } from "../src/auth/tokenStore.js"; +import { readToken, parseCredentials } from "../src/auth/tokenStore.js"; const original = process.env.CT_LOGINTOKEN; @@ -17,3 +17,21 @@ describe("readToken", () => { await expect(readToken()).resolves.toBe("env-token"); }); }); + +describe("parseCredentials", () => { + it("parses a well-formed {host, token} blob", () => { + expect(parseCredentials('{"host":"https://x.church.tools","token":"tok"}')).toEqual({ + host: "https://x.church.tools", + token: "tok", + }); + }); + + it("rejects a legacy bare-token value (non-JSON)", () => { + expect(parseCredentials("just-a-raw-token")).toBeNull(); + }); + + it("rejects a blob missing host or token", () => { + expect(parseCredentials('{"token":"tok"}')).toBeNull(); + expect(parseCredentials('{"host":"https://x","token":""}')).toBeNull(); + }); +});