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
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,84 @@ Open a new shell, then try `ct sta<Tab>`, `ct state rm <Tab>` 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/<name>/` 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/<bootstrap-config>.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 <personal-login-token>
Expand Down
26 changes: 26 additions & 0 deletions docs/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <directory> --template process --host <url> --env <name>` 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 <name>` (`-e`):
Expand Down
42 changes: 24 additions & 18 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,29 @@ async function reportAllEnvs(): Promise<void> {
}
}

/** Verify a personal token, cache the resulting session, store it, and report the login. */
export async function verifyAndStoreLoginToken(rawHost: string, rawToken: string): Promise<void> {
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<CtInfo>("/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");

Expand Down Expand Up @@ -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<CtInfo>("/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
Expand Down
91 changes: 91 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 <name>", "scaffold template: standard or process", "standard")
.option("--host <url>", "ChurchTools URL for the first environment")
.option("--env <name>", "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 <environment>`."
: "Next: add an environment to ct.envs.json, then run `ct auth login --host <url> --token <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}\`.`,
);
}
}
});
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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());
Expand Down
Loading
Loading