From 88a5cf2b4c710ef945a2b05a9e9d501f93a57bfd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 15 May 2026 16:42:39 -0300 Subject: [PATCH 1/2] feat(cli): improve agent discoverability and add headless auth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the five agentcli-bench gaps (D3, A4, P3, P2, T7) and adds a `clerk auth login --token ` flow for CI / agents: - Top-level `Examples:` block on `clerk --help` (D3) - New `Environment:` help section via `setEnvVars()`, documenting the five `CLERK_*` env vars the binary actually reads (A4) - `--json` field descriptions on `apps list|create`, `users list|create`, and `doctor --json` so consumers know the shape (P3) - Verified `--json` + `isAgent()` coverage across data-returning subcommands (P2) - `clerk auth login --token ` for headless auth: accepts a Clerk PLAPI access token (or `-` for stdin), validates JWT shape and audience (`azp` claim, soft check with back-compat) locally before the userinfo call, persists with no refresh token. Sibling `awaitConcurrentRefresh` skips the race-detection loop for token-only sessions so two parallel logins don't collide on the empty-refresh sentinel (T7) A property test guards the `Environment:` list against drift — every documented `CLERK_*` name must be one the CLI actually reads. --- .changeset/agent-cli-bench-discoverability.md | 9 ++ packages/cli-core/src/cli-program.test.ts | 61 ++++++++++++ packages/cli-core/src/cli-program.ts | 73 +++++++++++++- packages/cli-core/src/commands/auth/README.md | 12 +++ packages/cli-core/src/commands/auth/login.ts | 96 ++++++++++++++++--- .../cli-core/src/lib/credential-store.test.ts | 86 ++++++++++++++++- packages/cli-core/src/lib/credential-store.ts | 82 +++++++++++++++- packages/cli-core/src/lib/help.ts | 68 ++++++++++--- .../src/test/integration/lib/harness.ts | 5 + 9 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 .changeset/agent-cli-bench-discoverability.md diff --git a/.changeset/agent-cli-bench-discoverability.md b/.changeset/agent-cli-bench-discoverability.md new file mode 100644 index 00000000..ad939aff --- /dev/null +++ b/.changeset/agent-cli-bench-discoverability.md @@ -0,0 +1,9 @@ +--- +"clerk": minor +--- + +Improve agent-CLI discoverability and add headless authentication. + +- `clerk --help` now renders a top-level `Examples:` block and an `Environment:` section listing the `CLERK_*` env vars the CLI reads (`CLERK_SECRET_KEY`, `CLERK_MODE`, `CLERK_CONFIG_DIR`, `CLERK_UPDATE_CHANNEL`, `CLERK_NO_UPDATE_CHECK`). +- `clerk auth login` accepts `--token ` for headless authentication with a Clerk PLAPI access token. Pass `-` to read the token from stdin. The token is validated against `/oauth/userinfo`, stored without a refresh token, and surfaces a clear `AUTH_REQUIRED` error when it expires. +- `--json` option descriptions on `clerk apps list|create`, `clerk users list|create`, and `clerk doctor` now document the field shape so consumers know what to expect. diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 3dc13520..cd2fc186 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -127,6 +127,67 @@ test("users create documents -d and --file for raw BAPI request bodies", () => { expect(help).toContain("--file"); }); +describe("agent-CLI discoverability surface", () => { + test("top-level --help renders Examples: with at least one agent-pipeable command", () => { + const help = createProgram().helpInformation(); + expect(help).toContain("Examples:"); + expect(help).toMatch(/clerk apps list --json/); + }); + + test("top-level --help renders Environment: with CLERK_* env vars actually read in cli-core", () => { + const help = createProgram().helpInformation(); + expect(help).toContain("Environment:"); + expect(help).toContain("CLERK_SECRET_KEY"); + expect(help).toContain("CLERK_MODE"); + }); + + test("data-returning subcommands document --json field shape", () => { + const program = createProgram(); + const usersList = program.commands + .find((c) => c.name() === "users")! + .commands.find((c) => c.name() === "list")!; + const appsList = program.commands + .find((c) => c.name() === "apps")! + .commands.find((c) => c.name() === "list")!; + + expect(usersList.helpInformation()).toMatch(/--json[^\n]*\bdata\b[^\n]*\bhasMore\b/); + expect(appsList.helpInformation()).toMatch(/--json[^\n]*\bapplication_id\b/); + }); + + test("auth login --help documents the headless path via --token and CLERK_SECRET_KEY", () => { + const program = createProgram(); + const auth = program.commands.find((c) => c.name() === "auth")!; + const login = auth.commands.find((c) => c.name() === "login")!; + const help = login.helpInformation(); + + const optionNames = login.options.map((o) => o.long); + expect(optionNames).toContain("--token"); + expect(help).toContain("CLERK_SECRET_KEY"); + expect(help).toMatch(/headless/i); + }); + + test("setEnvVars only documents CLERK_* names the binary actually reads", () => { + // Names listed in `Environment:` must match what the CLI reads via + // process.env.CLERK_* — otherwise the help text drifts and lies. + const documentedEnvVars = [ + ...createProgram() + .helpInformation() + .matchAll(/\bCLERK_[A-Z0-9_]+\b/g), + ].map((m) => m[0]); + const knownReadByCli = new Set([ + "CLERK_SECRET_KEY", + "CLERK_MODE", + "CLERK_CONFIG_DIR", + "CLERK_UPDATE_CHANNEL", + "CLERK_NO_UPDATE_CHECK", + ]); + for (const name of new Set(documentedEnvVars)) { + expect(knownReadByCli).toContain(name); + } + expect(documentedEnvVars.length).toBeGreaterThan(0); + }); +}); + describe("formatApiBody", () => { // --- Single error with meta --- diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a406fdcb..199890c4 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -66,6 +66,12 @@ const USER_LIST_ORDER_BY_CHOICES = USER_LIST_ORDER_BY_FIELDS.flatMap((field) => `-${field}`, ]); +const APPS_JSON_FIELDS = + "Output as JSON. Fields: application_id, name, instances[] (instance_id, environment_type, publishable_key)"; + +const TOKEN_OPTION_DESC = + "Headless authentication with a Clerk PLAPI access token (skips OAuth; use `-` to read from stdin). For per-instance API access, CLERK_SECRET_KEY also works directly with `clerk api` / `users` / `config`."; + function collectOptionValues(value: string, previous: string[] = []): string[] { return [...previous, value]; } @@ -105,6 +111,44 @@ export function createProgram() { "Force interaction mode (human or agent). Defaults to auto-detect based on TTY.", ) .option("--verbose", "Show detailed output (enables debug messages)") + .setExamples([ + { command: "clerk init", description: "Initialize Clerk in this project" }, + { command: "clerk auth login", description: "Authenticate via browser OAuth" }, + { + command: "clerk apps list --json", + description: "List applications as JSON (agent-pipeable)", + }, + { + command: "clerk users list --json | jq '.data'", + description: "Pipe user list to jq", + }, + { + command: "clerk --mode agent api /users", + description: "Force agent mode for non-interactive use", + }, + ]) + .setEnvVars([ + { + name: "CLERK_SECRET_KEY", + description: "Backend API secret key for the linked instance (sk_test_… / sk_live_…)", + }, + { + name: "CLERK_MODE", + description: "Force interaction mode: human or agent (default: TTY auto-detect)", + }, + { + name: "CLERK_CONFIG_DIR", + description: "Override the directory for stored credentials and config", + }, + { + name: "CLERK_UPDATE_CHANNEL", + description: "Release channel for `clerk update` (e.g. latest, canary)", + }, + { + name: "CLERK_NO_UPDATE_CHECK", + description: "Set to any value to disable the post-command update notification", + }, + ]) .addHelpText("after", () => isClerkSkillInstalled() ? "" @@ -218,12 +262,21 @@ Give AI agents better Clerk context: install the Clerk skills .aliases(["signup", "signin", "sign-in"]) .description("Log in to your Clerk account") .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") + .option("--token ", TOKEN_OPTION_DESC) .setExamples([ { command: "clerk auth login", description: "Log in via browser (OAuth)" }, { command: "clerk auth login -y", description: "Re-authenticate via OAuth without confirmation when already signed in", }, + { + command: "clerk auth login --token $CLERK_OAUTH_TOKEN", + description: "Headless login with a PLAPI access token (CI / agents)", + }, + { + command: "cat token.txt | clerk auth login --token -", + description: "Read the token from stdin", + }, ]) .action(async (opts) => { await login(opts); @@ -240,6 +293,7 @@ Give AI agents better Clerk context: install the Clerk skills .command("login", { hidden: true }) .description("Log in to your Clerk account") .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") + .option("--token ", TOKEN_OPTION_DESC) .action(async (opts) => { await login(opts); }); @@ -297,7 +351,7 @@ Give AI agents better Clerk context: install the Clerk skills apps .command("list") .description("List your Clerk applications") - .option("--json", "Output as JSON") + .option("--json", APPS_JSON_FIELDS) .setExamples([ { command: "clerk apps list", description: "List all applications" }, { command: "clerk apps list --json", description: "Output as JSON" }, @@ -308,7 +362,7 @@ Give AI agents better Clerk context: install the Clerk skills .command("create") .description("Create a new Clerk application") .argument("", "Application name") - .option("--json", "Output as JSON") + .option("--json", APPS_JSON_FIELDS) .setExamples([ { command: 'clerk apps create "My App"', description: "Create a new application" }, { command: 'clerk apps create "My App" --json', description: "Output as JSON" }, @@ -339,7 +393,10 @@ Give AI agents better Clerk context: install the Clerk skills users .command("list") .description("List users") - .option("--json", "Output as JSON") + .option( + "--json", + "Output as JSON. Shape: {data: User[], hasMore: boolean}. User fields: id, first_name, last_name, username, email_addresses, phone_numbers, created_at, last_sign_in_at, external_id", + ) .option("--limit ", "Maximum users to return (1-250, default 100)", (value) => parseIntegerOption(value, "--limit", { min: 1, max: 250 }), ) @@ -405,7 +462,10 @@ Give AI agents better Clerk context: install the Clerk skills users .command("create") .description("Create a user") - .option("--json", "Output as JSON") + .option( + "--json", + "Output as JSON. Fields: id, first_name, last_name, username, email_addresses, phone_numbers, created_at, external_id", + ) .option("--email ", "Email address") .option("--phone ", "Phone number") .option("--username ", "Username") @@ -805,7 +865,10 @@ Give AI agents better Clerk context: install the Clerk skills .command("doctor") .description("Check your project's Clerk integration health") .option("--verbose", "Show detailed output for each check") - .option("--json", "Output results as JSON") + .option( + "--json", + "Output results as JSON. Each entry has fields: name, status (pass|warn|fail), message, detail, remedy", + ) .option("--spotlight", "Only show warnings and failures") .option("--fix", "Attempt to auto-fix issues") .setExamples([ diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index bfcd52d8..d8430a8d 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -17,6 +17,18 @@ Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or 7. Stores the token and user info in local config 8. **Autoclaim**: if `.clerk/keyless.json` exists in the current directory, claims the temporary application, links it to the project, and pulls environment variables +#### Headless authentication (`--token`) + +For CI and AI agents, pass a Clerk PLAPI access token directly with `--token `: + +- `clerk auth login --token sk_test_…` — token as an inline argument +- `clerk auth login --token -` — read the token from stdin (e.g. piped from a secret store) +- `clerk auth login --token "$CLERK_OAUTH_TOKEN"` — from an env var + +The flow short-circuits OAuth: the token is validated against `/oauth/userinfo`, then stored in the credential store with no refresh token. When the token expires, the next API call surfaces a clear `AUTH_REQUIRED` error and the user must re-run login with a fresh token. + +For per-instance API access (e.g. `clerk api`, `clerk users`, `clerk config`), `CLERK_SECRET_KEY=sk_…` in the environment works directly — no login needed. + #### Keyless autoclaim breadcrumb lifecycle When `clerk init` runs in keyless mode it writes `.clerk/keyless.json` containing a claim token. On the next `clerk auth login`: diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index c2c3eab6..01fa58c0 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -2,12 +2,19 @@ import { generateCodeVerifier, generateCodeChallenge, generateState } from "../. import { startAuthServer } from "../../lib/auth-server.ts"; import { exchangeCodeForToken, fetchUserInfo, type UserInfo } from "../../lib/token-exchange.ts"; import { getOAuthConfig } from "../../lib/environment.ts"; -import { createOAuthSession, getValidToken, storeToken } from "../../lib/credential-store.ts"; +import { + assertValidAccessToken, + createOAuthSession, + getJwtAuthorizedParty, + getValidToken, + storeAccessToken, + storeToken, +} from "../../lib/credential-store.ts"; import { getAuth, setAuth, resolveProfile } from "../../lib/config.ts"; import { AUTH_TIMEOUT_MS, CALLBACK_PATH, CLERK_CLIENT_CLI } from "../../lib/constants.ts"; import { confirm } from "../../lib/prompts.ts"; import { isHuman } from "../../mode.ts"; -import { throwUserAbort } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { attemptAutoclaim, type AutoclaimResult } from "../../lib/autoclaim.ts"; @@ -19,6 +26,63 @@ import { ensureFirstApplication } from "../../lib/first-application.ts"; interface LoginOptions { showNextSteps?: boolean; yes?: boolean; + token?: string; +} + +async function resolveTokenInput(raw: string): Promise { + if (raw !== "-") return assertNonEmpty(raw.trim()); + + // "-" reads from stdin; matches the `--input-json -` convention. Refuse a + // TTY so the user gets immediate feedback instead of a hung process waiting + // for EOF. + if (process.stdin.isTTY) { + throwUsageError("--token - expects a token piped on stdin, but stdin is a TTY."); + } + const text = await Bun.stdin.text(); + return assertNonEmpty(text.trim()); +} + +function assertNonEmpty(value: string): string { + if (!value) { + throwUsageError("--token requires a value (or pipe a token via `--token -`)."); + } + return value; +} + +/** + * Soft audience check: when the JWT carries an `azp` claim, require it to + * match this CLI's OAuth client. A foreign-app token that happens to pass + * userinfo would otherwise be persisted as a valid CLI session. Tokens + * without `azp` are accepted for back-compat with older Clerk OAuth issuance. + */ +function assertTokenAudience(token: string): void { + const azp = getJwtAuthorizedParty(token); + if (azp === null) { + log.debug("oauth: token has no azp claim — skipping audience check (back-compat)"); + return; + } + if (azp !== CLERK_CLIENT_CLI) { + throw new CliError( + "Token was issued for a different OAuth client and cannot be used by the CLI.", + { code: ERROR_CODE.AUTH_REQUIRED }, + ); + } +} + +async function performTokenLogin(rawToken: string): Promise { + const token = await resolveTokenInput(rawToken); + + // Validate everything locally first — shape, audience — so a non-JWT or a + // foreign-app token never reaches the userinfo endpoint over the network. + assertValidAccessToken(token); + assertTokenAudience(token); + + const userInfo = await withSpinner("Validating token...", () => fetchUserInfo(token)); + + await storeAccessToken(token); + await setAuth({ userId: userInfo.userId }); + + return userInfo; } async function getExistingSession(): Promise { @@ -90,19 +154,28 @@ async function performOAuthFlow(): Promise { return userInfo; } +function finishLogin(message: string | readonly string[], showNextSteps: boolean): void { + outro(showNextSteps ? message : "Done"); +} + export async function login(options: LoginOptions = {}): Promise { - const { showNextSteps = true, yes } = options; + const { showNextSteps = true, yes, token } = options; intro("clerk auth login"); + + if (token) { + const userInfo = await performTokenLogin(token); + bar(); + log.success(`Logged in as ${userInfo.email}`); + finishLogin(NEXT_STEPS.LOGIN, showNextSteps); + return userInfo; + } + const existingSession = await withSpinner("Checking session...", () => getExistingSession()); if (existingSession && !isHuman()) { log.success(`Logged in as ${existingSession.email}`); const claimResult = await handleAutoclaim(process.cwd()); - if (showNextSteps) { - outro(await loginNextSteps(claimResult)); - } else { - outro("Done"); - } + finishLogin(await loginNextSteps(claimResult), showNextSteps); return existingSession; } @@ -127,12 +200,7 @@ export async function login(options: LoginOptions = {}): Promise { log.success(`Logged in as ${userInfo.email}`); const claimResult = await handleAutoclaim(process.cwd()); - - if (showNextSteps) { - outro(await loginNextSteps(claimResult)); - } else { - outro("Done"); - } + finishLogin(await loginNextSteps(claimResult), showNextSteps); return userInfo; } diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index 8ad503da..db779cd3 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -29,8 +29,28 @@ mock.module("./token-exchange.ts", () => ({ refreshAccessToken: (...args: unknown[]) => mockRefreshAccessToken(...args), })); -const { createOAuthSession, deleteToken, getStoredSession, getToken, getValidToken, storeToken } = - await import("./credential-store.ts"); +const { + assertValidAccessToken, + createOAuthSession, + deleteToken, + getJwtAuthorizedParty, + getStoredSession, + getToken, + getValidToken, + storeAccessToken, + storeToken, +} = await import("./credential-store.ts"); + +/** Build a JWT-shaped token whose payload has the given fields. */ +function buildJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.sig`; +} + +function jwtWithExp(expSeconds: number): string { + return buildJwt({ exp: expSeconds }); +} async function writeLegacyToken(value: string): Promise { await writeFile(join(tempDir, "credentials"), value, { mode: 0o600 }); @@ -163,4 +183,66 @@ describe("credential-store", () => { } as never), ).toThrow("Authentication response did not include a refresh token"); }); + + test("storeAccessToken persists a JWT and exposes it through getValidToken without refresh", async () => { + const jwt = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + await storeAccessToken(jwt); + + expect(await getToken()).toBe(jwt); + expect(await getValidToken()).toBe(jwt); + + const session = await getStoredSession(); + expect(session?.refreshToken).toBe(""); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + test("storeAccessToken rejects non-JWT tokens with a clear secret-key hint", async () => { + await expect(storeAccessToken("sk_test_not_a_jwt")).rejects.toThrow(/JWT|secret key/); + }); + + test("storeAccessToken rejects an already-expired token", async () => { + const expiredJwt = jwtWithExp(Math.floor(Date.now() / 1000) - 60); + await expect(storeAccessToken(expiredJwt)).rejects.toThrow(/already expired/); + }); + + test("storeAccessToken rejects a token that will expire within the refresh leeway window", async () => { + // A token with ~5 s left would pass a naive `exp > now` check but + // isExpiredSession treats anything inside the 30 s leeway as expired, + // so accepting it would store a token that's instantly unusable. + const aboutToExpire = jwtWithExp(Math.floor(Date.now() / 1000) + 5); + await expect(storeAccessToken(aboutToExpire)).rejects.toThrow(/already expired/); + }); + + test("assertValidAccessToken rejects tokens larger than 8 KB", () => { + const oversized = `a.${"x".repeat(9_000)}.sig`; + expect(() => assertValidAccessToken(oversized)).toThrow(/maximum/); + }); + + test("assertValidAccessToken rejects strings that don't have three JWT segments", () => { + expect(() => assertValidAccessToken("a.b")).toThrow(/JWT/); + expect(() => assertValidAccessToken("a.b.c.d")).toThrow(/JWT/); + }); + + test("getJwtAuthorizedParty returns azp when present and null otherwise", () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + expect(getJwtAuthorizedParty(buildJwt({ exp, azp: "clerk-cli" }))).toBe("clerk-cli"); + expect(getJwtAuthorizedParty(jwtWithExp(exp))).toBeNull(); + expect(getJwtAuthorizedParty("not.a.jwt-payload")).toBeNull(); + }); + + test("getValidToken on an expired token-only session throws AUTH_REQUIRED instead of trying to refresh", async () => { + // Manually store an expired session with no refresh token, mirroring the + // state we'd be in after a CI token-login that has since aged out. + await writeLegacyToken( + JSON.stringify({ + accessToken: jwtWithExp(Math.floor(Date.now() / 1000) - 60), + refreshToken: "", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }), + ); + + await expect(getValidToken()).rejects.toThrow(/cannot be auto-refreshed/); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 5094b78d..09d64553 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -262,21 +262,34 @@ function encodeStoredValue(value: OAuthSession): string { return JSON.stringify(value); } -function getJwtExpiryMs(token: string): number | null { - const [, payload] = token.split("."); +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1]; if (!payload) return null; try { const normalized = payload.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); const decoded = Buffer.from(padded, "base64").toString("utf8"); - const parsed = JSON.parse(decoded) as Record; - return typeof parsed.exp === "number" ? parsed.exp * 1000 : null; + return JSON.parse(decoded) as Record; } catch { return null; } } +/** Read `azp` (authorized party) from a JWT, or null if absent or unparseable. */ +export function getJwtAuthorizedParty(token: string): string | null { + const parsed = decodeJwtPayload(token); + const azp = parsed?.azp; + return typeof azp === "string" ? azp : null; +} + +function getJwtExpiryMs(token: string): number | null { + const parsed = decodeJwtPayload(token); + return typeof parsed?.exp === "number" ? parsed.exp * 1000 : null; +} + function isExpiredJwt(token: string): boolean { const expiresAt = getJwtExpiryMs(token); if (expiresAt === null) return true; @@ -335,6 +348,11 @@ async function getValidAccessToken(session: OAuthSession): Promise { * session was written by another process. */ async function awaitConcurrentRefresh(session: OAuthSession): Promise { + // Token-only sessions (stored via `auth login --token`) carry refreshToken="". + // The race-detection compares refresh tokens, so two such sessions would + // collide on the empty-string sentinel and never converge. Skip outright. + if (!session.refreshToken) return null; + for (const delayMs of [0, ...INVALID_GRANT_RETRY_DELAYS_MS]) { if (delayMs > 0) { await sleep(delayMs); @@ -356,6 +374,15 @@ async function awaitConcurrentRefresh(session: OAuthSession): Promise { + if (!session.refreshToken) { + // Token was stored without a refresh credential (e.g. via `auth login + // --token`). The caller has to obtain a fresh token externally and re-run + // login — we can't rotate it on their behalf. + throw authRequiredError( + "Stored access token has expired and cannot be auto-refreshed. " + + "Re-run `clerk auth login` (or `clerk auth login --token `) with a fresh token.", + ); + } let tokenResponse: TokenResponse; try { log.debug("credentials: refreshing OAuth session"); @@ -413,6 +440,53 @@ export async function storeToken(value: OAuthSession): Promise { await fileStore(encoded); } +// Realistic Clerk OAuth JWTs are well under 4 KB. The cap is a defense-in-depth +// bound against a pathological / hostile input rather than a precise limit. +const MAX_TOKEN_BYTES = 8 * 1024; + +function authRequiredError(message: string): CliError { + return new CliError(message, { code: ERROR_CODE.AUTH_REQUIRED }); +} + +/** + * Validate that a token has the shape we expect for a Clerk PLAPI access token + * (a JWT with a future `exp` claim) without touching the network. Throws on + * invalid input; returns the expiry millis on success. + */ +export function assertValidAccessToken(accessToken: string): number { + if (accessToken.length > MAX_TOKEN_BYTES) { + throw authRequiredError(`Token exceeds the ${MAX_TOKEN_BYTES}-byte maximum.`); + } + const jwtExpiry = getJwtExpiryMs(accessToken); + if (jwtExpiry === null) { + throw authRequiredError( + "Token does not look like a Clerk access token (expected a JWT with an `exp` claim). " + + "Pass a Clerk PLAPI access token, not a secret key (sk_…).", + ); + } + // Mirror the leeway used by isExpiredSession so a token accepted here + // doesn't immediately fail the next getValidAccessToken check. + if (jwtExpiry <= Date.now() + JWT_EXPIRY_LEEWAY_MS) { + throw authRequiredError("The provided token is already expired."); + } + return jwtExpiry; +} + +/** + * Persist a raw access token (no refresh) — used by `auth login --token ` + * for headless / CI use. Without a refresh token, the user must obtain a new + * token and re-run login when it expires. + */ +export async function storeAccessToken(accessToken: string): Promise { + const expiresAt = assertValidAccessToken(accessToken); + await storeToken({ + accessToken, + refreshToken: "", + expiresAt, + tokenType: "Bearer", + }); +} + let tokenOverride: string | null | undefined; /** Test-only: override getToken() result. Pass undefined to clear. */ diff --git a/packages/cli-core/src/lib/help.ts b/packages/cli-core/src/lib/help.ts index ff4cb0c0..5b6570fa 100644 --- a/packages/cli-core/src/lib/help.ts +++ b/packages/cli-core/src/lib/help.ts @@ -1,17 +1,26 @@ import { Command, type Help } from "@commander-js/extra-typings"; -export interface Example { - command: string; +interface HelpItem { description: string; } +export interface Example extends HelpItem { + command: string; +} + +export interface EnvVar extends HelpItem { + name: string; +} + const examplesMap = new WeakMap(); +const envVarsMap = new WeakMap(); -// Augment Commander's Command type with .setExamples() +// Augment Commander's Command type with .setExamples() and .setEnvVars() declare module "@commander-js/extra-typings" { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- generics required for declaration merging interface Command { setExamples(examples: Example[]): this; + setEnvVars(vars: EnvVar[]): this; } } @@ -20,12 +29,40 @@ Command.prototype.setExamples = function (examples: Example[]) { return this; }; +Command.prototype.setEnvVars = function (vars: EnvVar[]) { + envVarsMap.set(this, vars); + return this; +}; + +/** + * Render a `Title:` section whose rows are `term` + `description` aligned + * to the longest term. Used by the Examples and Environment sections, which + * share the same shape but differ in how the term is derived. + */ +function appendItemSection( + output: string[], + helper: Help, + title: string, + items: T[] | undefined, + term: (item: T) => string, +): string[] { + if (!items || items.length === 0) return output; + // Resolve terms once — the lambda may be non-trivial and avoiding the + // Math.max(...spread) keeps the call stack bounded for large lists. + const terms = items.map(term); + const termWidth = terms.reduce((max, t) => Math.max(max, helper.displayWidth(t)), 0); + const formatted = items.map((item, i) => + helper.formatItem(terms[i]!, termWidth, item.description, helper), + ); + return output.concat(helper.formatItemList(title, formatted, helper)); +} + /** * Custom help formatter with three improvements over Commander defaults: * * 1. Commands display in three aligned columns: name | args | description * 2. Each section (Arguments, Options, Commands) computes its own column width - * 3. Examples are a first-class section with auto `$ ` prefix and aligned columns + * 3. Examples and Environment are first-class sections via setExamples / setEnvVars */ export function clerkHelpConfig(): Partial { return { @@ -113,15 +150,20 @@ export function clerkHelpConfig(): Partial { output = output.concat(helper.formatItemList("Commands:", items, helper)); } - // Examples — auto `$ ` prefix and aligned columns - const examples = examplesMap.get(cmd); - if (examples && examples.length > 0) { - const maxTermLen = Math.max(...examples.map((e) => helper.displayWidth(`$ ${e.command}`))); - const items = examples.map((e) => - helper.formatItem(`$ ${e.command}`, maxTermLen, e.description, helper), - ); - output = output.concat(helper.formatItemList("Examples:", items, helper)); - } + output = appendItemSection( + output, + helper, + "Examples:", + examplesMap.get(cmd), + (e) => `$ ${e.command}`, + ); + output = appendItemSection( + output, + helper, + "Environment:", + envVarsMap.get(cmd), + (e) => e.name, + ); return output.join("\n"); }, diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index a93c129b..e1e2e48d 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -59,6 +59,11 @@ mock.module( storeToken: async (value: { accessToken: string }) => { mockState.storedToken = value.accessToken; }, + storeAccessToken: async (accessToken: string) => { + mockState.storedToken = accessToken; + }, + assertValidAccessToken: () => Date.now() + 3_600_000, + getJwtAuthorizedParty: () => null, deleteToken: async () => { mockState.storedToken = null; }, From 0396b1ce5ecf0a2bc2b9c04cdef8fcecb394f9e1 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Sat, 16 May 2026 09:22:40 -0300 Subject: [PATCH 2/2] fix(test): add missing credential-store exports to test stubs login.ts now imports storeAccessToken, assertValidAccessToken, and getJwtAuthorizedParty from credential-store.ts. The shared test stubs were missing these exports, causing login.test.ts to fail with "Export named 'storeAccessToken' not found" when Bun resolved the mocked module. --- packages/cli-core/src/test/lib/stubs.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index 93faff37..c7349b1b 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -65,6 +65,9 @@ export const credentialStoreStubs = { getStoredSession: async () => null, hasStoredCredentials: async () => false, storeToken: async () => {}, + storeAccessToken: async () => {}, + assertValidAccessToken: () => Date.now() + 3_600_000, + getJwtAuthorizedParty: () => null, deleteToken: async () => {}, createOAuthSession: (tokenResponse: { access_token: string;