From c3f99b6401ea2961f2bb24b66c2dd7086c5ad6bb Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 24 Aug 2026 18:19:44 +0200 Subject: [PATCH 1/4] fix: report the real version and the running binary for --version (#116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--version` was the literal `.version("0.0.0")`, identical for a released install and a locally linked dev build. That is worse than a missing version: it reads like evidence, and in one downstream repo it sent the next person chasing a PATH problem that did not exist. The version is now baked in from package.json at build time — `tsup` and `bun build --compile` both inline the JSON import — and printed alongside the resolved entry path, so `ct --version` answers "which version" and "which ct" at once: 1.7.0 (/usr/local/bin/ct) Inside a standalone binary `import.meta.url` points into bun's embedded filesystem, which bun's `fs` shim reports as existing; that prefix is recognised so the binary's own path on disk is shown instead. Baking the version in makes the artifacts version-sensitive at BUILD time, which the release pipeline has to respect: the binaries are now recompiled in semantic-release's prepare step, after the version bump, for the same reason the tarball is already packed there (#84). Otherwise every released binary would report the repo's 0.1.0 placeholder. Both jobs call one shared build-binaries.sh so the two invocations cannot drift. Fixes #116 Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG --- .github/scripts/build-binaries.sh | 20 +++++++++++ .github/workflows/release.yml | 13 +++---- .releaserc.json | 4 +-- README.md | 1 + src/index.ts | 3 +- src/version.ts | 58 +++++++++++++++++++++++++++++++ tests/cli-version.test.ts | 48 +++++++++++++++++++++++++ 7 files changed, 138 insertions(+), 9 deletions(-) create mode 100755 .github/scripts/build-binaries.sh create mode 100644 src/version.ts create mode 100644 tests/cli-version.test.ts diff --git a/.github/scripts/build-binaries.sh b/.github/scripts/build-binaries.sh new file mode 100755 index 0000000..3d70143 --- /dev/null +++ b/.github/scripts/build-binaries.sh @@ -0,0 +1,20 @@ +#!/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. +set -euo pipefail + +mkdir -p release +for target in darwin-arm64 darwin-x64 linux-x64; do + bun build --compile --target="bun-${target}" ./src/index.ts --outfile "release/ct-${target}" +done +chmod +x release/ct-darwin-arm64 release/ct-darwin-x64 release/ct-linux-x64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71b4456..e30a42f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,12 +49,7 @@ jobs: bun-version: latest - 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 @@ -146,6 +141,12 @@ jobs: with: node-version: 22 + # The binaries are RECOMPILED here, inside semantic-release's prepare step — + # see the @semantic-release/exec command below and #116. + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + # @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/`. diff --git a/.releaserc.json b/.releaserc.json index c7c337f..d3fed5e 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -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; the smoke tests still ran against byte-identical binaries modulo that version constant. See #116.", + "prepareCmd": "bash .github/scripts/build-binaries.sh && npm pack --pack-destination release && mv release/eqrm-ct-cli-${nextRelease.version}.tgz release/ct-cli.tgz" } ], [ diff --git a/README.md b/README.md index 60de2cd..475c1c1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/index.ts b/src/index.ts index 4b11694..329d32a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { completionCommand } from "./commands/completion.js"; import { plannedCommands } from "./commands/placeholders.js"; import { isCompletionRequest, serveCompletionRequest } from "./completion/shell.js"; import { isMainModule } from "./isMain.js"; +import { versionLine } from "./version.js"; import { error, formatError } from "./ui.js"; export function buildProgram(): Command { @@ -24,7 +25,7 @@ export function buildProgram(): Command { "ChurchTools structure-as-code CLI — declarative plan/apply for the overarching, " + "rights-bearing structure. Never manages people.", ) - .version("0.0.0"); + .version(versionLine(import.meta.url)); program.addCommand(authCommand()); program.addCommand(getCommand()); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..e0730a6 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,58 @@ +/** + * The CLI's own version — and which `ct` is actually running (#116). (The + * ChurchTools *instance* version, and the minimum this tool requires, live in + * `api/version.ts`; the two are unrelated.) + * + * `--version` is what you reach for when a plan does something unexpected, so it + * has to answer two questions honestly: *which version* and *which binary*. It + * used to answer neither — the version was the literal `"0.0.0"`, identical for + * a released install and a locally linked dev build, which is worse than useless + * because it reads like evidence. + * + * The version is baked in at build time from `package.json`: both `tsup` and + * `bun build --compile` inline the JSON import, so an artifact carries the + * version its working tree declared when it was built. That is the released + * version for the published package — semantic-release bumps `package.json` + * before `npm pack`/`npm publish`, each of which re-runs the build through the + * `prepare` script (the same ordering `.releaserc.json` relies on for the + * tarball, see #84). Running from source (`npm run dev`) reports the repo's + * placeholder version, which the entry path below makes obvious. + */ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import pkg from "../package.json" with { type: "json" }; + +/** The version this build was compiled from. */ +export const VERSION: string = pkg.version; + +/** + * Paths inside a `bun build --compile` binary's embedded filesystem. Bun's `fs` + * shim reports them as existing, so they have to be recognised by prefix. + */ +const EMBEDDED_PREFIXES = ["/$bunfs/", "B:\\~BUN\\"]; + +/** + * Absolute path of the running `ct`. + * + * `import.meta.url` points at the bundle (`.../dist/index.js`) or, when running + * from source, at the checkout — which settles "am I running the pinned package + * or something else on this machine?". Inside a standalone binary it points into + * bun's embedded filesystem, which tells the user nothing; there, the binary's + * own path on disk is the useful answer. + */ +export function resolveEntry(moduleUrl: string = import.meta.url): string { + try { + const path = fileURLToPath(moduleUrl); + if (EMBEDDED_PREFIXES.some((prefix) => path.startsWith(prefix)) || !existsSync(path)) { + return process.execPath; + } + return path; + } catch { + return process.execPath; + } +} + +/** What `ct --version` prints: `1.7.0 (/path/to/dist/index.js)`. */ +export function versionLine(moduleUrl?: string): string { + return `${VERSION} (${resolveEntry(moduleUrl)})`; +} diff --git a/tests/cli-version.test.ts b/tests/cli-version.test.ts new file mode 100644 index 0000000..341340c --- /dev/null +++ b/tests/cli-version.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { VERSION, resolveEntry, versionLine } from "../src/version.js"; +import { buildProgram } from "../src/index.js"; + +const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + version: string; +}; + +describe("VERSION (#116)", () => { + it("is the version declared in package.json, not the old 0.0.0 literal", () => { + expect(VERSION).toBe(pkg.version); + expect(VERSION).not.toBe("0.0.0"); + }); + + it("is what the program reports for --version", () => { + expect(buildProgram().version()).toContain(VERSION); + }); +}); + +describe("resolveEntry", () => { + it("returns the module's own path when it exists on disk", () => { + const here = new URL("../src/version.ts", import.meta.url); + expect(resolveEntry(here.href)).toBe(here.pathname); + }); + + it("falls back to the executable for bun's embedded filesystem", () => { + // A compiled `bun build --compile` binary resolves to /$bunfs/..., which bun's + // fs shim reports as existing but which means nothing to the user. + expect(resolveEntry(pathToFileURL("/$bunfs/root/index.js").href)).toBe(process.execPath); + }); + + it("falls back to the executable for a path that is not on disk", () => { + expect(resolveEntry(pathToFileURL("/no/such/place/index.js").href)).toBe(process.execPath); + }); + + it("falls back to the executable for a non-file URL", () => { + expect(resolveEntry("https://example.invalid/index.js")).toBe(process.execPath); + }); +}); + +describe("versionLine", () => { + it("answers both 'which version' and 'which ct'", () => { + const here = new URL("../src/version.ts", import.meta.url); + expect(versionLine(here.href)).toBe(`${VERSION} (${here.pathname})`); + }); +}); From 0e37260303437dd4401748f48f879e49af3c73af Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 24 Aug 2026 18:42:42 +0200 Subject: [PATCH 2/4] feat(auth): ask "who am I?" per environment (#117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plan`, `apply`, `adopt`, `get`, `coverage`, `state`, `refresh` and `permissions` all take `-e, --env `; `auth status` did not, so the one command whose entire job is answering "which account is this?" could only ever answer it for the default host. Finding out which identity `--env dev` would use meant running something that touches the host for real, or reading ct.envs.json and the Keychain by hand. - `ct auth status --env ` resolves that env's host and reports the identity there. The host goes to stderr, so `| jq` still sees only the identity JSON. - `ct auth status --all` checks every environment in ct.envs.json — one line each, with where the token came from and nothing of the token itself: dev https://mychurch-dev.church.tools ✓ Ada Lovelace (#42) via Keychain prod https://mychurch.church.tools ✗ no token It exits non-zero if any environment has no working token, so CI can gate on it before an apply. A failing env is reported as that env's line rather than aborting the run, so one unreachable instance cannot hide the others. - `ct auth logout --env ` clears just that host's credentials and leaves other logins in place (the default blob goes too when it holds a copy of the same token, so no secret is orphaned). Token resolution mirrors authedSession exactly — profile `tokenEnv` → CT_LOGINTOKEN → the host-keyed Keychain entry — so a green line means the same command with `--env` will authenticate the same way. The stored lookup is host-keyed, so one env can never report another env's identity. Read-only throughout: the only network call is the whoami handshake, and only for an env that has a token to try. Fixes #117 Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG --- README.md | 2 +- docs/environments.md | 23 ++++++ src/auth/status.ts | 140 ++++++++++++++++++++++++++++++++ src/auth/tokenStore.ts | 38 ++++++--- src/commands/auth.ts | 70 ++++++++++++++-- src/env/envs.ts | 25 +++++- tests/auth-status.test.ts | 166 ++++++++++++++++++++++++++++++++++++++ tests/cli.test.ts | 18 +++++ tests/envs.test.ts | 50 +++++++++++- 9 files changed, 509 insertions(+), 23 deletions(-) create mode 100644 src/auth/status.ts create mode 100644 tests/auth-status.test.ts diff --git a/README.md b/README.md index 475c1c1..e58d589 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Open a new shell, then try `ct sta`, `ct state rm ` 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 -ct auth status # who am I? +ct auth status # who am I? (`--env ` 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 diff --git a/docs/environments.md b/docs/environments.md index 1997f38..ae28ff9 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -54,6 +54,29 @@ 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). +## 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. Tokens are never printed — only where each one came from. + +`ct auth logout --env ` removes just that host's credentials and leaves +your other logins in place. + **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` diff --git a/src/auth/status.ts b/src/auth/status.ts new file mode 100644 index 0000000..9b3451e --- /dev/null +++ b/src/auth/status.ts @@ -0,0 +1,140 @@ +/** + * 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 ` will authenticate the same + * way: a profile `tokenEnv` (CI) → `CT_LOGINTOKEN` → the host-keyed Keychain + * entry. Nothing here writes anything; the only network call is the `whoami` + * handshake, and only for an env that actually has a token. + */ +import { CtClient, type WhoAmI } from "../api/ctClient.js"; +import { readCredentials, type Credentials } from "./tokenStore.js"; +import type { EnvProfile } from "../env/envs.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). */ + error?: string; +} + +export interface StatusDeps { + env?: NodeJS.ProcessEnv; + readStored?: (host: string) => Promise; + whoami?: (host: string, token: string) => Promise; +} + +async function defaultWhoami(host: string, token: string): Promise { + return new CtClient({ host }).authenticate(token); +} + +/** Resolve the token an `--env ` command would use, without disclosing it. */ +async function resolveToken( + profile: EnvProfile, + env: NodeJS.ProcessEnv, + readStored: (host: string) => Promise, +): 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) { + 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 { + const env = deps.env ?? process.env; + const readStored = deps.readStored ?? readCredentials; + const whoami = deps.whoami ?? defaultWhoami; + + const { token, source } = await resolveToken(profile, env, readStored); + if (token === null) { + return { name: profile.name, host: profile.host, source }; + } + try { + return { name: profile.name, host: profile.host, source, identity: await whoami(profile.host, token) }; + } catch (err) { + return { + name: profile.name, + host: profile.host, + source, + error: err instanceof Error ? err.message : String(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 { + 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) { + return `${prefix} ✗ ${status.error} ${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); +} diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index 8a92e78..90a622a 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -168,20 +168,36 @@ export async function readStoredHost(): Promise { } /** - * Remove stored credentials. Clears the default blob, the pre-host bare-token - * entry, and — for the current default login — its per-host account, so a - * single-host logout leaves no secret behind. (Additional per-host logins for - * OTHER hosts are left in place; re-login overwrites them.) + * Remove stored credentials. + * + * With no `host`: clears the default blob, the pre-host bare-token entry, and — + * for the current default login — its per-host account, so a single-host logout + * leaves no secret behind. (Additional per-host logins for OTHER hosts are left + * in place; re-login overwrites them.) + * + * With a `host` (already normalized — `ct auth logout --env `, #117): + * clears only that host's per-host account, leaving other hosts logged in. The + * default blob is dropped too when it points at that same host, since it holds a + * copy of the very token being removed. */ -export async function clearCredentials(): Promise { +export async function clearCredentials(host?: string): Promise { if (isMac()) { - const current = await readCredentials(); // default blob → its host's per-host account - if (current) { - await keychainDelete(current.host); + if (host !== undefined) { + await keychainDelete(host); + const fallback = await readCredentials(); // the default blob may hold the same token + if (fallback?.host === host) { + await keychainDelete(DEFAULT_ACCOUNT); + await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT); + } + } else { + const current = await readCredentials(); // default blob → its host's per-host account + if (current) { + await keychainDelete(current.host); + } + await keychainDelete(DEFAULT_ACCOUNT); + // Also drop the pre-host bare-token entry so an upgrade doesn't leave a secret behind. + await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT); } - await keychainDelete(DEFAULT_ACCOUNT); - // Also drop the pre-host bare-token entry so an upgrade doesn't leave a secret behind. - await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT); } resetKeychainCache(); // a later read in the same process must not return the cleared secret } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 454d0de..af4a412 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,11 +1,36 @@ import { Command } from "commander"; import { CtClient } from "../api/ctClient.js"; -import { normalizeHost } from "../config.js"; +import { normalizeHost, resolveConfig } from "../config.js"; import { storeCredentials, readToken, clearCredentials } from "../auth/tokenStore.js"; +import { checkAllEnvAuth, renderEnvAuth, allEnvsAuthenticated } from "../auth/status.js"; import { authedSession } from "../api/session.js"; +import { prepareEnvHost } from "../env/context.js"; +import { loadEnvProfile, loadEnvProfiles, resolveEnvsPath } from "../env/envs.js"; import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "../api/version.js"; import { success, error, info, warn, out } from "../ui.js"; +/** + * The `--all` preflight: one line per environment, resolving each env's host and + * token exactly as an `--env` command would. Exits non-zero when any environment + * has no working token, so CI can gate on it. + */ +async function reportAllEnvs(): Promise { + const envsPath = resolveEnvsPath(); + const profiles = await loadEnvProfiles(envsPath); + if (profiles.length === 0) { + error(`No environments defined in ${envsPath}.`); + process.exitCode = 1; + return; + } + const statuses = await checkAllEnvAuth(profiles); + for (const line of renderEnvAuth(statuses)) { + process.stdout.write(`${line}\n`); + } + if (!allEnvsAuthenticated(statuses)) { + process.exitCode = 1; + } +} + export function authCommand(): Command { const cmd = new Command("auth").description("Authenticate against ChurchTools"); @@ -48,23 +73,54 @@ export function authCommand(): Command { cmd .command("status") - .description("Show the currently authenticated user") - .action(async () => { - if (!(await readToken())) { + .description("Show who you are — on the default host, on one --env, or on every env (--all)") + .option("-e, --env ", "environment profile from ct.envs.json (targets that host)") + .option("--all", "report every environment in ct.envs.json (read-only preflight)") + .action(async (opts: { env?: string; all?: boolean }) => { + if (opts.all) { + if (opts.env) { + error("--all reports every environment; drop --env (or drop --all to check just one)."); + process.exitCode = 1; + return; + } + await reportAllEnvs(); + return; + } + + // #22 wiring: point the unchanged host/token resolution at the env's instance. + await prepareEnvHost(opts); + let host: string; + try { + host = (await resolveConfig()).host; + } catch { error("Not logged in. Run `ct auth login --host --token `."); process.exitCode = 1; return; } + if (!(await readToken(host))) { + error(`No token for ${host}. Run \`ct auth login --host ${host} --token \`.`); + process.exitCode = 1; + return; + } const { me } = await authedSession(); + // The host goes to stderr so `ct auth status | jq` keeps seeing only the identity. + info(opts.env ? `${host} (env ${opts.env})` : host); out(me); }); cmd .command("logout") .description("Remove the stored host + login token") - .action(async () => { - await clearCredentials(); - success("Logged out — stored credentials removed."); + .option("-e, --env ", "environment profile from ct.envs.json (log out of that host only)") + .action(async (opts: { env?: string }) => { + if (!opts.env) { + await clearCredentials(); + success("Logged out — stored credentials removed."); + return; + } + const profile = await loadEnvProfile(opts.env, resolveEnvsPath()); + await clearCredentials(profile.host); + success(`Logged out of ${profile.host} (env ${profile.name}) — other hosts stay logged in.`); }); return cmd; diff --git a/src/env/envs.ts b/src/env/envs.ts index a8da2a1..4c8ad60 100644 --- a/src/env/envs.ts +++ b/src/env/envs.ts @@ -116,6 +116,13 @@ function resolveProfile(name: string, raw: RawProfile): EnvProfile { }; } +/** Reject a non-object profile entry before {@link resolveProfile} reads fields off it. */ +function assertProfileObject(name: string, raw: unknown, path: string): asserts raw is RawProfile { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new Error(`Environment "${name}" in ${path} must be a JSON object.`); + } +} + /** * Load and resolve the profile named `name` from the profile file at `path`. * Throws a friendly error when the file is missing, malformed, or has no such env. @@ -128,8 +135,20 @@ export async function loadEnvProfile(name: string, path: string): Promise { + const envs = await loadEnvsFile(path); + return Object.entries(envs).map(([name, raw]) => { + assertProfileObject(name, raw, path); + return resolveProfile(name, raw); + }); +} diff --git a/tests/auth-status.test.ts b/tests/auth-status.test.ts new file mode 100644 index 0000000..afebd7a --- /dev/null +++ b/tests/auth-status.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { + checkEnvAuth, + checkAllEnvAuth, + renderEnvAuth, + allEnvsAuthenticated, + describeIdentity, + type StatusDeps, +} from "../src/auth/status.js"; +import type { EnvProfile } from "../src/env/envs.js"; + +const dev: EnvProfile = { + name: "dev", + host: "https://mychurch-dev.church.tools", + statePath: "ct-state.dev.json", + protected: false, +}; +const prod: EnvProfile = { + name: "prod", + host: "https://mychurch.church.tools", + statePath: "ct-state.prod.json", + protected: true, + tokenEnv: "CT_PROD_TOKEN", +}; + +const ME = { id: 42, firstName: "Ada", lastName: "Lovelace" }; + +/** Deps with nothing stored, nothing in the environment, and a whoami that must not be called. */ +function deps(overrides: StatusDeps = {}): StatusDeps { + return { + env: {}, + readStored: async () => null, + whoami: async () => { + throw new Error("whoami should not be called without a token"); + }, + ...overrides, + }; +} + +describe("checkEnvAuth token resolution (#117)", () => { + it("reports no token when neither the environment nor the Keychain has one", async () => { + const status = await checkEnvAuth(dev, deps()); + expect(status).toEqual({ name: "dev", host: dev.host, source: { kind: "none" } }); + }); + + it("uses the host-keyed stored credentials", async () => { + const seen: string[] = []; + const status = await checkEnvAuth( + dev, + deps({ + readStored: async (host) => { + seen.push(host); + return { host, token: "stored-token" }; + }, + whoami: async (host, token) => { + expect(host).toBe(dev.host); + expect(token).toBe("stored-token"); + return ME; + }, + }), + ); + // The token is looked up for THIS env's host — never the default login's. + expect(seen).toEqual([dev.host]); + expect(status.source).toEqual({ kind: "stored" }); + expect(status.identity).toEqual(ME); + }); + + it("prefers the profile's tokenEnv over the Keychain (the CI path)", async () => { + const status = await checkEnvAuth( + prod, + deps({ + env: { CT_PROD_TOKEN: "ci-token" }, + readStored: async () => ({ host: prod.host, token: "stored-token" }), + whoami: async (_host, token) => { + expect(token).toBe("ci-token"); + return ME; + }, + }), + ); + expect(status.source).toEqual({ kind: "env", variable: "CT_PROD_TOKEN" }); + }); + + it("falls back to CT_LOGINTOKEN when the profile's tokenEnv is unset", async () => { + const status = await checkEnvAuth( + prod, + deps({ + env: { CT_LOGINTOKEN: "ambient" }, + whoami: async (_host, token) => { + expect(token).toBe("ambient"); + return ME; + }, + }), + ); + expect(status.source).toEqual({ kind: "env", variable: "CT_LOGINTOKEN" }); + }); + + it("reports a rejected token as that env's error rather than throwing", async () => { + const status = await checkEnvAuth( + dev, + deps({ + env: { CT_LOGINTOKEN: "expired" }, + whoami: async () => { + throw new Error("whoami failed (HTTP 401)"); + }, + }), + ); + expect(status.identity).toBeUndefined(); + expect(status.error).toBe("whoami failed (HTTP 401)"); + }); +}); + +describe("checkAllEnvAuth", () => { + it("checks every env, so one broken instance does not hide the others", async () => { + const statuses = await checkAllEnvAuth( + [dev, prod], + deps({ + env: { CT_PROD_TOKEN: "ci-token" }, + whoami: async (host) => { + if (host === dev.host) { + throw new Error("connect ECONNREFUSED"); + } + return ME; + }, + readStored: async (host) => ({ host, token: "stored-token" }), + }), + ); + expect(statuses.map((s) => s.name)).toEqual(["dev", "prod"]); + expect(statuses[0]!.error).toBe("connect ECONNREFUSED"); + expect(statuses[1]!.identity).toEqual(ME); + expect(allEnvsAuthenticated(statuses)).toBe(false); + }); +}); + +describe("renderEnvAuth", () => { + it("renders one aligned line per env with the token's provenance", () => { + const lines = renderEnvAuth([ + { name: "dev", host: dev.host, source: { kind: "stored" }, identity: ME }, + { name: "prod", host: prod.host, source: { kind: "none" } }, + { + name: "stage", + host: "https://s.church.tools", + source: { kind: "env", variable: "CT_PROD_TOKEN" }, + error: "whoami failed (HTTP 401)", + }, + ]); + expect(lines).toEqual([ + "dev https://mychurch-dev.church.tools ✓ Ada Lovelace (#42) via Keychain", + "prod https://mychurch.church.tools ✗ no token", + "stage https://s.church.tools ✗ whoami failed (HTTP 401) via $CT_PROD_TOKEN", + ]); + }); + + it("never prints the token itself", () => { + const line = renderEnvAuth([ + { name: "dev", host: dev.host, source: { kind: "stored" }, identity: ME }, + ]).join("\n"); + expect(line).not.toContain("token="); + }); +}); + +describe("describeIdentity", () => { + it("degrades to the bare person id when the instance returns no name", () => { + expect(describeIdentity({ id: 7 })).toBe("#7"); + expect(describeIdentity({ id: 7, lastName: "Lovelace" })).toBe("Lovelace (#7)"); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 79dc4eb..d76f97d 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -32,6 +32,24 @@ describe("ct program", () => { } }); + it("registers --env on auth status and auth logout (#117)", () => { + const auth = buildProgram().commands.find((c) => c.name() === "auth")!; + for (const name of ["status", "logout"]) { + const sub = auth.commands.find((c) => c.name() === name)!; + expect(sub.options.some((o) => o.long === "--env" && o.short === "-e")).toBe(true); + } + }); + + it("registers --all on auth status as the every-environment preflight (#117)", () => { + const auth = buildProgram().commands.find((c) => c.name() === "auth")!; + const status = auth.commands.find((c) => c.name() === "status")!; + expect(status.options.some((o) => o.long === "--all")).toBe(true); + }); + + it("reports a real version, not the 0.0.0 literal (#116)", () => { + expect(buildProgram().version()).not.toBe("0.0.0"); + }); + it("registers --confirm-env on apply and destroy (protected-env CI path, #22)", () => { for (const name of ["apply", "destroy"]) { const cmd = buildProgram().commands.find((c) => c.name() === name)!; diff --git a/tests/envs.test.ts b/tests/envs.test.ts index 1562313..b0bdd65 100644 --- a/tests/envs.test.ts +++ b/tests/envs.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, afterEach } from "vitest"; import { writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { DEFAULT_ENVS_PATH, resolveEnvsPath, defaultEnvStatePath, loadEnvProfile } from "../src/env/envs.js"; +import { + DEFAULT_ENVS_PATH, + resolveEnvsPath, + defaultEnvStatePath, + loadEnvProfile, + loadEnvProfiles, +} from "../src/env/envs.js"; const envsPath = join(tmpdir(), `ct-cli-envs-${process.pid}.json`); @@ -90,3 +96,45 @@ describe("loadEnvProfile", () => { await expect(loadEnvProfile("dev", envsPath)).rejects.toThrow(/not valid JSON/); }); }); + +describe("loadEnvProfiles (#117)", () => { + it("resolves every profile in declaration order", async () => { + await writeEnvs({ + environments: { + dev: { host: "https://d.church.tools/" }, + prod: { host: "https://p.church.tools", protected: true, tokenEnv: "CT_PROD_TOKEN" }, + }, + }); + const profiles = await loadEnvProfiles(envsPath); + expect(profiles.map((p) => p.name)).toEqual(["dev", "prod"]); + expect(profiles[0]).toEqual({ + name: "dev", + host: "https://d.church.tools", + statePath: defaultEnvStatePath("dev"), + tokenEnv: undefined, + protected: false, + }); + expect(profiles[1]!.protected).toBe(true); + }); + + it("returns an empty list for an empty environments map", async () => { + await writeEnvs({ environments: {} }); + await expect(loadEnvProfiles(envsPath)).resolves.toEqual([]); + }); + + it("fails on ONE malformed profile rather than silently omitting it", async () => { + await writeEnvs({ + environments: { dev: { host: "https://d.church.tools" }, prod: { protected: true } }, + }); + await expect(loadEnvProfiles(envsPath)).rejects.toThrow(/missing.*host/i); + }); + + it("rejects a profile that is not an object", async () => { + await writeEnvs({ environments: { dev: "https://d.church.tools" } }); + await expect(loadEnvProfiles(envsPath)).rejects.toThrow(/must be a JSON object/); + }); + + it("throws the same friendly error as the single lookup when the file is missing", async () => { + await expect(loadEnvProfiles(envsPath)).rejects.toThrow(/Environment profile file not found/); + }); +}); From dbe2479500e4f5b5988c2a0291e92884b806a518 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 24 Aug 2026 18:58:23 +0200 Subject: [PATCH 3/4] fix(auth,release): address review findings on #116/#117 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ct auth status --all` walks every host in ct.envs.json, so the bare `CT_LOGINTOKEN` fallback fanned one instance's token out to all of them — as a `login_token=` query parameter, into every instance's access log — and then reported a green line for envs nothing was configured for. The ambient token is now offered only to the host it is bound to (`CT_HOST`, else the stored default login's host); everything else reports no token. Failures are rendered with `formatError`, so the HTTP status a real `CtApiError` carries survives — 401, 403 and 500 no longer render alike. Token resolution moved inside the try, so a credential store that throws reports that env instead of aborting the sweep, and the preflight now runs `assertMinVersion` too, so a green line cannot be followed by an apply that refuses on the instance version. `ct auth logout --env ` still drops the default blob when it holds a copy of the same token — but it now says so, instead of promising that other logins are untouched while commands without `--env` lose their host. Release: the shipped binaries were the only ones never executed — they are recompiled in semantic-release's prepare step, after the smoke jobs ran, on an unpinned `bun-version: latest`. Both jobs now pin the same bun, the recompiled linux binary is smoke-tested again in the release job, and the smoke script asserts `ct --version`, which nothing in CI had ever run on the compiled path. The version constant is injected via `define` (tsup + `bun build`) instead of a default JSON import esbuild cannot tree-shake, which was inlining the whole manifest — devDependencies, scripts, dependency list — into the published bundle; running from source falls back to reading package.json. Finally, cli-version compared a decoded path against a percent-encoded one, which fails for any checkout path needing escaping. Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG --- .github/scripts/build-binaries.sh | 16 ++++- .github/scripts/smoke-test-binary.sh | 15 +++++ .github/workflows/release.yml | 14 +++-- .releaserc.json | 4 +- docs/environments.md | 16 ++++- src/auth/status.ts | 87 ++++++++++++++++++++++------ src/auth/tokenStore.ts | 16 ++++- src/commands/auth.ts | 10 +++- src/version.ts | 35 +++++++++-- tests/auth-status.test.ts | 81 +++++++++++++++++++++++++- tests/cli-version.test.ts | 11 +++- tsup.config.ts | 11 ++++ 12 files changed, 274 insertions(+), 42 deletions(-) diff --git a/.github/scripts/build-binaries.sh b/.github/scripts/build-binaries.sh index 3d70143..1ba7133 100755 --- a/.github/scripts/build-binaries.sh +++ b/.github/scripts/build-binaries.sh @@ -10,11 +10,23 @@ # 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. +# 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}" ./src/index.ts --outfile "release/ct-${target}" + 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 diff --git a/.github/scripts/smoke-test-binary.sh b/.github/scripts/smoke-test-binary.sh index 8ae7ac0..901c51b 100755 --- a/.github/scripts/smoke-test-binary.sh +++ b/.github/scripts/smoke-test-binary.sh @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e30a42f..005b5ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,9 +44,13 @@ 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: bash .github/scripts/build-binaries.sh @@ -141,11 +145,13 @@ jobs: with: node-version: 22 - # The binaries are RECOMPILED here, inside semantic-release's prepare step — - # see the @semantic-release/exec command below and #116. + # 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: latest + 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 diff --git a/.releaserc.json b/.releaserc.json index d3fed5e..531082e 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -7,8 +7,8 @@ [ "@semantic-release/exec", { - "//": "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; the smoke tests still ran against byte-identical binaries modulo that version constant. See #116.", - "prepareCmd": "bash .github/scripts/build-binaries.sh && 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" } ], [ diff --git a/docs/environments.md b/docs/environments.md index ae28ff9..4bb49fe 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -54,6 +54,13 @@ 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 @@ -72,10 +79,15 @@ 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. Tokens are never printed — only where each one came from. +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 ` removes just that host's credentials and leaves -your other logins in place. +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 — diff --git a/src/auth/status.ts b/src/auth/status.ts index 9b3451e..1cb64c3 100644 --- a/src/auth/status.ts +++ b/src/auth/status.ts @@ -9,13 +9,16 @@ * * The resolution order deliberately mirrors `authedSession`, so a green line * here means the same command with `--env ` will authenticate the same - * way: a profile `tokenEnv` (CI) → `CT_LOGINTOKEN` → the host-keyed Keychain - * entry. Nothing here writes anything; the only network call is the `whoami` - * handshake, and only for an env that actually has a token. + * 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 { readCredentials, type Credentials } from "./tokenStore.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" }; @@ -26,18 +29,56 @@ export interface EnvAuthStatus { 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). */ + /** 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; + /** The host the *default* (unqualified) login points at — see {@link ambientTokenHost}. */ + readDefaultHost?: () => Promise; whoami?: (host: string, token: string) => Promise; } +/** + * 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 ` + * refusing to run — exactly the failure a preflight exists to catch. + */ async function defaultWhoami(host: string, token: string): Promise { - return new CtClient({ host }).authenticate(token); + 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, +): Promise { + const fromEnv = env.CT_HOST?.trim(); + if (fromEnv) { + return normalizeHost(fromEnv); + } + return await readDefaultHost(); } /** Resolve the token an `--env ` command would use, without disclosing it. */ @@ -45,6 +86,7 @@ async function resolveToken( profile: EnvProfile, env: NodeJS.ProcessEnv, readStored: (host: string) => Promise, + readDefaultHost: () => Promise, ): Promise<{ token: string; source: TokenSource } | { token: null; source: TokenSource }> { if (profile.tokenEnv) { const fromProfileVar = env[profile.tokenEnv]?.trim(); @@ -53,7 +95,7 @@ async function resolveToken( } } const ambient = env.CT_LOGINTOKEN?.trim(); - if (ambient) { + if (ambient && (await ambientTokenHost(env, readDefaultHost)) === profile.host) { return { token: ambient, source: { kind: "env", variable: "CT_LOGINTOKEN" } }; } const stored = await readStored(profile.host); @@ -67,21 +109,25 @@ async function resolveToken( export async function checkEnvAuth(profile: EnvProfile, deps: StatusDeps = {}): Promise { const env = deps.env ?? process.env; const readStored = deps.readStored ?? readCredentials; + const readDefaultHost = deps.readDefaultHost ?? readStoredHost; const whoami = deps.whoami ?? defaultWhoami; - const { token, source } = await resolveToken(profile, env, readStored); - if (token === null) { - return { name: profile.name, host: profile.host, source }; - } + // 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 { - return { name: profile.name, host: profile.host, source, identity: await whoami(profile.host, token) }; + 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) { - return { - name: profile.name, - host: profile.host, - source, - error: err instanceof Error ? err.message : String(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) }; } } @@ -128,7 +174,10 @@ export function renderEnvAuth(statuses: EnvAuthStatus[]): string[] { return `${prefix} ✓ ${describeIdentity(status.identity)} ${describeSource(status.source)}`.trimEnd(); } if (status.error) { - return `${prefix} ✗ ${status.error} ${describeSource(status.source)}`.trimEnd(); + // 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`; }); diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index 90a622a..b0af59e 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -178,9 +178,18 @@ export async function readStoredHost(): Promise { * With a `host` (already normalized — `ct auth logout --env `, #117): * clears only that host's per-host account, leaving other hosts logged in. The * default blob is dropped too when it points at that same host, since it holds a - * copy of the very token being removed. + * copy of the very token being removed — which also un-sets the host that + * commands WITHOUT `--env` fall back to. That is reported back in + * `clearedDefault` so the caller can say so instead of promising, wrongly, that + * nothing else changed. */ -export async function clearCredentials(host?: string): Promise { +export interface ClearCredentialsResult { + /** True when the default (unqualified) login was removed along with the host's. */ + clearedDefault: boolean; +} + +export async function clearCredentials(host?: string): Promise { + let clearedDefault = false; if (isMac()) { if (host !== undefined) { await keychainDelete(host); @@ -188,8 +197,10 @@ export async function clearCredentials(host?: string): Promise { if (fallback?.host === host) { await keychainDelete(DEFAULT_ACCOUNT); await keychainDelete(LEGACY_KEYCHAIN_ACCOUNT); + clearedDefault = true; } } else { + clearedDefault = true; const current = await readCredentials(); // default blob → its host's per-host account if (current) { await keychainDelete(current.host); @@ -200,4 +211,5 @@ export async function clearCredentials(host?: string): Promise { } } resetKeychainCache(); // a later read in the same process must not return the cleared secret + return { clearedDefault }; } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index af4a412..0941ec0 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -119,8 +119,16 @@ export function authCommand(): Command { return; } const profile = await loadEnvProfile(opts.env, resolveEnvsPath()); - await clearCredentials(profile.host); + const { clearedDefault } = await clearCredentials(profile.host); success(`Logged out of ${profile.host} (env ${profile.name}) — other hosts stay logged in.`); + if (clearedDefault) { + // The default blob held a copy of the very token just removed, so it went + // with it — and with it the host that commands without --env fall back to. + warn( + `${profile.host} was also the default login, so commands without --env now have no host. ` + + `Run \`ct auth login --host --token \` (or pass --env) to set one again.`, + ); + } }); return cmd; diff --git a/src/version.ts b/src/version.ts index e0730a6..206cf39 100644 --- a/src/version.ts +++ b/src/version.ts @@ -10,20 +10,43 @@ * because it reads like evidence. * * The version is baked in at build time from `package.json`: both `tsup` and - * `bun build --compile` inline the JSON import, so an artifact carries the - * version its working tree declared when it was built. That is the released - * version for the published package — semantic-release bumps `package.json` + * `bun build --compile` replace the `__CT_VERSION__` constant below with a + * string literal, so an artifact carries the version its working tree declared + * when it was built. (It used to be a default JSON import, which esbuild cannot + * tree-shake — the whole manifest, `devDependencies` and `scripts` included, + * ended up inlined in the published bundle.) Running from source, where nothing + * defines the constant, falls back to reading `package.json` off disk. That is + * the released version for the published package — semantic-release bumps `package.json` * before `npm pack`/`npm publish`, each of which re-runs the build through the * `prepare` script (the same ordering `.releaserc.json` relies on for the * tarball, see #84). Running from source (`npm run dev`) reports the repo's * placeholder version, which the entry path below makes obvious. */ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import pkg from "../package.json" with { type: "json" }; + +/** + * Injected at build time: `tsup`'s `define` and `bun build --define` both + * replace this identifier with a string literal. Undeclared when running from + * source — `typeof` on a bare identifier is safe there, and the fallback reads + * the manifest that is right next to the checkout anyway. + */ +declare const __CT_VERSION__: string; + +/** What `--version` reports when neither the build nor `package.json` could say. */ +const UNKNOWN_VERSION = "0.0.0-unknown"; + +function packageVersion(): string { + try { + const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8"); + return (JSON.parse(raw) as { version?: string }).version ?? UNKNOWN_VERSION; + } catch { + return UNKNOWN_VERSION; + } +} /** The version this build was compiled from. */ -export const VERSION: string = pkg.version; +export const VERSION: string = typeof __CT_VERSION__ === "string" ? __CT_VERSION__ : packageVersion(); /** * Paths inside a `bun build --compile` binary's embedded filesystem. Bun's `fs` diff --git a/tests/auth-status.test.ts b/tests/auth-status.test.ts index afebd7a..db867ac 100644 --- a/tests/auth-status.test.ts +++ b/tests/auth-status.test.ts @@ -8,6 +8,7 @@ import { type StatusDeps, } from "../src/auth/status.js"; import type { EnvProfile } from "../src/env/envs.js"; +import { CtApiError } from "../src/api/ctClient.js"; const dev: EnvProfile = { name: "dev", @@ -30,6 +31,7 @@ function deps(overrides: StatusDeps = {}): StatusDeps { return { env: {}, readStored: async () => null, + readDefaultHost: async () => null, whoami: async () => { throw new Error("whoami should not be called without a token"); }, @@ -80,11 +82,27 @@ describe("checkEnvAuth token resolution (#117)", () => { expect(status.source).toEqual({ kind: "env", variable: "CT_PROD_TOKEN" }); }); - it("falls back to CT_LOGINTOKEN when the profile's tokenEnv is unset", async () => { + it("falls back to CT_LOGINTOKEN for the host that token is bound to (CT_HOST)", async () => { + const status = await checkEnvAuth( + prod, + deps({ + env: { CT_LOGINTOKEN: "ambient", CT_HOST: `${prod.host}/` }, + whoami: async (host, token) => { + expect(host).toBe(prod.host); + expect(token).toBe("ambient"); + return ME; + }, + }), + ); + expect(status.source).toEqual({ kind: "env", variable: "CT_LOGINTOKEN" }); + }); + + it("falls back to CT_LOGINTOKEN for the stored default login's host", async () => { const status = await checkEnvAuth( prod, deps({ env: { CT_LOGINTOKEN: "ambient" }, + readDefaultHost: async () => prod.host, whoami: async (_host, token) => { expect(token).toBe("ambient"); return ME; @@ -94,11 +112,42 @@ describe("checkEnvAuth token resolution (#117)", () => { expect(status.source).toEqual({ kind: "env", variable: "CT_LOGINTOKEN" }); }); + it("never sends an ambient CT_LOGINTOKEN to a host it is not bound to", async () => { + // `--all` walks every host in ct.envs.json; fanning one instance's token out to + // all of them would post it — as a login_token= query param — into every other + // instance's access log, and report a green line for an unconfigured env. + const status = await checkEnvAuth( + dev, + deps({ + env: { CT_LOGINTOKEN: "prod-token" }, + readDefaultHost: async () => prod.host, + }), + ); + expect(status.source).toEqual({ kind: "none" }); + expect(status.identity).toBeUndefined(); + }); + + it("still prefers a profile tokenEnv naming CT_LOGINTOKEN, whatever the default host", async () => { + const status = await checkEnvAuth( + { ...dev, tokenEnv: "CT_LOGINTOKEN" }, + deps({ + env: { CT_LOGINTOKEN: "explicit" }, + readDefaultHost: async () => prod.host, + whoami: async (_host, token) => { + expect(token).toBe("explicit"); + return ME; + }, + }), + ); + expect(status.source).toEqual({ kind: "env", variable: "CT_LOGINTOKEN" }); + }); + it("reports a rejected token as that env's error rather than throwing", async () => { const status = await checkEnvAuth( dev, deps({ env: { CT_LOGINTOKEN: "expired" }, + readDefaultHost: async () => dev.host, whoami: async () => { throw new Error("whoami failed (HTTP 401)"); }, @@ -107,6 +156,36 @@ describe("checkEnvAuth token resolution (#117)", () => { expect(status.identity).toBeUndefined(); expect(status.error).toBe("whoami failed (HTTP 401)"); }); + + it("surfaces the HTTP status a real CtApiError carries, not just its message", async () => { + // CtClient.authenticate throws CtApiError("Login failed (whoami)", status) — the + // status is on the error, so err.message alone renders 401, 403 and 500 alike. + const status = await checkEnvAuth( + dev, + deps({ + readStored: async (host) => ({ host, token: "expired" }), + whoami: async () => { + throw new CtApiError("Login failed (whoami)", 401, null); + }, + }), + ); + expect(status.error).toContain("HTTP 401"); + }); + + it("reports a credential store that throws instead of aborting the sweep", async () => { + const statuses = await checkAllEnvAuth( + [dev, prod], + deps({ + env: { CT_PROD_TOKEN: "ci-token" }, + readStored: async () => { + throw new Error("keychain locked"); + }, + whoami: async () => ME, + }), + ); + expect(statuses[0]!.error).toBe("keychain locked"); + expect(statuses[1]!.identity).toEqual(ME); + }); }); describe("checkAllEnvAuth", () => { diff --git a/tests/cli-version.test.ts b/tests/cli-version.test.ts index 341340c..2986e48 100644 --- a/tests/cli-version.test.ts +++ b/tests/cli-version.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; +import { pathToFileURL, fileURLToPath } from "node:url"; import { VERSION, resolveEntry, versionLine } from "../src/version.js"; import { buildProgram } from "../src/index.js"; @@ -12,6 +12,9 @@ describe("VERSION (#116)", () => { it("is the version declared in package.json, not the old 0.0.0 literal", () => { expect(VERSION).toBe(pkg.version); expect(VERSION).not.toBe("0.0.0"); + // The build-time define is absent when running from source; the package.json + // fallback must cover it rather than leaving the honest-answer placeholder. + expect(VERSION).not.toBe("0.0.0-unknown"); }); it("is what the program reports for --version", () => { @@ -22,7 +25,9 @@ describe("VERSION (#116)", () => { describe("resolveEntry", () => { it("returns the module's own path when it exists on disk", () => { const here = new URL("../src/version.ts", import.meta.url); - expect(resolveEntry(here.href)).toBe(here.pathname); + // fileURLToPath on both sides: `URL.pathname` is percent-encoded, so a checkout + // under a path with a space (or any Windows drive path) would never match. + expect(resolveEntry(here.href)).toBe(fileURLToPath(here)); }); it("falls back to the executable for bun's embedded filesystem", () => { @@ -43,6 +48,6 @@ describe("resolveEntry", () => { describe("versionLine", () => { it("answers both 'which version' and 'which ct'", () => { const here = new URL("../src/version.ts", import.meta.url); - expect(versionLine(here.href)).toBe(`${VERSION} (${here.pathname})`); + expect(versionLine(here.href)).toBe(`${VERSION} (${fileURLToPath(here)})`); }); }); diff --git a/tsup.config.ts b/tsup.config.ts index 4d45d1b..9f9e7f8 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,4 +1,14 @@ import { defineConfig } from "tsup"; +import { readFileSync } from "node:fs"; + +// Baked into the bundle as a plain string literal (#116). Injecting the one field +// we need keeps the rest of the manifest — devDependencies, scripts, the whole +// dependency list — out of the published artifact, which a default JSON import +// could not: esbuild cannot tree-shake it. src/version.ts falls back to reading +// package.json when this is undefined (running from source). +const { version } = JSON.parse(readFileSync(new URL("package.json", import.meta.url), "utf8")) as { + version: string; +}; export default defineConfig({ entry: ["src/index.ts"], @@ -7,4 +17,5 @@ export default defineConfig({ outDir: "dist", clean: true, sourcemap: true, + define: { __CT_VERSION__: JSON.stringify(version) }, }); From ac12184f61670c4baac2be7ffe101ed2f296dd16 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 24 Aug 2026 18:59:44 +0200 Subject: [PATCH 4/4] style: prettier Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG --- docs/environments.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/environments.md b/docs/environments.md index 4bb49fe..2e9460a 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -55,7 +55,7 @@ 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 +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 @@ -85,7 +85,7 @@ one an `apply` would refuse. Tokens are never printed — only where each one ca from. `ct auth logout --env ` removes just that host's credentials and leaves -your other logins in place. If that host also happened to be your *default* +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.