diff --git a/.changeset/impersonate.md b/.changeset/impersonate.md new file mode 100644 index 00000000..249d80a3 --- /dev/null +++ b/.changeset/impersonate.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk impersonate` (alias `clerk imp`) to create a short-lived sign-in URL that logs you in as another user, plus `clerk impersonate revoke` to cancel a pending token. Both require `clerk login` and stamp the impersonation with your account for auditing. diff --git a/.changeset/interactive-instance-picker.md b/.changeset/interactive-instance-picker.md new file mode 100644 index 00000000..9ed07507 --- /dev/null +++ b/.changeset/interactive-instance-picker.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Resolve the instance explicitly instead of silently defaulting to development: interactive flows (`clerk impersonate`, `clerk impersonate revoke`, `clerk users open`, the create wizard, and the application-picker fallback) now ask in human mode whenever the resolved app has more than one instance and no `--instance` flag pins one, and agent mode errors asking for an explicit `--instance` rather than defaulting — so the same command can't resolve a different instance depending on which instances the app happens to have. User lookups that find no match name the searched app and instance in the error, and instances targeted by raw ID are labeled by their environment type so the production impersonation warning always fires. diff --git a/README.md b/README.md index e372cef3..b6eb6d61 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,24 @@ Options: -h, --help Display help for command Commands: - init [options] Initialize Clerk in your project - auth Manage authentication - link [options] Link this project to a Clerk application - unlink [options] Unlink this project from its Clerk application - whoami [options] Show the current logged-in user and linked application - open Open Clerk resources in your browser - apps Manage your Clerk applications - users [options] Manage Clerk users - env Manage environment variables - config Manage instance configuration - enable Enable Clerk features on the linked instance - disable Disable Clerk features on the linked instance - api [options] [endpoint] [filter] Make authenticated requests to the Clerk API - doctor [options] Check your project's Clerk integration health - completion [shell] Generate shell autocompletion script - update [options] Update the Clerk CLI to the latest version - deploy Deploy a Clerk application to production - help [command] Display help for command + init [options] Initialize Clerk in your project + auth Manage authentication + link [options] Link this project to a Clerk application + unlink [options] Unlink this project from its Clerk application + whoami [options] Show the current logged-in user and linked application + open Open Clerk resources in your browser + apps Manage your Clerk applications + users [options] Manage Clerk users + impersonate|imp [options] [user] Impersonate a Clerk user + env Manage environment variables + config Manage instance configuration + enable Enable Clerk features on the linked instance + disable Disable Clerk features on the linked instance + api [options] [endpoint] [filter] Make authenticated requests to the Clerk API + doctor [options] Check your project's Clerk integration health + completion [shell] Generate shell autocompletion script + update [options] Update the Clerk CLI to the latest version + deploy Deploy a Clerk application to production + help [command] Display help for command + bird Play Clerk Bird, a Flappy Bird game in your terminal ``` diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a8e7f113..860b06d4 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -10,6 +10,7 @@ import { registerWhoami } from "./commands/whoami/index.ts"; import { registerOpen } from "./commands/open/index.ts"; import { registerApps } from "./commands/apps/index.ts"; import { registerUsers } from "./commands/users/index.ts"; +import { registerImpersonate } from "./commands/impersonate/index.ts"; import { registerEnv } from "./commands/env/index.ts"; import { registerConfig } from "./commands/config/index.ts"; import { registerToggles } from "./commands/toggles/index.ts"; @@ -60,6 +61,7 @@ const registrants: CommandRegistrant[] = [ registerOpen, registerApps, registerUsers, + registerImpersonate, registerEnv, registerConfig, registerToggles, diff --git a/packages/cli-core/src/commands/impersonate/README.md b/packages/cli-core/src/commands/impersonate/README.md new file mode 100644 index 00000000..eff89373 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/README.md @@ -0,0 +1,125 @@ +# `clerk impersonate` + +Create a short-lived actor token for a Clerk user and print the sign-in URL that +lets you sign in as them ("impersonate"). Alias: `clerk imp`. + +## Auth + +`clerk impersonate` and `clerk impersonate revoke` both **require `clerk auth +login`** — there is no `--secret-key`-only bypass. The actor token's +`actor.sub` field is stamped with your logged-in email (`cli:`, or +`cli:+` with `--actor `) so every impersonation +session is traceable back to a real Clerk account, not just an API key. + +## Usage + +```sh +clerk imp # pick a user interactively, then confirm +clerk imp user_2x9k # impersonate a specific user +clerk imp alice@example.com # resolve by exact email match +clerk imp alice --open # open the sign-in URL in your browser immediately +clerk imp user_2x9k --print # print the URL only, no prompt, no browser +clerk imp user_2x9k --yes --expires-in 900 # skip confirmation, 15-minute token +clerk imp user_2x9k --actor oncall # stamp the actor as cli:+oncall +clerk imp revoke act_29w9... # revoke a pending actor token +``` + +## Options + +| Flag | Applies to | Description | +| ------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------- | +| `[user]` | create | `user_...` ID, exact email, or fuzzy search term. Omit to pick interactively. | +| `` | revoke | Actor token ID to revoke (required) | +| `--secret-key ` | both | Backend API secret key to use | +| `--app ` | both | Application ID to target (works from any directory) | +| `--instance ` | both | Instance to target (`dev`, `prod`, or a full instance ID) | +| `--actor ` | create | Extra context appended to the actor stamp: `cli:+` | +| `--expires-in ` | create | Actor token lifetime in seconds, integer >= 1. Defaults to 3600 (1 hour), matching the dashboard's short expiry. | +| `--open` | create | Open the sign-in URL in your browser immediately, skipping the prompt | +| `--print` | create | Print the sign-in URL only — no prompt, no browser | +| `--yes` | create | Skip the confirmation prompt | + +`clerk impersonate revoke` never prompts for confirmation — it's a low-risk +operation on an already-pending token. + +## User resolution + +Given `[user]`: + +1. `/^user_[A-Za-z0-9]+$/` → used directly, no lookup. +2. Contains `@` → exact match via `GET /v1/users?email_address=`. +3. Otherwise → fuzzy match via `GET /v1/users?query=`. + +Then: + +- 0 matches → usage error naming the searched app and instance, e.g. + `No user found matching "alice@example.com" on My Application (development).` +- 1 match → used directly. +- 2+ matches, human mode → an interactive picker (the picker's search box + always starts empty — there's no way to prefill it with your original + search term; see `commands/users/interactive/pick-user.ts`). +- 2+ matches, agent mode → usage error listing candidate user IDs so you can + re-run with a specific `user_...` ID. +- No `[user]` argument, human mode → the interactive picker. +- No `[user]` argument, agent mode → usage error (agent mode never prompts). + +## Instance resolution + +The app comes from `--app`, the linked profile, or — in human mode with no +linked project — an interactive app picker. In human mode, whenever the +resolved app has more than one instance and no `--instance` flag was passed, +`clerk impersonate` and `clerk impersonate revoke` prompt +"Select an instance to use:" instead of silently defaulting to development — +even in a linked project. Users usually exist on only one instance (and actor +tokens are instance-scoped), so a silent development default makes lookups and +revokes fail confusingly. Agent mode never prompts: when the app has more than +one instance it errors and asks for an explicit `--instance` rather than +defaulting, so the same command can't resolve a different instance depending on +which instances the app happens to have. `--instance` or `--secret-key` always +pins the instance. + +## Confirmation and the production guardrail + +In human mode, unless `--yes` is passed, `clerk impersonate` asks +"Impersonate `` on `` (``)?" defaulting to **No**. When +the target instance's label is `production`, a warning is printed above the +prompt: + +> production — signs you in as this user and bypasses their MFA. may count +> against your monthly impersonation quota. + +The CLI cannot read your live impersonation quota (Backend API has no +endpoint for it), so this is a generic reminder, not a number. Agent mode +never prompts and proceeds directly. + +## Output modes + +The sign-in URL from BAPI's response is always printed **verbatim** via +`log.data()` — never reconstructed client-side. Human mode also prints a +`Revoke with: clerk imp revoke ` hint to stderr — BAPI has no list +endpoint for actor tokens, so creation is the only moment the ID is visible. + +| Condition | Behavior | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--print` | Print the URL, exit. No prompt, no browser. | +| `--open` | Print the URL, open the browser immediately. No prompt. | +| TTY, no `--print`/`--open` | Print the URL, then prompt "Press Enter to open in your browser (Ctrl+C to skip)". | +| Non-TTY, human mode, no `--print`/`--open` | Same as `--print` — never hangs waiting for input that can't arrive. | +| Agent mode | Emit one JSON line via `log.data()`: `{ url, id, userId, actor, appId, appLabel, instanceId, instanceLabel, expiresInSeconds }`. Never prompts, never opens a browser. | + +## Clerk API endpoints + +| Method | Path | Used by | +| ------ | --------------------------------- | ---------------------------------------------------- | +| `GET` | `/v1/users?email_address=` | Resolving `[user]` when it contains `@` | +| `GET` | `/v1/users?query=` | Resolving `[user]` fuzzy search | +| `GET` | `/v1/users?query=&limit=21` | The interactive user picker (`pickUser`) | +| `POST` | `/v1/actor_tokens` | Creating the actor token (`clerk impersonate`) | +| `POST` | `/v1/actor_tokens/{id}/revoke` | Revoking an actor token (`clerk impersonate revoke`) | + +`POST /v1/actor_tokens` returns `402` when impersonation isn't enabled on the +app's subscription plan, and `422` when the impersonation quota is exhausted +(the CLI surfaces `used`/`limit` from the error's `meta` when BAPI includes +them). + +No part of this command is mocked or stubbed. diff --git a/packages/cli-core/src/commands/impersonate/actor.test.ts b/packages/cli-core/src/commands/impersonate/actor.test.ts new file mode 100644 index 00000000..15b0be44 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/actor.test.ts @@ -0,0 +1,85 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { AuthError } from "../../lib/errors.ts"; + +const mockGetValidToken = mock(); +mock.module("../../lib/credential-store.ts", () => ({ + getValidToken: (...args: unknown[]) => mockGetValidToken(...args), +})); + +const mockFetchUserInfo = mock(); +mock.module("../../lib/token-exchange.ts", () => ({ + fetchUserInfo: (...args: unknown[]) => mockFetchUserInfo(...args), +})); + +const { requireLoginEmail, buildActorStamp, CLERK_CLI_ISSUER } = await import("./actor.ts"); + +describe("requireLoginEmail", () => { + beforeEach(() => { + mockGetValidToken.mockResolvedValue("valid-token"); + mockFetchUserInfo.mockResolvedValue({ userId: "user_admin", email: "admin@example.com" }); + }); + + afterEach(() => { + mockGetValidToken.mockReset(); + mockFetchUserInfo.mockReset(); + }); + + test("returns the login email when a valid token exists", async () => { + await expect(requireLoginEmail()).resolves.toBe("admin@example.com"); + }); + + test("throws AuthError(not_logged_in) when there is no stored token", async () => { + mockGetValidToken.mockResolvedValue(null); + + let error: unknown; + try { + await requireLoginEmail(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(AuthError); + expect((error as AuthError).reason).toBe("not_logged_in"); + expect(mockFetchUserInfo).not.toHaveBeenCalled(); + }); + + test("throws AuthError(session_expired) when fetchUserInfo fails", async () => { + mockFetchUserInfo.mockRejectedValue(new Error("401")); + + let error: unknown; + try { + await requireLoginEmail(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(AuthError); + expect((error as AuthError).reason).toBe("session_expired"); + }); +}); + +describe("buildActorStamp", () => { + test("stamps `cli:` with no --actor context", () => { + expect(buildActorStamp("admin@example.com")).toEqual({ + sub: "cli:admin@example.com", + iss: CLERK_CLI_ISSUER, + }); + }); + + test("appends `+` when a --actor context is supplied", () => { + expect(buildActorStamp("admin@example.com", "oncall")).toEqual({ + sub: "cli:admin@example.com+oncall", + iss: CLERK_CLI_ISSUER, + }); + }); + + test("does not attempt to parse `+` characters already present in the email", () => { + // Emails can legally contain '+' (plus-addressing). The stamp is a + // write-only audit label — this locks in that we never try to be clever + // about splitting it back apart. + expect(buildActorStamp("admin+test@example.com", "oncall")).toEqual({ + sub: "cli:admin+test@example.com+oncall", + iss: CLERK_CLI_ISSUER, + }); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/actor.ts b/packages/cli-core/src/commands/impersonate/actor.ts new file mode 100644 index 00000000..a1b46341 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/actor.ts @@ -0,0 +1,38 @@ +import { getValidToken } from "../../lib/credential-store.ts"; +import { AuthError } from "../../lib/errors.ts"; +import { fetchUserInfo } from "../../lib/token-exchange.ts"; + +export const CLERK_CLI_ISSUER = "clerk-cli"; + +/** + * Verify the CLI has an active login session and return the operator's + * login email. Impersonation always requires `clerk login` — there is no + * secret-key bypass, because the actor stamp on every actor token must + * identify a real Clerk account for audit purposes. + */ +export async function requireLoginEmail(): Promise { + const token = await getValidToken(); + if (!token) { + throw new AuthError({ reason: "not_logged_in" }); + } + + try { + const userInfo = await fetchUserInfo(token); + return userInfo.email; + } catch { + throw new AuthError({ reason: "session_expired" }); + } +} + +/** + * Build the actor stamp embedded in every actor token: `cli:`, + * or `cli:+` when `--actor ` is supplied. + * This is a write-only audit label — never parse it back apart. + */ +export function buildActorStamp( + loginEmail: string, + actorContext?: string, +): { sub: string; iss: typeof CLERK_CLI_ISSUER } { + const sub = actorContext ? `cli:${loginEmail}+${actorContext}` : `cli:${loginEmail}`; + return { sub, iss: CLERK_CLI_ISSUER }; +} diff --git a/packages/cli-core/src/commands/impersonate/impersonate.test.ts b/packages/cli-core/src/commands/impersonate/impersonate.test.ts new file mode 100644 index 00000000..e2faf8d5 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/impersonate.test.ts @@ -0,0 +1,288 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { setMode } from "../../mode.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { BapiError, CliError, ERROR_CODE } from "../../lib/errors.ts"; + +const mockRequireLoginEmail = mock(); +const mockBuildActorStamp = mock(); +mock.module("./actor.ts", () => ({ + requireLoginEmail: (...args: unknown[]) => mockRequireLoginEmail(...args), + buildActorStamp: (...args: unknown[]) => mockBuildActorStamp(...args), +})); + +const mockResolveImpersonationTarget = mock(); +mock.module("./resolve-user.ts", () => ({ + resolveImpersonationTarget: (...args: unknown[]) => mockResolveImpersonationTarget(...args), +})); + +const mockResolveUsersInstanceContext = mock(); +mock.module("../users/interactive/instance-context.ts", () => ({ + resolveUsersInstanceContext: (...args: unknown[]) => mockResolveUsersInstanceContext(...args), +})); + +const mockBapiRequest = mock(); +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const mockOpenBrowser = mock(); +mock.module("../../lib/open.ts", () => ({ + openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), +})); + +const mockConfirm = mock(); +mock.module("../../lib/prompts.ts", () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), + text: async () => "", + password: async () => "", + editor: async () => "{}", +})); + +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + +const { impersonate } = await import("./impersonate.ts"); + +const SIGN_IN_URL = "https://example.clerk.accounts.dev/v1/tickets/accept?ticket=tkt_abc"; + +const CTX = { + secretKey: "sk_test_123", + appId: "app_abc123", + appLabel: "My App", + instanceId: "ins_dev789", + instanceLabel: "development", +}; + +const PROD_CTX = { ...CTX, instanceLabel: "production" }; + +function setStdinTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, "isTTY", { value, configurable: true }); +} + +describe("impersonate", () => { + const captured = useCaptureLog(); + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + setMode("human"); + setStdinTTY(true); + mockRequireLoginEmail.mockResolvedValue("admin@example.com"); + mockBuildActorStamp.mockReturnValue({ sub: "cli:admin@example.com", iss: "clerk-cli" }); + mockResolveUsersInstanceContext.mockResolvedValue(CTX); + mockResolveImpersonationTarget.mockResolvedValue("user_2x9k"); + mockConfirm.mockResolvedValue(true); + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: { id: "act_1", url: SIGN_IN_URL }, + rawBody: "", + }); + }); + + afterEach(() => { + setStdinTTY(originalIsTTY); + mockRequireLoginEmail.mockReset(); + mockBuildActorStamp.mockReset(); + mockResolveUsersInstanceContext.mockReset(); + mockResolveImpersonationTarget.mockReset(); + mockBapiRequest.mockReset(); + mockOpenBrowser.mockReset(); + mockConfirm.mockReset(); + }); + + test("hard-fails before any BAPI call when requireLoginEmail rejects", async () => { + mockRequireLoginEmail.mockRejectedValue( + new CliError("Not logged in. Run `clerk auth login` to authenticate", { + code: ERROR_CODE.AUTH_REQUIRED, + }), + ); + + await expect(impersonate({ user: "user_2x9k" })).rejects.toThrow(/Not logged in/); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("human mode: confirms before creating the token, prints the URL verbatim, and offers to open it", async () => { + mockConfirm.mockResolvedValueOnce(true); // "Impersonate ... ?" + mockConfirm.mockResolvedValueOnce(true); // "Press Enter to open ..." + + await impersonate({ user: "user_2x9k" }); + + expect(mockConfirm).toHaveBeenNthCalledWith(1, expect.objectContaining({ default: false })); + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "POST", + path: "/actor_tokens", + secretKey: CTX.secretKey, + body: JSON.stringify({ + user_id: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + expires_in_seconds: 3600, + }), + }); + expect(captured.out).toBe(SIGN_IN_URL); + expect(captured.err).toContain("Revoke with: clerk imp revoke act_1"); + expect(mockOpenBrowser).toHaveBeenCalledWith(SIGN_IN_URL); + }); + + test("declining the confirm prompt aborts without calling BAPI", async () => { + mockConfirm.mockResolvedValueOnce(false); + + await expect(impersonate({ user: "user_2x9k" })).rejects.toThrow("User aborted"); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("--yes skips the confirm prompt", async () => { + await impersonate({ user: "user_2x9k", yes: true, print: true }); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("production instance: prints the guardrail warning before confirming", async () => { + mockResolveUsersInstanceContext.mockResolvedValue(PROD_CTX); + + await impersonate({ user: "user_2x9k", print: true }); + + expect(captured.err).toContain("production — signs you in as this user and bypasses their MFA"); + }); + + test("sk_live_ secret key without an instance label still prints the guardrail warning", async () => { + const { instanceLabel: _drop, ...rest } = CTX; + mockResolveUsersInstanceContext.mockResolvedValue({ ...rest, secretKey: "sk_live_123" }); + + await impersonate({ user: "user_2x9k", print: true }); + + expect(captured.err).toContain("production — signs you in as this user and bypasses their MFA"); + }); + + test("--expires-in overrides the default 3600s lifetime", async () => { + await impersonate({ user: "user_2x9k", expiresIn: 900, print: true, yes: true }); + + const requestBody = (mockBapiRequest.mock.calls[0]![0] as { body: string }).body; + expect(JSON.parse(requestBody)).toEqual({ + user_id: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + expires_in_seconds: 900, + }); + }); + + test("--actor passes context through to buildActorStamp", async () => { + mockBuildActorStamp.mockReturnValue({ sub: "cli:admin@example.com+oncall", iss: "clerk-cli" }); + + await impersonate({ user: "user_2x9k", actor: "oncall", print: true, yes: true }); + + expect(mockBuildActorStamp).toHaveBeenCalledWith("admin@example.com", "oncall"); + }); + + test("--print prints the URL only — no confirm-to-open prompt, no browser", async () => { + await impersonate({ user: "user_2x9k", yes: true, print: true }); + + expect(captured.out).toBe(SIGN_IN_URL); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("--open prints the URL and opens the browser immediately, skipping the prompt", async () => { + await impersonate({ user: "user_2x9k", yes: true, open: true }); + + expect(mockOpenBrowser).toHaveBeenCalledTimes(1); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("non-TTY stdin in human mode behaves like --print and never prompts", async () => { + setStdinTTY(false); + + await impersonate({ user: "user_2x9k", yes: true }); + + expect(captured.out).toBe(SIGN_IN_URL); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + test("agent mode: emits structured JSON, never confirms, never opens a browser", async () => { + setMode("agent"); + + await impersonate({ user: "user_2x9k" }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + expect(JSON.parse(captured.out)).toEqual({ + url: SIGN_IN_URL, + id: "act_1", + userId: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + appId: CTX.appId, + appLabel: CTX.appLabel, + instanceId: CTX.instanceId, + instanceLabel: CTX.instanceLabel, + expiresInSeconds: 3600, + }); + }); + + test("402 from BAPI surfaces the plan-gate error, distinct from the quota error", async () => { + mockBapiRequest.mockRejectedValue( + new BapiError(402, JSON.stringify({ errors: [{ message: "not enabled" }] }), new Headers()), + ); + + let error: unknown; + try { + await impersonate({ user: "user_2x9k", print: true, yes: true }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toBe("Impersonation isn't enabled on this app's plan."); + expect((error as CliError).code).toBe(ERROR_CODE.IMPERSONATION_NOT_ENABLED); + }); + + test("422 from BAPI with limit/used meta surfaces a quota message including the counts", async () => { + mockBapiRequest.mockRejectedValue( + new BapiError( + 422, + JSON.stringify({ + errors: [{ message: "limit exceeded", meta: { limit: 100, used: 100 } }], + }), + new Headers(), + ), + ); + + let error: unknown; + try { + await impersonate({ user: "user_2x9k", print: true, yes: true }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toBe( + "Impersonation limit exceeded (used 100/100 this billing period).", + ); + expect((error as CliError).code).toBe(ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED); + }); + + test("422 from BAPI without limit/used meta still surfaces a generic quota message", async () => { + mockBapiRequest.mockRejectedValue( + new BapiError( + 422, + JSON.stringify({ errors: [{ message: "limit exceeded" }] }), + new Headers(), + ), + ); + + let error: unknown; + try { + await impersonate({ user: "user_2x9k", print: true, yes: true }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toBe("Impersonation limit exceeded."); + expect((error as CliError).code).toBe(ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/impersonate.ts b/packages/cli-core/src/commands/impersonate/impersonate.ts new file mode 100644 index 00000000..100e6a99 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/impersonate.ts @@ -0,0 +1,179 @@ +import { bold, cyan, dim } from "../../lib/color.ts"; +import { createActorToken } from "../../lib/actor-tokens.ts"; +import { + BapiError, + BILLING_ERROR_REASON, + BillingError, + CliError, + ERROR_CODE, + throwUserAbort, + withApiContext, +} from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { openBrowser } from "../../lib/open.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { isAgent, isHuman } from "../../mode.ts"; +import { + resolveUsersInstanceContext, + type UsersInstanceContext, +} from "../users/interactive/instance-context.ts"; +import { buildActorStamp, requireLoginEmail } from "./actor.ts"; +import { resolveImpersonationTarget } from "./resolve-user.ts"; + +export type ImpersonateOptions = { + user?: string; + secretKey?: string; + app?: string; + instance?: string; + actor?: string; + expiresIn?: number; + open?: boolean; + print?: boolean; + yes?: boolean; +}; + +const DEFAULT_EXPIRES_IN_SECONDS = 3600; + +function isProductionInstance(ctx: UsersInstanceContext): boolean { + if (ctx.instanceLabel) return ctx.instanceLabel === "production"; + return ctx.secretKey.startsWith("sk_live_"); +} + +async function openOrWarn(url: string): Promise { + const result = await openBrowser(url); + if (!result.ok) { + log.warn( + `Could not open your browser automatically. Open this URL to continue:\n ${cyan(url)}\n${dim(`(Reason: ${result.reason})`)}`, + ); + } +} + +function actorTokenErrorToCliError(error: unknown): CliError | undefined { + if (!(error instanceof BapiError)) return undefined; + + if (error.status === 402) { + return new BillingError("Impersonation isn't enabled on this app's plan.", { + reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, + code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, + }); + } + + if (error.status === 422) { + const meta = error.meta ?? {}; + const limit = typeof meta.limit === "number" ? meta.limit : undefined; + const used = typeof meta.used === "number" ? meta.used : undefined; + const quota = + limit !== undefined && used !== undefined + ? ` (used ${used}/${limit} this billing period)` + : ""; + return new BillingError(`Impersonation limit exceeded${quota}.`, { + reason: BILLING_ERROR_REASON.QUOTA_EXCEEDED, + code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED, + limit, + used, + }); + } + + return undefined; +} + +export async function impersonate(options: ImpersonateOptions = {}): Promise { + const loginEmail = await requireLoginEmail(); + + const ctx = await resolveUsersInstanceContext({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + + const userId = await resolveImpersonationTarget(options.user, ctx); + const expiresIn = options.expiresIn ?? DEFAULT_EXPIRES_IN_SECONDS; + const actor = buildActorStamp(loginEmail, options.actor); + + const appLabel = ctx.appLabel ?? ctx.appId ?? "this application"; + const instanceLabel = ctx.instanceLabel ?? ctx.instanceId ?? "this instance"; + + if (!options.yes && isHuman()) { + if (isProductionInstance(ctx)) { + log.warn( + "production — signs you in as this user and bypasses their MFA. may count against your monthly impersonation quota.", + ); + } + const proceed = await confirm({ + message: `Impersonate ${cyan(userId)} on ${bold(appLabel)} (${instanceLabel})?`, + default: false, + }); + if (!proceed) { + throwUserAbort(); + } + } + + let token; + try { + token = await withApiContext( + withSpinner("Creating impersonation session...", () => + createActorToken(ctx.secretKey, { + userId, + actor: { sub: actor.sub, iss: actor.iss }, + expiresInSeconds: expiresIn, + }), + ), + "Failed to create impersonation session", + ); + } catch (error) { + const cliError = actorTokenErrorToCliError(error); + if (cliError) throw cliError; + throw error; + } + + if (isAgent()) { + log.data( + JSON.stringify({ + url: token.url, + id: token.id, + userId, + actor: { sub: actor.sub, iss: actor.iss }, + appId: ctx.appId, + appLabel: ctx.appLabel, + instanceId: ctx.instanceId, + instanceLabel: ctx.instanceLabel, + expiresInSeconds: expiresIn, + }), + ); + return; + } + + // Always print the URL verbatim first, regardless of what happens next — + // if --print/--open are both passed, --print wins (never opens). + log.data(token.url); + // BAPI has no list endpoint for actor tokens, so this is the only moment a + // human can learn the ID needed to revoke. Pin the same app/instance the + // token was created on so the hint can't resolve a different target. + const revokeTarget = [ + ctx.appId ? ` --app ${ctx.appId}` : "", + ctx.instanceId ? ` --instance ${ctx.instanceId}` : "", + ].join(""); + log.info(dim(`Revoke with: clerk imp revoke ${token.id}${revokeTarget}`)); + + if (options.print) { + return; + } + + if (options.open) { + await openOrWarn(token.url); + return; + } + + if (!process.stdin.isTTY) { + return; + } + + const shouldOpen = await confirm({ + message: "Press Enter to open in your browser (Ctrl+C to skip)", + default: true, + }); + if (shouldOpen) { + await openOrWarn(token.url); + } +} diff --git a/packages/cli-core/src/commands/impersonate/index.test.ts b/packages/cli-core/src/commands/impersonate/index.test.ts new file mode 100644 index 00000000..756bcd04 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/index.test.ts @@ -0,0 +1,24 @@ +import { test, expect, describe } from "bun:test"; +import { createProgram } from "../../cli-program.ts"; + +describe("impersonate command wiring", () => { + test("registers `impersonate` with `imp` alias and an optional `[user]` argument", () => { + const program = createProgram(); + const impersonateCommand = program.commands.find((c) => c.name() === "impersonate"); + + expect(impersonateCommand).toBeDefined(); + expect(impersonateCommand?.aliases()).toContain("imp"); + expect(impersonateCommand?.registeredArguments[0]?.name()).toBe("user"); + expect(impersonateCommand?.registeredArguments[0]?.required).toBe(false); + }); + + test("registers a required `revoke ` subcommand", () => { + const program = createProgram(); + const impersonateCommand = program.commands.find((c) => c.name() === "impersonate"); + const revokeCommand = impersonateCommand?.commands.find((c) => c.name() === "revoke"); + + expect(revokeCommand).toBeDefined(); + expect(revokeCommand?.registeredArguments[0]?.name()).toBe("actorTokenId"); + expect(revokeCommand?.registeredArguments[0]?.required).toBe(true); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/index.ts b/packages/cli-core/src/commands/impersonate/index.ts new file mode 100644 index 00000000..78357315 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/index.ts @@ -0,0 +1,60 @@ +import { createArgument } from "@commander-js/extra-typings"; +import type { Program } from "../../cli-program.ts"; +import { parseIntegerOption } from "../../lib/option-parsers.ts"; +import { impersonate } from "./impersonate.ts"; +import { revoke } from "./revoke.ts"; + +export function registerImpersonate(program: Program): void { + const impersonateCommand = program + .command("impersonate") + .alias("imp") + .description("Impersonate a Clerk user") + .addArgument( + createArgument( + "[user]", + "User ID (user_...), exact email, or search term to impersonate. Omit to pick interactively.", + ), + ) + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--actor ", "Extra context appended to the actor stamp: cli:+") + .option("--expires-in ", "Actor token lifetime in seconds (default 3600)", (value) => + parseIntegerOption(value, "--expires-in", { min: 1 }), + ) + .option("--open", "Open the sign-in URL in your browser immediately, skipping the prompt") + .option("--print", "Print the sign-in URL only — no prompt, no browser") + .option("--yes", "Skip the impersonation confirmation prompt") + .setExamples([ + { command: "clerk imp", description: "Pick a user interactively and impersonate" }, + { command: "clerk imp user_2x9k", description: "Impersonate a specific user" }, + { + command: "clerk imp alice@example.com --open", + description: "Impersonate by exact email and open the session in your browser", + }, + { command: "clerk imp revoke act_29w9...", description: "Revoke a pending actor token" }, + ]) + .action((user, _opts, cmd) => + impersonate({ + ...(cmd.optsWithGlobals() as Parameters[0]), + user, + }), + ); + + impersonateCommand + .command("revoke") + .description("Revoke a pending actor token") + .addArgument(createArgument("", "Actor token ID to revoke")) + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .setExamples([ + { command: "clerk imp revoke act_29w9...", description: "Revoke a pending actor token" }, + ]) + .action((actorTokenId, _opts, cmd) => + revoke({ + ...(cmd.optsWithGlobals() as Parameters[0]), + actorTokenId, + }), + ); +} diff --git a/packages/cli-core/src/commands/impersonate/resolve-user.test.ts b/packages/cli-core/src/commands/impersonate/resolve-user.test.ts new file mode 100644 index 00000000..0786d341 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/resolve-user.test.ts @@ -0,0 +1,148 @@ +import { test, expect, describe, beforeEach } from "bun:test"; +import { setMode } from "../../mode.ts"; +import { CliError } from "../../lib/errors.ts"; +import { mock } from "bun:test"; + +const mockBapiRequest = mock(); +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const mockPickUser = mock(); +mock.module("../users/interactive/pick-user.ts", () => ({ + pickUser: (...args: unknown[]) => mockPickUser(...args), +})); + +const { resolveImpersonationTarget } = await import("./resolve-user.ts"); + +describe("resolveImpersonationTarget", () => { + beforeEach(() => { + setMode("human"); + mockBapiRequest.mockReset(); + mockPickUser.mockReset(); + }); + + test("uses a user_xxx argument directly without calling BAPI", async () => { + const result = await resolveImpersonationTarget("user_2x9k", { secretKey: "sk_test_123" }); + expect(result).toBe("user_2x9k"); + expect(mockBapiRequest).not.toHaveBeenCalled(); + expect(mockPickUser).not.toHaveBeenCalled(); + }); + + test("searches by exact email_address filter when the argument contains '@'", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_alice" }], + rawBody: "", + }); + + const result = await resolveImpersonationTarget("alice@example.com", { + secretKey: "sk_test_123", + }); + + expect(result).toBe("user_alice"); + expect(mockBapiRequest.mock.calls[0]?.[0]).toMatchObject({ + method: "GET", + path: "/users?email_address=alice%40example.com&limit=6", + secretKey: "sk_test_123", + }); + }); + + test("searches by fuzzy query when the argument has no '@'", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_bob" }], + rawBody: "", + }); + + await resolveImpersonationTarget("bob", { secretKey: "sk_test_123" }); + + expect(mockBapiRequest.mock.calls[0]?.[0]).toMatchObject({ + path: "/users?query=bob&limit=6", + }); + }); + + test("throws a usage error when zero users match", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [], + rawBody: "", + }); + + await expect( + resolveImpersonationTarget("nobody@example.com", { secretKey: "sk_test_123" }), + ).rejects.toThrow(/no user found matching "nobody@example.com"/i); + }); + + test("names the searched app and instance in the zero-match error when known", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [], + rawBody: "", + }); + + await expect( + resolveImpersonationTarget("nobody@example.com", { + secretKey: "sk_test_123", + appLabel: "My Application", + instanceLabel: "development", + }), + ).rejects.toThrow( + /no user found matching "nobody@example.com" on My Application \(development\)/i, + ); + }); + + test("human mode: opens the picker (no prefilled query) when 2+ users match", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_a" }, { id: "user_b" }], + rawBody: "", + }); + mockPickUser.mockResolvedValue("user_a"); + + const result = await resolveImpersonationTarget("smith", { secretKey: "sk_test_123" }); + + expect(result).toBe("user_a"); + expect(mockPickUser).toHaveBeenCalledWith( + expect.objectContaining({ secretKey: "sk_test_123" }), + ); + }); + + test("agent mode: throws a usage error listing candidate user IDs when 2+ users match", async () => { + setMode("agent"); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_a" }, { id: "user_b" }], + rawBody: "", + }); + + await expect(resolveImpersonationTarget("smith", { secretKey: "sk_test_123" })).rejects.toThrow( + /user_a, user_b/, + ); + expect(mockPickUser).not.toHaveBeenCalled(); + }); + + test("human mode: no argument opens the picker", async () => { + mockPickUser.mockResolvedValue("user_picked"); + + const result = await resolveImpersonationTarget(undefined, { secretKey: "sk_test_123" }); + + expect(result).toBe("user_picked"); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("agent mode: no argument throws a usage error instead of prompting", async () => { + setMode("agent"); + + await expect( + resolveImpersonationTarget(undefined, { secretKey: "sk_test_123" }), + ).rejects.toThrow(CliError); + expect(mockPickUser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/resolve-user.ts b/packages/cli-core/src/commands/impersonate/resolve-user.ts new file mode 100644 index 00000000..0a6f3ed5 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/resolve-user.ts @@ -0,0 +1,76 @@ +import { throwUsageError } from "../../lib/errors.ts"; +import { searchUsers } from "../../lib/users.ts"; +import { isAgent } from "../../mode.ts"; +import { pickUser } from "../users/interactive/pick-user.ts"; + +const USER_ID_PATTERN = /^user_[A-Za-z0-9]+$/; +const CANDIDATE_LIMIT = 5; + +export type ImpersonationSearchContext = { + secretKey: string; + appLabel?: string; + instanceLabel?: string; +}; + +function searchScope(ctx: ImpersonationSearchContext): string { + if (ctx.appLabel && ctx.instanceLabel) return ` on ${ctx.appLabel} (${ctx.instanceLabel})`; + if (ctx.instanceLabel) return ` on the ${ctx.instanceLabel} instance`; + return ""; +} + +/** + * Resolve the `[user]` positional argument (or its absence) to a single + * `user_...` ID: + * + * - `user_...` → used directly, no lookup. + * - contains `@` → exact match via `email_address` filter. + * - otherwise → fuzzy match via `query`. + * - 0 matches → usage error naming the searched app/instance. 1 match → used + * directly. + * - 2+ matches: human mode opens the picker (no prefilled query support — + * see pick-user.ts); agent mode errors with candidate IDs. + * - no argument: human mode opens the picker; agent mode errors (agent mode + * never prompts). + */ +export async function resolveImpersonationTarget( + user: string | undefined, + ctx: ImpersonationSearchContext, +): Promise { + const secretKey = ctx.secretKey; + if (user === undefined) { + if (isAgent()) { + throwUsageError("A user is required in agent mode. Pass it as a positional argument."); + } + return pickUser({ secretKey, message: "Pick a user to impersonate:" }); + } + + if (USER_ID_PATTERN.test(user)) { + return user; + } + + const filter = user.includes("@") ? { email: user } : { query: user }; + const users = await searchUsers(secretKey, filter, CANDIDATE_LIMIT + 1); + + if (users.length === 0) { + throwUsageError(`No user found matching "${user}"${searchScope(ctx)}.`); + } + + if (users.length === 1) { + return users[0]!.id; + } + + if (isAgent()) { + const candidates = users + .slice(0, CANDIDATE_LIMIT) + .map((candidate) => candidate.id) + .join(", "); + throwUsageError( + `Multiple users match "${user}": ${candidates}. Pass a specific user_id instead.`, + ); + } + + // pickUser has no prefilled/initial-query support (see pick-user.ts), so + // the picker re-opens with an empty search box rather than the original + // term. + return pickUser({ secretKey, message: `Multiple users match "${user}" — pick one:` }); +} diff --git a/packages/cli-core/src/commands/impersonate/revoke.test.ts b/packages/cli-core/src/commands/impersonate/revoke.test.ts new file mode 100644 index 00000000..7e5a79c5 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/revoke.test.ts @@ -0,0 +1,102 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { setMode } from "../../mode.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; + +const mockRequireLoginEmail = mock(); +mock.module("./actor.ts", () => ({ + requireLoginEmail: (...args: unknown[]) => mockRequireLoginEmail(...args), + buildActorStamp: () => ({ sub: "cli:unused", iss: "clerk-cli" }), +})); + +const mockResolveUsersInstanceContext = mock(); +mock.module("../users/interactive/instance-context.ts", () => ({ + resolveUsersInstanceContext: (...args: unknown[]) => mockResolveUsersInstanceContext(...args), +})); + +const mockBapiRequest = mock(); +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + +const { revoke } = await import("./revoke.ts"); + +const CTX = { + secretKey: "sk_test_123", + appId: "app_abc123", + appLabel: "My App", + instanceId: "ins_dev789", + instanceLabel: "development", +}; + +describe("revoke", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + setMode("human"); + mockRequireLoginEmail.mockResolvedValue("admin@example.com"); + mockResolveUsersInstanceContext.mockResolvedValue(CTX); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: { id: "act_1", status: "revoked" }, + rawBody: "", + }); + }); + + afterEach(() => { + mockRequireLoginEmail.mockReset(); + mockResolveUsersInstanceContext.mockReset(); + mockBapiRequest.mockReset(); + }); + + test("hard-fails before touching BAPI when not logged in", async () => { + mockRequireLoginEmail.mockRejectedValue( + new CliError("Not logged in. Run `clerk auth login` to authenticate", { + code: ERROR_CODE.AUTH_REQUIRED, + }), + ); + + await expect(revoke({ actorTokenId: "act_1" })).rejects.toThrow(/Not logged in/); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("calls POST /actor_tokens/{id}/revoke against the resolved secret key", async () => { + await revoke({ actorTokenId: "act_1" }); + + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "POST", + path: "/actor_tokens/act_1/revoke", + secretKey: CTX.secretKey, + }); + }); + + test("human mode: prints a success message to stderr", async () => { + await revoke({ actorTokenId: "act_1" }); + expect(captured.err).toContain("Revoked actor token act_1."); + }); + + test("agent mode: emits structured JSON with the token id and status", async () => { + setMode("agent"); + await revoke({ actorTokenId: "act_1" }); + expect(JSON.parse(captured.out)).toEqual({ id: "act_1", status: "revoked" }); + }); + + test("targets the app/instance from resolveUsersInstanceContext", async () => { + await revoke({ actorTokenId: "act_1", app: "app_abc123", instance: "prod" }); + expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ + secretKey: undefined, + app: "app_abc123", + instance: "prod", + }); + }); +}); diff --git a/packages/cli-core/src/commands/impersonate/revoke.ts b/packages/cli-core/src/commands/impersonate/revoke.ts new file mode 100644 index 00000000..408d1246 --- /dev/null +++ b/packages/cli-core/src/commands/impersonate/revoke.ts @@ -0,0 +1,43 @@ +import { revokeActorToken } from "../../lib/actor-tokens.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; +import { resolveUsersInstanceContext } from "../users/interactive/instance-context.ts"; +import { requireLoginEmail } from "./actor.ts"; + +export type RevokeOptions = { + actorTokenId: string; + secretKey?: string; + app?: string; + instance?: string; +}; + +export async function revoke(options: RevokeOptions): Promise { + // Login is required for revoke too — the login email itself isn't used + // here (revoking doesn't stamp a new actor), but the gate must run before + // any BAPI call, same as create. + await requireLoginEmail(); + + const ctx = await resolveUsersInstanceContext({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + + const body = await withApiContext( + withSpinner(`Revoking actor token ${options.actorTokenId}...`, () => + revokeActorToken(ctx.secretKey, options.actorTokenId), + ), + `Failed to revoke actor token ${options.actorTokenId}`, + ); + + if (isAgent()) { + log.data( + JSON.stringify({ id: body.id ?? options.actorTokenId, status: body.status ?? "revoked" }), + ); + return; + } + + log.success(`Revoked actor token ${options.actorTokenId}.`); +} diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 4b6ee4ee..c6ee420b 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -6,13 +6,13 @@ Manage direct Clerk user resources with first-class commands. Use `clerk api` fo Most `clerk users` commands accept the same targeting flags: -| Flag | Description | -| ------------------ | --------------------------------------------------------------------------------- | -| `--secret-key ` | Use a specific Backend API secret key directly | -| `--app ` | Target an application directly, even outside a linked project | -| `--instance ` | Target `dev`, `prod`, or a full instance ID. Defaults to the development instance | -| `--dry-run` | Preview the request without executing it, where supported | -| `--yes` | Skip confirmation prompts for mutating commands | +| Flag | Description | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--secret-key ` | Use a specific Backend API secret key directly | +| `--app ` | Target an application directly, even outside a linked project | +| `--instance ` | Target `dev`, `prod`, or a full instance ID. Without it, single-instance apps use development; interactive flows that hit a multi-instance app prompt for one in human mode | +| `--dry-run` | Preview the request without executing it, where supported | +| `--yes` | Skip confirmation prompts for mutating commands | Authentication is resolved in this order: @@ -42,7 +42,7 @@ Two complementary mechanisms for JSON input work across the users command family ### `clerk users list` -List users from the target instance. In human mode without a linked project, an env var, or a targeting flag, the command opens the same application picker as `clerk users create` so you can choose an instance interactively. +List users from the target instance. In human mode without a linked project, an env var, or a targeting flag, the command opens the same application picker as `clerk users create`; if the picked application has more than one instance, an instance picker follows (no silent development default). ```sh clerk users list diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.test.ts b/packages/cli-core/src/commands/users/interactive/instance-context.test.ts index 0f61dd4d..96b62031 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.test.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.test.ts @@ -7,8 +7,13 @@ const mockResolveProfile = mock(); const mockFetchApplication = mock(); const mockFetchAppsTolerantly = mock(); const mockPickOrCreateApp = mock(); +const mockSelect = mock(); const mockIsHuman = mock(() => true); +mock.module("../../../lib/listage.ts", () => ({ + select: (...args: unknown[]) => mockSelect(...args), +})); + mock.module("../../../lib/config.ts", () => ({ resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), @@ -22,15 +27,21 @@ mock.module("../../../lib/config.ts", () => ({ publishable_key: string; }[]; }, - _instance?: string, + instance?: string, ) => { - const development = app.instances.find((i) => i.environment_type === "development"); - if (!development) return { found: false, instanceId: "unknown", instanceLabel: "unknown" }; + // Mirrors the real resolver: an env-type or instance-id hint wins, + // otherwise fall back to the development instance. Labels use the + // matched instance's environment type. + const hint = instance ?? "development"; + const matched = app.instances.find( + (i) => i.environment_type === hint || i.instance_id === hint, + ); + if (!matched) return { found: false, instanceId: hint, instanceLabel: hint }; return { found: true, - instance: development, - instanceId: development.instance_id, - instanceLabel: "development", + instance: matched, + instanceId: matched.instance_id, + instanceLabel: matched.environment_type, }; }, })); @@ -61,6 +72,7 @@ describe("resolveUsersInstanceContext", () => { mockFetchApplication.mockReset(); mockFetchAppsTolerantly.mockReset(); mockPickOrCreateApp.mockReset(); + mockSelect.mockReset(); mockIsHuman.mockReturnValue(true); mockResolveProfile.mockResolvedValue(undefined); }); @@ -121,6 +133,7 @@ describe("resolveUsersInstanceContext", () => { const ctx = await resolveUsersInstanceContext({}); expect(mockPickOrCreateApp).toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); expect(ctx.secretKey).toBe("sk_test_picked"); expect(ctx.appId).toBe("app_picked"); expect(ctx.appLabel).toBe("Picked"); @@ -129,6 +142,171 @@ describe("resolveUsersInstanceContext", () => { expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); }); + test("prompts for an instance when the picked app has multiple instances", async () => { + mockResolveAppContext.mockRejectedValue( + new CliError("No Clerk project linked", { code: ERROR_CODE.NOT_LINKED }), + ); + mockFetchAppsTolerantly.mockResolvedValue([{ application_id: "app_picked", name: "Picked" }]); + mockPickOrCreateApp.mockResolvedValue({ application_id: "app_picked", name: "Picked" }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_picked", + name: "Picked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_picked", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_live_picked", + }, + ], + }); + mockSelect.mockResolvedValue("ins_prod"); + + const ctx = await resolveUsersInstanceContext({}); + + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(mockSelect.mock.calls[0]?.[0]).toMatchObject({ + choices: [ + { name: "ins_dev (development)", value: "ins_dev" }, + { name: "ins_prod (production)", value: "ins_prod" }, + ], + }); + expect(ctx.secretKey).toBe("sk_live_picked"); + expect(ctx.instanceId).toBe("ins_prod"); + expect(ctx.instanceLabel).toBe("production"); + }); + + test("does not prompt for an instance when --instance is passed alongside the app picker", async () => { + mockResolveAppContext.mockRejectedValue( + new CliError("No Clerk project linked", { code: ERROR_CODE.NOT_LINKED }), + ); + mockFetchAppsTolerantly.mockResolvedValue([{ application_id: "app_picked", name: "Picked" }]); + mockPickOrCreateApp.mockResolvedValue({ application_id: "app_picked", name: "Picked" }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_picked", + name: "Picked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_picked", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_live_picked", + }, + ], + }); + + const ctx = await resolveUsersInstanceContext({ instance: "production" }); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(ctx.instanceId).toBe("ins_prod"); + expect(ctx.instanceLabel).toBe("production"); + }); + + test("prompts even when the app comes from a linked profile", async () => { + mockResolveAppContext.mockResolvedValue({ + appId: "app_linked", + appLabel: "Linked", + instanceId: "ins_dev", + instanceLabel: "development", + }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_linked", + name: "Linked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_linked", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_live_linked", + }, + ], + }); + mockSelect.mockResolvedValue("ins_prod"); + + const ctx = await resolveUsersInstanceContext({}); + + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(ctx.instanceId).toBe("ins_prod"); + expect(ctx.instanceLabel).toBe("production"); + expect(ctx.secretKey).toBe("sk_live_linked"); + }); + + test("errors in agent mode when the app has multiple instances and no --instance", async () => { + mockIsHuman.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_linked", + appLabel: "Linked", + instanceId: "ins_dev", + instanceLabel: "development", + }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_linked", + name: "Linked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_linked", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_live_linked", + }, + ], + }); + + await expect(resolveUsersInstanceContext({})).rejects.toThrow(/multiple instances/); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + test("agent mode resolves without prompting when the app has a single instance", async () => { + mockIsHuman.mockReturnValue(false); + mockResolveAppContext.mockResolvedValue({ + appId: "app_linked", + appLabel: "Linked", + instanceId: "ins_dev", + instanceLabel: "development", + }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_linked", + name: "Linked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_linked", + }, + ], + }); + + const ctx = await resolveUsersInstanceContext({}); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(ctx.instanceId).toBe("ins_dev"); + }); + test("re-throws NOT_LINKED in agent mode without invoking picker", async () => { mockIsHuman.mockReturnValue(false); mockResolveAppContext.mockRejectedValue( diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.ts b/packages/cli-core/src/commands/users/interactive/instance-context.ts index f57619c0..4f6db1c6 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -14,6 +14,7 @@ import { import { getBapiBaseUrl } from "../../../lib/environment.ts"; import { decodePublishableKey } from "../../../lib/fapi.ts"; import { loggedFetch } from "../../../lib/fetch.ts"; +import { select } from "../../../lib/listage.ts"; import { fetchApplication, validateKeyPrefix } from "../../../lib/plapi.ts"; import { isHuman } from "../../../mode.ts"; @@ -163,6 +164,29 @@ export async function resolveUsersInstanceContext( } const app = await withApiContext(fetchApplication(appId), "Failed to resolve instance context"); + + // Never guess silently between instances. Defaulting to development would + // make the same command resolve differently depending on which instances an + // app happens to have (e.g. once a production instance is added), breaking + // the guarantee that a command produces a repeatable result. So when the app + // has more than one instance and --instance didn't pin one, resolve the + // ambiguity explicitly: human mode prompts, agent mode errors. + if (!options.instance && app.instances.length > 1) { + if (isHuman()) { + instanceHint = await select({ + message: "Select an instance to use:", + choices: app.instances.map((entry) => ({ + name: `${entry.instance_id} (${entry.environment_type})`, + value: entry.instance_id, + })), + }); + } else { + throwUsageError( + "This application has multiple instances. Pass --instance to choose one explicitly.", + ); + } + } + const resolved = resolveFetchedApplicationInstance(appId, app, instanceHint); if (!resolved.found) { throw new CliError(`Instance ${resolved.instanceId} not found in application.`, { diff --git a/packages/cli-core/src/commands/users/interactive/pick-user.ts b/packages/cli-core/src/commands/users/interactive/pick-user.ts index 6aeedfe1..cb3f10d1 100644 --- a/packages/cli-core/src/commands/users/interactive/pick-user.ts +++ b/packages/cli-core/src/commands/users/interactive/pick-user.ts @@ -1,5 +1,5 @@ import { search, Separator } from "../../../lib/listage.ts"; -import { bapiRequest } from "../../../lib/bapi.ts"; +import { type BapiUserSummary, searchUsers } from "../../../lib/users.ts"; export type PickUserOptions = { secretKey: string; @@ -8,15 +8,7 @@ export type PickUserOptions = { const PICKER_LIMIT = 20; -type UserSummary = { - id: string; - first_name?: string | null; - last_name?: string | null; - username?: string | null; - email_addresses?: Array<{ email_address?: string }> | null; -}; - -export function formatUserChoice(user: UserSummary): string { +export function formatUserChoice(user: BapiUserSummary): string { const email = user.email_addresses?.[0]?.email_address ?? "no email"; const nameParts = [user.first_name, user.last_name].filter( (part): part is string => typeof part === "string" && part.length > 0, @@ -33,16 +25,11 @@ export async function pickUser(options: PickUserOptions): Promise { message: options.message ?? "Pick a user:", source: async (term) => { // Request one extra so we can flag overflow with a refine-search hint. - const params = new URLSearchParams(); - if (term) params.set("query", term); - params.set("limit", String(PICKER_LIMIT + 1)); - const response = await bapiRequest({ - method: "GET", - path: `/users?${params}`, - secretKey: options.secretKey, - }); - const body = response.body; - const allUsers = Array.isArray(body) ? (body as UserSummary[]) : []; + const allUsers = await searchUsers( + options.secretKey, + { query: term ?? "" }, + PICKER_LIMIT + 1, + ); const hasMore = allUsers.length > PICKER_LIMIT; const users = hasMore ? allUsers.slice(0, PICKER_LIMIT) : allUsers; diff --git a/packages/cli-core/src/lib/actor-tokens.test.ts b/packages/cli-core/src/lib/actor-tokens.test.ts new file mode 100644 index 00000000..4e487fd5 --- /dev/null +++ b/packages/cli-core/src/lib/actor-tokens.test.ts @@ -0,0 +1,55 @@ +import { test, expect, describe, beforeEach, mock } from "bun:test"; + +const mockBapiRequest = mock(); +mock.module("./bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const { createActorToken, revokeActorToken } = await import("./actor-tokens.ts"); + +describe("createActorToken", () => { + beforeEach(() => { + mockBapiRequest.mockReset(); + }); + + test("POSTs the snake_case actor-token contract and returns the typed token", async () => { + mockBapiRequest.mockResolvedValue({ body: { id: "act_1", url: "https://example.com/ticket" } }); + + const token = await createActorToken("sk_test_123", { + userId: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + expiresInSeconds: 900, + }); + + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "POST", + path: "/actor_tokens", + secretKey: "sk_test_123", + body: JSON.stringify({ + user_id: "user_2x9k", + actor: { sub: "cli:admin@example.com", iss: "clerk-cli" }, + expires_in_seconds: 900, + }), + }); + expect(token).toEqual({ id: "act_1", url: "https://example.com/ticket" }); + }); +}); + +describe("revokeActorToken", () => { + beforeEach(() => { + mockBapiRequest.mockReset(); + }); + + test("POSTs to the revoke path and returns the parsed body", async () => { + mockBapiRequest.mockResolvedValue({ body: { id: "act_1", status: "revoked" } }); + + const result = await revokeActorToken("sk_test_123", "act_1"); + + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "POST", + path: "/actor_tokens/act_1/revoke", + secretKey: "sk_test_123", + }); + expect(result).toEqual({ id: "act_1", status: "revoked" }); + }); +}); diff --git a/packages/cli-core/src/lib/actor-tokens.ts b/packages/cli-core/src/lib/actor-tokens.ts new file mode 100644 index 00000000..92e5d687 --- /dev/null +++ b/packages/cli-core/src/lib/actor-tokens.ts @@ -0,0 +1,66 @@ +/** + * Backend API (BAPI) actor-token client. + * + * Actor tokens back the `clerk impersonate` flow: creating one returns a + * sign-in URL that logs the caller in as another user, stamped with the + * `actor` who initiated it. This module owns the BAPI request/response + * contract (the snake_case wire shape) so commands work with named types + * instead of hand-built object literals. + */ + +import { bapiRequest } from "./bapi.ts"; + +/** The initiator stamped onto an actor token, echoed into the session's JWT. */ +export type ActorTokenActor = { + sub: string; + iss: string; +}; + +export type CreateActorTokenRequest = { + userId: string; + actor: ActorTokenActor; + expiresInSeconds: number; +}; + +/** A freshly created actor token: `url` signs the caller in as the target user. */ +export type ActorToken = { + id: string; + url: string; +}; + +/** Result of revoking an actor token. Fields are optional — BAPI may echo them. */ +export type RevokedActorToken = { + id?: string; + status?: string; +}; + +export async function createActorToken( + secretKey: string, + request: CreateActorTokenRequest, +): Promise { + const response = await bapiRequest({ + method: "POST", + path: "/actor_tokens", + secretKey, + body: JSON.stringify({ + user_id: request.userId, + actor: { sub: request.actor.sub, iss: request.actor.iss }, + expires_in_seconds: request.expiresInSeconds, + }), + }); + + return response.body as ActorToken; +} + +export async function revokeActorToken( + secretKey: string, + actorTokenId: string, +): Promise { + const response = await bapiRequest({ + method: "POST", + path: `/actor_tokens/${actorTokenId}/revoke`, + secretKey, + }); + + return response.body as RevokedActorToken; +} diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index 25049b14..c8deae64 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -248,13 +248,34 @@ describe("config", () => { expect(result).toMatchObject({ found: true, instanceId: "ins_custom_123", - instanceLabel: "ins_custom_123", + instanceLabel: "staging", }); if (result.found) { expect(result.instance.instance_id).toBe("ins_custom_123"); } }); + test("labels a production instance targeted by literal id as production", () => { + const prodApp = { + application_id: "app_123", + instances: [ + { + instance_id: "ins_prod_123", + environment_type: "production", + publishable_key: "pk_live_123", + }, + ], + }; + + const result = resolveFetchedApplicationInstance("app_123", prodApp, "ins_prod_123"); + + expect(result).toMatchObject({ + found: true, + instanceId: "ins_prod_123", + instanceLabel: "production", + }); + }); + test("returns explicit missing state for unknown literal instance ids", () => { const result = resolveFetchedApplicationInstance("app_123", app, "ins_missing_123"); @@ -286,7 +307,7 @@ describe("config", () => { appId: "app_123", appLabel: "My App", instanceId: "ins_custom_123", - instanceLabel: "ins_custom_123", + instanceLabel: "staging", }); }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 9dd85de6..830a7a92 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -286,7 +286,9 @@ export function resolveFetchedApplicationInstance( found: true, instance: matched, instanceId: matched.instance_id, - instanceLabel: instance, + // Label by environment type, not the raw id — downstream guardrails + // (e.g. the production impersonation warning) key off this label. + instanceLabel: matched.environment_type || instance, }; } diff --git a/packages/cli-core/src/lib/errors.test.ts b/packages/cli-core/src/lib/errors.test.ts index ee27d014..577331c3 100644 --- a/packages/cli-core/src/lib/errors.test.ts +++ b/packages/cli-core/src/lib/errors.test.ts @@ -1,5 +1,13 @@ import { test, expect, describe } from "bun:test"; -import { PlapiError, FapiError, BapiError } from "./errors.ts"; +import { + PlapiError, + FapiError, + BapiError, + BillingError, + BILLING_ERROR_REASON, + CliError, + ERROR_CODE, +} from "./errors.ts"; describe("ApiError envelope parsing (via PlapiError.fromBody)", () => { test("parses a standard single-error envelope", () => { @@ -151,3 +159,33 @@ describe("BapiError factories", () => { expect(err.headers.get("x-y")).toBe("z"); }); }); + +describe("BillingError", () => { + test("is a CliError carrying the plan-not-enabled reason and caller-supplied code", () => { + const err = new BillingError("Impersonation isn't enabled on this app's plan.", { + reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, + code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, + }); + expect(err).toBeInstanceOf(CliError); + expect(err.name).toBe("BillingError"); + expect(err.reason).toBe(BILLING_ERROR_REASON.PLAN_NOT_ENABLED); + expect(err.code).toBe(ERROR_CODE.IMPERSONATION_NOT_ENABLED); + expect(err.limit).toBeUndefined(); + expect(err.used).toBeUndefined(); + }); + + test("carries quota figures for the quota-exceeded reason", () => { + const err = new BillingError( + "Impersonation limit exceeded (used 100/100 this billing period).", + { + reason: BILLING_ERROR_REASON.QUOTA_EXCEEDED, + code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED, + limit: 100, + used: 100, + }, + ); + expect(err.reason).toBe(BILLING_ERROR_REASON.QUOTA_EXCEEDED); + expect(err.limit).toBe(100); + expect(err.used).toBe(100); + }); +}); diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index bff5a8cf..2ead49f2 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -55,6 +55,10 @@ export const ERROR_CODE = { HOME_URL_TAKEN: "home_url_taken", /** PLAPI rejected a request parameter as malformed. */ FORM_PARAM_INVALID: "form_param_invalid", + /** Impersonation isn't enabled on the app's subscription plan. */ + IMPERSONATION_NOT_ENABLED: "impersonation_not_enabled", + /** Actor token creation rejected because the impersonation quota is exhausted. */ + IMPERSONATION_LIMIT_EXCEEDED: "impersonation_limit_exceeded", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; @@ -65,6 +69,21 @@ export const AUTH_ERROR_REASON = { export type AuthErrorReason = (typeof AUTH_ERROR_REASON)[keyof typeof AUTH_ERROR_REASON]; +/** + * Why a billing/usage guardrail rejected a request. + * + * - `plan_not_enabled` — the feature isn't included in the app's subscription + * plan (typically surfaced as HTTP 402). + * - `quota_exceeded` — the usage quota for the current billing period is + * exhausted (typically surfaced as HTTP 422). + */ +export const BILLING_ERROR_REASON = { + PLAN_NOT_ENABLED: "plan_not_enabled", + QUOTA_EXCEEDED: "quota_exceeded", +} as const; + +export type BillingErrorReason = (typeof BILLING_ERROR_REASON)[keyof typeof BILLING_ERROR_REASON]; + interface CliErrorOptions { /** Machine-readable error code for programmatic consumption. */ code?: ErrorCode; @@ -79,6 +98,14 @@ interface AuthErrorOptions extends Omit { reason: AuthErrorReason; } +interface BillingErrorOptions extends CliErrorOptions { + reason: BillingErrorReason; + /** Quota ceiling for the current billing period, when the API reports it. */ + limit?: number; + /** Amount of the quota already consumed, when the API reports it. */ + used?: number; +} + /** * General-purpose CLI error for user-facing messages. * @@ -146,6 +173,39 @@ export class AuthError extends CliError { } } +/** + * A billing or usage guardrail rejected the request. + * + * Throw this for plan-gating (feature not on the plan) and quota-exhaustion + * failures so every command surfaces them the same way. The `reason` + * discriminates the two cases for programmatic consumers, and `limit`/`used` + * carry quota figures when the API reports them. Callers still supply a + * feature-specific `code` (e.g. {@link ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED}) + * and a human-readable message. + * + * @example + * ```ts + * throw new BillingError("Impersonation isn't enabled on this app's plan.", { + * reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, + * code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, + * }); + * ``` + */ +export class BillingError extends CliError { + public reason: BillingErrorReason; + public limit?: number; + public used?: number; + + constructor(message: string, options: BillingErrorOptions) { + const { reason, limit, used, ...rest } = options; + super(message, rest); + this.name = "BillingError"; + this.reason = reason; + this.limit = limit; + this.used = used; + } +} + /** * Signals that the user cancelled an interactive prompt or confirmation. * diff --git a/packages/cli-core/src/lib/users.ts b/packages/cli-core/src/lib/users.ts index 6a4d4641..d43a767e 100644 --- a/packages/cli-core/src/lib/users.ts +++ b/packages/cli-core/src/lib/users.ts @@ -1,3 +1,4 @@ +import { bapiRequest } from "./bapi.ts"; import { ERROR_CODE, throwUsageError } from "./errors.ts"; const USERS_INVALID_JSON_MESSAGE = "User payload must be a JSON object."; @@ -5,6 +6,47 @@ const REDACTED = "[REDACTED]"; const DIRECT_REDACT_KEYS = new Set(["password", "code"]); const OBJECT_REDACT_KEYS = new Set(["private_metadata", "unsafe_metadata"]); +export type BapiUserSummary = { + id: string; + first_name?: string | null; + last_name?: string | null; + username?: string | null; + email_addresses?: Array<{ email_address?: string }> | null; +}; + +/** + * How to filter the user search: an exact email match or a fuzzy query. An + * empty `query` returns the unfiltered first page (used by the interactive + * picker before the user types). + */ +export type UserSearchFilter = { email: string } | { query: string }; + +/** + * Centralizes the `/users` request so commands don't each hand-roll the query + * string, limit handling, and `Array.isArray` body guard. + */ +export async function searchUsers( + secretKey: string, + filter: UserSearchFilter, + limit: number, +): Promise { + const params = new URLSearchParams(); + if ("email" in filter) { + params.set("email_address", filter.email); + } else if (filter.query) { + params.set("query", filter.query); + } + params.set("limit", String(limit)); + + const response = await bapiRequest({ + method: "GET", + path: `/users?${params}`, + secretKey, + }); + + return Array.isArray(response.body) ? (response.body as BapiUserSummary[]) : []; +} + export function buildCreateUserPayload(options: { email?: string; phone?: string; diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index edc8c506..e1e10d2e 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -79,6 +79,33 @@ describe("generateCompletions", () => { }); }); + describe("impersonate completion", () => { + test("completes root subcommands including impersonate", () => { + const names = completionNames(""); + expect(names).toContain("impersonate"); + }); + + test("completes the imp alias as its own command with its subcommand and flags", () => { + const names = completionNames("imp", ""); + expect(names).toContain("revoke"); + expect(names).toContain("--secret-key"); + expect(names).toContain("--actor"); + expect(names).toContain("--expires-in"); + }); + + test("completes --instance hints under `clerk impersonate`", () => { + const names = completionNames("impersonate", "--instance", ""); + expect(names).toContain("dev"); + expect(names).toContain("prod"); + }); + + test("completes revoke subcommand options", () => { + const names = completionNames("impersonate", "revoke", ""); + expect(names).toContain("--app"); + expect(names).toContain("--instance"); + }); + }); + describe("alias completion", () => { test("completes command aliases", () => { const names = completionNames("auth", ""); diff --git a/packages/cli-core/src/test/integration/users-commands.test.ts b/packages/cli-core/src/test/integration/users-commands.test.ts index 4dee21c9..398bed40 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -227,10 +227,12 @@ describe("users commands", () => { "/v1/users": CREATED_USER, }); - // Picker returns app_1; wizard then prompts for the optional curated set - // because MOCK_APP's publishable key does not decode to a valid fapiHost - // (the wizard skips the FAPI fetch and falls back to optional curated fields). + // Picker returns app_1; MOCK_APP has two instances so an instance picker + // follows; the wizard then prompts for the optional curated set because + // MOCK_APP's publishable key does not decode to a valid fapiHost (the + // wizard skips the FAPI fetch and falls back to optional curated fields). mockPrompts.search("app_1"); + mockPrompts.select("development"); mockPrompts.input("alice@example.com"); // email mockPrompts.input(""); // phone mockPrompts.input(""); // username