Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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 <url>` 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=
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <personal-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 <personal-login-token>
ct auth status # who am I?

ct get campuses # JSON to stdout — pipe into jq
Expand Down
4 changes: 2 additions & 2 deletions src/api/ctClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ export interface AuthedSession {
}

export async function authedSession(): Promise<AuthedSession> {
const config = resolveConfig();
const config = await resolveConfig();
const token = await readToken();
if (!token) {
throw new Error("Not logged in. Run `ct auth login --token <token>` first.");
throw new Error("Not logged in. Run `ct auth login --host <url> --token <token>` first.");
}
const client = new CtClient(config);
const me = await client.authenticate(token);
Expand Down
87 changes: 64 additions & 23 deletions src/auth/tokenStore.ts
Original file line number Diff line number Diff line change
@@ -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 <token>` passes the token as an argv, briefly visible
* Note: `security ... -w <value>` 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";
Expand All @@ -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<void> {
/** 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<string, unknown>;
if (typeof host !== "string" || host === "" || typeof token !== "string" || token === "") {
return null;
}
return { host, token };
}

async function keychainSet(value: string): Promise<void> {
await run("security", [
"add-generic-password",
"-U",
Expand All @@ -33,7 +60,7 @@ async function keychainSet(token: string): Promise<void> {
"-a",
KEYCHAIN_ACCOUNT,
"-w",
token,
value,
]);
}

Expand All @@ -53,38 +80,52 @@ async function keychainGet(): Promise<string | null> {
}
}

async function keychainDelete(): Promise<void> {
async function keychainDelete(account: string): Promise<void> {
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<string> {
/** Persist host + token in the macOS Keychain; returns a human-readable location. */
export async function storeCredentials(creds: Credentials): Promise<string> {
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<Credentials | null> {
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<string | null> {
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<string | null> {
return (await readCredentials())?.host ?? null;
}

export async function clearToken(): Promise<void> {
export async function clearCredentials(): Promise<void> {
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);
}
}
2 changes: 1 addition & 1 deletion src/commands/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/commands/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function applyCommand(): Command {
.option("--backup-dir <path>", "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);
Expand Down
29 changes: 18 additions & 11 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <url>", "ChurchTools host, e.g. https://mychurch.church.tools (or set CT_HOST)")
.option("-t, --token <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 <url> 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 <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<CtInfo>("/info");
if (ctInfo.version) {
Expand All @@ -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 <token>`.");
error("Not logged in. Run `ct auth login --host <url> --token <token>`.");
process.exitCode = 1;
return;
}
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function planCommand(): Command {
.option("-s, --state <path>", "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);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function stateCommand(): Command {
.option("-s, --state <path>", "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);
Expand Down
37 changes: 26 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> = readStoredHost,
): Promise<CtConfig> {
const host = env.CT_HOST?.trim() || (await readHost());
if (!host) {
throw new Error(
"No ChurchTools host configured. Run `ct auth login --host <url> --token <token>` (or set CT_HOST).",
);
}
return { host: normalizeHost(host) };
}
8 changes: 8 additions & 0 deletions tests/adopt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,19 @@ async function runAdopt(args: string[]): Promise<void> {
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 });
});

Expand Down
Loading
Loading