diff --git a/.changeset/users-list.md b/.changeset/users-list.md new file mode 100644 index 00000000..c8b5da9c --- /dev/null +++ b/.changeset/users-list.md @@ -0,0 +1,10 @@ +--- +"clerk": minor +--- + +Add direct user-management commands to `clerk users`: + +- `clerk users list` with pagination, query search, repeatable identifier filters (`--email-address`, `--phone-number`, `--username`, `--user-id`, `--external-id`), `--order-by` over Clerk's common user ordering fields, and an application picker when invoked without a linked project, env var, or targeting flag. `--limit` defaults to 100 and accepts 1-250. `--json` (and agent mode) emits `{ data, hasMore }` so callers can paginate without a separate count call; the human-mode table footer surfaces the next `--offset` when more pages are available. The interactive user picker (used by `clerk users open` and other update flows) shows a "More results, refine your search" hint when matches overflow its window. +- `clerk users open [user-id]` for opening a user's Clerk dashboard page in the browser, with interactive pickers for the application and the user, plus `--print` for emitting the URL. + +Both commands appear in the interactive `clerk users` menu. diff --git a/README.md b/README.md index 050c52a2..21560a6f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Commands: whoami Show the current logged-in user open Open Clerk resources in your browser apps Manage your Clerk applications - users Manage Clerk users + users [options] Manage Clerk users env Manage environment variables config Manage instance configuration api [options] [endpoint] [filter] Make authenticated requests to the Clerk API @@ -49,4 +49,7 @@ Commands: skill Manage the bundled Clerk CLI agent skill update [options] Update the Clerk CLI to the latest version help [command] Display help for command + +Give AI agents better Clerk context: install the Clerk skills + $ clerk skill install ``` diff --git a/docs/superpowers/specs/2026-04-30-users-open-consistency-design.md b/docs/superpowers/specs/2026-04-30-users-open-consistency-design.md new file mode 100644 index 00000000..bcb55d55 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-users-open-consistency-design.md @@ -0,0 +1,162 @@ +# Clerk Users Open Consistency Design + +Date: 2026-04-30 +Status: Ready for review +Owner: CLI + +## Summary + +Make `clerk users open` consistent with `clerk users list` and `clerk users create` by aligning its targeting and auth semantics with the existing Backend API command family. + +This change fixes the current `users open` regressions without broadening the scope into a new interactive pagination system. + +## Goals + +- Make `users open` accept targeting flags with the same semantics as `users list` and `users create`. +- Fix the broken interactive picker so human-mode `clerk users open` can populate user choices again. +- Report the actual resolved instance label in human and agent output instead of hardcoding `development`. +- Keep the implementation narrow and local to `users open` and the existing picker. + +## Non-Goals + +- Adding lazy next-page or previous-page loading to the interactive picker. +- Making `clerk users list` auto-fetch all API pages. +- Extending `listage` with a generic remote-pagination abstraction. +- Redesigning the overall `users` command family beyond `open` consistency fixes. + +## Problem Statement + +`clerk users open` currently diverges from the rest of the `users` family in three ways: + +1. It uses a custom instance-context resolver that rejects `--secret-key` combined with `--app` or `--instance`, unlike `users list` and `users create`. +2. Its interactive picker still expects a raw array response from `/v1/users`, so it fails when Clerk returns `{ data, totalCount }`. +3. It hardcodes `development` in agent output and human logging even when the command opens a user in another instance. + +These inconsistencies create both functional regressions and confusing command semantics. + +## Decision Summary + +The approved contract is: + +- `users open` should follow the same auth-resolution rule as `users list` and `users create`. +- `--secret-key` remains supported for Backend API user lookup. +- `--app` and `--instance` remain the source of dashboard URL targeting. +- `--secret-key` alone remains insufficient for `users open`, because opening the dashboard also requires a resolvable app target. +- The interactive picker should be fixed to parse Clerk's paginated `/users` response shape, but should remain a single-request result set per search term for now. + +## Command Contract + +### Supported Invocations + +`clerk users open` should accept the same option set already used by `users list` and `users create`, with one command-specific constraint for dashboard URL construction. + +Supported resolved invocations: + +- linked project only +- `--app ` +- `--app --instance ` +- `--secret-key --app ` +- `--secret-key --app --instance ` + +### Resolution Rules + +For `users open`, resolution is split into two concerns: + +- Backend API auth: resolved through the existing `resolveBapiSecretKey()` helper, exactly like `users list` and `users create` +- Dashboard targeting: resolved from app context so the command can build a URL from `appId`, `instanceId`, and `instanceLabel` + +This yields the following behavior: + +- If `--secret-key` is present, it is used for Backend API requests. +- If `--secret-key` is absent, Backend API auth is derived the same way as the other commands: `--app`, `CLERK_SECRET_KEY`, or linked-project resolution. +- Dashboard targeting comes from explicit `--app` and `--instance` when provided, otherwise from linked-project or interactive app selection in human mode. + +### Unsupported Invocation + +`clerk users open --secret-key ` without any resolvable app context remains invalid. + +Reason: + +- a secret key is enough to talk to `/v1/users` +- it is not enough to build the dashboard URL, which requires an application target and instance target + +The usage error should explain that `users open` needs an app target to construct the dashboard URL. + +## Interactive Picker Behavior + +The picker used by `clerk users open` should remain search-based and should continue to fetch one result page per search term. + +Approved behavior: + +- keep the current `limit=20` request size +- keep one API request per search term +- parse both raw-array responses and `{ data, totalCount }` responses +- render the returned `data` rows as choices + +Explicitly deferred: + +- loading the next API page when the user arrows past the last loaded result +- loading the previous API page when the user arrows above the first loaded result +- aggregating all pages in memory + +## Output Rules + +`users open` should report the actual resolved target instance everywhere it surfaces command context. + +Requirements: + +- agent-mode JSON must emit the resolved `instanceLabel` +- human-mode status logging must print the resolved `instanceLabel` +- URL construction continues to use the resolved `appId` and `instanceId` + +This fixes the current mismatch where the command opens the correct URL while claiming the target was `development`. + +## Error Handling + +### Missing Dashboard Context + +When no app context can be resolved, `users open` should fail with the same class of linked-project or targeting errors already used elsewhere in the CLI. + +If `--secret-key` is present but no app can be resolved, the message should make the missing requirement explicit: the command still needs an app target to build the dashboard URL. + +### Picker Data Shape + +If `/v1/users` returns an unexpected body shape, the picker should degrade to no choices rather than crashing. This preserves current prompt behavior while fixing the normal `{ data, totalCount }` case. + +## Internal Architecture + +Keep the implementation local and narrow. + +Changes: + +- update `users open` to resolve Backend API auth through `resolveBapiSecretKey()` +- resolve dashboard targeting through app-context resolution instead of the current all-in-one users resolver path +- update the picker mapper to read paginated `/users` responses +- remove the hardcoded `development` label in `users open` + +Avoid: + +- changing `users list` behavior +- changing `listage` APIs +- adding a new generic pagination abstraction in this pass + +## Testing + +Required coverage: + +- `users open` accepts `--secret-key` combined with `--app` and optional `--instance` +- `users open --secret-key` alone still fails with an app-targeting usage error +- `users open` emits the resolved `instanceLabel` in agent mode +- `users open` logs the resolved `instanceLabel` in human mode +- `pickUser()` accepts `{ data, totalCount }` from `/v1/users` + +Tests that should remain unchanged: + +- `users list` manual pagination behavior +- any `listage` prompt behavior unrelated to the picker response shape + +## Rollout Notes + +This change intentionally fixes consistency and correctness without claiming support for interactive cross-page scrolling. + +Future pagination work, if needed, should be treated as a separate design and implementation effort because the current prompt stack does not support remote boundary pagination by configuration alone. diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index d7850168..3dc13520 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -11,12 +11,78 @@ test("registers users as a top-level command", () => { expect(users).toBeDefined(); }); -test("registers users create as a subcommand", () => { +test("registers users create and list as subcommands", () => { const program = createProgram(); const users = program.commands.find((command) => command.name() === "users")!; const names = users.commands.map((command) => command.name()); - expect(names).toContain("create"); + expect(names).toEqual(expect.arrayContaining(["create", "list"])); +}); + +test("users list exposes common filters and pagination options", () => { + const program = createProgram(); + const users = program.commands.find((command) => command.name() === "users")!; + const list = users.commands.find((command) => command.name() === "list")!; + const optionNames = list.options.map((option) => option.long); + + expect(optionNames).toEqual( + expect.arrayContaining([ + "--json", + "--limit", + "--offset", + "--query", + "--email-address", + "--phone-number", + "--username", + "--user-id", + "--external-id", + "--order-by", + "--secret-key", + "--app", + "--instance", + ]), + ); +}); + +describe("parseIntegerOption (via users list --limit / --offset)", () => { + function parseUsersList(args: readonly string[]) { + return createProgram().parseAsync(["users", "list", ...args], { from: "user" }); + } + + test.each([ + { + label: "--limit 0", + args: ["--limit", "0"], + expected: /Must be 1-250/, + }, + { + label: "--limit 251", + args: ["--limit", "251"], + expected: /Must be 1-250/, + }, + { + label: "--limit -5 (post-fix surfaces range message)", + args: ["--limit", "-5"], + expected: /Must be 1-250/, + }, + { + label: "--limit abc", + args: ["--limit", "abc"], + expected: /Must be an integer/, + }, + { + label: "--limit 1.5", + args: ["--limit", "1.5"], + expected: /Must be an integer/, + }, + { + label: "--offset -1", + args: ["--offset", "-1"], + expected: /Must be >= 0/, + }, + ])("rejects $label", async ({ args, expected }) => { + await expect(parseUsersList(args)).rejects.toThrow(expected); + }); }); test("users create exposes --json output, curated flags, and -d/--data for inline request bodies", () => { diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index e3975c36..d67346b6 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -47,6 +47,44 @@ import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { update } from "./commands/update/index.ts"; import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; +const USER_LIST_ORDER_BY_FIELDS = [ + "created_at", + "email_address", + "first_name", + "last_name", + "phone_number", + "username", + "last_sign_in_at", +] as const; + +const USER_LIST_ORDER_BY_CHOICES = USER_LIST_ORDER_BY_FIELDS.flatMap((field) => [ + field, + `+${field}`, + `-${field}`, +]); + +function collectOptionValues(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +function parseIntegerOption( + value: string, + flag: string, + { min, max }: { min: number; max?: number }, +): number { + if (!/^-?\d+$/.test(value)) { + throwUsageError(`Invalid ${flag} value "${value}". Must be an integer.`); + } + + const parsed = Number.parseInt(value, 10); + if (parsed < min || (typeof max === "number" && parsed > max)) { + const range = typeof max === "number" ? `${min}-${max}` : `>= ${min}`; + throwUsageError(`Invalid ${flag} value "${value}". Must be ${range}.`); + } + + return parsed; +} + export function createProgram() { const program = new Command() .name("clerk") @@ -265,6 +303,7 @@ Give AI agents better Clerk context: install the Clerk skills .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 users list", description: "List users" }, { command: "clerk users create --email alice@example.com --first-name Alice --yes", description: "Create a user from curated flags", @@ -274,7 +313,75 @@ Give AI agents better Clerk context: install the Clerk skills description: "Create a user from an inline BAPI request body", }, ]) - .action(usersHandlers.menu); + .action((_opts, cmd) => + usersHandlers.menu(cmd.optsWithGlobals() as Parameters[0]), + ); + + users + .command("list") + .description("List users") + .option("--json", "Output as JSON") + .option("--limit ", "Maximum users to return (1-250, default 100)", (value) => + parseIntegerOption(value, "--limit", { min: 1, max: 250 }), + ) + .option("--offset ", "Users to skip before returning results (0+)", (value) => + parseIntegerOption(value, "--offset", { min: 0 }), + ) + .option("--query ", "Search across common user fields") + .option( + "--email-address ", + "Filter by email address (repeat or comma-separate)", + collectOptionValues, + [], + ) + .option( + "--phone-number ", + "Filter by phone number (repeat or comma-separate)", + collectOptionValues, + [], + ) + .option( + "--username ", + "Filter by username (repeat or comma-separate)", + collectOptionValues, + [], + ) + .option( + "--user-id ", + "Filter by user ID (repeat or comma-separate)", + collectOptionValues, + [], + ) + .option( + "--external-id ", + "Filter by external ID (repeat or comma-separate)", + collectOptionValues, + [], + ) + .addOption( + createOption( + "--order-by ", + "Order by a supported field, optionally prefixed with + or -", + ).choices(USER_LIST_ORDER_BY_CHOICES), + ) + .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 users list", description: "List users with the default ordering" }, + { + command: "clerk users list --query alice --limit 20", + description: "Search across common user fields with pagination", + }, + { + command: + "clerk users list --email-address alice@example.com --external-id crm_123 --order-by -last_sign_in_at", + description: "Filter by common identifiers and sort by recent sign-in", + }, + ]) + .action((_opts, cmd) => + usersHandlers.list(cmd.optsWithGlobals() as Parameters[0]), + ); users .command("create") @@ -309,6 +416,36 @@ Give AI agents better Clerk context: install the Clerk skills usersHandlers.create(cmd.optsWithGlobals() as Parameters[0]), ); + users + .command("open") + .description("Open a user's dashboard page in your browser") + .addArgument(createArgument("[user-id]", "User ID to open. Omit to pick interactively.")) + .option("--print", "Print the URL without opening the browser") + .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 users open", description: "Pick app (if not linked) and user, then open" }, + { + command: "clerk users open user_2x9k", + description: "Open a specific user (pick app if not linked)", + }, + { + command: "clerk users open user_2x9k --app app_123", + description: "Open a specific user against an explicit app", + }, + { + command: "clerk users open user_2x9k --print", + description: "Print the dashboard URL instead of opening", + }, + ]) + .action((userId, _opts, cmd) => + usersHandlers.open({ + ...(cmd.optsWithGlobals() as Parameters[0]), + userId, + }), + ); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 11ee9d82..276edb1e 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -40,6 +40,44 @@ Two complementary mechanisms for JSON input work across the users command family ## Commands +### `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. + +```sh +clerk users list +clerk users list --json +clerk users list --query alice --limit 20 --offset 40 +clerk users list --email-address alice@example.com --phone-number +15551234567 +clerk users list --user-id user_123 --external-id crm_123 --order-by -last_sign_in_at +clerk users list --app app_123 --instance prod +``` + +Common list filters: + +- `--limit ` page size, 1-250, defaults to 100 +- `--offset ` rows to skip before returning results +- `--query ` +- `--email-address ` repeat or comma-separate values +- `--phone-number ` repeat or comma-separate values +- `--username ` repeat or comma-separate values +- `--user-id ` repeat or comma-separate values +- `--external-id ` repeat or comma-separate values +- `--order-by ` supports Clerk's common `getUserList()` order fields, with optional `+` or `-` + +`--json` output (and agent mode) wraps the page in an envelope so callers can paginate without a separate count call: + +```json +{ + "data": [ + /* user objects */ + ], + "hasMore": true +} +``` + +`hasMore` is computed by requesting one more row than the page size and reporting whether BAPI returned it. When `true`, advance with `--offset $((offset + limit))` to fetch the next page. Human-mode table output appends the same hint as a footer. + ### `clerk users create` Create a user from curated flags or a raw BAPI request body via `-d` or `--file`. By default, human mode prints a terse success message; pass `--json` for the response body. @@ -64,11 +102,29 @@ Supported curated flags: - `-d, --data ` - `--file ` +### `clerk users open` + +Open a user's dashboard page in your browser, or print the URL with `--print`. With no positional ``, prompts a search-as-you-type picker. Without a linked project (or matching app targeting), prompts the same application picker as `clerk users list`. + +```sh +clerk users open +clerk users open user_2x9k +clerk users open user_2x9k --app app_123 +clerk users open user_2x9k --instance prod +clerk users open user_2x9k --secret-key sk_test_123 --app app_123 +clerk users open user_2x9k --print +``` + +In agent mode the user-id is required (no interactive picker) and output is a JSON object with `url`, `appId`, `appName`, `instanceId`, `instanceLabel`, and `userId`. `--print` always wins and emits the plain URL on stdout. + +`--secret-key` chooses the Backend API key used for user lookup. `users open` still requires an app target to resolve the dashboard URL, either from `--app`, a linked project, or the human-mode app picker. Use `--instance` when you want something other than the default development instance. + ## API Endpoints -| Method | Endpoint | Command(s) | -| ------ | ----------- | ---------- | -| `POST` | `/v1/users` | `create` | +| Method | Endpoint | Command(s) | +| ------ | ----------- | ------------------------------------------- | +| `GET` | `/v1/users` | `list`, `open` (when picking interactively) | +| `POST` | `/v1/users` | `create` | ## Notes diff --git a/packages/cli-core/src/commands/users/index.ts b/packages/cli-core/src/commands/users/index.ts index 051760e0..a2c3ca1a 100644 --- a/packages/cli-core/src/commands/users/index.ts +++ b/packages/cli-core/src/commands/users/index.ts @@ -1,5 +1,7 @@ import { create } from "./create.ts"; +import { list } from "./list.ts"; import { usersMenu } from "./menu.ts"; +import { open } from "./open.ts"; export type { UsersActionTargeting, UsersAction } from "./registry.ts"; export { @@ -10,5 +12,7 @@ export { export const users = { create, + list, menu: usersMenu, + open, }; 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 6c2de76b..0f61dd4d 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 @@ -1,7 +1,9 @@ -import { test, expect, describe, beforeEach, mock } from "bun:test"; +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; import { CliError, ERROR_CODE } from "../../../lib/errors.ts"; +import { stubFetch } from "../../../test/lib/stubs.ts"; const mockResolveAppContext = mock(); +const mockResolveProfile = mock(); const mockFetchApplication = mock(); const mockFetchAppsTolerantly = mock(); const mockPickOrCreateApp = mock(); @@ -9,6 +11,7 @@ const mockIsHuman = mock(() => true); mock.module("../../../lib/config.ts", () => ({ resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), resolveFetchedApplicationInstance: ( _appId: string, app: { @@ -50,17 +53,26 @@ mock.module("../../../mode.ts", () => ({ const { resolveUsersInstanceContext } = await import("./instance-context.ts"); describe("resolveUsersInstanceContext", () => { + const originalFetch = globalThis.fetch; + beforeEach(() => { mockResolveAppContext.mockReset(); + mockResolveProfile.mockReset(); mockFetchApplication.mockReset(); mockFetchAppsTolerantly.mockReset(); mockPickOrCreateApp.mockReset(); mockIsHuman.mockReturnValue(true); + mockResolveProfile.mockResolvedValue(undefined); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; }); - test("returns publishable key when --app is provided", async () => { + test("returns app and instance labels when --app is provided", async () => { mockFetchApplication.mockResolvedValue({ application_id: "app_123", + name: "My App", instances: [ { instance_id: "ins_dev", @@ -74,7 +86,9 @@ describe("resolveUsersInstanceContext", () => { const ctx = await resolveUsersInstanceContext({ app: "app_123" }); expect(ctx.secretKey).toBe("sk_test_xyz"); expect(ctx.appId).toBe("app_123"); + expect(ctx.appLabel).toBe("My App"); expect(ctx.instanceId).toBe("ins_dev"); + expect(ctx.instanceLabel).toBe("development"); expect(ctx.publishableKey).toBe("pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA"); expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); }); @@ -94,6 +108,7 @@ describe("resolveUsersInstanceContext", () => { mockPickOrCreateApp.mockResolvedValue({ application_id: "app_picked", name: "Picked" }); mockFetchApplication.mockResolvedValue({ application_id: "app_picked", + name: "Picked", instances: [ { instance_id: "ins_dev", @@ -108,7 +123,9 @@ describe("resolveUsersInstanceContext", () => { expect(mockPickOrCreateApp).toHaveBeenCalled(); expect(ctx.secretKey).toBe("sk_test_picked"); expect(ctx.appId).toBe("app_picked"); + expect(ctx.appLabel).toBe("Picked"); expect(ctx.instanceId).toBe("ins_dev"); + expect(ctx.instanceLabel).toBe("development"); expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); }); @@ -122,17 +139,45 @@ describe("resolveUsersInstanceContext", () => { expect(mockPickOrCreateApp).not.toHaveBeenCalled(); }); - test("rejects --secret-key combined with --app", async () => { - await expect( - resolveUsersInstanceContext({ secretKey: "sk_test_raw", app: "app_123" }), - ).rejects.toThrow(/--secret-key cannot be combined with --app or --instance/); + test("resolves the current instance from BAPI when --secret-key and --app are provided", async () => { + stubFetch( + async () => + new Response( + JSON.stringify({ + id: "ins_dev", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + }), + { status: 200 }, + ), + ); + + const ctx = await resolveUsersInstanceContext({ secretKey: "sk_test_raw", app: "app_123" }); + + expect(ctx.secretKey).toBe("sk_test_raw"); + expect(ctx.appId).toBe("app_123"); + expect(ctx.appLabel).toBe("app_123"); + expect(ctx.instanceId).toBe("ins_dev"); + expect(ctx.instanceLabel).toBe("development"); + expect(ctx.publishableKey).toBe("pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA"); + expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); expect(mockFetchApplication).not.toHaveBeenCalled(); + expect(mockResolveAppContext).not.toHaveBeenCalled(); }); - test("rejects --secret-key combined with --instance", async () => { + test("rejects a mismatched --instance when --secret-key already targets another instance", async () => { + stubFetch( + async () => + new Response( + JSON.stringify({ + id: "ins_dev", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + }), + { status: 200 }, + ), + ); + await expect( - resolveUsersInstanceContext({ secretKey: "sk_test_raw", instance: "ins_dev" }), - ).rejects.toThrow(/--secret-key cannot be combined with --app or --instance/); - expect(mockResolveAppContext).not.toHaveBeenCalled(); + resolveUsersInstanceContext({ secretKey: "sk_test_raw", instance: "prod" }), + ).rejects.toThrow(/does not match the supplied --secret-key/); }); }); 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 a233dbe0..5e9bcf41 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -1,14 +1,28 @@ import { fetchAppsTolerantly, pickOrCreateApp } from "../../../lib/app-picker.ts"; -import { resolveAppContext, resolveFetchedApplicationInstance } from "../../../lib/config.ts"; -import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../../lib/errors.ts"; +import { + resolveAppContext, + resolveFetchedApplicationInstance, + resolveProfile, +} from "../../../lib/config.ts"; +import { + BapiError, + CliError, + ERROR_CODE, + throwUsageError, + withApiContext, +} from "../../../lib/errors.ts"; +import { getBapiBaseUrl } from "../../../lib/environment.ts"; import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { loggedFetch } from "../../../lib/fetch.ts"; import { fetchApplication, validateKeyPrefix } from "../../../lib/plapi.ts"; import { isHuman } from "../../../mode.ts"; export type UsersInstanceContext = { secretKey: string; appId?: string; + appLabel?: string; instanceId?: string; + instanceLabel?: string; publishableKey?: string; fapiHost?: string; }; @@ -19,17 +33,108 @@ export type ResolveUsersInstanceContextOptions = { secretKey?: string; }; +type CurrentBapiInstance = { + id: string; + publishableKey?: string; + instanceLabel: string; + fapiHost?: string; +}; + +async function fetchCurrentBapiInstance(secretKey: string): Promise { + const url = new URL("/v1/instance", getBapiBaseUrl()); + const response = await loggedFetch(url, { + tag: "bapi", + method: "GET", + headers: { + Authorization: `Bearer ${secretKey}`, + Accept: "application/json", + }, + }); + + const rawBody = await response.text(); + if (!response.ok) { + throw new BapiError(response.status, rawBody, response.headers); + } + + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + throw new CliError("BAPI returned non-JSON response from /v1/instance."); + } + + const instance = body as { id?: unknown; publishable_key?: unknown }; + if (typeof instance.id !== "string" || instance.id.length === 0) { + throw new CliError("BAPI /v1/instance response did not include an instance id."); + } + + let instanceLabel = secretKey.startsWith("sk_live_") ? "production" : "development"; + let publishableKey: string | undefined; + let fapiHost: string | undefined; + if (typeof instance.publishable_key === "string" && instance.publishable_key.length > 0) { + publishableKey = instance.publishable_key; + try { + const decoded = decodePublishableKey(instance.publishable_key); + instanceLabel = decoded.instanceType; + fapiHost = decoded.fapiHost; + } catch { + // Fall back to the secret-key prefix when the publishable key is malformed. + } + } + + return { + id: instance.id, + publishableKey, + instanceLabel, + fapiHost, + }; +} + +async function resolveExplicitAppLabel(appId: string): Promise { + const resolved = await resolveProfile(process.cwd()); + if (resolved?.profile.appId === appId) { + return resolved.profile.appName || appId; + } + return appId; +} + +function validateSecretKeyInstanceTarget( + instanceFlag: string | undefined, + current: CurrentBapiInstance, +): void { + if (!instanceFlag) return; + + if (instanceFlag === current.id || instanceFlag === current.instanceLabel) { + return; + } + + throwUsageError( + `--instance ${instanceFlag} does not match the supplied --secret-key target (${current.instanceLabel}, ${current.id}).`, + ); +} + export async function resolveUsersInstanceContext( options: ResolveUsersInstanceContextOptions, ): Promise { if (options.secretKey) { - if (options.app || options.instance) { - throwUsageError( - "--secret-key cannot be combined with --app or --instance. The secret key already targets a specific instance.", - ); - } validateKeyPrefix(options.secretKey, "sk_"); - return { secretKey: options.secretKey }; + if (!options.app && !options.instance) { + return { secretKey: options.secretKey }; + } + + const current = await fetchCurrentBapiInstance(options.secretKey); + validateSecretKeyInstanceTarget(options.instance, current); + + const ctx: UsersInstanceContext = { + secretKey: options.secretKey, + appId: options.app, + appLabel: options.app ? await resolveExplicitAppLabel(options.app) : undefined, + instanceId: current.id, + instanceLabel: current.instanceLabel, + publishableKey: current.publishableKey, + fapiHost: current.fapiHost, + }; + return ctx; } let appId: string | undefined = options.app; @@ -74,7 +179,9 @@ export async function resolveUsersInstanceContext( const ctx: UsersInstanceContext = { secretKey: instance.secret_key, appId, + appLabel: app.name || appId, instanceId: resolved.instanceId, + instanceLabel: resolved.instanceLabel, }; if (instance.publishable_key) { ctx.publishableKey = instance.publishable_key; diff --git a/packages/cli-core/src/commands/users/interactive/pick-user.test.ts b/packages/cli-core/src/commands/users/interactive/pick-user.test.ts index 7a374685..9d9ebfa5 100644 --- a/packages/cli-core/src/commands/users/interactive/pick-user.test.ts +++ b/packages/cli-core/src/commands/users/interactive/pick-user.test.ts @@ -20,7 +20,7 @@ describe("pickUser", () => { mockSearch.mockReset(); }); - test("calls bapiRequest with /users?query=...&limit=20 when source is invoked", async () => { + test("calls bapiRequest with /users?query=...&limit=21 when source is invoked", async () => { let capturedSource: | ((term: string | undefined, opt: { signal: AbortSignal }) => Promise) | undefined; @@ -44,9 +44,40 @@ describe("pickUser", () => { path: expect.stringContaining("/users?query=ali"), secretKey: "sk_test_xyz", }); + const requestPath = (mockBapiRequest.mock.calls[0]?.[0] as { path: string } | undefined)?.path; + expect(requestPath).toContain("limit=21"); expect(choices).toHaveLength(1); expect((choices[0] as { value: string }).value).toBe("user_1"); }); + + test("appends a refine-search separator when results overflow the picker limit", async () => { + let capturedSource: + | ((term: string | undefined, opt: { signal: AbortSignal }) => Promise) + | undefined; + mockSearch.mockImplementation(async (config: { source: typeof capturedSource }) => { + capturedSource = config.source; + return "user_picked"; + }); + const overflow = Array.from({ length: 21 }, (_, i) => ({ + id: `user_${i}`, + first_name: `User${i}`, + email_addresses: [{ email_address: `u${i}@example.com` }], + })); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: overflow, + rawBody: JSON.stringify(overflow), + }); + + await pickUser({ secretKey: "sk_test_xyz" }); + + const choices = await capturedSource!(undefined, { signal: new AbortController().signal }); + // 20 user choices plus the trailing separator hint. + expect(choices).toHaveLength(21); + expect((choices[19] as { value: string }).value).toBe("user_19"); + expect((choices[20] as { value?: string }).value).toBeUndefined(); + }); }); describe("formatUserChoice", () => { 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 0205ab19..1e4329ec 100644 --- a/packages/cli-core/src/commands/users/interactive/pick-user.ts +++ b/packages/cli-core/src/commands/users/interactive/pick-user.ts @@ -1,4 +1,4 @@ -import { search } from "../../../lib/listage.ts"; +import { search, Separator } from "../../../lib/listage.ts"; import { bapiRequest } from "../../api/bapi.ts"; export type PickUserOptions = { @@ -6,6 +6,8 @@ export type PickUserOptions = { message?: string; }; +const PICKER_LIMIT = 20; + type UserSummary = { id: string; first_name?: string | null; @@ -30,18 +32,30 @@ export async function pickUser(options: PickUserOptions): Promise { return search({ message: options.message ?? "Pick a user:", source: async (term) => { - const query = term ? `?query=${encodeURIComponent(term)}&limit=20` : "?limit=20"; + // 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${query}`, + path: `/users?${params}`, secretKey: options.secretKey, }); - const body = response.body as UserSummary[] | undefined; - if (!Array.isArray(body)) return []; - return body.map((user) => ({ + const body = response.body; + const allUsers = Array.isArray(body) ? (body as UserSummary[]) : []; + const hasMore = allUsers.length > PICKER_LIMIT; + const users = hasMore ? allUsers.slice(0, PICKER_LIMIT) : allUsers; + + const choices: Array<{ value: string; name: string } | Separator> = users.map((user) => ({ value: user.id, name: formatUserChoice(user), })); + + if (hasMore) { + choices.push(new Separator("More results available, type to refine your search")); + } + + return choices; }, }); } diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts new file mode 100644 index 00000000..a104e91a --- /dev/null +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -0,0 +1,314 @@ +import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { popPrefix, pushPrefix } from "../../lib/log.ts"; +import { captureLog } from "../../test/lib/stubs.ts"; + +const mockBapiRequest = mock(); +mock.module("../../commands/api/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const mockResolveBapiSecretKey = mock(); +mock.module("../../lib/bapi-command.ts", () => ({ + resolveBapiSecretKey: (...args: unknown[]) => mockResolveBapiSecretKey(...args), +})); + +const mockResolveUsersInstanceContext = mock(); +mock.module("./interactive/instance-context.ts", () => ({ + resolveUsersInstanceContext: (...args: unknown[]) => mockResolveUsersInstanceContext(...args), +})); + +const mockWithSpinner = mock((_msg: string, fn: () => Promise) => fn()); +mock.module("../../lib/spinner.ts", () => ({ + withSpinner: (...args: Parameters) => mockWithSpinner(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { list } = await import("./list.ts"); + +const mockUsers = [ + { + id: "user_123", + first_name: "Alice", + last_name: "Example", + username: "alice", + primary_email_address_id: "idn_1", + email_addresses: [ + { + id: "idn_1", + email_address: "alice@example.com", + }, + ], + }, + { + id: "user_456", + username: "bob", + phone_numbers: [ + { + id: "phn_1", + phone_number: "+15551234567", + }, + ], + }, +]; + +describe("users list", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + let captured: ReturnType; + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + mockResolveBapiSecretKey.mockResolvedValue("sk_test_123"); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: mockUsers, + rawBody: JSON.stringify(mockUsers), + }); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + captured = captureLog(); + }); + + afterEach(() => { + captured.teardown(); + mockBapiRequest.mockReset(); + mockResolveBapiSecretKey.mockReset(); + mockResolveUsersInstanceContext.mockReset(); + mockWithSpinner.mockReset(); + mockWithSpinner.mockImplementation((_msg: string, fn: () => Promise) => fn()); + mockIsAgent.mockReset(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function runList(options: Parameters[0] = {}) { + return captured.run(() => list(options)); + } + + test("forwards targeting options when resolving the secret key", async () => { + await runList({ json: true, secretKey: "sk_test_override", app: "app_123", instance: "prod" }); + + expect(mockResolveBapiSecretKey).toHaveBeenCalledWith({ + secretKey: "sk_test_override", + app: "app_123", + instance: "prod", + }); + // Default page size 100; request limit is +1 to detect hasMore. + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "GET", + path: "/users?limit=101", + secretKey: "sk_test_123", + }); + }); + + test("wraps the network read in the standard users list spinner", async () => { + await runList(); + + expect(mockWithSpinner).toHaveBeenCalledWith("Fetching users...", expect.any(Function)); + }); + + test("serializes common filters and pagination into query params", async () => { + await runList({ + query: "alice", + emailAddress: ["alice@example.com", "admin@example.com"], + phoneNumber: ["+15551234567"], + username: ["alice-user"], + userId: ["user_123", "user_456"], + externalId: ["ext_123"], + orderBy: "-last_sign_in_at", + limit: 25, + offset: 50, + }); + + const request = mockBapiRequest.mock.calls[0]?.[0] as { path: string } | undefined; + expect(request).toBeDefined(); + + const url = new URL(request!.path, "https://api.clerk.test"); + expect(url.pathname).toBe("/users"); + expect(url.searchParams.get("query")).toBe("alice"); + expect(url.searchParams.getAll("email_address")).toEqual([ + "alice@example.com", + "admin@example.com", + ]); + expect(url.searchParams.getAll("phone_number")).toEqual(["+15551234567"]); + expect(url.searchParams.getAll("username")).toEqual(["alice-user"]); + expect(url.searchParams.getAll("user_id")).toEqual(["user_123", "user_456"]); + expect(url.searchParams.getAll("external_id")).toEqual(["ext_123"]); + expect(url.searchParams.get("order_by")).toBe("-last_sign_in_at"); + expect(url.searchParams.get("limit")).toBe("26"); + expect(url.searchParams.get("offset")).toBe("50"); + }); + + test("prints a concise human-readable table by default", async () => { + await runList(); + + expect(captured.out).toContain("Alice Example"); + expect(captured.out).toContain("alice@example.com"); + expect(captured.out).toContain("user_123"); + expect(captured.out).toContain("bob"); + expect(captured.out).toContain("+15551234567"); + expect(captured.err).toContain("2 users returned"); + }); + + test("prints a helpful message when no users are returned", async () => { + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [], + rawBody: "[]", + }); + + await runList(); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("No users found"); + }); + + test("outputs JSON when requested", async () => { + await runList({ json: true }); + + expect(JSON.parse(captured.out)).toEqual({ data: mockUsers, hasMore: false }); + expect(captured.err).toBe(""); + }); + + test("outputs JSON in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await runList(); + + expect(JSON.parse(captured.out)).toEqual({ data: mockUsers, hasMore: false }); + }); + + test("flags hasMore=true when BAPI returns one more row than the page size", async () => { + const overflowUsers = Array.from({ length: 4 }, (_, i) => ({ id: `user_${i}` })); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: overflowUsers, + rawBody: JSON.stringify(overflowUsers), + }); + + await runList({ json: true, limit: 3 }); + + const parsed = JSON.parse(captured.out) as { data: unknown[]; hasMore: boolean }; + expect(parsed.hasMore).toBe(true); + expect(parsed.data).toHaveLength(3); + expect((parsed.data[0] as { id: string }).id).toBe("user_0"); + }); + + test("flags hasMore=false when BAPI returns fewer than limit+1 rows", async () => { + const underflowUsers = [{ id: "user_0" }, { id: "user_1" }]; + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: underflowUsers, + rawBody: JSON.stringify(underflowUsers), + }); + + await runList({ json: true, limit: 3 }); + + const parsed = JSON.parse(captured.out) as { data: unknown[]; hasMore: boolean }; + expect(parsed.hasMore).toBe(false); + expect(parsed.data).toHaveLength(2); + }); + + test("table footer hints at the next --offset when more results are available", async () => { + const overflowUsers = Array.from({ length: 4 }, (_, i) => ({ + id: `user_${i}`, + first_name: `User${i}`, + })); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: overflowUsers, + rawBody: JSON.stringify(overflowUsers), + }); + + await runList({ limit: 3 }); + + expect(captured.err).toContain("3 users returned"); + expect(captured.err).toContain("more available"); + expect(captured.err).toContain("--offset 3"); + }); + + test("offsets the next-page hint by the supplied --offset", async () => { + const overflowUsers = Array.from({ length: 4 }, (_, i) => ({ + id: `user_${i}`, + first_name: `User${i}`, + })); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: overflowUsers, + rawBody: JSON.stringify(overflowUsers), + }); + + await runList({ limit: 3, offset: 9 }); + + expect(captured.err).toContain("--offset 12"); + }); + + test("falls back to the shared picker-aware resolver in human mode when no credentials resolve", async () => { + mockResolveBapiSecretKey.mockRejectedValue( + new CliError("No secret key found.", { code: ERROR_CODE.NO_SECRET_KEY }), + ); + mockResolveUsersInstanceContext.mockResolvedValue({ secretKey: "sk_test_picked" }); + + await runList(); + + expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({}); + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "GET", + path: "/users?limit=101", + secretKey: "sk_test_picked", + }); + }); + + test("routes the table to stderr (under the gutter) when invoked inside an intro/outro block", async () => { + pushPrefix(); + try { + await runList(); + } finally { + popPrefix(); + } + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Alice Example"); + expect(captured.err).toContain("user_123"); + expect(captured.err).toContain("alice@example.com"); + expect(captured.err).toContain("2 users returned"); + }); + + test("re-throws the original NO_SECRET_KEY error in agent mode without invoking the picker", async () => { + mockIsAgent.mockReturnValue(true); + const original = new CliError("No secret key found.", { code: ERROR_CODE.NO_SECRET_KEY }); + mockResolveBapiSecretKey.mockRejectedValue(original); + + await expect(runList()).rejects.toBe(original); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + }); + + test.each([ + { label: "--app", options: { app: "app_123" } }, + { label: "--instance", options: { instance: "prod" } }, + { label: "--secret-key", options: { secretKey: "sk_test_explicit" } }, + ])( + "re-throws NO_SECRET_KEY without invoking the picker when $label is set", + async ({ options }) => { + const original = new CliError("No secret key found.", { code: ERROR_CODE.NO_SECRET_KEY }); + mockResolveBapiSecretKey.mockRejectedValue(original); + + await expect(runList(options)).rejects.toBe(original); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts new file mode 100644 index 00000000..d200bc17 --- /dev/null +++ b/packages/cli-core/src/commands/users/list.ts @@ -0,0 +1,214 @@ +import { resolveBapiSecretKey } from "../../lib/bapi-command.ts"; +import { dim, cyan } from "../../lib/color.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; +import { isAgent, isHuman } from "../../mode.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { bapiRequest } from "../api/bapi.ts"; +import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; +import { registerUsersAction } from "./registry.ts"; + +type UsersListOptions = { + json?: boolean; + secretKey?: string; + app?: string; + instance?: string; + limit?: number; + offset?: number; + query?: string; + emailAddress?: string[]; + phoneNumber?: string[]; + username?: string[]; + userId?: string[]; + externalId?: string[]; + orderBy?: string; +}; + +type UserIdentifier = { id?: string; email_address?: string; phone_number?: string }; + +type BapiUser = { + id: string; + first_name?: string | null; + last_name?: string | null; + username?: string | null; + primary_email_address_id?: string | null; + primary_phone_number_id?: string | null; + email_addresses?: UserIdentifier[]; + phone_numbers?: UserIdentifier[]; +}; + +const COLUMN_PADDING = 2; +const DEFAULT_LIMIT = 100; + +function printJson(data: unknown, options: UsersListOptions = {}): boolean { + if (!options.json && !isAgent()) return false; + log.data(JSON.stringify(data, null, 2)); + return true; +} + +function appendMultiValueParam( + searchParams: URLSearchParams, + key: string, + values?: string[], +): void { + if (!values?.length) return; + + for (const value of values) { + for (const part of value.split(",")) { + const trimmed = part.trim(); + if (trimmed) searchParams.append(key, trimmed); + } + } +} + +function buildUsersListPath(options: UsersListOptions, requestLimit: number): string { + const searchParams = new URLSearchParams(); + + searchParams.set("limit", String(requestLimit)); + if (typeof options.offset === "number") { + searchParams.set("offset", String(options.offset)); + } + if (options.query?.trim()) { + searchParams.set("query", options.query.trim()); + } + if (options.orderBy?.trim()) { + searchParams.set("order_by", options.orderBy.trim()); + } + + appendMultiValueParam(searchParams, "email_address", options.emailAddress); + appendMultiValueParam(searchParams, "phone_number", options.phoneNumber); + appendMultiValueParam(searchParams, "username", options.username); + appendMultiValueParam(searchParams, "user_id", options.userId); + appendMultiValueParam(searchParams, "external_id", options.externalId); + + const query = searchParams.toString(); + return query ? `/users?${query}` : "/users"; +} + +function userDisplayName(user: BapiUser): string { + const fullName = [user.first_name, user.last_name].filter(Boolean).join(" ").trim(); + return fullName || user.username || primaryIdentifier(user) || user.id; +} + +function primaryIdentifier(user: BapiUser): string { + const primaryEmail = user.email_addresses?.find( + (email) => email.id && email.id === user.primary_email_address_id, + ); + if (primaryEmail?.email_address) return primaryEmail.email_address; + + const firstEmail = user.email_addresses?.find((email) => email.email_address); + if (firstEmail?.email_address) return firstEmail.email_address; + + const primaryPhone = user.phone_numbers?.find( + (phone) => phone.id && phone.id === user.primary_phone_number_id, + ); + if (primaryPhone?.phone_number) return primaryPhone.phone_number; + + const firstPhone = user.phone_numbers?.find((phone) => phone.phone_number); + if (firstPhone?.phone_number) return firstPhone.phone_number; + + if (user.username) return user.username; + + return user.id; +} + +function formatUsersTable(users: BapiUser[]): void { + const nameWidth = + Math.max("NAME".length, ...users.map((user) => userDisplayName(user).length)) + COLUMN_PADDING; + const idWidth = + Math.max("USER ID".length, ...users.map((user) => user.id.length)) + COLUMN_PADDING; + + // Inside an intro/outro block, route rows to stderr so the gutter prefix is + // applied. Direct invocations still get the table on stdout for piping. + const emit = isInsideGutter() + ? (line: string) => log.info(line) + : (line: string) => log.data(line); + + emit( + `${dim("NAME".padEnd(nameWidth))}${dim("USER ID".padEnd(idWidth))}${dim("PRIMARY IDENTIFIER")}`, + ); + + for (const user of users) { + const name = cyan(userDisplayName(user).padEnd(nameWidth)); + const id = dim(user.id.padEnd(idWidth)); + emit(`${name}${id}${primaryIdentifier(user)}`); + } +} + +async function resolveListSecretKey(options: UsersListOptions): Promise { + try { + return await resolveBapiSecretKey({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + } catch (error) { + // Mirror `users create`: when there is no link, no env var, and no + // targeting flags, fall back to the shared picker-aware resolver in human + // mode so the user can choose an application interactively. With an + // explicit target the user already chose where to operate; surface the + // original error instead of silently switching applications. + const hasExplicitTarget = + Boolean(options.secretKey) || + Boolean(options.app) || + Boolean(options.instance) || + Boolean(process.env.CLERK_SECRET_KEY); + + if ( + isHuman() && + !hasExplicitTarget && + error instanceof CliError && + error.code === ERROR_CODE.NO_SECRET_KEY + ) { + const ctx = await resolveUsersInstanceContext({}); + return ctx.secretKey; + } + throw error; + } +} + +export async function list(options: UsersListOptions = {}): Promise { + const secretKey = await resolveListSecretKey(options); + const pageSize = options.limit ?? DEFAULT_LIMIT; + const offset = options.offset ?? 0; + // Request one extra row so we can detect whether more pages exist without + // a separate /users/count round-trip. The CLI's --limit caps at 250, so + // pageSize + 1 always fits under BAPI's MaxLimit of 500. + const response = await withSpinner("Fetching users...", () => + bapiRequest({ + method: "GET", + path: buildUsersListPath(options, pageSize + 1), + secretKey, + }), + ); + + const body = response.body; + const allUsers = Array.isArray(body) ? (body as BapiUser[]) : []; + const hasMore = allUsers.length > pageSize; + const users = hasMore ? allUsers.slice(0, pageSize) : allUsers; + + if (printJson({ data: users, hasMore }, options)) return; + + if (users.length === 0) { + log.warn("No users found."); + return; + } + + formatUsersTable(users); + const summary = `\n${users.length} user${users.length === 1 ? "" : "s"} returned`; + if (hasMore) { + const nextOffset = offset + pageSize; + log.info(`${summary} (more available, re-run with \`--offset ${nextOffset}\`)`); + } else { + log.info(summary); + } +} + +registerUsersAction({ + key: "list", + label: "List users", + description: "List users with filters and pagination", + handler: async (targeting) => { + await list(targeting); + }, +}); diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts new file mode 100644 index 00000000..b7bd1171 --- /dev/null +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -0,0 +1,271 @@ +import { test, expect, describe, afterEach, beforeEach, mock } from "bun:test"; +import { setMode } from "../../mode.ts"; +import { setCurrentEnv } from "../../lib/environment.ts"; +import { captureLog } from "../../test/lib/stubs.ts"; + +const mockResolveAppContext = mock(); +const mockResolveProfile = mock(); +const mockResolveInstanceId = mock(); +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), + resolveInstanceId: (...args: unknown[]) => mockResolveInstanceId(...args), +})); + +const mockResolveUsersInstanceContext = mock(); +mock.module("./interactive/instance-context.ts", () => ({ + resolveUsersInstanceContext: (...args: unknown[]) => mockResolveUsersInstanceContext(...args), +})); + +const mockPickUser = mock(); +mock.module("./interactive/pick-user.ts", () => ({ + pickUser: (...args: unknown[]) => mockPickUser(...args), +})); + +const mockOpenBrowser = mock(); +mock.module("../../lib/open.ts", () => ({ + openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), +})); + +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + withSpinner: (_msg: string, fn: () => Promise) => fn(), +})); + +const { open } = await import("./open.ts"); + +const CTX = { + secretKey: "sk_test_123", + appId: "app_abc123", + appLabel: "My App", + instanceId: "ins_prod789", + instanceLabel: "production", +}; + +describe("users open", () => { + let captured: ReturnType; + + beforeEach(() => { + setMode("human"); + setCurrentEnv("production"); + mockResolveAppContext.mockResolvedValue({ + appId: CTX.appId, + appLabel: CTX.appLabel, + instanceId: CTX.instanceId, + instanceLabel: CTX.instanceLabel, + }); + mockResolveProfile.mockResolvedValue(undefined); + mockResolveInstanceId.mockReturnValue({ + id: CTX.instanceId, + label: CTX.instanceLabel, + }); + mockResolveUsersInstanceContext.mockResolvedValue(CTX); + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + captured = captureLog(); + }); + + afterEach(() => { + captured.teardown(); + mockResolveAppContext.mockReset(); + mockResolveProfile.mockReset(); + mockResolveInstanceId.mockReset(); + mockResolveUsersInstanceContext.mockReset(); + mockPickUser.mockReset(); + mockOpenBrowser.mockReset(); + }); + + test("explicit user-id + linked profile: opens dashboard URL for that user", async () => { + await captured.run(() => open({ userId: "user_2x9k" })); + + expect(mockResolveAppContext).toHaveBeenCalledWith({ + instance: undefined, + }); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + expect(mockPickUser).not.toHaveBeenCalled(); + expect(mockOpenBrowser).toHaveBeenCalledWith( + "https://dashboard.clerk.com/apps/app_abc123/instances/ins_prod789/users/user_2x9k", + ); + expect(captured.err).toContain("Opening"); + expect(captured.err).toContain("users/user_2x9k"); + expect(captured.err).toContain("My App"); + expect(captured.err).toContain("(production)"); + }); + + test("--print: plain URL only on stdout, no browser, no intro/outro", async () => { + await captured.run(() => open({ userId: "user_2x9k", print: true })); + + expect(captured.out).toBe( + "https://dashboard.clerk.com/apps/app_abc123/instances/ins_prod789/users/user_2x9k", + ); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("agent mode without --print: emits structured JSON, no browser", async () => { + setMode("agent"); + + await captured.run(() => open({ userId: "user_2x9k" })); + + const payload = JSON.parse(captured.out); + expect(payload).toEqual({ + url: "https://dashboard.clerk.com/apps/app_abc123/instances/ins_prod789/users/user_2x9k", + appId: "app_abc123", + appName: "My App", + instanceId: "ins_prod789", + instanceLabel: "production", + userId: "user_2x9k", + }); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("agent mode with --print still wins: URL only, no JSON", async () => { + setMode("agent"); + + await captured.run(() => open({ userId: "user_2x9k", print: true })); + + expect(captured.out).toBe( + "https://dashboard.clerk.com/apps/app_abc123/instances/ins_prod789/users/user_2x9k", + ); + expect(() => JSON.parse(captured.out)).toThrow(); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("--secret-key alone: uses linked app context and direct BAPI auth", async () => { + await captured.run(() => + open({ secretKey: "sk_test_loose", userId: "user_2x9k", print: true }), + ); + + expect(mockResolveAppContext).toHaveBeenCalledWith({ + instance: undefined, + }); + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + expect(captured.out).toBe( + "https://dashboard.clerk.com/apps/app_abc123/instances/ins_prod789/users/user_2x9k", + ); + }); + + test("--secret-key without a resolvable dashboard target: throws usage error", async () => { + setMode("agent"); + const { CliError, ERROR_CODE } = await import("../../lib/errors.ts"); + mockResolveAppContext.mockRejectedValueOnce( + new CliError("Not linked.", { + code: ERROR_CODE.NOT_LINKED, + }), + ); + mockResolveUsersInstanceContext + .mockRejectedValueOnce( + new CliError("Not linked.", { + code: ERROR_CODE.NOT_LINKED, + }), + ) + .mockResolvedValueOnce({ + secretKey: "sk_test_loose", + }); + + await expect( + captured.run(() => open({ secretKey: "sk_test_loose", userId: "user_2x9k" })), + ).rejects.toThrow(/dashboard URL|--app/); + expect(mockResolveUsersInstanceContext).toHaveBeenCalledTimes(2); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("no user-id + human mode: invokes pickUser and uses returned id", async () => { + mockPickUser.mockResolvedValue("user_picked"); + + await captured.run(() => open({ print: true })); + + expect(mockPickUser).toHaveBeenCalledWith({ + secretKey: CTX.secretKey, + message: "Pick a user to open in the dashboard:", + }); + expect(captured.out).toBe( + "https://dashboard.clerk.com/apps/app_abc123/instances/ins_prod789/users/user_picked", + ); + }); + + test("no user-id + agent mode: throws usage error, does not invoke pickUser", async () => { + setMode("agent"); + + await expect(captured.run(() => open({}))).rejects.toThrow(/User ID is required/); + expect(mockPickUser).not.toHaveBeenCalled(); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("forwards --app and --instance to the resolver", async () => { + mockResolveProfile.mockResolvedValue(undefined); + + await captured.run(() => + open({ userId: "user_2x9k", app: "app_other", instance: "prod", print: true }), + ); + + expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ + app: "app_other", + instance: "prod", + }); + }); + + test("accepts --secret-key combined with --app and --instance", async () => { + mockResolveProfile.mockResolvedValue(undefined); + mockResolveUsersInstanceContext.mockReset(); + mockResolveUsersInstanceContext + .mockRejectedValueOnce(new Error("Not authenticated")) + .mockResolvedValueOnce(CTX); + + await captured.run(() => + open({ + userId: "user_2x9k", + print: true, + secretKey: "sk_test_direct", + app: "app_other", + instance: "prod", + }), + ); + + expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ + app: "app_other", + instance: "prod", + }); + expect(mockResolveUsersInstanceContext).toHaveBeenCalledWith({ + secretKey: "sk_test_direct", + app: "app_other", + instance: "prod", + }); + }); + + test("propagates NOT_LINKED when resolver throws (agent mode, no targeting)", async () => { + setMode("agent"); + const { CliError, ERROR_CODE } = await import("../../lib/errors.ts"); + mockResolveAppContext.mockRejectedValueOnce( + new CliError("Not linked.", { + code: ERROR_CODE.NOT_LINKED, + }), + ); + mockResolveUsersInstanceContext.mockRejectedValueOnce( + new CliError("Not linked.", { + code: ERROR_CODE.NOT_LINKED, + }), + ); + + await expect(captured.run(() => open({ userId: "user_2x9k" }))).rejects.toThrow(/Not linked/); + }); + + test("registers an action in the users registry", async () => { + const { listUsersActions } = await import("./registry.ts"); + const actions = listUsersActions(); + const action = actions.find((a) => a.key === "open"); + expect(action).toBeDefined(); + expect(action?.label).toBe("Open user in dashboard"); + }); + + test("rejects malformed user IDs with a usage error", async () => { + await expect(captured.run(() => open({ userId: "../foo", print: true }))).rejects.toThrow( + /Invalid user ID/, + ); + + await expect( + captured.run(() => open({ userId: "not-a-user-id", print: true })), + ).rejects.toThrow(/Invalid user ID/); + + expect(mockResolveUsersInstanceContext).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts new file mode 100644 index 00000000..e7472a52 --- /dev/null +++ b/packages/cli-core/src/commands/users/open.ts @@ -0,0 +1,211 @@ +import { bold, cyan, dim } from "../../lib/color.ts"; +import { resolveAppContext, resolveInstanceId, resolveProfile } from "../../lib/config.ts"; +import { CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { openBrowser } from "../../lib/open.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; +import { buildDashboardUrl } from "../open/index.ts"; +import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; +import { pickUser } from "./interactive/pick-user.ts"; +import { registerUsersAction } from "./registry.ts"; + +export type UsersOpenOptions = { + userId?: string; + print?: boolean; + secretKey?: string; + app?: string; + instance?: string; +}; + +async function resolveKnownUserDashboardTarget(options: UsersOpenOptions): Promise<{ + appId: string; + appLabel: string; + instanceId: string; + instanceLabel: string; +}> { + if (options.app) { + const resolved = await resolveProfile(process.cwd()); + if (resolved?.profile.appId === options.app) { + const instance = resolveInstanceId(resolved.profile, options.instance); + return { + appId: options.app, + appLabel: resolved.profile.appName || options.app, + instanceId: instance.id, + instanceLabel: instance.label, + }; + } + } else { + try { + return await resolveAppContext({ instance: options.instance }); + } catch (error) { + if (!(error instanceof CliError) || error.code !== ERROR_CODE.NOT_LINKED) { + throw error; + } + } + } + + const target = await resolveUsersInstanceContext({ + app: options.app, + instance: options.instance, + }); + + if (!target.appId || !target.instanceId) { + throw new CliError("Internal: dashboard target missing appId/instanceId after resolution.", { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + + return { + appId: target.appId, + appLabel: target.appLabel ?? target.appId, + instanceId: target.instanceId, + instanceLabel: target.instanceLabel ?? target.instanceId, + }; +} + +export async function open(options: UsersOpenOptions = {}): Promise { + let userId = options.userId; + if (userId !== undefined && !/^user_[A-Za-z0-9]+$/.test(userId)) { + throwUsageError(`Invalid user ID '${userId}'. Expected format: user_.`); + } + + if (userId) { + let target: + | { + appId: string; + appLabel: string; + instanceId: string; + instanceLabel: string; + } + | undefined; + + try { + target = await resolveKnownUserDashboardTarget(options); + } catch (error) { + if (!options.secretKey) { + throw error; + } + + const secretKeyTarget = await resolveUsersInstanceContext({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + if (!secretKeyTarget.appId || !secretKeyTarget.instanceId) { + throwUsageError( + "Cannot build a dashboard URL from --secret-key alone when no app target can be resolved. Use --app instead, or run `clerk link` to link this directory.", + ); + } + + target = { + appId: secretKeyTarget.appId, + appLabel: secretKeyTarget.appLabel ?? secretKeyTarget.appId, + instanceId: secretKeyTarget.instanceId, + instanceLabel: secretKeyTarget.instanceLabel ?? secretKeyTarget.instanceId, + }; + } + + const subpath = `users/${userId}`; + const url = buildDashboardUrl(target.appId, target.instanceId, subpath); + + if (options.print) { + log.data(url); + return; + } + + if (isAgent()) { + log.data( + JSON.stringify({ + url, + appId: target.appId, + appName: target.appLabel, + instanceId: target.instanceId, + instanceLabel: target.instanceLabel, + userId, + }), + ); + return; + } + + intro("clerk users open"); + log.info(`↗ Opening ${bold(target.appLabel)} (${target.instanceLabel}) → ${cyan(subpath)}`); + log.info(` ${dim(url)}`); + + 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})`)}`, + ); + } + + outro(); + return; + } + + if (isAgent()) { + throwUsageError("User ID is required in agent mode. Pass it as a positional argument."); + } + + const target = await resolveUsersInstanceContext({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + + if (!target.secretKey) { + throw new CliError("Internal: users open target is missing a secret key for pickUser.", { + code: ERROR_CODE.NO_SECRET_KEY, + }); + } + + userId = await pickUser({ + secretKey: target.secretKey, + message: "Pick a user to open in the dashboard:", + }); + + if (!target.appId || !target.instanceId) { + if (options.secretKey) { + throwUsageError( + "Cannot build a dashboard URL from --secret-key alone when no app target can be resolved. Use --app instead, or run `clerk link` to link this directory.", + ); + } + + throw new CliError("Internal: dashboard target missing appId/instanceId after resolution.", { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + + const subpath = `users/${userId}`; + const url = buildDashboardUrl(target.appId, target.instanceId, subpath); + + if (options.print) { + log.data(url); + return; + } + + const appLabel = target.appLabel ?? target.appId; + const instanceLabel = target.instanceLabel ?? target.instanceId; + + intro("clerk users open"); + log.info(`↗ Opening ${bold(appLabel)} (${instanceLabel}) → ${cyan(subpath)}`); + log.info(` ${dim(url)}`); + + 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})`)}`, + ); + } + + outro(); +} + +registerUsersAction({ + key: "open", + label: "Open user in dashboard", + description: "Open a user's profile page in the Clerk dashboard", + handler: async (targeting) => { + await open(targeting); + }, +}); diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index d2b2f957..3530f132 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -40,6 +40,11 @@ export function popPrefix() { prefixDepth = Math.max(0, prefixDepth - 1); } +/** True while an intro/outro block is active and stderr output is gutter-prefixed. */ +export function isInsideGutter(): boolean { + return prefixDepth > 0; +} + function applyPrefix(msg: string): string { if (prefixDepth === 0) return msg; const bar = dim(S_BAR); 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 6fc61d9c..659949be 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -1,7 +1,8 @@ /** * Exercise primary users flows through the real CLI program. * Covers create against linked-project, --app, --secret-key, raw -d/--data, - * --dry-run, and the wizard picker fallback when no project is linked. + * --dry-run, and the wizard picker fallback when no project is linked, plus + * list wired up against linked-project resolution. */ import { writeFile } from "node:fs/promises"; @@ -9,9 +10,11 @@ import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { MOCK_APP, + MOCK_USERS, clerk, getInstance, http, + mockState, mockPrompts, setProfile, useIntegrationTestHarness, @@ -265,4 +268,133 @@ describe("users commands", () => { expect(stderr + stdout).toContain("No input provided"); expect(findBapiCreateRequest()).toBeUndefined(); }); + + test.each([{ mode: "human" }, { mode: "agent" }])( + "lists users from linked project context ($mode mode)", + async ({ mode }) => { + await setProfile("github.com/test/project", { + workspaceId: "", + appId: MOCK_APP.application_id, + appName: MOCK_APP.name, + instances: { development: devInstance.instance_id }, + }); + + http.mock({ + "/v1/platform/applications/app_1?include_secret_keys=true": MOCK_APP, + "/v1/users": MOCK_USERS, + }); + + const { stdout, stderr } = await clerk("--mode", mode, "users", "list"); + + if (mode === "human") { + expect(stdout).toContain("John Doe"); + expect(stdout).toContain("john@example.com"); + expect(stderr).toContain("1 user returned"); + } else { + expect(JSON.parse(stdout)).toEqual({ data: MOCK_USERS, hasMore: false }); + } + + expect( + http.requests.some( + (request) => + request.method === "GET" && + request.url.includes("/v1/platform/applications/app_1") && + request.url.includes("include_secret_keys=true"), + ), + ).toBe(true); + expect( + http.requests.some( + (request) => + request.method === "GET" && + request.url.includes("https://test-bapi.clerk.dev/v1/users"), + ), + ).toBe(true); + }, + ); + + test("list resolves the secret key via --app without a linked project", async () => { + // No setProfile call: --app must thread through the parent command's + // option, not silently fall through to the no-secret-key error. + http.mock({ + [PLAPI_APP_ROUTE]: MOCK_APP, + "/v1/users": MOCK_USERS, + }); + + const { stdout, exitCode } = await clerk.raw( + "--mode", + "agent", + "users", + "list", + "--app", + "app_1", + "--instance", + "development", + ); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual({ data: MOCK_USERS, hasMore: false }); + + const fetchAppCall = http.requests.find( + (request) => + request.method === "GET" && request.url.includes("/v1/platform/applications/app_1"), + ); + expect(fetchAppCall).toBeDefined(); + }); + + test("users open prints a linked-project URL without PLAPI auth when the user id is already known", async () => { + await linkDevProject(); + delete process.env.CLERK_PLATFORM_API_KEY; + mockState.storedToken = null; + http.mock({}); + + const { stdout, exitCode } = await clerk.raw( + "--mode", + "agent", + "users", + "open", + "user_123", + "--print", + ); + + expect(exitCode).toBe(0); + expect(stdout).toBe( + `https://dashboard.clerk.com/apps/${MOCK_APP.application_id}/instances/${devInstance.instance_id}/users/user_123`, + ); + expect(http.requests).toHaveLength(0); + }); + + test("users open accepts --secret-key with --app and prints the dashboard URL without PLAPI auth", async () => { + delete process.env.CLERK_PLATFORM_API_KEY; + mockState.storedToken = null; + http.mock({ + "/v1/instance": { + id: devInstance.instance_id, + publishable_key: devInstance.publishable_key, + }, + }); + + const { stdout, exitCode } = await clerk.raw( + "--mode", + "agent", + "users", + "open", + "user_123", + "--secret-key", + "sk_test_directkey", + "--app", + "app_1", + "--print", + ); + + expect(exitCode).toBe(0); + expect(stdout).toBe( + `https://dashboard.clerk.com/apps/${MOCK_APP.application_id}/instances/${devInstance.instance_id}/users/user_123`, + ); + + const fetchAppCall = http.requests.find( + (request) => + request.method === "GET" && request.url.includes("/v1/platform/applications/app_1"), + ); + expect(fetchAppCall).toBeUndefined(); + }); }); diff --git a/skills/clerk-cli/SKILL.md b/skills/clerk-cli/SKILL.md index 96035822..8576c4f3 100644 --- a/skills/clerk-cli/SKILL.md +++ b/skills/clerk-cli/SKILL.md @@ -91,11 +91,11 @@ clerk --version # confirm the binary is on PATH clerk doctor --json # structured health check; exit 1 if anything failed ``` -**Always run `clerk doctor --json` first.** It catches the common setup failures (not logged in, project not linked, missing keys, outdated bundled skill) up front, so later commands don't fail with confusing errors. In agent mode it also includes a `Host execution` check that warns when Clerk's host-side config / credential directories are not writable, which is the canonical signal that the current invocation is likely sandboxed. +**Always run `clerk doctor --json` first.** It catches the common setup failures (not logged in, project not linked, missing keys, stale CLI version) up front, so later commands don't fail with confusing errors. In agent mode it also includes a `Host execution` check that warns when Clerk's host-side config / credential directories are not writable, which is the canonical signal that the current invocation is likely sandboxed. Each result has `name`, `status` (`pass`/`warn`/`fail`), `message`, optional `detail`, optional `remedy` (how to fix it), and optional `fix` (label for auto-fixable issues). Parse that and act on it, or surface it to the user. If `Host execution` warns, rerun the command on the host before trusting any auth/link/env/API failures from the same sandboxed run. Rerun `clerk doctor --json` whenever a later command starts misbehaving. -If `clerk skill --help` reports a newer CLI than the skill you're reading, run `clerk skill install` to refresh the bundled skill. The CLI binary is always the source of truth. +If `clerk --version` reports a newer CLI than this skill (which is pinned to `{{CLI_VERSION}}`), run `clerk skill install` to refresh the bundled skill. The CLI binary is always the source of truth. ## The mental model @@ -163,30 +163,58 @@ For instance config, prefer the dedicated `clerk config ...` commands over raw P See [references/recipes.md](references/recipes.md) for concrete patterns: listing/filtering users, creating orgs, impersonation sessions, etc. +## Inspecting large outputs (do not flood your context) + +`users list`, `apps list`, `config pull`, and most `clerk api` GETs return payloads that can be many kilobytes or megabytes. Production tenants commonly have thousands of users; an instance config can be hundreds of fields deep. Reading those responses into the conversation costs context window for no benefit. Save the response to a file first, then query just what you need with `jq`: + +```sh +# 1. Persist the response. Use --limit 250 to maximize page size for users list. +clerk users list --json --limit 250 > /tmp/users.json +clerk apps list --json > /tmp/apps.json +clerk api /users/user_abc123 > /tmp/user.json + +# 2. Inspect only what you need. +jq '.data | length' /tmp/users.json # current page size +jq '.hasMore' /tmp/users.json # are more pages available? +jq '.data[0] | keys' /tmp/users.json # discover the user shape once +jq '.data[] | {id, email_addresses}' /tmp/users.json # project to a few fields +jq '[.data[] | select(.banned)] | length' /tmp/users.json # aggregate without reading rows +``` + +**If `jq` is not available**, fall back to Python or Node — both can stream the file without printing it whole: + +```sh +python3 -c 'import json; d=json.load(open("/tmp/users.json")); print(len(d["data"]), d["hasMore"])' +node -e 'const d=require("/tmp/users.json"); console.log(d.data.length, d.hasMore)' +``` + +`cat` / `head` the file only when you genuinely need to see the raw structure for one-off debugging. When walking pages, write each page to its own file (e.g. `page-${offset}.json`) so individual pages stay independently inspectable. + ## Core commands at a glance -| Command | Purpose | Key flags | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -| `clerk init` | Scaffold Clerk into a project, or emit an agent handoff with `--prompt`. `--starter` only supports bootstrap for Next.js, React Router, Astro, Nuxt, TanStack Start, React, Vue, and JavaScript. | `--framework`, `--pm`, `--name` (with `--starter`), `--app`, `--prompt`, `--starter`, `-y`, `--no-skills` | -| `clerk auth login` | OAuth browser login (stores token). Agent mode: no-op if already logged in, else prints guidance. Aliases: `signup`, `signin`, `sign-in`. Top-level shortcut: `clerk login`. | — | -| `clerk auth logout` | Clear stored credentials. Aliases: `signout`, `sign-out`. Top-level shortcut: `clerk logout`. | — | -| `clerk whoami` | Print the logged-in email. | — | -| `clerk link` | Link this repo to a Clerk app. | `--app ` | -| `clerk unlink` | Remove the link. | `--yes` | -| `clerk env pull` | Write publishable + secret keys to `.env.local` (merge, not clobber). | `--app`, `--instance`, `--file` | -| `clerk config pull` | Fetch instance config JSON. | `--app`, `--instance`, `--output`, `--keys` | -| `clerk config schema` | Fetch the JSON Schema for the instance config. | `--app`, `--instance`, `--output`, `--keys` | -| `clerk config patch` | Partial update (PATCH) of instance config. Pass `--destructive` to actually delete sub-resources touched by the patch rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | -| `clerk config put` | Full replacement (PUT) of instance config. Pass `--destructive` to actually delete removed sub-resources rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | -| `clerk apps list` | List Clerk applications. | `--json` | -| `clerk apps create ` | Create a new Clerk application. | `--json` | -| `clerk open [subpath]` | Open the linked app's dashboard in a browser. Agent mode: prints a JSON descriptor instead of opening. | `--print` | -| `clerk doctor` | Health check. | `--json`, `--spotlight`, `--verbose`, `--fix` | -| `clerk api [path]` | Authenticated HTTP to Backend/Platform API. | `-X`, `-d`, `--file`, `--dry-run`, `--yes`, `--include`, `--app`, `--secret-key`, `--instance`, `--platform` | -| `clerk api ls [filter]` | Discover endpoints from the bundled OpenAPI catalog. | `--platform` | -| `clerk completion [shell]` | Print a shell completion script (`bash`, `zsh`, `fish`, `powershell`). | — | -| `clerk update` | Update the CLI to the latest version. | `--channel`, `-y`, `--all` | -| `clerk skill install` | Reinstall the bundled `clerk-cli` skill. Run after upgrading the CLI so the skill matches the new binary. | `-y`, `--pm` | +| Command | Purpose | Key flags | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `clerk init` | Scaffold Clerk into a project. `--starter` only supports bootstrap for Next.js, React Router, Astro, Nuxt, TanStack Start, React, Vue, and JavaScript. | `--framework`, `--pm`, `--name` (with `--starter`), `--app`, `--starter`, `-y`, `--no-skills` | +| `clerk auth login` | OAuth browser login (stores token). Agent mode: no-op if already logged in. With no stored session it still opens a browser and binds a localhost callback, so it is not unattended; prefer `CLERK_PLATFORM_API_KEY` for headless flows. Aliases: `signup`, `signin`, `sign-in`. Top-level shortcut: `clerk login`. | — | +| `clerk auth logout` | Clear stored credentials. Aliases: `signout`, `sign-out`. Top-level shortcut: `clerk logout`. | — | +| `clerk whoami` | Print the logged-in email. | — | +| `clerk link` / `clerk unlink` | Link this repo to a Clerk app, or remove the link. `unlink` requires `--yes` in agent mode. | (see `--help`) | +| `clerk env pull` | Write publishable + secret keys to the framework's env file (merge, not clobber). Resolves `.env.development.local` → framework-preferred file → `.env.local`; override with `--file`. | (see `--help`) | +| `clerk config {pull,schema}` | Fetch instance config JSON, or its JSON Schema. | (see `--help`) | +| `clerk config patch` | Partial update (PATCH) of instance config. Pass `--destructive` to actually delete sub-resources touched by the patch rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | +| `clerk config put` | Full replacement (PUT) of instance config. Pass `--destructive` to actually delete removed sub-resources rather than resetting them to defaults. | `--app`, `--instance`, `--file`, `--json`, `--dry-run`, `--yes`, `--destructive` | +| `clerk apps {list,create}` | List or create Clerk applications. Defaults to JSON in agent mode. | (see `--help`) | +| `clerk users` (no subcommand) | Interactive picker for `users` actions in human mode; in agent mode prints the action list and exits `2`. Always pass an explicit subcommand from agents. | `--app`, `--instance`, `--secret-key` | +| `clerk users list` | List users via curated BAPI flags. JSON output (default when piped or in agent mode) is `{data, hasMore}` so callers can paginate without `/users/count`. `--limit` defaults to 100 (max 250). | `--limit`, `--offset`, `--query`, `--email-address`, `--phone-number`, `--username`, `--user-id`, `--external-id`, `--order-by`, `--json`, `--app`, `--instance`, `--secret-key` | +| `clerk users create` | Create a user from curated flags or a raw BAPI body. Confirmation prompt unless `--yes`. | `--email`, `--phone`, `--username`, `--password`, `--first-name`, `--last-name`, `--external-id`, `-d, --data`, `--file`, `--dry-run`, `--yes`, `--json` | +| `clerk users open [user-id]` | Open a user's dashboard page. Agent mode requires `user-id` and prints a JSON descriptor instead of launching a browser. | (see `--help`) | +| `clerk open [subpath]` | Open the linked app's dashboard in a browser. Agent mode: prints a JSON descriptor instead of opening. | (see `--help`) | +| `clerk doctor` | Health check (CLI version, login, link, env, config, completion; plus host-execution probe in agent mode). | `--json`, `--spotlight`, `--verbose`, `--fix` | +| `clerk api [path]` | Authenticated HTTP to Backend/Platform API. | `-X`, `-d`, `--file`, `--dry-run`, `--yes`, `--include`, `--app`, `--secret-key`, `--instance`, `--platform` | +| `clerk api ls [filter]` | Discover endpoints from the bundled OpenAPI catalog. | (see `--help`) | +| `clerk completion [shell]` | Print a shell completion script (`bash`, `zsh`, `fish`, `powershell`). | — | +| `clerk update` | Update the CLI to the latest version. | `--channel`, `-y`, `--all` | +| `clerk skill install` | Reinstall the bundled `clerk-cli` skill. Run after upgrading the CLI so the skill matches the new binary. | (see `--help`) | **`clerk --help` is the source of truth for flags.** This table is a hint, not a spec. Before running an unfamiliar command or flag combination, run `clerk --help` once per session. Every command also defines `setExamples([...])` in source, which `--help` renders as a copy-pasteable Examples block, so you rarely need to guess syntax. @@ -194,7 +222,7 @@ See [references/recipes.md](references/recipes.md) for concrete patterns: listin The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` / `CLERK_MODE=agent` is set. In agent mode: -- **Interactive prompts are disabled.** Commands that would normally show pickers (`link` without `--app`, interactive `api`, `unlink` without `--yes`) either auto-resolve or exit with a usage error. Always pass explicit flags (`--app`, `--yes`) in scripted calls. +- **Interactive prompts are disabled.** Commands that would normally show pickers (`link` without `--app`, `unlink` without `--yes`, `users` without a subcommand) either auto-resolve or exit with a usage error. `clerk api` with no args prints usage guidance and exits 0; pass an endpoint (or `ls`) explicitly. Always pass explicit flags (`--app`, `--yes`) in scripted calls. - **Host-sensitive operations emit a sandbox warning once per invocation.** Home-directory Clerk state, keychain access, networked Clerk calls, browser launch, and localhost OAuth callback setup can trigger the warning shown above. If it appears, rerun the same command on the host before trusting the result. - **If your harness does not clearly present as agent mode, force it.** Use `--mode agent` or `CLERK_MODE=agent` when you want the CLI's non-interactive behavior and sandbox warning path to apply deterministically. - **`link` supports deterministic agent flows.** In agent mode, `clerk link --app ` links directly. Without `--app`, the CLI will try silent key-based autolink first; if it cannot determine the app unambiguously, it exits and tells you to pass `--app`. @@ -203,7 +231,7 @@ The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` - **Mutations still require `--yes`** unless you accept per-call confirmation is impossible. - **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. - **`apps list` and `apps create` default to JSON** when piped. -- **`clerk init --prompt`** prints a short agent-oriented handoff telling the agent to run `clerk init -y` (it is NOT a framework-specific integration guide; use the runtime `clerk init` output itself for that). +- **`users` defaults to JSON when piped, like `apps`.** `clerk users list` and `clerk users create` emit JSON in agent mode. Bare `clerk users` (no subcommand) is a usage error in agent mode — pass `list`, `create`, or `open` explicitly. `clerk users open` requires the `user-id` positional in agent mode and prints a JSON descriptor instead of launching a browser. - **`--input-json `** expands JSON into flags on any command (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Piped stdin is also accepted: `echo '{"yes":true}' | clerk init`. Place `--input-json` after the leaf subcommand. Full rules in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). Full matrix and sandbox details in [references/agent-mode.md](references/agent-mode.md). diff --git a/skills/clerk-cli/references/agent-mode.md b/skills/clerk-cli/references/agent-mode.md index 93713939..8efb2d8e 100644 --- a/skills/clerk-cli/references/agent-mode.md +++ b/skills/clerk-cli/references/agent-mode.md @@ -48,19 +48,22 @@ Force human mode with `--mode human` or `CLERK_MODE=human`. Typical AI-agent inv ## What changes in agent mode -| Behavior | Human mode | Agent mode | -| ---------------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Interactive pickers (`link` without `--app`, `api` with no args) | Show a TUI picker | Print structured guidance and exit, or auto-resolve | -| `clerk link --app ` | Links directly | Links directly | -| `clerk link` without `--app` | Interactive picker / create UI | Tries silent autolink from detected publishable keys; if no deterministic match exists, exits with a usage error telling the caller to pass `--app` | -| Confirmation prompts (`unlink`, `config patch`, `api -X DELETE`) | Prompt y/n | Require `--yes`, otherwise error | -| `clerk doctor --fix` | Interactively offers fixes | **Ignored**; output the `remedy` field and let the caller act | -| `clerk apps list` default output | Table | JSON (when piped) | -| `clerk apps create ` output | Human-readable summary | JSON (auto-detected, same as `apps list`); `--json` also works explicitly | -| `clerk open [subpath]` | Opens the browser to the URL | Does not open a browser. Prints a JSON descriptor (`{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}`) on stdout so the agent can surface it | -| `clerk auth login` when already authenticated | Prompt to re-auth | Silent no-op | -| `clerk init` | Full interactive scaffold flow | Runs non-interactively. Explicit `--app` or a linked profile uses the real-app auth/link/env flow; otherwise keyless-capable frameworks use keyless and non-keyless frameworks print manual setup guidance. | -| Color / spinners | Enabled | Disabled | +| Behavior | Human mode | Agent mode | +| ---------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Interactive pickers (`link` without `--app`, `api` with no args) | Show a TUI picker | Print structured guidance and exit, or auto-resolve | +| `clerk link --app ` | Links directly | Links directly | +| `clerk link` without `--app` | Interactive picker / create UI | Tries silent autolink from detected publishable keys; if no deterministic match exists, exits with a usage error telling the caller to pass `--app` | +| Confirmation prompts (`unlink`, `config patch`, `api -X DELETE`) | Prompt y/n | Require `--yes`, otherwise error | +| `clerk doctor --fix` | Interactively offers fixes | **Ignored**; output the `remedy` field and let the caller act | +| `clerk apps list` default output | Table | JSON (when piped) | +| `clerk apps create ` output | Human-readable summary | JSON (auto-detected, same as `apps list`); `--json` also works explicitly | +| `clerk users list` / `clerk users create` default output | Table / human-readable | JSON (auto-detected when piped); `--json` also works explicitly | +| `clerk users` (no subcommand) | Interactive action picker | Prints the action list and exits with a usage error (code `2`) — pass `list` / `create` / `open` | +| `clerk users open [user-id]` | Picks a user interactively, opens browser | Requires `user-id`; prints `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` and does not open a browser | +| `clerk open [subpath]` | Opens the browser to the URL | Does not open a browser. Prints a JSON descriptor (`{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}`) on stdout so the agent can surface it | +| `clerk auth login` when already authenticated | Prompt to re-auth | Silent no-op | +| `clerk init` | Full interactive scaffold flow | Runs non-interactively. Explicit `--app` or a linked profile uses the real-app auth/link/env flow; otherwise keyless-capable frameworks use keyless and non-keyless frameworks print manual setup guidance. | +| Color / spinners | Enabled | Disabled | In addition, sandboxed agent-mode invocations may emit the warning above once per CLI invocation when a host-sensitive operation is blocked. @@ -127,18 +130,21 @@ Errors use the standard agent-mode format: bad JSON → `invalid_json`, missing ## Structured outputs you can rely on -| Command | Structured output | -| ---------------------------- | --------------------------------------------------------------------------------------- | -| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | -| `clerk apps list --json` | Array of application objects | -| `clerk apps create --json` | Single application object | -| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | -| `clerk api --include` | Response headers on stderr, body on stdout | -| `clerk config pull` | Instance config JSON | -| `clerk config schema` | JSON Schema | -| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | -| `clerk open --print` | Plain dashboard URL on stdout | -| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | +| Command | Structured output | +| --------------------------------------------- | --------------------------------------------------------------------------------------- | +| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | +| `clerk apps list --json` | Array of application objects | +| `clerk apps create --json` | Single application object | +| `clerk users list` (agent mode or `--json`) | `{data: [...users], hasMore}` envelope (BAPI user shape inside `data`) | +| `clerk users create` (agent mode or `--json`) | Single user object (raw BAPI shape) | +| `clerk users open` (agent mode) | `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` | +| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | +| `clerk api --include` | Response headers on stderr, body on stdout | +| `clerk config pull` | Instance config JSON | +| `clerk config schema` | JSON Schema | +| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | +| `clerk open --print` | Plain dashboard URL on stdout | +| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | For commands without an explicit `--json` flag, `clerk api` is your escape hatch: hit the underlying endpoint directly. diff --git a/skills/clerk-cli/references/recipes.md b/skills/clerk-cli/references/recipes.md index ba9e0d86..4fe3930b 100644 --- a/skills/clerk-cli/references/recipes.md +++ b/skills/clerk-cli/references/recipes.md @@ -10,23 +10,28 @@ clerk api ls users # filter by keyword clerk api ls --platform # Platform API (account-level) ``` -The bundled catalog is cached locally for 1 hour; run `clerk api ls` to force a refresh if needed. +The bundled catalog is cached locally for 1 hour. There is no force-refresh flag — once the TTL expires the next `clerk api ls` re-fetches automatically; on fetch failure the CLI falls back to the stale cache and prints a warning. ## Users ```sh -# List users (paginated) -clerk api /users -clerk api '/users?limit=10&offset=0&order_by=-created_at' +# List users (preferred; curated flags). --limit defaults to 100 (max 250). +# JSON output is `{ data: [...], hasMore }` so callers can paginate without /users/count. +clerk users list +clerk users list --limit 50 --offset 0 --order-by -created_at -# Count users +# Count users (no curated subcommand; use the raw API) clerk api /users/count -# Fetch a user +# Fetch a user (no curated subcommand; use the raw API) clerk api /users/user_abc123 # Search by email -clerk api '/users?email_address=alice@example.com' +clerk users list --email-address alice@example.com + +# Open a user's profile in the dashboard +clerk users open user_abc123 +clerk users open user_abc123 --print # print the URL instead of opening # Create a user (preferred; curated flags) clerk users create \ @@ -213,14 +218,50 @@ clerk api /v1/platform/applications/app_abc123 --platform ## Scripting patterns -### Pipe to `jq` +### Save large responses to a file before reading them + +`users list`, `apps list`, `config pull`, and most `clerk api` GETs can return responses ranging from kilobytes to megabytes. Reading the full payload into an LLM-driven session burns context for no benefit. Persist the response, then query just the slice you need: ```sh -# Get a list of user IDs -clerk api /users | jq -r '.[] | .id' +# Persist once, query as many times as you need. +clerk users list --json --limit 250 > /tmp/users.json + +jq '.data | length' /tmp/users.json # count rows on the page +jq '.hasMore' /tmp/users.json # any more pages? +jq '.data[0] | keys' /tmp/users.json # learn the shape of one record +jq '.data[] | {id, email_addresses}' /tmp/users.json # project to relevant fields only +``` + +If `jq` is not on `PATH`, fall back to Python or Node, which most environments have: -# Count banned users -clerk api /users | jq '[.[] | select(.banned)] | length' +```sh +python3 -c 'import json; d=json.load(open("/tmp/users.json")); print(len(d["data"]), d["hasMore"])' +node -e 'const d=require("/tmp/users.json"); console.log(d.data.length, d.hasMore)' +``` + +Only `cat`/`head` the file when you genuinely need the raw structure for one-off debugging. + +### Pipe to `jq` + +For small responses (or one-shot lookups), inline piping to `jq` is fine: + +```sh +# Get a list of user IDs from the current page (the page envelope is `{ data, hasMore }`) +clerk users list --json | jq -r '.data[] | .id' + +# Count banned users on the current page +clerk users list --json | jq '[.data[] | select(.banned)] | length' + +# Walk every page until hasMore is false. Save each page to its own file so you +# can inspect them independently without re-fetching. +offset=0 +while :; do + page="/tmp/users-${offset}.json" + clerk users list --json --limit 250 --offset "$offset" > "$page" + jq -r '.data[] | .id' "$page" + [ "$(jq -r '.hasMore' "$page")" = "true" ] || break + offset=$((offset + 250)) +done ``` ### Read body from stdin @@ -233,8 +274,9 @@ jq -n '{email_address:["c@d.co"]}' | clerk api /users ### Loop safely ```sh -# Always --dry-run first across the whole set -for id in $(clerk api /users | jq -r '.[] | .id'); do +# Always --dry-run first across the whole set. `users list` paginates; +# bump --limit (max 250) and walk pages with --offset until .hasMore is false. +for id in $(clerk users list --json --limit 250 | jq -r '.data[] | .id'); do clerk api /users/$id -X PATCH -d '{"public_metadata":{"migrated":true}}' --dry-run done # Re-run without --dry-run once the previews look right diff --git a/test/e2e/lib/fixture-test.ts b/test/e2e/lib/fixture-test.ts index ee733ddf..7dae9724 100644 --- a/test/e2e/lib/fixture-test.ts +++ b/test/e2e/lib/fixture-test.ts @@ -187,7 +187,7 @@ export function runBrowserTest(getFixture: () => FixtureState, config: FixtureCo try { // 1. Create test user - testUser = await createTestUser(configDir, secretKey, fixtureName); + testUser = await createTestUser(configDir, { secretKey }, fixtureName); // 2. Start dev server (port is allocated inside, with retries on collision) const server = await startDevServer({ @@ -296,7 +296,7 @@ export function runBrowserTest(getFixture: () => FixtureState, config: FixtureCo ); } if (testUser) { - await deleteTestUser(testUser.id, configDir, secretKey, fixtureName).catch(() => {}); + await deleteTestUser(testUser.id, configDir, { secretKey }, fixtureName).catch(() => {}); } } }, diff --git a/test/e2e/lib/test-user.ts b/test/e2e/lib/test-user.ts index 300e26a5..d9207e43 100644 --- a/test/e2e/lib/test-user.ts +++ b/test/e2e/lib/test-user.ts @@ -10,24 +10,41 @@ export interface TestUser { password: string; } -/** Build env for CLI commands with the secret key from the fixture's env files. */ -function clerkEnv(configDir: string, secretKey: string): Record { - return { +/** + * Identifies the target Clerk instance for the test user. + * + * - `secretKey`: the existing path used by framework fixtures, which resolve + * the secret key from the linked profile's env file. The key is injected + * as CLERK_SECRET_KEY in the CLI subprocess env. + * - `appId`: used by BAPI roundtrip tests that don't run a fixture. The CLI + * resolves the secret key per-call via PLAPI using `--app `. Useful + * when the test only has CLERK_CLI_TEST_APP_ID and CLERK_PLATFORM_API_KEY. + */ +export type TestUserTarget = { secretKey: string } | { appId: string }; + +/** Build env for CLI commands. Only injects CLERK_SECRET_KEY when targeting by key. */ +function clerkEnv(configDir: string, target: TestUserTarget): Record { + const env: Record = { ...process.env, CLERK_CONFIG_DIR: configDir, - CLERK_SECRET_KEY: secretKey, }; + if ("secretKey" in target) env.CLERK_SECRET_KEY = target.secretKey; + return env; +} + +/** Append `--app ` when targeting by app, otherwise nothing. */ +function targetArgs(target: TestUserTarget): string[] { + return "appId" in target ? ["--app", target.appId] : []; } /** * Create a test user via `clerk users create`. Uses +clerk_test email suffix * so OTP code 424242 works without real email delivery. Passes the BAPI body - * via `-d` because `skip_password_checks` is not a curated flag. The instance - * BAPI secret key comes from the fixture's env via `CLERK_SECRET_KEY`. + * via `-d` because `skip_password_checks` is not a curated flag. */ export async function createTestUser( configDir: string, - secretKey: string, + target: TestUserTarget, fixtureName: string, ): Promise { const hex = randomBytes(8).toString("hex"); @@ -42,10 +59,11 @@ export async function createTestUser( log(fixtureName, `creating test user: ${email}`); - const result = await Bun.$`bun ${CLI_PATH} users create -d ${body} --json --yes` - .env(clerkEnv(configDir, secretKey)) - .quiet() - .nothrow(); + const result = + await Bun.$`bun ${CLI_PATH} users create -d ${body} --json --yes ${targetArgs(target)}` + .env(clerkEnv(configDir, target)) + .quiet() + .nothrow(); if (result.exitCode !== 0) { const stdout = result.stdout.toString().trim(); @@ -64,15 +82,16 @@ export async function createTestUser( export async function deleteTestUser( userId: string, configDir: string, - secretKey: string, + target: TestUserTarget, fixtureName: string, ): Promise { log(fixtureName, `deleting test user: ${userId}`); - const result = await Bun.$`bun ${CLI_PATH} api /users/${userId} -X DELETE --yes` - .env(clerkEnv(configDir, secretKey)) - .quiet() - .nothrow(); + const result = + await Bun.$`bun ${CLI_PATH} api /users/${userId} -X DELETE --yes ${targetArgs(target)}` + .env(clerkEnv(configDir, target)) + .quiet() + .nothrow(); if (result.exitCode !== 0) { const stdout = result.stdout.toString().trim(); diff --git a/test/e2e/users-list.test.ts b/test/e2e/users-list.test.ts new file mode 100644 index 00000000..7a3dcf59 --- /dev/null +++ b/test/e2e/users-list.test.ts @@ -0,0 +1,72 @@ +/** + * Live-BAPI roundtrip test for `clerk users list`. Pins the response shape + * (`{ data: [...users], hasMore: boolean }` envelope on top of BAPI's flat + * user array) against the real Clerk Backend API. If BAPI's underlying + * shape ever changes, this test fails immediately rather than being masked + * by a fixture mock. + * + * Requires `CLERK_PLATFORM_API_KEY` and `CLERK_CLI_TEST_APP_ID`. Locally, + * run via `bun run test:e2e:op` so 1Password resolves both in-memory. + */ + +import { test, expect, afterAll, beforeAll } from "bun:test"; +import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { createTestUser, deleteTestUser } from "./lib/test-user.ts"; + +const FIXTURE_NAME = "users-list"; +const APP_ID = process.env.CLERK_CLI_TEST_APP_ID; +const PLATFORM_KEY = process.env.CLERK_PLATFORM_API_KEY; + +if (!APP_ID || !PLATFORM_KEY) { + throw new Error( + "CLERK_CLI_TEST_APP_ID and CLERK_PLATFORM_API_KEY are required. " + + "Run via `bun run test:e2e:op` for local 1Password injection.", + ); +} + +const CLI_PATH = join(import.meta.dir, "../../packages/cli-core/src/cli.ts"); + +let configDir: string; +const createdIds: string[] = []; + +beforeAll(() => { + configDir = mkdtempSync(join(tmpdir(), "clerk-cli-e2e-users-list-")); +}); + +afterAll(async () => { + for (const id of createdIds) { + await deleteTestUser(id, configDir, { appId: APP_ID }, FIXTURE_NAME); + } + rmSync(configDir, { recursive: true, force: true }); +}); + +test("users list --json returns a { data, hasMore } envelope around the BAPI users", async () => { + const users = await Promise.all( + [1, 2, 3].map(async () => { + const u = await createTestUser(configDir, { appId: APP_ID }, FIXTURE_NAME); + createdIds.push(u.id); + return u; + }), + ); + + const result = await Bun.$`bun ${CLI_PATH} users list --json --app ${APP_ID} \ + --user-id ${users[0].id} --user-id ${users[1].id} --user-id ${users[2].id}` + .env({ ...process.env, CLERK_CONFIG_DIR: configDir }) + .quiet(); + + const body = JSON.parse(result.stdout.toString()) as { + data: Array<{ id: unknown }>; + hasMore: boolean; + }; + + expect(Array.isArray(body.data)).toBe(true); + expect(typeof body.hasMore).toBe("boolean"); + // Three explicit user_id filters and a 100-row default page size, so the + // page can never overflow. + expect(body.hasMore).toBe(false); + expect(body.data.every((u) => typeof u.id === "string")).toBe(true); + const returned = body.data.map((u) => u.id as string).sort(); + expect(returned).toEqual(users.map((u) => u.id).sort()); +});