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
32 changes: 32 additions & 0 deletions .github/scripts/build-binaries.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Cross-compiles the three standalone `ct` binaries into release/.
#
# `bun build --compile` embeds a prebuilt runtime per target, so all three are
# produced from one Linux runner; only *executing* them needs the target OS,
# which is what the smoke-test jobs are for.
#
# Shared by two jobs on purpose (#116): the `build` job compiles the binaries the
# smoke tests run, and the `release` job recompiles them AFTER semantic-release
# has bumped package.json, so the attached binaries report the version they were
# released as instead of the repo's 0.1.0 placeholder (`ct --version` bakes in
# package.json's version at build time). Keeping one script keeps the two
# invocations from drifting apart. Both jobs pin the SAME bun version (see
# release.yml) so the recompile really is the smoke-tested build plus a different
# version constant. The release job re-runs the smoke test on the recompiled
# linux binary — the only one it can execute — so no artifact ships unexecuted.
set -euo pipefail

# `ct --version` reads a build-time constant (src/version.ts); bun's --define
# substitutes it, exactly as tsup.config.ts does for the npm bundle. The value has
# to arrive as a JS string *expression*, hence the JSON quoting.
version="$(node -p 'require("./package.json").version')"
version_literal="$(node -p 'JSON.stringify(process.argv[1])' "$version")"
echo "Compiling ct binaries for version ${version}"

mkdir -p release
for target in darwin-arm64 darwin-x64 linux-x64; do
bun build --compile --target="bun-${target}" \
--define "__CT_VERSION__=${version_literal}" \
./src/index.ts --outfile "release/ct-${target}"
done
chmod +x release/ct-darwin-arm64 release/ct-darwin-x64 release/ct-linux-x64
15 changes: 15 additions & 0 deletions .github/scripts/smoke-test-binary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ chmod +x "$bin"
echo "== ct --help =="
"$bin" --help

# The version constant is injected at compile time (bun --define, see
# build-binaries.sh). If that injection ever stops working the binary falls back
# to reading package.json, which does not exist inside the compiled bundle — so
# it would silently ship "0.0.0-unknown", the exact dishonest answer #116 removed.
# Nothing else in CI executes `--version` on the compiled path, so assert it here.
echo
echo "== ct --version =="
version_line="$("$bin" --version)"
echo "$version_line"
expected="$(node -p 'require("./package.json").version')"
if ! grep -Eq "^${expected} \(" <<<"$version_line"; then
echo "FAIL: expected --version to start with the package.json version ${expected}; got '${version_line}' (build-time version injection is broken)" >&2
exit 1
fi

echo
echo "== ct plan (config-load exercise, no network/creds) =="
set +e
Expand Down
21 changes: 14 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,16 @@ jobs:
- run: npm test
- run: npm run build

# Pinned, not `latest`: the `release` job recompiles these very binaries and
# ships the recompiled ones, so both jobs must resolve the SAME bun — a bun
# release landing between the two jobs would otherwise mean the attached
# artifacts are not the ones the smoke tests ran (#116). Bump both together.
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
bun-version: 1.4.0

- name: Compile standalone binaries
run: |
mkdir -p release
bun build --compile --target=bun-darwin-arm64 ./src/index.ts --outfile release/ct-darwin-arm64
bun build --compile --target=bun-darwin-x64 ./src/index.ts --outfile release/ct-darwin-x64
bun build --compile --target=bun-linux-x64 ./src/index.ts --outfile release/ct-linux-x64
chmod +x release/ct-darwin-arm64 release/ct-darwin-x64 release/ct-linux-x64
run: bash .github/scripts/build-binaries.sh

# The release tarball is packed in the `release` job (via @semantic-release/exec)
# AFTER semantic-release bumps the version — packing it here would embed the
Expand Down Expand Up @@ -146,6 +145,14 @@ jobs:
with:
node-version: 22

# The binaries are RECOMPILED here, inside semantic-release's prepare step,
# and the recompiled linux one is smoke-tested again there — see the
# @semantic-release/exec command below and #116. Keep this pin in lockstep
# with the `build` job's.
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0

# @semantic-release/npm publishes the package from THIS working tree, so `dist/`
# must exist and devDependencies (tsup, via the `prepare` script) must be present
# here — the `release-assets` artifact only carries the binaries, not `dist/`.
Expand Down
4 changes: 2 additions & 2 deletions .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
[
"@semantic-release/exec",
{
"//": "Runs AFTER @semantic-release/npm has written the bumped version into package.json, so this tarball carries the real release version (not the placeholder 0.1.0 the build job packs pre-bump). Renamed to the fixed filename the stable releases/latest/download/ct-cli.tgz URL depends on (README + INSTALL.md). See #84.",
"prepareCmd": "npm pack --pack-destination release && mv release/eqrm-ct-cli-${nextRelease.version}.tgz release/ct-cli.tgz"
"//": "Runs AFTER @semantic-release/npm has written the bumped version into package.json, so these artifacts carry the real release version (not the placeholder 0.1.0 the build job produces pre-bump). The tarball is renamed to the fixed filename the stable releases/latest/download/ct-cli.tgz URL depends on (README + INSTALL.md). See #84. The binaries are recompiled for the same reason: `ct --version` bakes in package.json's version at build time, so the smoke-tested pre-bump binaries would report the placeholder. They are overwritten in place, so the assets attached below are the recompiled ones — which is why the recompiled linux binary is smoke-tested again right here, on the runner that can execute it, instead of trusting that the pre-bump smoke run still speaks for these bytes (both jobs pin the same bun version, so the only intended difference is the version constant). The darwin binaries cannot be executed on this runner; their `--version` assertion is the one the smoke jobs ran pre-bump. See #116.",
"prepareCmd": "bash .github/scripts/build-binaries.sh && bash .github/scripts/smoke-test-binary.sh release/ct-linux-x64 && npm pack --pack-destination release && mv release/eqrm-ct-cli-${nextRelease.version}.tgz release/ct-cli.tgz"
}
],
[
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ curl -L -o ct https://github.com/eqrm/ct-cli/releases/latest/download/ct-linux-x
chmod +x ct
sudo mv ct /usr/local/bin/ct # or anywhere on your PATH
ct --help
ct --version # e.g. 1.7.0 (/usr/local/bin/ct) — version AND which binary
```

With Node ≥ 20 already installed, the npm-pack tarball works too:
Expand Down Expand Up @@ -129,7 +130,7 @@ Open a new shell, then try `ct sta<Tab>`, `ct state rm <Tab>` or `ct plan --env
```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>
ct auth status # who am I?
ct auth status # who am I? (`--env <name>` asks on another instance)

ct get groups # JSON to stdout — pipe into jq (every page, not just the first)
ct adopt campus 0 # bring ONE existing resource under management
Expand Down
35 changes: 35 additions & 0 deletions docs/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,41 @@ Without `--env`, behaviour is unchanged (single stored login, `ct-state.json`).
login` stores credentials **per host**, so one machine can hold logins for `dev`
and `prod` at once (a pre-existing single login still works as a fallback).

`ct auth status --all` is stricter about the bare `CT_LOGINTOKEN` fallback than
an `--env` command is, because it walks _every_ host: an ambient
`CT_LOGINTOKEN` is offered only to the host it is bound to (`CT_HOST`, else the
stored default login's host). Give each env its own `tokenEnv` to authenticate
more than one host in CI — the alternative would post one instance's token to
every other instance listed in `ct.envs.json`.

## Which account am I using where?

`ct auth status` answers it per environment, resolving the same host and token an
`--env` command would — without writing anything:

```bash
ct auth status --env dev # identity on dev's host (JSON on stdout, host on stderr)
ct auth status --all # preflight: every env in ct.envs.json, one line each
```

```text
dev https://mychurch-dev.church.tools ✓ Ada Lovelace (#42) via Keychain
prod https://mychurch.church.tools ✗ no token
```

`--all` exits non-zero if any environment has no working token, so CI can gate on
it before an apply. It is the first thing to run when an `--env` command returns
401 and you need to know whether the token is missing, expired, or simply belongs
to somebody else — failures carry the HTTP status the instance returned. A green
line also means the instance meets the minimum ChurchTools version, so it is not
one an `apply` would refuse. Tokens are never printed — only where each one came
from.

`ct auth logout --env <name>` removes just that host's credentials and leaves
your other logins in place. If that host also happened to be your _default_
login, the shared entry goes with it and the command says so — commands without
`--env` then need a `ct auth login` again.

**Cross-contamination is impossible:** every state file is bound to its host, and
loading a state file against a different host is refused —
`State file host (…) does not match … Refusing to mix instances.` — so `--env prod`
Expand Down
189 changes: 189 additions & 0 deletions src/auth/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Per-environment authentication status (#117).
*
* "Which account am I using against dev?" is a question you ask *before* an
* apply, not after — but the only way to answer it used to be to run something
* that touches the host for real, or to read `ct.envs.json` and the Keychain by
* hand. This resolves the same host + token an `--env` command would resolve,
* and reports who that token belongs to.
*
* The resolution order deliberately mirrors `authedSession`, so a green line
* here means the same command with `--env <name>` will authenticate the same
* way: a profile `tokenEnv` (CI) → `CT_LOGINTOKEN` *for the host that token is
* bound to* → the host-keyed Keychain entry. Nothing here writes anything; the
* only network call is the `whoami` handshake plus the same minimum-version
* check `authedSession` runs, and only for an env that actually has a token.
*/
import { CtClient, type WhoAmI } from "../api/ctClient.js";
import { normalizeHost } from "../config.js";
import { readCredentials, readStoredHost, type Credentials } from "./tokenStore.js";
import type { EnvProfile } from "../env/envs.js";
import { formatError } from "../ui.js";

/** Where an env's token came from — `null` when there is none to try. */
export type TokenSource = { kind: "env"; variable: string } | { kind: "stored" } | { kind: "none" };

export interface EnvAuthStatus {
name: string;
host: string;
source: TokenSource;
/** Who the token authenticates as. Absent when there is no token, or the check failed. */
identity?: WhoAmI;
/** Why the check failed (expired token, wrong host, instance unreachable, too-old instance). */
error?: string;
}

export interface StatusDeps {
env?: NodeJS.ProcessEnv;
readStored?: (host: string) => Promise<Credentials | null>;
/** The host the *default* (unqualified) login points at — see {@link ambientTokenHost}. */
readDefaultHost?: () => Promise<string | null>;
whoami?: (host: string, token: string) => Promise<WhoAmI>;
}

/**
* The handshake, plus the very check that would refuse the next `apply`.
*
* `authedSession` follows `authenticate` with `assertMinVersion`, so without it
* a green preflight line could still be followed by `ct apply --env <name>`
* refusing to run — exactly the failure a preflight exists to catch.
*/
async function defaultWhoami(host: string, token: string): Promise<WhoAmI> {
const client = new CtClient({ host });
const me = await client.authenticate(token);
await client.assertMinVersion();
return me;
}

/**
* The host an ambient `CT_LOGINTOKEN` belongs to — `null` when it belongs to
* nothing in particular.
*
* A login token is bound to the instance it was issued by (issue #30), and
* `--all` walks *every* host in `ct.envs.json`. Handing the ambient token to all
* of them would post one instance's secret to every other one — as a
* `login_token=` URL query parameter, straight into their access logs — and then
* report `✓ … via $CT_LOGINTOKEN` for envs nothing was ever configured for.
* `authedSession` gets away with the same fallback only because `--env` is the
* operator naming one host explicitly.
*
* So the ambient token is offered to exactly the host it pairs with: `CT_HOST`
* when set (the CI shape), else the stored default login's host.
*/
async function ambientTokenHost(
env: NodeJS.ProcessEnv,
readDefaultHost: () => Promise<string | null>,
): Promise<string | null> {
const fromEnv = env.CT_HOST?.trim();
if (fromEnv) {
return normalizeHost(fromEnv);
}
return await readDefaultHost();
}

/** Resolve the token an `--env <name>` command would use, without disclosing it. */
async function resolveToken(
profile: EnvProfile,
env: NodeJS.ProcessEnv,
readStored: (host: string) => Promise<Credentials | null>,
readDefaultHost: () => Promise<string | null>,
): Promise<{ token: string; source: TokenSource } | { token: null; source: TokenSource }> {
if (profile.tokenEnv) {
const fromProfileVar = env[profile.tokenEnv]?.trim();
if (fromProfileVar) {
return { token: fromProfileVar, source: { kind: "env", variable: profile.tokenEnv } };
}
}
const ambient = env.CT_LOGINTOKEN?.trim();
if (ambient && (await ambientTokenHost(env, readDefaultHost)) === profile.host) {
return { token: ambient, source: { kind: "env", variable: "CT_LOGINTOKEN" } };
}
const stored = await readStored(profile.host);
if (stored) {
return { token: stored.token, source: { kind: "stored" } };
}
return { token: null, source: { kind: "none" } };
}

/** Check one environment. Never throws: a failure is reported as the env's status. */
export async function checkEnvAuth(profile: EnvProfile, deps: StatusDeps = {}): Promise<EnvAuthStatus> {
const env = deps.env ?? process.env;
const readStored = deps.readStored ?? readCredentials;
const readDefaultHost = deps.readDefaultHost ?? readStoredHost;
const whoami = deps.whoami ?? defaultWhoami;

// Resolution happens INSIDE the try: it touches the credential store, and a
// store that throws must not abort the whole `--all` sweep.
let source: TokenSource = { kind: "none" };
try {
const resolved = await resolveToken(profile, env, readStored, readDefaultHost);
source = resolved.source;
if (resolved.token === null) {
return { name: profile.name, host: profile.host, source };
}
const identity = await whoami(profile.host, resolved.token);
return { name: profile.name, host: profile.host, source, identity };
} catch (err) {
// formatError, not err.message: `authenticate` throws CtApiError("Login failed
// (whoami)", status) — the status lives on the error, not in its message, and
// "expired token" vs "not your instance" vs "instance down" is the whole point.
return { name: profile.name, host: profile.host, source, error: formatError(err) };
}
}

/**
* Check every environment. Sequential on purpose: each check is a login
* handshake against a different instance, and a burst of them across hosts is
* exactly the traffic pattern this project keeps deliberately polite.
*/
export async function checkAllEnvAuth(
profiles: EnvProfile[],
deps: StatusDeps = {},
): Promise<EnvAuthStatus[]> {
const statuses: EnvAuthStatus[] = [];
for (const profile of profiles) {
statuses.push(await checkEnvAuth(profile, deps));
}
return statuses;
}

/** `Vorname Nachname (#42)`, degrading to `#42` when the instance returns no name. */
export function describeIdentity(me: WhoAmI): string {
const name = `${me.firstName ?? ""} ${me.lastName ?? ""}`.trim();
return name ? `${name} (#${me.id})` : `#${me.id}`;
}

function describeSource(source: TokenSource): string {
switch (source.kind) {
case "env":
return `via $${source.variable}`;
case "stored":
return "via Keychain";
case "none":
return "";
}
}

/** One aligned report line per env — the columns are padded to the widest entry. */
export function renderEnvAuth(statuses: EnvAuthStatus[]): string[] {
const nameWidth = Math.max(0, ...statuses.map((s) => s.name.length));
const hostWidth = Math.max(0, ...statuses.map((s) => s.host.length));
return statuses.map((status) => {
const prefix = `${status.name.padEnd(nameWidth)} ${status.host.padEnd(hostWidth)}`;
if (status.identity) {
return `${prefix} ✓ ${describeIdentity(status.identity)} ${describeSource(status.source)}`.trimEnd();
}
if (status.error) {
// A multi-line body (formatError appends the response body) would break the
// one-line-per-env alignment; the first line carries status + message.
const [first = ""] = status.error.split("\n");
return `${prefix} ✗ ${first} ${describeSource(status.source)}`.trimEnd();
}
return `${prefix} ✗ no token`;
});
}

/** True when every env resolved to a working identity — the preflight's exit code. */
export function allEnvsAuthenticated(statuses: EnvAuthStatus[]): boolean {
return statuses.every((status) => status.identity !== undefined);
}
Loading
Loading