diff --git a/.changeset/impersonate-upgrade-nudge.md b/.changeset/impersonate-upgrade-nudge.md new file mode 100644 index 00000000..53048216 --- /dev/null +++ b/.changeset/impersonate-upgrade-nudge.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Guide you to the billing page to add the impersonation add-on when `clerk impersonate` is blocked by your plan (402) or your billing-period limit (422). In an interactive terminal it offers to open the page for you; `--yes` opens it directly, `--print` and agent mode surface the URL without opening a browser. diff --git a/packages/cli-core/src/commands/impersonate/README.md b/packages/cli-core/src/commands/impersonate/README.md index eff89373..6dc934fa 100644 --- a/packages/cli-core/src/commands/impersonate/README.md +++ b/packages/cli-core/src/commands/impersonate/README.md @@ -117,9 +117,27 @@ endpoint for actor tokens, so creation is the only moment the ID is visible. | `POST` | `/v1/actor_tokens` | Creating the actor token (`clerk impersonate`) | | `POST` | `/v1/actor_tokens/{id}/revoke` | Revoking an actor token (`clerk impersonate revoke`) | +## Billing block → upgrade nudge + `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). +app's subscription plan, and `422` when the impersonation quota for the billing +period is exhausted. Both mean no session was created, so the command exits +non-zero. The messages are value-framed and numberless (BAPI has no quota-read +endpoint, so the CLI never shows an `X/5`-style counter): + +- `402` → `Impersonation is available as an add-on.` +- `422` → `You've reached your impersonation limit this billing period.` + +Both attach the account-level billing page — `/settings/billing` +(from `getDashboardUrl()`, honoring `CLERK_DASHBOARD_URL`) — as `docsUrl` so the +add-on is one click away. How the URL is surfaced depends on the output mode: + +| Condition | Behavior | +| ------------------------- | ----------------------------------------------------------------------- | +| Agent mode | JSON error with the billing URL as `docsUrl`. Never opens a browser. | +| `--print` | Prints the URL beneath the error. No prompt, no browser. | +| `--yes` | Opens the billing page immediately (prints it if headless). No prompt. | +| TTY human, no `--yes` | Prompts "Add more impersonations now?" (default **Yes**); opens on yes. | +| Non-TTY human, no `--yes` | Prints the URL beneath the error. No prompt, no browser. | No part of this command is mocked or stubbed. diff --git a/packages/cli-core/src/commands/impersonate/impersonate.test.ts b/packages/cli-core/src/commands/impersonate/impersonate.test.ts index e2faf8d5..0dcfd6e8 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.test.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.test.ts @@ -1,7 +1,8 @@ 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"; +import { BapiError, BillingError, CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { getDashboardUrl } from "../../lib/environment.ts"; const mockRequireLoginEmail = mock(); const mockBuildActorStamp = mock(); @@ -49,6 +50,17 @@ mock.module("../../lib/spinner.ts", () => ({ const { impersonate } = await import("./impersonate.ts"); const SIGN_IN_URL = "https://example.clerk.accounts.dev/v1/tickets/accept?ticket=tkt_abc"; +const BILLING_URL = `${getDashboardUrl().replace(/\/$/, "")}/settings/billing`; + +function reject422(): void { + mockBapiRequest.mockRejectedValue( + new BapiError( + 422, + JSON.stringify({ errors: [{ message: "limit exceeded", meta: { limit: 100, used: 100 } }] }), + new Headers(), + ), + ); +} const CTX = { secretKey: "sk_test_123", @@ -223,7 +235,7 @@ describe("impersonate", () => { }); }); - test("402 from BAPI surfaces the plan-gate error, distinct from the quota error", async () => { + test("402 surfaces a value-framed add-on message with the billing page as docsUrl", async () => { mockBapiRequest.mockRejectedValue( new BapiError(402, JSON.stringify({ errors: [{ message: "not enabled" }] }), new Headers()), ); @@ -235,21 +247,14 @@ describe("impersonate", () => { 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); + expect(error).toBeInstanceOf(BillingError); + expect((error as BillingError).message).toBe("Impersonation is available as an add-on."); + expect((error as BillingError).code).toBe(ERROR_CODE.IMPERSONATION_NOT_ENABLED); + expect((error as BillingError).docsUrl).toBe(BILLING_URL); }); - 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(), - ), - ); + test("422 surfaces a numberless limit message with the billing page as docsUrl", async () => { + reject422(); let error: unknown; try { @@ -258,21 +263,16 @@ describe("impersonate", () => { error = caught; } - expect(error).toBeInstanceOf(CliError); - expect((error as CliError).message).toBe( - "Impersonation limit exceeded (used 100/100 this billing period).", + expect(error).toBeInstanceOf(BillingError); + expect((error as BillingError).message).toBe( + "You've reached your impersonation limit this billing period.", ); - expect((error as CliError).code).toBe(ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED); + expect((error as BillingError).code).toBe(ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED); + expect((error as BillingError).docsUrl).toBe(BILLING_URL); }); - 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(), - ), - ); + test("422 drops the used/limit counts even when BAPI reports them in meta", async () => { + reject422(); let error: unknown; try { @@ -281,8 +281,67 @@ describe("impersonate", () => { 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); + expect((error as BillingError).message).not.toMatch(/100/); + }); + + test("billing block in agent mode throws with docsUrl and never prompts or opens a browser", async () => { + setMode("agent"); + reject422(); + + await expect(impersonate({ user: "user_2x9k" })).rejects.toBeInstanceOf(BillingError); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("billing block with --print prints the URL via the error, no prompt, no browser", async () => { + reject422(); + + await expect(impersonate({ user: "user_2x9k", print: true, yes: true })).rejects.toBeInstanceOf( + BillingError, + ); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("billing block with --yes opens the billing page without prompting, then exits non-zero", async () => { + reject422(); + + await expect(impersonate({ user: "user_2x9k", yes: true })).rejects.toBeInstanceOf( + BillingError, + ); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockOpenBrowser).toHaveBeenCalledWith(BILLING_URL); + }); + + test("billing block in a TTY prompts to upgrade and opens the billing page on yes", async () => { + reject422(); + mockConfirm.mockResolvedValueOnce(true); // "Impersonate ...?" + mockConfirm.mockResolvedValueOnce(true); // "Add more impersonations now?" + + await expect(impersonate({ user: "user_2x9k" })).rejects.toBeInstanceOf(BillingError); + expect(mockConfirm).toHaveBeenLastCalledWith( + expect.objectContaining({ message: "Add more impersonations now?", default: true }), + ); + expect(mockOpenBrowser).toHaveBeenCalledWith(BILLING_URL); + }); + + test("billing block in a TTY leaves the browser closed when the upgrade prompt is declined", async () => { + reject422(); + mockConfirm.mockResolvedValueOnce(true); // "Impersonate ...?" + mockConfirm.mockResolvedValueOnce(false); // "Add more impersonations now?" + + await expect(impersonate({ user: "user_2x9k" })).rejects.toBeInstanceOf(BillingError); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("billing block in non-TTY human mode never opens a browser (no --yes)", async () => { + setStdinTTY(false); + reject422(); + + // Pre-flight "Impersonate ...?" confirm still resolves (mocked); the upgrade + // nudge must NOT open a browser because stdin is not a TTY and --yes is absent. + await expect(impersonate({ user: "user_2x9k" })).rejects.toBeInstanceOf(BillingError); + expect(mockConfirm).toHaveBeenCalledTimes(1); // only the pre-flight confirm, no upgrade prompt + expect(mockOpenBrowser).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli-core/src/commands/impersonate/impersonate.ts b/packages/cli-core/src/commands/impersonate/impersonate.ts index 100e6a99..bda78dc8 100644 --- a/packages/cli-core/src/commands/impersonate/impersonate.ts +++ b/packages/cli-core/src/commands/impersonate/impersonate.ts @@ -9,6 +9,7 @@ import { throwUserAbort, withApiContext, } from "../../lib/errors.ts"; +import { getDashboardUrl } from "../../lib/environment.ts"; import { log } from "../../lib/log.ts"; import { openBrowser } from "../../lib/open.ts"; import { confirm } from "../../lib/prompts.ts"; @@ -49,35 +50,55 @@ async function openOrWarn(url: string): Promise { } } +// The impersonation add-on is bought per account, not per app/instance, so this +// is deliberately the account-level billing page, not the per-instance dashboard path. +function impersonationBillingUrl(): string { + return `${getDashboardUrl().replace(/\/$/, "")}/settings/billing`; +} + 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.", { + return new BillingError("Impersonation is available as an add-on.", { reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, + docsUrl: impersonationBillingUrl(), }); } 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}.`, { + return new BillingError("You've reached your impersonation limit this billing period.", { reason: BILLING_ERROR_REASON.QUOTA_EXCEEDED, code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED, - limit, - used, + docsUrl: impersonationBillingUrl(), }); } return undefined; } +// The session was never created, so the caller still rethrows to exit non-zero +// after this — nudging only decides whether to actively open the billing page. +// Agent, --print, and non-TTY stay passive: the global handler already surfaces +// the URL, and we can't assume consent to open without a prompt or --yes. +async function nudgeToBilling(error: BillingError, options: ImpersonateOptions): Promise { + const url = error.docsUrl; + if (!url || isAgent() || options.print) return; + + if (options.yes) { + await openOrWarn(url); + return; + } + + if (!process.stdin.isTTY) return; + + const upgrade = await confirm({ message: "Add more impersonations now?", default: true }); + if (upgrade) { + await openOrWarn(url); + } +} + export async function impersonate(options: ImpersonateOptions = {}): Promise { const loginEmail = await requireLoginEmail(); @@ -123,6 +144,10 @@ export async function impersonate(options: ImpersonateOptions = {}): Promise { 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.", { + const err = new BillingError("Impersonation is available as an add-on.", { reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, }); @@ -170,22 +170,15 @@ describe("BillingError", () => { 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, - }, - ); + }); + + test("carries a docsUrl to the billing page for the quota-exceeded reason", () => { + const err = new BillingError("You've reached your impersonation limit this billing period.", { + reason: BILLING_ERROR_REASON.QUOTA_EXCEEDED, + code: ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED, + docsUrl: "https://dashboard.clerk.com/settings/billing", + }); expect(err.reason).toBe(BILLING_ERROR_REASON.QUOTA_EXCEEDED); - expect(err.limit).toBe(100); - expect(err.used).toBe(100); + expect(err.docsUrl).toBe("https://dashboard.clerk.com/settings/billing"); }); }); diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index c0d2561f..b2b86ea2 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -108,10 +108,6 @@ interface AuthErrorOptions extends Omit { 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; } /** @@ -188,14 +184,14 @@ export class AuthError extends CliError { * * 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. + * discriminates the two cases for programmatic consumers. Callers still supply + * a feature-specific `code` (e.g. {@link ERROR_CODE.IMPERSONATION_LIMIT_EXCEEDED}) + * and a human-readable message, and typically a `docsUrl` pointing to the + * relevant billing page. * * @example * ```ts - * throw new BillingError("Impersonation isn't enabled on this app's plan.", { + * throw new BillingError("Impersonation is available as an add-on.", { * reason: BILLING_ERROR_REASON.PLAN_NOT_ENABLED, * code: ERROR_CODE.IMPERSONATION_NOT_ENABLED, * }); @@ -203,16 +199,12 @@ export class AuthError extends CliError { */ 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; + const { reason, ...rest } = options; super(message, rest); this.name = "BillingError"; this.reason = reason; - this.limit = limit; - this.used = used; } }