From 5217451b31a312682236e22234f6fa62cd51e9bd Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 12:54:36 -0600 Subject: [PATCH 01/29] feat(users): add users command scaffolding and `clerk users create` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the top-level `clerk users` command with shared targeting and mutation output helpers, plus `clerk users create` as the first subcommand. Curated flags cover the common fields and `-d, --data` / `--file` send raw BAPI request bodies for fields the flags don't expose. - Wire `users` into the CLI program with `--app`, `--secret-key`, `--instance`, `--dry-run`, `--yes`, and `--json` behavior aligned across future users subcommands. - Extract payload builders, input parsing, and JSON payload reading into `lib/users.ts` so future subcommands can share them. - Add `lib/bapi-command.ts` to centralize BAPI secret-key resolution and error handling across users mutations. - Add `lifecycle-runner.ts` as the shared runner for direct state transitions, ready for the upcoming lifecycle and specialized subcommands to import. - Migrate the e2e test-user helper to exercise `clerk users create` end-to-end across every framework fixture. BAPI enforces identifier and required-field rules server-side, so the command only needs a BAPI secret key — no `applications:manage` Platform API scope is required. --- .changeset/users-scaffolding-and-create.md | 5 + .gitignore | 3 + packages/cli-core/src/cli-program.test.ts | 51 +++ packages/cli-core/src/cli-program.ts | 35 ++ packages/cli-core/src/commands/api/bapi.ts | 7 +- .../cli-core/src/commands/api/index.test.ts | 58 +++- packages/cli-core/src/commands/api/index.ts | 70 +--- .../cli-core/src/commands/users/README.md | 70 ++++ .../src/commands/users/create.test.ts | 199 ++++++++++++ .../cli-core/src/commands/users/create.ts | 128 ++++++++ packages/cli-core/src/commands/users/index.ts | 5 + .../src/commands/users/lifecycle-runner.ts | 156 +++++++++ .../cli-core/src/commands/users/output.ts | 134 ++++++++ .../cli-core/src/commands/users/shared.ts | 13 + .../cli-core/src/lib/bapi-command.test.ts | 304 ++++++++++++++++++ packages/cli-core/src/lib/bapi-command.ts | 113 +++++++ packages/cli-core/src/lib/config.test.ts | 87 ++++- packages/cli-core/src/lib/config.ts | 96 +++--- packages/cli-core/src/lib/users.test.ts | 101 ++++++ packages/cli-core/src/lib/users.ts | 124 +++++++ .../test/integration/users-commands.test.ts | 72 +++++ test/e2e/lib/test-user.ts | 8 +- 22 files changed, 1722 insertions(+), 117 deletions(-) create mode 100644 .changeset/users-scaffolding-and-create.md create mode 100644 packages/cli-core/src/commands/users/README.md create mode 100644 packages/cli-core/src/commands/users/create.test.ts create mode 100644 packages/cli-core/src/commands/users/create.ts create mode 100644 packages/cli-core/src/commands/users/index.ts create mode 100644 packages/cli-core/src/commands/users/lifecycle-runner.ts create mode 100644 packages/cli-core/src/commands/users/output.ts create mode 100644 packages/cli-core/src/commands/users/shared.ts create mode 100644 packages/cli-core/src/lib/bapi-command.test.ts create mode 100644 packages/cli-core/src/lib/bapi-command.ts create mode 100644 packages/cli-core/src/lib/users.test.ts create mode 100644 packages/cli-core/src/lib/users.ts create mode 100644 packages/cli-core/src/test/integration/users-commands.test.ts diff --git a/.changeset/users-scaffolding-and-create.md b/.changeset/users-scaffolding-and-create.md new file mode 100644 index 00000000..ac8602f9 --- /dev/null +++ b/.changeset/users-scaffolding-and-create.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk users create` for creating users from curated flags (`--email`, `--phone`, `--username`, `--password`, `--first-name`, `--last-name`, `--external-id`) or a raw BAPI request body via `-d, --data ` or `--file `. The command supports `--dry-run`, `--yes`, and `--json`. BAPI enforces identifier and required-field rules, so any BAPI secret key (`CLERK_SECRET_KEY`, `--secret-key`, or `--app`-resolved) is sufficient — no `applications:manage` Platform API scope is needed. Program-level `--input-json` drives the curated flags from a JSON object; `-d` / `--file` cover fields the curated flags don't expose. diff --git a/.gitignore b/.gitignore index 7afe5007..91df7469 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # E2E HAR output test/e2e/.har + +# Local planning/spec docs +docs/superpowers/ diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index e1ee982d..57c926be 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -5,6 +5,57 @@ import { dirname, join } from "node:path"; import { createProgram, formatApiBody } from "./cli-program.ts"; import { STANDARD_AGENT_DIRS, EXTRA_REL_PATHS } from "./lib/skill-detection.ts"; +test("registers users as a top-level command", () => { + const program = createProgram(); + const users = program.commands.find((command) => command.name() === "users"); + expect(users).toBeDefined(); +}); + +test("registers users create as a subcommand", () => { + const program = createProgram(); + const users = program.commands.find((command) => command.name() === "users")!; + const names = users.commands.map((command) => command.name()); + + expect(names).toContain("create"); +}); + +test("users create exposes --json output, curated flags, and -d/--data for inline request bodies", () => { + const program = createProgram(); + const users = program.commands.find((command) => command.name() === "users")!; + const create = users.commands.find((command) => command.name() === "create")!; + const optionNames = create.options.map((option) => option.long); + + expect(optionNames).toEqual( + expect.arrayContaining([ + "--json", + "--email", + "--phone", + "--username", + "--password", + "--first-name", + "--last-name", + "--external-id", + "--data", + "--file", + "--secret-key", + "--app", + "--instance", + "--dry-run", + "--yes", + ]), + ); +}); + +test("users create documents -d and --file for raw BAPI request bodies", () => { + const program = createProgram(); + const users = program.commands.find((command) => command.name() === "users")!; + const create = users.commands.find((command) => command.name() === "create")!; + const help = create.helpInformation(); + + expect(help).toContain("-d, --data"); + expect(help).toContain("--file"); +}); + describe("formatApiBody", () => { // --- Single error with meta --- diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 6266fb01..abe303bb 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -14,6 +14,7 @@ import { api } from "./commands/api/index.ts"; import { link } from "./commands/link/index.ts"; import { unlink } from "./commands/unlink/index.ts"; import { apps as appsHandlers } from "./commands/apps/index.ts"; +import { users as usersHandlers } from "./commands/users/index.ts"; import { doctor } from "./commands/doctor/index.ts"; import { switchEnv } from "./commands/switch-env/index.ts"; import { openDashboard } from "./commands/open/index.ts"; @@ -258,6 +259,40 @@ Give AI agents better Clerk context: install the Clerk skills ]) .action(appsHandlers.create); + const users = program + .command("users") + .description("Manage Clerk users") + .setExamples([ + { + command: "clerk users create --email alice@example.com --first-name Alice --yes", + description: "Create a user from curated flags", + }, + { + command: 'clerk users create -d \'{"email_address":["alice@example.com"]}\' --yes', + description: "Create a user from an inline BAPI request body", + }, + ]); + + users + .command("create") + .description("Create a user") + .option("--json", "Output as JSON") + .option("--email ", "Email address") + .option("--phone ", "Phone number") + .option("--username ", "Username") + .option("--password ", "Password") + .option("--first-name ", "First name") + .option("--last-name ", "Last name") + .option("--external-id ", "External ID") + .option("-d, --data ", "Inline BAPI request body") + .option("--file ", "Read BAPI request body from a file") + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--dry-run", "Show the request without executing it") + .option("--yes", "Skip confirmation prompt") + .action(usersHandlers.create); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/api/bapi.ts b/packages/cli-core/src/commands/api/bapi.ts index 35e865d1..a4860a6f 100644 --- a/packages/cli-core/src/commands/api/bapi.ts +++ b/packages/cli-core/src/commands/api/bapi.ts @@ -4,6 +4,7 @@ */ import { getBapiBaseUrl } from "../../lib/environment.ts"; +import { normalizeBapiPath } from "../../lib/bapi-command.ts"; import { BapiError } from "../../lib/errors.ts"; import { loggedFetch } from "../../lib/fetch.ts"; @@ -22,11 +23,7 @@ export async function bapiRequest(options: { baseUrl?: string; }): Promise { const base = options.baseUrl ?? getBapiBaseUrl(); - - // Normalize: ensure path starts with /v1/ if not already versioned - let path = options.path; - if (!path.startsWith("/")) path = `/${path}`; - if (!path.startsWith("/v1/")) path = `/v1${path}`; + const path = normalizeBapiPath(options.path); const url = `${base}${path}`; diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index 00b7ec95..d66b3613 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { captureLog, credentialStoreStubs, @@ -55,6 +56,57 @@ mock.module("../../lib/config.ts", () => ({ if (!id) throw new Error(`No ${env} instance configured.`); return { id, label: env }; }, + resolveFetchedApplicationInstance: ( + appId: string, + app: { instances: Array }, + flag?: string, + ) => { + const aliases: Record = { + dev: "development", + development: "development", + prod: "production", + production: "production", + }; + + if (!flag) { + const development = app.instances.find((entry) => entry.environment_type === "development"); + if (!development) throw new Error(`No development instance found for application ${appId}.`); + return { + found: true as const, + instance: development, + instanceId: development.instance_id, + instanceLabel: "development", + }; + } + + const env = aliases[flag]; + if (env) { + const matched = app.instances.find((entry) => entry.environment_type === env); + if (!matched) throw new Error(`No ${env} instance found for application ${appId}.`); + return { + found: true as const, + instance: matched, + instanceId: matched.instance_id, + instanceLabel: env, + }; + } + + const matched = app.instances.find((entry) => entry.instance_id === flag); + if (matched) { + return { + found: true as const, + instance: matched, + instanceId: matched.instance_id, + instanceLabel: flag, + }; + } + + return { + found: false as const, + instanceId: flag, + instanceLabel: flag, + }; + }, resolveAppContext: async (options: { app?: string; instance?: string }) => { if (options.app) { const aliases: Record = { @@ -89,7 +141,11 @@ mock.module("../../lib/config.ts", () => ({ } const profile = _profiles[process.cwd()]; - if (!profile) throw new Error("No Clerk project linked"); + if (!profile) { + throw new CliError("No Clerk project linked to this directory.", { + code: ERROR_CODE.NOT_LINKED, + }); + } const instance = !options.instance ? { id: profile.instances.development, label: "development" } : (() => { diff --git a/packages/cli-core/src/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index d1320d54..1bdf7624 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -1,15 +1,8 @@ -import { resolveAppContext } from "../../lib/config.ts"; -import { fetchApplication, getAuthToken, validateKeyPrefix } from "../../lib/plapi.ts"; +import { getAuthToken } from "../../lib/plapi.ts"; import { getBapiBaseUrl, getPlapiBaseUrl } from "../../lib/environment.ts"; +import { normalizeBapiPath, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; import { bapiRequest } from "./bapi.ts"; -import { - BapiError, - CliError, - ERROR_CODE, - throwUsageError, - throwUserAbort, - withApiContext, -} from "../../lib/errors.ts"; +import { BapiError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; import { isHuman } from "../../mode.ts"; import { confirm } from "../../lib/prompts.ts"; import { withSpinner } from "../../lib/spinner.ts"; @@ -61,13 +54,13 @@ export async function api( secretKey = await getAuthToken(); baseUrl = getPlapiBaseUrl(); } else { - secretKey = await resolveSecretKey(options); + secretKey = await resolveBapiSecretKey(options); baseUrl = getBapiBaseUrl(); } // 4. Dry run if (options.dryRun) { - log.info(`[dry-run] ${method} ${baseUrl}${normalizePath(endpoint)}`); + log.info(`[dry-run] ${method} ${baseUrl}${normalizeBapiPath(endpoint)}`); if (body) { prettyPrint(body); } @@ -117,52 +110,6 @@ export async function api( } } -async function resolveSecretKey(options: ApiOptions): Promise { - if (options.secretKey) { - validateKeyPrefix(options.secretKey, "sk_"); - return options.secretKey; - } - - if (process.env.CLERK_SECRET_KEY) { - validateKeyPrefix(process.env.CLERK_SECRET_KEY, "sk_"); - return process.env.CLERK_SECRET_KEY; - } - - // Resolve from linked profile via Platform API - let ctx: Awaited>; - try { - ctx = await resolveAppContext({ app: options.app, instance: options.instance }); - } catch (error) { - if (error instanceof Error && error.message.startsWith("No Clerk project linked")) { - throwUsageError( - "No secret key found. Provide one via:\n" + - " --secret-key \n" + - " CLERK_SECRET_KEY environment variable\n" + - " Link a project with `clerk link`, or pass --app ", - "https://clerk.com/docs/guides/development/clerk-environment-variables", - ERROR_CODE.NO_SECRET_KEY, - ); - } - throw error; - } - - const app = await withApiContext(fetchApplication(ctx.appId), "Failed to resolve secret key"); - const matched = app.instances.find((i) => i.instance_id === ctx.instanceId); - if (!matched) { - throw new CliError(`Instance ${ctx.instanceId} not found in application.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - docsUrl: "https://clerk.com/docs/guides/development/managing-environments", - }); - } - if (!matched.secret_key) { - throw new CliError(`No secret key found for ${ctx.instanceLabel} instance.`, { - code: ERROR_CODE.NO_SECRET_KEY, - docsUrl: "https://clerk.com/docs/guides/development/clerk-environment-variables", - }); - } - return matched.secret_key; -} - async function resolveBody(options: { data?: string; file?: string }): Promise { if (options.data) return options.data; @@ -187,13 +134,6 @@ async function resolveBody(options: { data?: string; file?: string }): Promise { diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md new file mode 100644 index 00000000..881c430a --- /dev/null +++ b/packages/cli-core/src/commands/users/README.md @@ -0,0 +1,70 @@ +# `clerk users` + +Manage direct Clerk user resources with first-class commands. Use `clerk api` for unsupported or fully custom user requests. + +## Shared Targeting And Auth + +Most `clerk users` commands accept the same targeting flags: + +| Flag | Description | +| ------------------ | --------------------------------------------------------------------------------- | +| `--secret-key ` | Use a specific Backend API secret key directly | +| `--app ` | Target an application directly, even outside a linked project | +| `--instance ` | Target `dev`, `prod`, or a full instance ID. Defaults to the development instance | +| `--dry-run` | Preview the request without executing it, where supported | +| `--yes` | Skip confirmation prompts for mutating commands | + +Authentication is resolved in this order: + +- `--app ` plus Platform API auth to resolve the instance secret key +- `--secret-key ` +- `CLERK_SECRET_KEY` +- a linked project profile via `clerk link` + +The users commands talk to the instance's Backend API. Identifier and required-field rules are enforced by BAPI, so any BAPI secret key (via `CLERK_SECRET_KEY`, `--secret-key`, or `--app`-resolved) is enough — no `applications:manage` Platform API scope is required. + +## Passing input as JSON + +Two complementary mechanisms for JSON input work across the users command family: + +- **`--input-json `** (program-level). Expands JSON object keys into argv flags before Commander parses them. Drive the curated flags with structured JSON, from an agent or a pipeline: `clerk users create --input-json '{"email":"alice@example.com","first-name":"Alice","yes":true}'`. Accepts inline JSON, `@path/to/file.json`, or `-` for stdin. Piped stdin is auto-detected when `--input-json` is absent. +- **`-d, --data ` plus `--file `** (per-command). Send a raw BAPI request body directly to `/v1/users`. Use this when you need a BAPI field the curated flags don't expose (for example, `primary_email_address_id` or `web3_wallets`). Mirrors `clerk api -d` / `--file`. + +## Commands + +### `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. + +```sh +clerk users create --email alice@example.com --first-name Alice --yes +clerk users create --app app_123 --instance prod -d '{"email_address":["alice@example.com"]}' --yes +clerk users create --app app_123 --instance prod -d '{"email_address":["alice@example.com"]}' --json --yes +clerk users create --file user.json --dry-run +``` + +Supported curated flags: + +- `--email ` +- `--phone ` +- `--username ` +- `--password ` +- `--first-name ` +- `--last-name ` +- `--external-id ` +- `--json` +- `-d, --data ` +- `--file ` + +## API Endpoints + +| Method | Endpoint | Command(s) | +| ------ | ----------- | ---------- | +| `POST` | `/v1/users` | `create` | + +## Notes + +- Human mode prints concise tables or summaries for reads and terse success summaries for mutations by default; agent mode defaults to JSON across the users command family. +- `--json` is the response-output flag across the users command family. +- `-d, --data` is the inline raw-BAPI-body flag for `create`; `--file` reads the same body from a file. +- `--dry-run` is available on mutating commands to preview the outgoing request. diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts new file mode 100644 index 00000000..86f33740 --- /dev/null +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -0,0 +1,199 @@ +import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { captureLog, promptsStubs } from "../../test/lib/stubs.ts"; +import { BapiError, CliError, ERROR_CODE, EXIT_CODE } from "../../lib/errors.ts"; + +const mockResolveBapiSecretKey = mock(); +const mockHandleBapiError = mock((_error: unknown) => false); +mock.module("../../lib/bapi-command.ts", () => ({ + resolveBapiSecretKey: (...args: unknown[]) => mockResolveBapiSecretKey(...args), + handleBapiError: (error: unknown) => mockHandleBapiError(error), +})); + +const mockBapiRequest = mock(); +mock.module("../../commands/api/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +mock.module("@inquirer/prompts", () => promptsStubs); +mock.module("../../lib/spinner.ts", () => ({ + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + +const { create } = await import("./create.ts"); + +describe("users create", () => { + 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: { id: "user_123" }, + rawBody: JSON.stringify({ id: "user_123" }), + }); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + captured = captureLog(); + }); + + afterEach(() => { + captured.teardown(); + process.exitCode = 0; + mockResolveBapiSecretKey.mockReset(); + mockHandleBapiError.mockReset(); + mockHandleBapiError.mockImplementation(() => false); + mockBapiRequest.mockReset(); + mockIsAgent.mockReset(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function runCreate(options: Parameters[0]) { + return captured.run(() => create(options)); + } + + test("curated flags override conflicting JSON payload fields and forward targeting to the secret key resolver", async () => { + await runCreate({ + app: "app_123", + data: '{"first_name":"Json","email_address":["json@example.com"]}', + secretKey: "sk_test_override", + email: "flag@example.com", + firstName: "Flag", + yes: true, + }); + + expect(mockResolveBapiSecretKey).toHaveBeenCalledWith({ + secretKey: "sk_test_override", + app: "app_123", + instance: undefined, + }); + expect(mockBapiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + path: "/users", + secretKey: "sk_test_123", + }), + ); + expect(JSON.parse(mockBapiRequest.mock.calls[0]?.[0]?.body)).toEqual({ + first_name: "Flag", + email_address: ["flag@example.com"], + }); + }); + + test("fails with a usage error when no input source is provided", async () => { + const error = await runCreate({ + app: "app_123", + yes: true, + }).catch((error_) => error_); + + expect(error).toBeInstanceOf(CliError); + expect(error.code).toBe(ERROR_CODE.USAGE_ERROR); + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain("No input provided"); + expect(mockResolveBapiSecretKey).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("dry-run redacts sensitive preview fields without calling BAPI", async () => { + await runCreate({ + app: "app_123", + email: "alice@example.com", + password: "Password123!", + dryRun: true, + }); + + expect(captured.err).toContain("[dry-run] POST /v1/users"); + expect(JSON.parse(captured.out)).toEqual({ + email_address: ["alice@example.com"], + password: "[REDACTED]", + }); + expect(mockResolveBapiSecretKey).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("prints a terse success message to stderr with no stdout body in human mode", async () => { + await runCreate({ + app: "app_123", + email: "alice@example.com", + yes: true, + }); + + expect(captured.out).toBe(""); + expect(captured.err).toContain("Created user"); + expect(captured.err).toContain("user_123"); + expect(captured.err).not.toContain('"id"'); + }); + + test("prints response JSON to stdout when --json output is requested", async () => { + await runCreate({ + app: "app_123", + email: "alice@example.com", + json: true, + yes: true, + }); + + expect(JSON.parse(captured.out)).toEqual({ id: "user_123" }); + expect(captured.err).not.toContain("Created user"); + }); + + test("prints response JSON to stdout in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await runCreate({ + app: "app_123", + email: "alice@example.com", + yes: true, + }); + + expect(JSON.parse(captured.out)).toEqual({ id: "user_123" }); + expect(captured.err).not.toContain("Created user"); + }); + + test("prints raw BAPI validation errors to stdout for machine use", async () => { + mockHandleBapiError.mockImplementation((error: unknown) => error instanceof BapiError); + mockBapiRequest.mockRejectedValue( + new BapiError( + 422, + JSON.stringify({ + errors: [ + { + code: "form_param_missing", + message: "email_address is required", + }, + ], + }), + new Headers(), + ), + ); + mockIsAgent.mockReturnValue(true); + + await runCreate({ + app: "app_123", + email: "alice@example.com", + yes: true, + }); + + expect(process.exitCode).toBe(1); + expect(JSON.parse(captured.out)).toEqual({ + errors: [ + { + code: "form_param_missing", + message: "email_address is required", + }, + ], + }); + expect(captured.err).not.toContain("Created user"); + }); +}); diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts new file mode 100644 index 00000000..4db34965 --- /dev/null +++ b/packages/cli-core/src/commands/users/create.ts @@ -0,0 +1,128 @@ +import { handleBapiError, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; +import { throwUsageError, throwUserAbort } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { + buildCreateUserPayload, + mergeUsersPayload, + parseUsersPayload, + readUsersPayloadInput, + redactUsersDisplayPayload, +} from "../../lib/users.ts"; +import { isHuman } from "../../mode.ts"; +import { bapiRequest } from "../api/bapi.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { handleUsersBapiError, printUsersMutationResult } from "./output.ts"; + +type CreateUserOptions = { + email?: string; + phone?: string; + username?: string; + password?: string; + firstName?: string; + lastName?: string; + externalId?: string; + json?: boolean; + data?: string; + file?: string; + app?: string; + instance?: string; + secretKey?: string; + dryRun?: boolean; + yes?: boolean; +}; + +export async function create(options: CreateUserOptions): Promise { + const payload = await resolveCreatePayload(options); + + if (options.dryRun) { + log.info("[dry-run] POST /v1/users"); + log.data(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); + return; + } + + await confirmMutation("POST", "/v1/users", payload, options); + + const secretKey = await resolveBapiSecretKey({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + + try { + const response = await withSpinner("Creating user...", () => + bapiRequest({ + method: "POST", + path: "/users", + secretKey, + body: JSON.stringify(payload), + }), + ); + + printUsersMutationResult("Created user", response.body, options); + } catch (error) { + if (handleUsersBapiError(error, "Failed to create user", options)) { + return; + } + if (handleBapiError(error)) { + return; + } + throw error; + } +} + +async function resolveCreatePayload(options: CreateUserOptions): Promise> { + const basePayload = await resolveBasePayload(options, hasCreateFlagPayload(options)); + return mergeUsersPayload(basePayload, buildCreateUserPayload(options)); +} + +async function resolveBasePayload( + options: { data?: string; file?: string }, + hasFlagPayload: boolean, +): Promise> { + if (options.data || options.file) { + return parseUsersPayload( + await readUsersPayloadInput({ data: options.data, file: options.file }), + ); + } + + if (hasFlagPayload) { + return {}; + } + + throwUsageError( + "No input provided. Pass curated flags, -d , or --file .\n" + + " Example: clerk users create --email alice@example.com --first-name Alice\n" + + ' Example: clerk users create -d \'{"email_address":["alice@example.com"]}\'\n' + + " Example: clerk users create --file user.json", + ); +} + +function hasCreateFlagPayload(options: CreateUserOptions): boolean { + return Boolean( + options.email || + options.phone || + options.username || + options.password || + options.firstName || + options.lastName || + options.externalId, + ); +} + +async function confirmMutation( + method: string, + path: string, + payload: Record, + options: { yes?: boolean }, +): Promise { + if (!isHuman() || options.yes) return; + + log.info(`\nAbout to ${method} ${path}`); + log.raw(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); + + const ok = await confirm({ message: "Proceed?" }); + if (!ok) { + throwUserAbort(); + } +} diff --git a/packages/cli-core/src/commands/users/index.ts b/packages/cli-core/src/commands/users/index.ts new file mode 100644 index 00000000..2f02e803 --- /dev/null +++ b/packages/cli-core/src/commands/users/index.ts @@ -0,0 +1,5 @@ +import { create } from "./create.ts"; + +export const users = { + create, +}; diff --git a/packages/cli-core/src/commands/users/lifecycle-runner.ts b/packages/cli-core/src/commands/users/lifecycle-runner.ts new file mode 100644 index 00000000..4f22ac26 --- /dev/null +++ b/packages/cli-core/src/commands/users/lifecycle-runner.ts @@ -0,0 +1,156 @@ +import { + describeBapiTarget, + handleBapiError, + normalizeBapiPath, + resolveBapiSecretKey, +} from "../../lib/bapi-command.ts"; +import { throwUserAbort } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { isHuman } from "../../mode.ts"; +import { bapiRequest } from "../api/bapi.ts"; +import { handleUsersBapiError, printUsersMutationSuccess } from "./output.ts"; + +export type UserLifecycleOptions = { + json?: boolean; + secretKey?: string; + app?: string; + instance?: string; + dryRun?: boolean; + yes?: boolean; +}; + +type UserLifecycleCommand = { + method: "DELETE" | "POST"; + path: string; + spinnerMessage: string; + destructiveWarning?: string; + successMessage?: string; + errorMessage?: string; +}; + +export async function runUserLifecycleCommand( + command: UserLifecycleCommand, + options: UserLifecycleOptions = {}, +): Promise { + if (options.dryRun) { + const target = await describeBapiTarget(options); + const targetSuffix = target ? ` for ${target}` : ""; + log.info(`[dry-run] ${command.method} ${normalizeBapiPath(command.path)}${targetSuffix}`); + return; + } + + const secretKey = await resolveBapiSecretKey({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + + if (isHuman() && !options.yes) { + log.info(`\nAbout to ${command.method} ${command.path}`); + if (command.destructiveWarning) { + log.info(command.destructiveWarning); + } + const ok = await confirm({ message: "Proceed?" }); + if (!ok) { + throwUserAbort(); + } + } + + try { + const response = await withSpinner(command.spinnerMessage, () => + bapiRequest({ + method: command.method, + path: command.path, + secretKey, + }), + ); + + printUsersMutationSuccess( + command.successMessage ?? getLifecycleSuccessMessage(command.path), + response.body, + options, + ); + } catch (error) { + if ( + handleUsersBapiError( + error, + command.errorMessage ?? getLifecycleErrorMessage(command.path), + options, + ) + ) { + return; + } + if (handleBapiError(error)) { + return; + } + throw error; + } +} + +function getLifecycleSuccessMessage(path: string): string { + const userId = getUserIdFromPath(path); + + if (path.endsWith("/ban")) { + return `Banned user ${userId}`; + } + if (path.endsWith("/unban")) { + return `Unbanned user ${userId}`; + } + if (path.endsWith("/lock")) { + return `Locked user ${userId}`; + } + if (path.endsWith("/unlock")) { + return `Unlocked user ${userId}`; + } + if (path.endsWith("/profile_image")) { + return `Removed profile image for user ${userId}`; + } + if (path.endsWith("/mfa")) { + return `Disabled MFA for user ${userId}`; + } + if (path.endsWith("/totp")) { + return `Removed TOTP for user ${userId}`; + } + if (path.endsWith("/backup_code")) { + return `Removed backup codes for user ${userId}`; + } + + return `Updated user ${userId}`; +} + +function getLifecycleErrorMessage(path: string): string { + const userId = getUserIdFromPath(path); + + if (path.endsWith("/ban")) { + return `Failed to ban user ${userId}`; + } + if (path.endsWith("/unban")) { + return `Failed to unban user ${userId}`; + } + if (path.endsWith("/lock")) { + return `Failed to lock user ${userId}`; + } + if (path.endsWith("/unlock")) { + return `Failed to unlock user ${userId}`; + } + if (path.endsWith("/profile_image")) { + return `Failed to remove profile image for user ${userId}`; + } + if (path.endsWith("/mfa")) { + return `Failed to disable MFA for user ${userId}`; + } + if (path.endsWith("/totp")) { + return `Failed to remove TOTP for user ${userId}`; + } + if (path.endsWith("/backup_code")) { + return `Failed to remove backup codes for user ${userId}`; + } + + return `Failed to update user ${userId}`; +} + +function getUserIdFromPath(path: string): string { + return path.split("/")[2] ?? "unknown"; +} diff --git a/packages/cli-core/src/commands/users/output.ts b/packages/cli-core/src/commands/users/output.ts new file mode 100644 index 00000000..b60cf999 --- /dev/null +++ b/packages/cli-core/src/commands/users/output.ts @@ -0,0 +1,134 @@ +import { BapiError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; + +export type UsersOutputOptions = { + json?: boolean; +}; + +export function shouldPrintUsersJson(options: UsersOutputOptions = {}): boolean { + return Boolean(options.json || isAgent()); +} + +export function printUsersResponseBody(body: unknown): void { + if (typeof body === "string") { + if (body) { + log.data(body); + } + return; + } + + if (typeof body === "undefined" || body === null) { + return; + } + + log.data(JSON.stringify(body, null, 2)); +} + +export function printUsersJson(body: unknown, options: UsersOutputOptions = {}): boolean { + if (!shouldPrintUsersJson(options)) { + return false; + } + + printUsersResponseBody(body); + return true; +} + +export function printUsersMutationResult( + action: string, + body: unknown, + options: UsersOutputOptions = {}, +): void { + if (printUsersJson(body, options)) { + return; + } + + const userId = getUserId(body); + log.success(userId ? `${action} ${userId}` : action); +} + +export function printUsersMutationSuccess( + message: string, + body: unknown, + options: UsersOutputOptions = {}, +): void { + if (printUsersJson(body, options)) { + return; + } + + log.success(message); +} + +export function handleUsersBapiError( + error: unknown, + context: string, + options: UsersOutputOptions = {}, +): boolean { + if (!(error instanceof BapiError)) { + return false; + } + + if (shouldPrintUsersJson(options)) { + printUsersResponseBody(parseUsersErrorBody(error.body)); + } else { + log.error(`${context}: ${formatUsersErrorBody(error.body)}`); + } + + process.exitCode = 1; + return true; +} + +function getUserId(body: unknown): string | undefined { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return undefined; + } + + const { id } = body as { id?: unknown }; + return typeof id === "string" && id.length > 0 ? id : undefined; +} + +function parseUsersErrorBody(body: string): unknown { + try { + return JSON.parse(body); + } catch { + return body; + } +} + +function formatUsersErrorBody(body: string): string { + const parsed = parseUsersErrorBody(body); + + if (typeof parsed === "string") { + return parsed.length > 200 ? `${parsed.slice(0, 200)}...` : parsed; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return "Request failed"; + } + + const { errors, error, message } = parsed as { + errors?: Array<{ message?: unknown }>; + error?: unknown; + message?: unknown; + }; + + if (Array.isArray(errors) && errors.length > 0) { + return errors + .map((entry) => + typeof entry?.message === "string" && entry.message.length > 0 + ? entry.message + : "Unknown error", + ) + .join("\n"); + } + + if (typeof error === "string" && error.length > 0) { + return error; + } + + if (typeof message === "string" && message.length > 0) { + return message; + } + + return "Request failed"; +} diff --git a/packages/cli-core/src/commands/users/shared.ts b/packages/cli-core/src/commands/users/shared.ts new file mode 100644 index 00000000..5d201d35 --- /dev/null +++ b/packages/cli-core/src/commands/users/shared.ts @@ -0,0 +1,13 @@ +import { CliError } from "../../lib/errors.ts"; + +export { + buildCreateUserPayload, + buildUpdateUserPayload, + mergeUsersPayload, +} from "../../lib/users.ts"; + +export function createUsersStub(commandName: string) { + return async () => { + throw new CliError(`clerk users ${commandName} is not implemented yet.`); + }; +} diff --git a/packages/cli-core/src/lib/bapi-command.test.ts b/packages/cli-core/src/lib/bapi-command.test.ts new file mode 100644 index 00000000..74f86bb7 --- /dev/null +++ b/packages/cli-core/src/lib/bapi-command.test.ts @@ -0,0 +1,304 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { BapiError, CliError, ERROR_CODE } from "./errors.ts"; +import { captureLog } from "../test/lib/stubs.ts"; + +const configModule = await import("./config.ts"); +const plapiModule = await import("./plapi.ts"); + +const { normalizeBapiPath, handleBapiError, resolveBapiSecretKey, describeBapiTarget } = + await import("./bapi-command.ts"); + +describe("bapi-command", () => { + let resolveAppContextSpy: ReturnType; + let fetchApplicationSpy: ReturnType; + let validateKeyPrefixSpy: ReturnType; + let captured: ReturnType; + + beforeEach(() => { + delete process.env.CLERK_SECRET_KEY; + resolveAppContextSpy = spyOn(configModule, "resolveAppContext"); + fetchApplicationSpy = spyOn(plapiModule, "fetchApplication"); + validateKeyPrefixSpy = spyOn(plapiModule, "validateKeyPrefix"); + captured = captureLog(); + }); + + afterEach(() => { + delete process.env.CLERK_SECRET_KEY; + process.exitCode = 0; + captured.teardown(); + resolveAppContextSpy.mockRestore(); + fetchApplicationSpy.mockRestore(); + validateKeyPrefixSpy.mockRestore(); + }); + + test("normalizes unversioned paths", () => { + expect(normalizeBapiPath("users")).toBe("/v1/users"); + expect(normalizeBapiPath("/users")).toBe("/v1/users"); + expect(normalizeBapiPath("/v1/users")).toBe("/v1/users"); + }); + + test("prints raw BAPI error bodies for machine use", async () => { + const handled = await captured.run(() => + Promise.resolve( + handleBapiError( + new BapiError( + 422, + JSON.stringify({ + errors: [ + { + code: "form_param_missing", + message: "email_address is required", + }, + ], + }), + new Headers(), + ), + ), + ), + ); + + expect(handled).toBe(true); + expect(JSON.parse(captured.out)).toEqual({ + errors: [ + { + code: "form_param_missing", + message: "email_address is required", + }, + ], + }); + expect(process.exitCode).toBe(1); + }); + + test("resolves secret key from explicit app and instance", async () => { + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + name: "My App", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + secret_key: "sk_test_123", + publishable_key: "pk_test_123", + }, + ], + }); + + await expect(resolveBapiSecretKey({ app: "app_123", instance: "dev" })).resolves.toBe( + "sk_test_123", + ); + + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + expect(fetchApplicationSpy).toHaveBeenCalledTimes(1); + expect(fetchApplicationSpy).toHaveBeenCalledWith("app_123"); + }); + + test("resolves secret key from explicit app and literal instance id", async () => { + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + name: "My App", + instances: [ + { + instance_id: "ins_custom_123", + environment_type: "staging", + secret_key: "sk_test_custom_123", + publishable_key: "pk_test_custom_123", + }, + ], + }); + + await expect( + resolveBapiSecretKey({ app: "app_123", instance: "ins_custom_123" }), + ).resolves.toBe("sk_test_custom_123"); + + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + expect(fetchApplicationSpy).toHaveBeenCalledTimes(1); + }); + + test("throws instance-not-found for explicit app and missing literal instance id", async () => { + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + name: "My App", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + secret_key: "sk_test_123", + publishable_key: "pk_test_123", + }, + ], + }); + + const error = await resolveBapiSecretKey({ + app: "app_123", + instance: "ins_missing_123", + }).catch((error_) => error_); + + expect(error).toBeInstanceOf(CliError); + expect(error.code).toBe(ERROR_CODE.INSTANCE_NOT_FOUND); + expect(error.message).toContain("Instance ins_missing_123 not found in application."); + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + expect(fetchApplicationSpy).toHaveBeenCalledTimes(1); + }); + + test("resolves secret key from linked app context when no explicit app is provided", async () => { + resolveAppContextSpy.mockResolvedValue({ + appId: "app_123", + appLabel: "My App", + instanceId: "ins_dev", + instanceLabel: "development", + }); + + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + instances: [{ instance_id: "ins_dev", secret_key: "sk_test_123" }], + }); + + await expect(resolveBapiSecretKey({ instance: "dev" })).resolves.toBe("sk_test_123"); + }); + + test("prefers an explicit secret key over env and app resolution", async () => { + process.env.CLERK_SECRET_KEY = "sk_env_123"; + + await expect( + resolveBapiSecretKey({ + secretKey: "sk_option_123", + app: "app_123", + instance: "dev", + }), + ).resolves.toBe("sk_option_123"); + + expect(validateKeyPrefixSpy).toHaveBeenCalledWith("sk_option_123", "sk_"); + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + expect(fetchApplicationSpy).not.toHaveBeenCalled(); + }); + + test("prefers explicit app targeting over CLERK_SECRET_KEY", async () => { + process.env.CLERK_SECRET_KEY = "sk_env_123"; + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + secret_key: "sk_test_123", + }, + ], + }); + + await expect(resolveBapiSecretKey({ app: "app_123", instance: "dev" })).resolves.toBe( + "sk_test_123", + ); + + expect(validateKeyPrefixSpy).not.toHaveBeenCalledWith("sk_env_123", "sk_"); + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + expect(fetchApplicationSpy).toHaveBeenCalledWith("app_123"); + }); + + test("falls back to CLERK_SECRET_KEY when no explicit app is provided", async () => { + process.env.CLERK_SECRET_KEY = "sk_env_123"; + + await expect(resolveBapiSecretKey({ instance: "dev" })).resolves.toBe("sk_env_123"); + + expect(validateKeyPrefixSpy).toHaveBeenCalledWith("sk_env_123", "sk_"); + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + expect(fetchApplicationSpy).not.toHaveBeenCalled(); + }); + + test("remaps not-linked app context errors to a no-secret-key usage error", async () => { + resolveAppContextSpy.mockRejectedValue( + new CliError("linked profile missing", { + code: ERROR_CODE.NOT_LINKED, + }), + ); + + const error = await resolveBapiSecretKey({}).catch((error_) => error_); + expect(error).toBeInstanceOf(CliError); + expect(error.code).toBe(ERROR_CODE.NO_SECRET_KEY); + expect(error.exitCode).toBe(2); + expect(error.docsUrl).toContain( + "https://clerk.com/docs/guides/development/clerk-environment-variables", + ); + expect(error.message).toContain("No secret key found."); + + expect(fetchApplicationSpy).not.toHaveBeenCalled(); + }); + + test("describes the resolved app and instance target", async () => { + resolveAppContextSpy.mockResolvedValue({ + appId: "app_123", + appLabel: "My App", + instanceId: "ins_prod", + instanceLabel: "production", + }); + + await expect(describeBapiTarget({ app: "app_123", instance: "prod" })).resolves.toBe( + "My App (production)", + ); + + expect(resolveAppContextSpy).toHaveBeenCalledWith({ + app: "app_123", + instance: "prod", + }); + }); + + test("returns no target description when only a secret key is available", async () => { + resolveAppContextSpy.mockRejectedValue( + new CliError("linked profile missing", { + code: ERROR_CODE.NOT_LINKED, + }), + ); + + await expect(describeBapiTarget({ secretKey: "sk_test_123" })).resolves.toBeUndefined(); + }); + + test("throws instance-not-found when the resolved instance is missing from the application", async () => { + resolveAppContextSpy.mockResolvedValue({ + appId: "app_123", + appLabel: "My App", + instanceId: "ins_missing", + instanceLabel: "development", + }); + + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + secret_key: "sk_test_123", + publishable_key: "pk_test_123", + }, + ], + }); + + const error = await resolveBapiSecretKey({}).catch((error_) => error_); + expect(error).toBeInstanceOf(CliError); + expect(error.code).toBe(ERROR_CODE.INSTANCE_NOT_FOUND); + expect(error.message).toContain("Instance ins_missing not found in application."); + }); + + test("throws no-secret-key when the resolved instance has no secret key", async () => { + resolveAppContextSpy.mockResolvedValue({ + appId: "app_123", + appLabel: "My App", + instanceId: "ins_dev", + instanceLabel: "development", + }); + + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_123", + }, + ], + }); + + const error = await resolveBapiSecretKey({}).catch((error_) => error_); + expect(error).toBeInstanceOf(CliError); + expect(error.code).toBe(ERROR_CODE.NO_SECRET_KEY); + expect(error.message).toContain("No secret key found for development instance."); + }); +}); diff --git a/packages/cli-core/src/lib/bapi-command.ts b/packages/cli-core/src/lib/bapi-command.ts new file mode 100644 index 00000000..7f26a5d2 --- /dev/null +++ b/packages/cli-core/src/lib/bapi-command.ts @@ -0,0 +1,113 @@ +import { resolveAppContext, resolveFetchedApplicationInstance } from "./config.ts"; +import { BapiError, CliError, ERROR_CODE, throwUsageError, withApiContext } from "./errors.ts"; +import { log } from "./log.ts"; +import { fetchApplication, validateKeyPrefix } from "./plapi.ts"; + +export function normalizeBapiPath(path: string): string { + let normalized = path; + if (!normalized.startsWith("/")) normalized = `/${normalized}`; + if (!normalized.startsWith("/v1/")) normalized = `/v1${normalized}`; + return normalized; +} + +interface ResolveBapiSecretKeyOptions { + app?: string; + instance?: string; + secretKey?: string; +} + +export async function describeBapiTarget( + options: ResolveBapiSecretKeyOptions, +): Promise { + try { + const ctx = await resolveAppContext({ app: options.app, instance: options.instance }); + return `${ctx.appLabel} (${ctx.instanceLabel})`; + } catch (error) { + if ( + error instanceof CliError && + error.code === ERROR_CODE.NOT_LINKED && + (options.secretKey || process.env.CLERK_SECRET_KEY) + ) { + return undefined; + } + throw error; + } +} + +export async function resolveBapiSecretKey(options: ResolveBapiSecretKeyOptions): Promise { + if (options.secretKey) { + validateKeyPrefix(options.secretKey, "sk_"); + return options.secretKey; + } + + if (options.app) { + const app = await withApiContext(fetchApplication(options.app), "Failed to resolve secret key"); + const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); + if (!resolved.found) { + throw new CliError(`Instance ${resolved.instanceId} not found in application.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } + if (!resolved.instance.secret_key) { + throw new CliError(`No secret key found for ${resolved.instanceLabel} instance.`, { + code: ERROR_CODE.NO_SECRET_KEY, + docsUrl: "https://clerk.com/docs/guides/development/clerk-environment-variables", + }); + } + return resolved.instance.secret_key; + } + + if (process.env.CLERK_SECRET_KEY) { + validateKeyPrefix(process.env.CLERK_SECRET_KEY, "sk_"); + return process.env.CLERK_SECRET_KEY; + } + + let ctx: Awaited>; + try { + ctx = await resolveAppContext({ app: options.app, instance: options.instance }); + } catch (error) { + if (error instanceof CliError && error.code === ERROR_CODE.NOT_LINKED) { + throwUsageError( + "No secret key found. Provide one via:\n" + + " --secret-key \n" + + " CLERK_SECRET_KEY environment variable\n" + + " Link a project with `clerk link`, or pass --app ", + "https://clerk.com/docs/guides/development/clerk-environment-variables", + ERROR_CODE.NO_SECRET_KEY, + ); + } + throw error; + } + + const app = await withApiContext(fetchApplication(ctx.appId), "Failed to resolve secret key"); + const instance = app.instances.find((entry) => entry.instance_id === ctx.instanceId); + if (!instance) { + throw new CliError(`Instance ${ctx.instanceId} not found in application.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } + if (!instance.secret_key) { + throw new CliError(`No secret key found for ${ctx.instanceLabel} instance.`, { + code: ERROR_CODE.NO_SECRET_KEY, + docsUrl: "https://clerk.com/docs/guides/development/clerk-environment-variables", + }); + } + return instance.secret_key; +} + +export function handleBapiError(error: unknown): boolean { + if (!(error instanceof BapiError)) { + return false; + } + + try { + log.data(JSON.stringify(JSON.parse(error.body), null, 2)); + } catch { + log.data(error.body); + } + + process.exitCode = 1; + return true; +} diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index c1805e1f..b6deea22 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -1,5 +1,9 @@ -import { test, expect, describe, beforeEach, afterEach } from "bun:test"; -import { +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +const { readConfig, writeConfig, getAuth, @@ -11,23 +15,26 @@ import { resolveProfile, resolveInstanceId, resolveAppContext, + resolveFetchedApplicationInstance, _setConfigDir, - type Profile, -} from "./config.ts"; -import { join } from "node:path"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +} = await import("./config.ts"); +type Profile = + Awaited> extends infer T ? Exclude : never; +const plapiModule = await import("./plapi.ts"); describe("config", () => { let tempDir: string; + let fetchApplicationSpy: ReturnType; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-config-test-")); _setConfigDir(tempDir); + fetchApplicationSpy = spyOn(plapiModule, "fetchApplication"); }); afterEach(async () => { _setConfigDir(undefined); + fetchApplicationSpy.mockRestore(); await rm(tempDir, { recursive: true, force: true }); }); @@ -217,4 +224,70 @@ describe("config", () => { expect(ctx.appId).toBe("app_in_child"); }); }); + + describe("resolveFetchedApplicationInstance", () => { + const app = { + application_id: "app_123", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_123", + }, + { + instance_id: "ins_custom_123", + environment_type: "staging", + publishable_key: "pk_test_custom_123", + }, + ], + }; + + test("selects a literal existing instance id from fetched application data", () => { + const result = resolveFetchedApplicationInstance("app_123", app, "ins_custom_123"); + + expect(result).toMatchObject({ + found: true, + instanceId: "ins_custom_123", + instanceLabel: "ins_custom_123", + }); + if (result.found) { + expect(result.instance.instance_id).toBe("ins_custom_123"); + } + }); + + test("returns explicit missing state for unknown literal instance ids", () => { + const result = resolveFetchedApplicationInstance("app_123", app, "ins_missing_123"); + + expect(result).toEqual({ + found: false, + instanceId: "ins_missing_123", + instanceLabel: "ins_missing_123", + }); + }); + }); + + describe("resolveAppContext (explicit app)", () => { + test("resolves a literal existing instance id from fetched application data", async () => { + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + name: "My App", + instances: [ + { + instance_id: "ins_custom_123", + environment_type: "staging", + publishable_key: "pk_test_custom_123", + }, + ], + }); + + await expect( + resolveAppContext({ app: "app_123", instance: "ins_custom_123" }), + ).resolves.toEqual({ + appId: "app_123", + appLabel: "My App", + instanceId: "ins_custom_123", + instanceLabel: "ins_custom_123", + }); + }); + }); }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 38af0c16..33dbc355 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -11,6 +11,7 @@ import { getGitRepoIdentifier, getGitNormalizedRemote } from "./git.ts"; import { CliError, ERROR_CODE } from "./errors.ts"; import { withHomeFsAccess } from "./host-execution.ts"; import { log } from "./log.ts"; +import type { Application, ApplicationInstance } from "./plapi.ts"; let overrideConfigFile: string | undefined; @@ -251,6 +252,62 @@ interface AppContextOptions { cwd?: string; } +export function resolveFetchedApplicationInstance( + appId: string, + app: Application, + instance?: string, +): + | { found: true; instance: ApplicationInstance; instanceId: string; instanceLabel: string } + | { found: false; instanceId: string; instanceLabel: string } { + if (instance) { + const env = INSTANCE_ALIASES[instance]; + if (env) { + const matched = app.instances.find((entry) => entry.environment_type === env); + if (!matched) { + throw new CliError(`No ${env} instance found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + instanceLabel: env, + }; + } + + const matched = app.instances.find((entry) => entry.instance_id === instance); + if (matched) { + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + instanceLabel: instance, + }; + } + + return { + found: false, + instanceId: instance, + instanceLabel: instance, + }; + } + + const development = app.instances.find((entry) => entry.environment_type === "development"); + if (!development) { + throw new CliError(`No development instance found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + + return { + found: true, + instance: development, + instanceId: development.instance_id, + instanceLabel: "development", + }; +} + /** * Resolve app context from explicit flags or linked profile. * This is the isomorphic resolution chain used by profile-dependent commands: @@ -265,46 +322,13 @@ export async function resolveAppContext( const { fetchApplication } = await import("./plapi.ts"); const app = await fetchApplication(options.app); const appLabel = app.name || options.app; - - if (options.instance) { - const env = INSTANCE_ALIASES[options.instance]; - if (env) { - const matched = app.instances.find((instance) => instance.environment_type === env); - if (!matched) { - throw new CliError(`No ${env} instance found for application ${options.app}.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - }); - } - return { - appId: options.app, - appLabel, - instanceId: matched.instance_id, - instanceLabel: env, - }; - } - - return { - appId: options.app, - appLabel, - instanceId: options.instance, - instanceLabel: options.instance, - }; - } - - const development = app.instances.find( - (instance) => instance.environment_type === "development", - ); - if (!development) { - throw new CliError(`No development instance found for application ${options.app}.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - }); - } + const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); return { appId: options.app, appLabel, - instanceId: development.instance_id, - instanceLabel: "development", + instanceId: resolved.instanceId, + instanceLabel: resolved.instanceLabel, }; } diff --git a/packages/cli-core/src/lib/users.test.ts b/packages/cli-core/src/lib/users.test.ts new file mode 100644 index 00000000..302a8327 --- /dev/null +++ b/packages/cli-core/src/lib/users.test.ts @@ -0,0 +1,101 @@ +import { test, expect, describe } from "bun:test"; +import { CliError, ERROR_CODE, EXIT_CODE } from "./errors.ts"; +import { + buildCreateUserPayload, + buildUpdateUserPayload, + mergeUsersPayload, + parseUsersPayload, + redactUsersDisplayPayload, +} from "./users.ts"; + +describe("users helpers", () => { + test("buildCreateUserPayload maps curated flags to Clerk API payload", () => { + expect( + buildCreateUserPayload({ + email: "alice@example.com", + password: "Password123", + firstName: "Alice", + }), + ).toEqual({ + email_address: ["alice@example.com"], + password: "Password123", + first_name: "Alice", + }); + }); + + test("buildUpdateUserPayload maps update flags to Clerk API fields", () => { + expect(buildUpdateUserPayload({ firstName: "Alice", externalId: "ext_123" })).toEqual({ + first_name: "Alice", + external_id: "ext_123", + }); + }); + + test("mergeUsersPayload lets curated flags override JSON payload fields", () => { + expect( + mergeUsersPayload({ first_name: "Json" }, buildCreateUserPayload({ firstName: "Flag" })), + ).toEqual({ + first_name: "Flag", + }); + }); + + test("parseUsersPayload returns the parsed object for a valid JSON string", () => { + expect(parseUsersPayload('{"email_address":["alice@example.com"]}')).toEqual({ + email_address: ["alice@example.com"], + }); + }); + + test("parseUsersPayload rejects invalid JSON with an invalid_json CliError", () => { + let error: unknown; + try { + parseUsersPayload("not json"); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).code).toBe(ERROR_CODE.INVALID_JSON); + expect((error as CliError).exitCode).toBe(EXIT_CODE.USAGE); + }); + + test("parseUsersPayload rejects non-object JSON (arrays, primitives, null)", () => { + for (const input of ['["email@example.com"]', '"just a string"', "42", "null"]) { + let error: unknown; + try { + parseUsersPayload(input); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).code).toBe(ERROR_CODE.INVALID_JSON); + } + }); + + test("redactUsersDisplayPayload masks passwords, codes, and private/unsafe metadata", () => { + expect( + redactUsersDisplayPayload({ + email_address: ["alice@example.com"], + password: "Password123", + code: "123456", + private_metadata: { secret: "hidden" }, + unsafe_metadata: { token: "abc" }, + public_metadata: { role: "admin" }, + }), + ).toEqual({ + email_address: ["alice@example.com"], + password: "[REDACTED]", + code: "[REDACTED]", + private_metadata: "[REDACTED]", + unsafe_metadata: "[REDACTED]", + public_metadata: { role: "admin" }, + }); + }); + + test("redactUsersDisplayPayload recurses into arrays and nested objects", () => { + expect( + redactUsersDisplayPayload({ + users: [{ password: "one" }, { password: "two" }], + }), + ).toEqual({ + users: [{ password: "[REDACTED]" }, { password: "[REDACTED]" }], + }); + }); +}); diff --git a/packages/cli-core/src/lib/users.ts b/packages/cli-core/src/lib/users.ts new file mode 100644 index 00000000..6a4d4641 --- /dev/null +++ b/packages/cli-core/src/lib/users.ts @@ -0,0 +1,124 @@ +import { ERROR_CODE, throwUsageError } from "./errors.ts"; + +const USERS_INVALID_JSON_MESSAGE = "User payload must be a JSON object."; +const REDACTED = "[REDACTED]"; +const DIRECT_REDACT_KEYS = new Set(["password", "code"]); +const OBJECT_REDACT_KEYS = new Set(["private_metadata", "unsafe_metadata"]); + +export function buildCreateUserPayload(options: { + email?: string; + phone?: string; + username?: string; + password?: string; + firstName?: string; + lastName?: string; + externalId?: string; +}) { + const payload: Record = {}; + + if (options.email) payload.email_address = [options.email]; + if (options.phone) payload.phone_number = [options.phone]; + if (options.username) payload.username = options.username; + if (options.password) payload.password = options.password; + if (options.firstName) payload.first_name = options.firstName; + if (options.lastName) payload.last_name = options.lastName; + if (options.externalId) payload.external_id = options.externalId; + + return payload; +} + +export function buildUpdateUserPayload(options: { + firstName?: string; + lastName?: string; + username?: string; + password?: string; + externalId?: string; +}) { + const payload: Record = {}; + + if (options.firstName) payload.first_name = options.firstName; + if (options.lastName) payload.last_name = options.lastName; + if (options.username) payload.username = options.username; + if (options.password) payload.password = options.password; + if (options.externalId) payload.external_id = options.externalId; + + return payload; +} + +export function mergeUsersPayload( + basePayload: Record, + flagPayload: Record, +): Record { + return { ...basePayload, ...flagPayload }; +} + +export function parseUsersPayload(rawInput: string): Record { + let payload: unknown; + + try { + payload = JSON.parse(rawInput); + } catch { + throwUsageError( + "Invalid JSON input. Please provide valid JSON.", + undefined, + ERROR_CODE.INVALID_JSON, + ); + } + + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throwUsageError(USERS_INVALID_JSON_MESSAGE, undefined, ERROR_CODE.INVALID_JSON); + } + + return payload as Record; +} + +export async function readUsersPayloadInput(options: { + file?: string; + data?: string; +}): Promise { + if (options.data) { + return options.data; + } + + if (options.file) { + const file = Bun.file(options.file); + if (!(await file.exists())) { + throwUsageError(`File not found: ${options.file}`, undefined, ERROR_CODE.FILE_NOT_FOUND); + } + return file.text(); + } + + throwUsageError( + "No input provided. Use -d or --file .\n" + + ' Example: clerk users create -d \'{"email_address":["alice@example.com"]}\'\n' + + " Example: clerk users create --file user.json", + ); +} + +export function redactUsersDisplayPayload(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => redactUsersDisplayPayload(entry)); + } + + if (value && typeof value === "object") { + const redacted: Record = {}; + + for (const [key, entry] of Object.entries(value as Record)) { + if (DIRECT_REDACT_KEYS.has(key)) { + redacted[key] = REDACTED; + continue; + } + + if (OBJECT_REDACT_KEYS.has(key) && entry != null) { + redacted[key] = REDACTED; + continue; + } + + redacted[key] = redactUsersDisplayPayload(entry); + } + + return redacted; + } + + return value; +} diff --git a/packages/cli-core/src/test/integration/users-commands.test.ts b/packages/cli-core/src/test/integration/users-commands.test.ts new file mode 100644 index 00000000..987514af --- /dev/null +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -0,0 +1,72 @@ +/** + * Exercise primary users flows through the real CLI program. + * Covers create wired up against linked-project resolution. + */ + +import { describe, expect, test } from "bun:test"; +import { + MOCK_APP, + clerk, + getInstance, + http, + setProfile, + useIntegrationTestHarness, +} from "./lib/harness.ts"; + +useIntegrationTestHarness(); + +describe("users commands", () => { + const devInstance = getInstance(MOCK_APP, "development"); + + test.each([{ mode: "human" }, { mode: "agent" }])( + "creates a user 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 }, + }); + + const createdUser = { + id: "user_2", + email_addresses: [{ email_address: "alice@example.com" }], + first_name: "Alice", + }; + http.mock({ + "/v1/platform/applications/app_1?include_secret_keys=true": MOCK_APP, + "/v1/users": createdUser, + }); + + const { stdout: createOutput, stderr: createStderr } = await clerk( + "--mode", + mode, + "users", + "create", + "--email", + "alice@example.com", + "--first-name", + "Alice", + "--yes", + ); + + if (mode === "human") { + expect(createOutput).toBe(""); + expect(createStderr).toContain("Created user"); + expect(createStderr).toContain("user_2"); + } else { + expect(JSON.parse(createOutput)).toEqual(createdUser); + } + + const createRequest = http.requests.find( + (request) => + request.method === "POST" && request.url.includes("https://test-bapi.clerk.dev/v1/users"), + ); + expect(createRequest).toBeDefined(); + expect(JSON.parse(createRequest!.body!)).toEqual({ + email_address: ["alice@example.com"], + first_name: "Alice", + }); + }, + ); +}); diff --git a/test/e2e/lib/test-user.ts b/test/e2e/lib/test-user.ts index 3bbebaac..300e26a5 100644 --- a/test/e2e/lib/test-user.ts +++ b/test/e2e/lib/test-user.ts @@ -20,8 +20,10 @@ function clerkEnv(configDir: string, secretKey: string): Record Date: Fri, 24 Apr 2026 15:35:06 -0600 Subject: [PATCH 02/29] build(cli-core): add @clerk/shared devDependency for FAPI types --- bun.lock | 9 +++++++-- packages/cli-core/package.json | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 766246d0..690bbb9a 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "packages/cli": { "name": "clerk", - "version": "0.8.4", + "version": "1.0.3", "bin": { "clerk": "./bin/clerk", }, @@ -42,6 +42,7 @@ "yaml": "^2.8.3", }, "devDependencies": { + "@clerk/shared": "^4.8.3", "@types/semver": "^7.7.1", }, }, @@ -98,7 +99,7 @@ "@clerk/cli-core": ["@clerk/cli-core@workspace:packages/cli-core"], - "@clerk/shared": ["@clerk/shared@4.4.0", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-3iBX7Svp2XrSIgFk4VtyVq5OZsGStkMGqVfTBbbiFCbSKQ745OfM8j/c2wgpq5QdyavesoeDA6YiMWlpZM9/ng=="], + "@clerk/shared": ["@clerk/shared@4.8.3", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-HZViZBCTfOR2OreSBDMXcIRPgYiiYCE+GCCPrpjq/ZPcA6OsGiRCIQgUoGgGdAoFgr6Hk0TT00hnVK7g0qRKqQ=="], "@clerk/testing": ["@clerk/testing@2.0.8", "", { "dependencies": { "@clerk/backend": "^3.2.4", "@clerk/shared": "^4.4.0", "dotenv": "17.2.2" }, "peerDependencies": { "@playwright/test": "^1", "cypress": "^13 || ^14" }, "optionalPeers": ["@playwright/test", "cypress"] }, "sha512-p1m0CZ1GsIUkE4c5SPcapdHoH0rCBqkECgGWs4340w/LrgyVWX1+Z1auWdcX+HvY/Soi6OzThSBP+n/acBO5OQ=="], @@ -460,6 +461,10 @@ "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "@clerk/backend/@clerk/shared": ["@clerk/shared@4.4.0", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-3iBX7Svp2XrSIgFk4VtyVq5OZsGStkMGqVfTBbbiFCbSKQ745OfM8j/c2wgpq5QdyavesoeDA6YiMWlpZM9/ng=="], + + "@clerk/testing/@clerk/shared": ["@clerk/shared@4.4.0", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-3iBX7Svp2XrSIgFk4VtyVq5OZsGStkMGqVfTBbbiFCbSKQ745OfM8j/c2wgpq5QdyavesoeDA6YiMWlpZM9/ng=="], + "@inquirer/checkbox/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], "@inquirer/confirm/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index deb230b6..60e1ecdf 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -29,6 +29,7 @@ "yaml": "^2.8.3" }, "devDependencies": { + "@clerk/shared": "^4.8.3", "@types/semver": "^7.7.1" } } From 4fa5534eb6cb5e99e8e1f16d4ef528a32a7dc84e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 15:44:31 -0600 Subject: [PATCH 03/29] feat(fapi): add Frontend API client for instance settings --- packages/cli-core/src/lib/errors.ts | 2 + packages/cli-core/src/lib/fapi.test.ts | 107 +++++++++++++++++++++++++ packages/cli-core/src/lib/fapi.ts | 103 ++++++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 packages/cli-core/src/lib/fapi.test.ts create mode 100644 packages/cli-core/src/lib/fapi.ts diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 48d1c821..b0bd7436 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -43,6 +43,8 @@ export const ERROR_CODE = { CATALOG_ERROR: "catalog_error", /** Doctor checks found issues. */ DOCTOR_FAILED: "doctor_failed", + /** Frontend API request failed. */ + FAPI_ERROR: "fapi_error", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; diff --git a/packages/cli-core/src/lib/fapi.test.ts b/packages/cli-core/src/lib/fapi.test.ts new file mode 100644 index 00000000..bcdad7b8 --- /dev/null +++ b/packages/cli-core/src/lib/fapi.test.ts @@ -0,0 +1,107 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { decodePublishableKey, bootstrapDevBrowser, fetchUserSettings } from "./fapi.ts"; +import { CliError } from "./errors.ts"; +import { stubFetch } from "../test/lib/stubs.ts"; + +describe("decodePublishableKey", () => { + test("decodes a development publishable key", () => { + // base64("ideal-louse-61.clerk.accounts.dev$") = "aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA" + const result = decodePublishableKey("pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA"); + expect(result.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); + expect(result.instanceType).toBe("development"); + }); + + test("decodes a production publishable key", () => { + // base64("clerk.example.com$") = "Y2xlcmsuZXhhbXBsZS5jb20k" + const result = decodePublishableKey("pk_live_Y2xlcmsuZXhhbXBsZS5jb20k"); + expect(result.fapiHost).toBe("clerk.example.com"); + expect(result.instanceType).toBe("production"); + }); + + test("throws CliError on missing prefix", () => { + expect(() => decodePublishableKey("not_a_key")).toThrow(CliError); + }); + + test("throws CliError when decoded value does not end with $", () => { + // base64("clerk.example.com") = "Y2xlcmsuZXhhbXBsZS5jb20=" + expect(() => decodePublishableKey("pk_test_Y2xlcmsuZXhhbXBsZS5jb20=")).toThrow(CliError); + }); +}); + +describe("bootstrapDevBrowser", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("POSTs to /v1/dev_browser and returns the token", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + stubFetch(async (input, init) => { + capturedUrl = String(input); + capturedMethod = String(init?.method ?? "GET"); + return new Response(JSON.stringify({ token: "jwt-abc" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const token = await bootstrapDevBrowser("ideal-louse-61.clerk.accounts.dev"); + + expect(token).toBe("jwt-abc"); + expect(capturedUrl).toBe("https://ideal-louse-61.clerk.accounts.dev/v1/dev_browser"); + expect(capturedMethod).toBe("POST"); + }); + + test("throws CliError on non-2xx response", async () => { + stubFetch(async () => new Response("nope", { status: 500 })); + await expect(bootstrapDevBrowser("foo.example.com")).rejects.toThrow(CliError); + }); +}); + +describe("fetchUserSettings", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("GETs /v1/environment and returns user_settings", async () => { + let capturedUrl = ""; + stubFetch(async (input) => { + capturedUrl = String(input); + return new Response( + JSON.stringify({ + user_settings: { + attributes: { + email_address: { enabled: true, required: true, used_for_first_factor: true }, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const settings = await fetchUserSettings("foo.example.com", { jwt: "jwt-abc" }); + + expect(settings.attributes.email_address?.enabled).toBe(true); + expect(capturedUrl).toContain("https://foo.example.com/v1/environment"); + expect(capturedUrl).toContain("__clerk_db_jwt=jwt-abc"); + expect(capturedUrl).toContain("_clerk_js_version=5"); + }); + + test("omits __clerk_db_jwt when no jwt is provided", async () => { + let capturedUrl = ""; + stubFetch(async (input) => { + capturedUrl = String(input); + return new Response(JSON.stringify({ user_settings: { attributes: {} } }), { status: 200 }); + }); + + await fetchUserSettings("foo.example.com", {}); + expect(capturedUrl).not.toContain("__clerk_db_jwt"); + }); + + test("throws CliError on non-2xx response", async () => { + stubFetch(async () => new Response("nope", { status: 401 })); + await expect(fetchUserSettings("foo.example.com", {})).rejects.toThrow(CliError); + }); +}); diff --git a/packages/cli-core/src/lib/fapi.ts b/packages/cli-core/src/lib/fapi.ts new file mode 100644 index 00000000..4db08fea --- /dev/null +++ b/packages/cli-core/src/lib/fapi.ts @@ -0,0 +1,103 @@ +/** + * Frontend API (FAPI) client. + * Thin HTTP wrapper for Clerk's Frontend API endpoints used by the interactive + * users wizard. Sibling to lib/plapi.ts. + */ + +import type { UserSettingsJSON } from "@clerk/shared/types"; +import { CliError, ERROR_CODE } from "./errors.ts"; +import { loggedFetch } from "./fetch.ts"; + +const PK_TEST_PREFIX = "pk_test_"; +const PK_LIVE_PREFIX = "pk_live_"; + +export type InstanceType = "development" | "production"; + +export type DecodedPublishableKey = { + fapiHost: string; + instanceType: InstanceType; +}; + +export function decodePublishableKey(pk: string): DecodedPublishableKey { + let instanceType: InstanceType; + let encoded: string; + + if (pk.startsWith(PK_TEST_PREFIX)) { + instanceType = "development"; + encoded = pk.slice(PK_TEST_PREFIX.length); + } else if (pk.startsWith(PK_LIVE_PREFIX)) { + instanceType = "production"; + encoded = pk.slice(PK_LIVE_PREFIX.length); + } else { + throw new CliError("Invalid publishable key format: missing pk_test_ or pk_live_ prefix.", { + code: ERROR_CODE.INVALID_KEY_FORMAT, + }); + } + + let decoded: string; + try { + decoded = atob(encoded); + } catch { + throw new CliError("Invalid publishable key format: not valid base64.", { + code: ERROR_CODE.INVALID_KEY_FORMAT, + }); + } + + if (!decoded.endsWith("$")) { + throw new CliError("Invalid publishable key format: decoded host missing $ terminator.", { + code: ERROR_CODE.INVALID_KEY_FORMAT, + }); + } + + return { + fapiHost: decoded.slice(0, -1), + instanceType, + }; +} + +export type { UserSettingsJSON }; + +export async function bootstrapDevBrowser(fapiHost: string): Promise { + const url = new URL(`https://${fapiHost}/v1/dev_browser`); + const response = await loggedFetch(url, { tag: "fapi", method: "POST" }); + if (!response.ok) { + throw new CliError( + `Failed to bootstrap dev browser: ${response.status} ${response.statusText}`, + { + code: ERROR_CODE.FAPI_ERROR, + }, + ); + } + const body = (await response.json()) as { token?: unknown }; + if (typeof body.token !== "string" || body.token.length === 0) { + throw new CliError("Dev browser response did not include a token.", { + code: ERROR_CODE.FAPI_ERROR, + }); + } + return body.token; +} + +export async function fetchUserSettings( + fapiHost: string, + opts: { jwt?: string }, +): Promise { + const url = new URL(`https://${fapiHost}/v1/environment`); + url.searchParams.set("_clerk_js_version", "5"); + if (opts.jwt) { + url.searchParams.set("__clerk_db_jwt", opts.jwt); + } + const response = await loggedFetch(url, { tag: "fapi", method: "GET" }); + if (!response.ok) { + throw new CliError( + `Failed to fetch instance settings: ${response.status} ${response.statusText}`, + { code: ERROR_CODE.FAPI_ERROR }, + ); + } + const body = (await response.json()) as { user_settings?: UserSettingsJSON }; + if (!body.user_settings) { + throw new CliError("FAPI environment response did not include user_settings.", { + code: ERROR_CODE.FAPI_ERROR, + }); + } + return body.user_settings; +} From 978c20421577e57f2e7bd1317ea803be226fef7b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:02:27 -0600 Subject: [PATCH 04/29] refactor(fapi): add FapiError class, factor private fapiFetch wrapper, validate hostname - Add FapiError extends ApiError in errors.ts so HTTP failures carry status, body, and url instead of a stringified CliError message. - Extend cli-program.ts verbose branch to print url for FapiError as well as PlapiError. - Factor fapiFetch/fapiFetchJson private helpers in fapi.ts so both public functions share a single HTTP + JSON-parse path with safe error handling. - Validate the decoded FAPI hostname against /^[a-zA-Z0-9.-]+$/ in decodePublishableKey to reject keys with embedded slashes, @, etc. - Lift the magic literal "5" into CLERK_JS_API_VERSION constant. - Update tests: existing 500/401 cases now assert FapiError; add missing-token and missing-user_settings cases; add hostname validation cases for "/" and "@" characters. --- packages/cli-core/src/cli-program.ts | 3 +- packages/cli-core/src/lib/errors.ts | 23 ++++++++++ packages/cli-core/src/lib/fapi.test.ts | 27 +++++++++-- packages/cli-core/src/lib/fapi.ts | 62 +++++++++++++++++--------- 4 files changed, 90 insertions(+), 25 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index abe303bb..f5b1ca54 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -35,6 +35,7 @@ import { UserAbortError, ApiError, PlapiError, + FapiError, EXIT_CODE, throwUsageError, } from "./lib/errors.ts"; @@ -734,7 +735,7 @@ export async function runProgram( ); } else { log.error(`${prefix} (${error.status}): ${detail}`); - if (verbose && error instanceof PlapiError && error.url) { + if (verbose && (error instanceof PlapiError || error instanceof FapiError) && error.url) { log.error(` URL: ${error.url}`); } } diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index b0bd7436..ef577503 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -197,6 +197,29 @@ export class PlapiError extends ApiError { } } +/** + * Error from the Clerk Frontend API (FAPI). + * + * Thrown by `src/lib/fapi.ts` helpers when a Frontend API request fails. + * Displayed as "Frontend API request failed" in the global error handler when + * wrapped with `withApiContext()`. Carries the request URL so verbose mode can + * surface it for debugging. + * + * @param status - HTTP status code + * @param body - Raw response body text + * @param url - The request URL that failed + */ +export class FapiError extends ApiError { + constructor( + status: number, + body: string, + public url?: string, + ) { + super(status, body); + this.name = "FapiError"; + } +} + /** * Error from the Clerk Backend API (BAPI). * diff --git a/packages/cli-core/src/lib/fapi.test.ts b/packages/cli-core/src/lib/fapi.test.ts index bcdad7b8..2e7c4291 100644 --- a/packages/cli-core/src/lib/fapi.test.ts +++ b/packages/cli-core/src/lib/fapi.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe, afterEach } from "bun:test"; import { decodePublishableKey, bootstrapDevBrowser, fetchUserSettings } from "./fapi.ts"; -import { CliError } from "./errors.ts"; +import { CliError, FapiError } from "./errors.ts"; import { stubFetch } from "../test/lib/stubs.ts"; describe("decodePublishableKey", () => { @@ -26,6 +26,17 @@ describe("decodePublishableKey", () => { // base64("clerk.example.com") = "Y2xlcmsuZXhhbXBsZS5jb20=" expect(() => decodePublishableKey("pk_test_Y2xlcmsuZXhhbXBsZS5jb20=")).toThrow(CliError); }); + + test("throws CliError when decoded host contains a slash", () => { + // base64("clerk.example.com/path$") = encodeToB64("clerk.example.com/path$") + const encoded = btoa("clerk.example.com/path$"); + expect(() => decodePublishableKey(`pk_test_${encoded}`)).toThrow(CliError); + }); + + test("throws CliError when decoded host contains an @ sign", () => { + const encoded = btoa("user@clerk.example.com$"); + expect(() => decodePublishableKey(`pk_test_${encoded}`)).toThrow(CliError); + }); }); describe("bootstrapDevBrowser", () => { @@ -53,8 +64,13 @@ describe("bootstrapDevBrowser", () => { expect(capturedMethod).toBe("POST"); }); - test("throws CliError on non-2xx response", async () => { + test("throws FapiError on non-2xx response", async () => { stubFetch(async () => new Response("nope", { status: 500 })); + await expect(bootstrapDevBrowser("foo.example.com")).rejects.toThrow(FapiError); + }); + + test("throws when response has no token", async () => { + stubFetch(async () => new Response(JSON.stringify({}), { status: 200 })); await expect(bootstrapDevBrowser("foo.example.com")).rejects.toThrow(CliError); }); }); @@ -100,8 +116,13 @@ describe("fetchUserSettings", () => { expect(capturedUrl).not.toContain("__clerk_db_jwt"); }); - test("throws CliError on non-2xx response", async () => { + test("throws FapiError on non-2xx response", async () => { stubFetch(async () => new Response("nope", { status: 401 })); + await expect(fetchUserSettings("foo.example.com", {})).rejects.toThrow(FapiError); + }); + + test("throws when response has no user_settings", async () => { + stubFetch(async () => new Response(JSON.stringify({}), { status: 200 })); await expect(fetchUserSettings("foo.example.com", {})).rejects.toThrow(CliError); }); }); diff --git a/packages/cli-core/src/lib/fapi.ts b/packages/cli-core/src/lib/fapi.ts index 4db08fea..abc56b65 100644 --- a/packages/cli-core/src/lib/fapi.ts +++ b/packages/cli-core/src/lib/fapi.ts @@ -5,12 +5,18 @@ */ import type { UserSettingsJSON } from "@clerk/shared/types"; -import { CliError, ERROR_CODE } from "./errors.ts"; +import { CliError, FapiError, ERROR_CODE } from "./errors.ts"; import { loggedFetch } from "./fetch.ts"; const PK_TEST_PREFIX = "pk_test_"; const PK_LIVE_PREFIX = "pk_live_"; +/** + * The clerk-js client version FAPI's `/v1/environment` payload is shaped for. + * Bump when consuming response fields introduced in a later major version. + */ +const CLERK_JS_API_VERSION = "5"; + export type InstanceType = "development" | "production"; export type DecodedPublishableKey = { @@ -49,26 +55,47 @@ export function decodePublishableKey(pk: string): DecodedPublishableKey { }); } + const host = decoded.slice(0, -1); + + if (!/^[a-zA-Z0-9.-]+$/.test(host)) { + throw new CliError( + "Invalid publishable key format: decoded host contains invalid characters.", + { code: ERROR_CODE.INVALID_KEY_FORMAT }, + ); + } + return { - fapiHost: decoded.slice(0, -1), + fapiHost: host, instanceType, }; } export type { UserSettingsJSON }; -export async function bootstrapDevBrowser(fapiHost: string): Promise { - const url = new URL(`https://${fapiHost}/v1/dev_browser`); - const response = await loggedFetch(url, { tag: "fapi", method: "POST" }); +async function fapiFetch(method: "GET" | "POST", url: URL): Promise { + const response = await loggedFetch(url, { tag: "fapi", method }); if (!response.ok) { - throw new CliError( - `Failed to bootstrap dev browser: ${response.status} ${response.statusText}`, - { - code: ERROR_CODE.FAPI_ERROR, - }, - ); + const body = await response.text(); + throw new FapiError(response.status, body, url.toString()); } - const body = (await response.json()) as { token?: unknown }; + return response; +} + +async function fapiFetchJson(method: "GET" | "POST", url: URL): Promise { + const response = await fapiFetch(method, url); + const text = await response.text(); + try { + return JSON.parse(text) as T; + } catch { + throw new CliError(`FAPI returned non-JSON response from ${url.pathname}.`, { + code: ERROR_CODE.FAPI_ERROR, + }); + } +} + +export async function bootstrapDevBrowser(fapiHost: string): Promise { + const url = new URL(`https://${fapiHost}/v1/dev_browser`); + const body = await fapiFetchJson<{ token?: unknown }>("POST", url); if (typeof body.token !== "string" || body.token.length === 0) { throw new CliError("Dev browser response did not include a token.", { code: ERROR_CODE.FAPI_ERROR, @@ -82,18 +109,11 @@ export async function fetchUserSettings( opts: { jwt?: string }, ): Promise { const url = new URL(`https://${fapiHost}/v1/environment`); - url.searchParams.set("_clerk_js_version", "5"); + url.searchParams.set("_clerk_js_version", CLERK_JS_API_VERSION); if (opts.jwt) { url.searchParams.set("__clerk_db_jwt", opts.jwt); } - const response = await loggedFetch(url, { tag: "fapi", method: "GET" }); - if (!response.ok) { - throw new CliError( - `Failed to fetch instance settings: ${response.status} ${response.statusText}`, - { code: ERROR_CODE.FAPI_ERROR }, - ); - } - const body = (await response.json()) as { user_settings?: UserSettingsJSON }; + const body = await fapiFetchJson<{ user_settings?: UserSettingsJSON }>("GET", url); if (!body.user_settings) { throw new CliError("FAPI environment response did not include user_settings.", { code: ERROR_CODE.FAPI_ERROR, From 58a1c84e70195b34ca995b235116490d4159a113 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:04:51 -0600 Subject: [PATCH 05/29] feat(users): add interactive attribute helpers Add `isEnabled`, `isRequired`, and `enabledAttributes` pure helper functions over `UserSettingsJSON` in the new `interactive/` subdirectory. --- .../users/interactive/attributes.test.ts | 32 +++++++++++++++++++ .../commands/users/interactive/attributes.ts | 25 +++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packages/cli-core/src/commands/users/interactive/attributes.test.ts create mode 100644 packages/cli-core/src/commands/users/interactive/attributes.ts diff --git a/packages/cli-core/src/commands/users/interactive/attributes.test.ts b/packages/cli-core/src/commands/users/interactive/attributes.test.ts new file mode 100644 index 00000000..4c104ac1 --- /dev/null +++ b/packages/cli-core/src/commands/users/interactive/attributes.test.ts @@ -0,0 +1,32 @@ +import { test, expect, describe } from "bun:test"; +import { isEnabled, isRequired, enabledAttributes } from "./attributes.ts"; +import type { UserSettingsJSON } from "@clerk/shared/types"; + +const settings = { + attributes: { + email_address: { enabled: true, required: false, used_for_first_factor: true }, + password: { enabled: true, required: true, used_for_first_factor: false }, + username: { enabled: false, required: false, used_for_first_factor: false }, + }, +} as unknown as UserSettingsJSON; + +describe("attributes helpers", () => { + test("isEnabled returns true for enabled attributes", () => { + expect(isEnabled(settings, "email_address")).toBe(true); + expect(isEnabled(settings, "username")).toBe(false); + }); + + test("isRequired returns true only when required and enabled", () => { + expect(isRequired(settings, "password")).toBe(true); + expect(isRequired(settings, "email_address")).toBe(false); + expect(isRequired(settings, "username")).toBe(false); + }); + + test("isEnabled returns false for unknown attribute", () => { + expect(isEnabled(settings, "phone_number")).toBe(false); + }); + + test("enabledAttributes returns only enabled attribute names", () => { + expect(enabledAttributes(settings)).toEqual(["email_address", "password"]); + }); +}); diff --git a/packages/cli-core/src/commands/users/interactive/attributes.ts b/packages/cli-core/src/commands/users/interactive/attributes.ts new file mode 100644 index 00000000..7e514879 --- /dev/null +++ b/packages/cli-core/src/commands/users/interactive/attributes.ts @@ -0,0 +1,25 @@ +import type { Attribute, AttributeDataJSON, UserSettingsJSON } from "@clerk/shared/types"; + +export type AttributeName = Attribute; + +function attribute(settings: UserSettingsJSON, name: AttributeName): AttributeDataJSON | undefined { + const attrs = settings.attributes as Record; + return attrs[name]; +} + +export function isEnabled(settings: UserSettingsJSON, name: AttributeName): boolean { + return attribute(settings, name)?.enabled === true; +} + +export function isRequired(settings: UserSettingsJSON, name: AttributeName): boolean { + const attr = attribute(settings, name); + return attr?.enabled === true && attr.required === true; +} + +export function enabledAttributes(settings: UserSettingsJSON): AttributeName[] { + return ( + Object.entries(settings.attributes) as Array<[AttributeName, AttributeDataJSON | undefined]> + ) + .filter(([, data]) => data?.enabled === true) + .map(([name]) => name); +} From ae3e6e0a2993119f8a025d98413a1771c0241175 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:07:27 -0600 Subject: [PATCH 06/29] feat(users): resolve instance context with publishable key --- .../interactive/instance-context.test.ts | 68 +++++++++++++++++++ .../users/interactive/instance-context.ts | 67 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 packages/cli-core/src/commands/users/interactive/instance-context.test.ts create mode 100644 packages/cli-core/src/commands/users/interactive/instance-context.ts 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 new file mode 100644 index 00000000..3949a553 --- /dev/null +++ b/packages/cli-core/src/commands/users/interactive/instance-context.test.ts @@ -0,0 +1,68 @@ +import { test, expect, describe, beforeEach, mock } from "bun:test"; + +const mockResolveAppContext = mock(); +const mockFetchApplication = mock(); + +mock.module("../../../lib/config.ts", () => ({ + resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), + resolveFetchedApplicationInstance: ( + _appId: string, + app: { + instances: { + environment_type: string; + instance_id: string; + secret_key?: string; + publishable_key: string; + }[]; + }, + _instance?: string, + ) => { + const development = app.instances.find((i) => i.environment_type === "development"); + if (!development) return { found: false, instanceId: "unknown", instanceLabel: "unknown" }; + return { + found: true, + instance: development, + instanceId: development.instance_id, + instanceLabel: "development", + }; + }, +})); +mock.module("../../../lib/plapi.ts", () => ({ + fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + validateKeyPrefix: () => {}, +})); + +const { resolveUsersInstanceContext } = await import("./instance-context.ts"); + +describe("resolveUsersInstanceContext", () => { + beforeEach(() => { + mockResolveAppContext.mockReset(); + mockFetchApplication.mockReset(); + }); + + test("returns publishable key when --app is provided", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_123", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_xyz", + }, + ], + }); + + const ctx = await resolveUsersInstanceContext({ app: "app_123" }); + expect(ctx.secretKey).toBe("sk_test_xyz"); + expect(ctx.publishableKey).toBe("pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA"); + expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); + }); + + test("returns undefined publishable key when only --secret-key is provided", async () => { + const ctx = await resolveUsersInstanceContext({ secretKey: "sk_test_raw" }); + expect(ctx.secretKey).toBe("sk_test_raw"); + expect(ctx.publishableKey).toBeUndefined(); + expect(ctx.fapiHost).toBeUndefined(); + }); +}); diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.ts b/packages/cli-core/src/commands/users/interactive/instance-context.ts new file mode 100644 index 00000000..a5881fd8 --- /dev/null +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -0,0 +1,67 @@ +import { resolveAppContext, resolveFetchedApplicationInstance } from "../../../lib/config.ts"; +import { CliError, ERROR_CODE, withApiContext } from "../../../lib/errors.ts"; +import { fetchApplication, validateKeyPrefix } from "../../../lib/plapi.ts"; +import { decodePublishableKey } from "../../../lib/fapi.ts"; + +export type UsersInstanceContext = { + secretKey: string; + publishableKey?: string; + fapiHost?: string; +}; + +export type ResolveUsersInstanceContextOptions = { + app?: string; + instance?: string; + secretKey?: string; +}; + +export async function resolveUsersInstanceContext( + options: ResolveUsersInstanceContextOptions, +): Promise { + if (options.secretKey && !options.app) { + validateKeyPrefix(options.secretKey, "sk_"); + return { secretKey: options.secretKey }; + } + + let appId: string | undefined = options.app; + let instanceHint: string | undefined = options.instance; + + if (!appId) { + try { + const ctx = await resolveAppContext({ instance: options.instance }); + appId = ctx.appId; + instanceHint = ctx.instanceId; + } catch (error) { + if (error instanceof CliError && error.code === ERROR_CODE.NOT_LINKED && options.secretKey) { + validateKeyPrefix(options.secretKey, "sk_"); + return { secretKey: options.secretKey }; + } + throw error; + } + } + + const app = await withApiContext(fetchApplication(appId), "Failed to resolve instance context"); + const resolved = resolveFetchedApplicationInstance(appId, app, instanceHint); + if (!resolved.found) { + throw new CliError(`Instance ${resolved.instanceId} not found in application.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + const instance = resolved.instance; + if (!instance.secret_key) { + throw new CliError(`No secret key found for ${resolved.instanceLabel} instance.`, { + code: ERROR_CODE.NO_SECRET_KEY, + }); + } + + const ctx: UsersInstanceContext = { secretKey: options.secretKey ?? instance.secret_key }; + if (instance.publishable_key) { + ctx.publishableKey = instance.publishable_key; + try { + ctx.fapiHost = decodePublishableKey(instance.publishable_key).fapiHost; + } catch { + // Leave fapiHost undefined if the publishable key is malformed. + } + } + return ctx; +} From 36e37f93a027f1b530d0174a2c3e4cb39c28567a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:09:35 -0600 Subject: [PATCH 07/29] feat(users): add interactive user picker --- .../users/interactive/pick-user.test.ts | 76 +++++++++++++++++++ .../commands/users/interactive/pick-user.ts | 47 ++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 packages/cli-core/src/commands/users/interactive/pick-user.test.ts create mode 100644 packages/cli-core/src/commands/users/interactive/pick-user.ts 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 new file mode 100644 index 00000000..7a374685 --- /dev/null +++ b/packages/cli-core/src/commands/users/interactive/pick-user.test.ts @@ -0,0 +1,76 @@ +import { test, expect, describe, beforeEach, mock } from "bun:test"; + +const mockBapiRequest = mock(); +const mockSearch = mock(); + +mock.module("../../api/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); +mock.module("../../../lib/listage.ts", () => ({ + search: (...args: unknown[]) => mockSearch(...args), + filterChoices: () => [], + Separator: class {}, +})); + +const { pickUser, formatUserChoice } = await import("./pick-user.ts"); + +describe("pickUser", () => { + beforeEach(() => { + mockBapiRequest.mockReset(); + mockSearch.mockReset(); + }); + + test("calls bapiRequest with /users?query=...&limit=20 when source is invoked", async () => { + let capturedSource: + | ((term: string | undefined, opt: { signal: AbortSignal }) => Promise) + | undefined; + mockSearch.mockImplementation(async (config: { source: typeof capturedSource }) => { + capturedSource = config.source; + return "user_picked"; + }); + mockBapiRequest.mockResolvedValue({ + status: 200, + headers: new Headers(), + body: [{ id: "user_1", first_name: "Alice", email_addresses: [{ email_address: "a@b.co" }] }], + rawBody: "[]", + }); + + const result = await pickUser({ secretKey: "sk_test_xyz" }); + expect(result).toBe("user_picked"); + + const choices = await capturedSource!("ali", { signal: new AbortController().signal }); + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "GET", + path: expect.stringContaining("/users?query=ali"), + secretKey: "sk_test_xyz", + }); + expect(choices).toHaveLength(1); + expect((choices[0] as { value: string }).value).toBe("user_1"); + }); +}); + +describe("formatUserChoice", () => { + test("renders name + email + id", () => { + expect( + formatUserChoice({ + id: "user_1", + first_name: "Alice", + last_name: "Smith", + email_addresses: [{ email_address: "a@example.com" }], + }), + ).toBe("Alice Smith (a@example.com) — user_1"); + }); + + test("falls back to email when no name", () => { + expect( + formatUserChoice({ + id: "user_2", + email_addresses: [{ email_address: "b@example.com" }], + }), + ).toBe("b@example.com (b@example.com) — user_2"); + }); + + test("falls back to user_id when no name or email", () => { + expect(formatUserChoice({ id: "user_3" })).toBe("user_3 (no email) — user_3"); + }); +}); diff --git a/packages/cli-core/src/commands/users/interactive/pick-user.ts b/packages/cli-core/src/commands/users/interactive/pick-user.ts new file mode 100644 index 00000000..0205ab19 --- /dev/null +++ b/packages/cli-core/src/commands/users/interactive/pick-user.ts @@ -0,0 +1,47 @@ +import { search } from "../../../lib/listage.ts"; +import { bapiRequest } from "../../api/bapi.ts"; + +export type PickUserOptions = { + secretKey: string; + message?: string; +}; + +type UserSummary = { + id: string; + first_name?: string | null; + last_name?: string | null; + username?: string | null; + email_addresses?: Array<{ email_address?: string }> | null; +}; + +export function formatUserChoice(user: UserSummary): string { + const email = user.email_addresses?.[0]?.email_address ?? "no email"; + const nameParts = [user.first_name, user.last_name].filter( + (part): part is string => typeof part === "string" && part.length > 0, + ); + const name = + nameParts.length > 0 + ? nameParts.join(" ") + : user.username || (email !== "no email" ? email : user.id); + return `${name} (${email}) — ${user.id}`; +} + +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"; + const response = await bapiRequest({ + method: "GET", + path: `/users${query}`, + secretKey: options.secretKey, + }); + const body = response.body as UserSummary[] | undefined; + if (!Array.isArray(body)) return []; + return body.map((user) => ({ + value: user.id, + name: formatUserChoice(user), + })); + }, + }); +} From 00f251f7fb5d0c7897ac5f920970802ff7fe6b12 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:13:04 -0600 Subject: [PATCH 08/29] feat(users): add action registry and register create Introduces a side-effect-driven action registry (registry.ts) with registerUsersAction / listUsersActions / __resetUsersActionRegistryForTesting, re-exported from index.ts. Registers the create action in create.ts. A separate registry.ts breaks the circular dependency that would arise from create.ts importing index.ts while index.ts imports create.ts. --- .../cli-core/src/commands/users/create.ts | 10 +++++++ .../cli-core/src/commands/users/index.test.ts | 22 +++++++++++++++ packages/cli-core/src/commands/users/index.ts | 7 +++++ .../cli-core/src/commands/users/registry.ts | 27 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 packages/cli-core/src/commands/users/index.test.ts create mode 100644 packages/cli-core/src/commands/users/registry.ts diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index 4db34965..392dc0c9 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -13,6 +13,7 @@ import { bapiRequest } from "../api/bapi.ts"; import { confirm } from "../../lib/prompts.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { handleUsersBapiError, printUsersMutationResult } from "./output.ts"; +import { registerUsersAction } from "./registry.ts"; type CreateUserOptions = { email?: string; @@ -126,3 +127,12 @@ async function confirmMutation( throwUserAbort(); } } + +registerUsersAction({ + key: "create", + label: "Create user", + description: "Create a new user", + handler: async (targeting) => { + await create(targeting); + }, +}); diff --git a/packages/cli-core/src/commands/users/index.test.ts b/packages/cli-core/src/commands/users/index.test.ts new file mode 100644 index 00000000..20d4779a --- /dev/null +++ b/packages/cli-core/src/commands/users/index.test.ts @@ -0,0 +1,22 @@ +import { test, expect, describe, beforeEach } from "bun:test"; + +describe("users action registry", () => { + beforeEach(async () => { + const mod = await import("./index.ts"); + mod.__resetUsersActionRegistryForTesting(); + }); + + test("registerUsersAction appends to listUsersActions in order", async () => { + const { registerUsersAction, listUsersActions } = await import("./index.ts"); + registerUsersAction({ key: "a", label: "A", description: "first", handler: async () => {} }); + registerUsersAction({ key: "b", label: "B", description: "second", handler: async () => {} }); + expect(listUsersActions().map((a) => a.key)).toEqual(["a", "b"]); + }); + + test("listUsersActions returns a frozen view (not a mutable reference)", async () => { + const { registerUsersAction, listUsersActions } = await import("./index.ts"); + registerUsersAction({ key: "a", label: "A", description: "x", handler: async () => {} }); + const view = listUsersActions(); + expect(() => (view as unknown as Array).push("nope")).toThrow(); + }); +}); diff --git a/packages/cli-core/src/commands/users/index.ts b/packages/cli-core/src/commands/users/index.ts index 2f02e803..61b8e90e 100644 --- a/packages/cli-core/src/commands/users/index.ts +++ b/packages/cli-core/src/commands/users/index.ts @@ -1,5 +1,12 @@ import { create } from "./create.ts"; +export type { UsersActionTargeting, UsersAction } from "./registry.ts"; +export { + registerUsersAction, + listUsersActions, + __resetUsersActionRegistryForTesting, +} from "./registry.ts"; + export const users = { create, }; diff --git a/packages/cli-core/src/commands/users/registry.ts b/packages/cli-core/src/commands/users/registry.ts new file mode 100644 index 00000000..e2aef5a7 --- /dev/null +++ b/packages/cli-core/src/commands/users/registry.ts @@ -0,0 +1,27 @@ +export type UsersActionTargeting = { + app?: string; + instance?: string; + secretKey?: string; +}; + +export type UsersAction = { + key: string; + label: string; + description: string; + handler: (targeting: UsersActionTargeting) => Promise; +}; + +const REGISTRY: UsersAction[] = []; + +export function registerUsersAction(action: UsersAction): void { + REGISTRY.push(action); +} + +export function listUsersActions(): readonly UsersAction[] { + return Object.freeze([...REGISTRY]); +} + +/** Test-only: clear the registry between tests. Do not call from production code. */ +export function __resetUsersActionRegistryForTesting(): void { + REGISTRY.length = 0; +} From 8991d4a07d78a96c0173efe9f6a2144072eaffc6 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:16:57 -0600 Subject: [PATCH 09/29] feat(users): add top-level interactive menu --- packages/cli-core/src/cli-program.ts | 3 +- packages/cli-core/src/commands/users/index.ts | 2 + .../cli-core/src/commands/users/menu.test.ts | 81 +++++++++++++++++++ packages/cli-core/src/commands/users/menu.ts | 43 ++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 packages/cli-core/src/commands/users/menu.test.ts create mode 100644 packages/cli-core/src/commands/users/menu.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index f5b1ca54..e5f7f49d 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -272,7 +272,8 @@ Give AI agents better Clerk context: install the Clerk skills command: 'clerk users create -d \'{"email_address":["alice@example.com"]}\' --yes', description: "Create a user from an inline BAPI request body", }, - ]); + ]) + .action(usersHandlers.menu); users .command("create") diff --git a/packages/cli-core/src/commands/users/index.ts b/packages/cli-core/src/commands/users/index.ts index 61b8e90e..051760e0 100644 --- a/packages/cli-core/src/commands/users/index.ts +++ b/packages/cli-core/src/commands/users/index.ts @@ -1,4 +1,5 @@ import { create } from "./create.ts"; +import { usersMenu } from "./menu.ts"; export type { UsersActionTargeting, UsersAction } from "./registry.ts"; export { @@ -9,4 +10,5 @@ export { export const users = { create, + menu: usersMenu, }; diff --git a/packages/cli-core/src/commands/users/menu.test.ts b/packages/cli-core/src/commands/users/menu.test.ts new file mode 100644 index 00000000..caa8bc9e --- /dev/null +++ b/packages/cli-core/src/commands/users/menu.test.ts @@ -0,0 +1,81 @@ +import { test, expect, describe, beforeEach, mock } from "bun:test"; + +const mockSelect = mock(); +const mockIntro = mock(); +const mockOutro = mock(); +const mockIsAgent = mock(() => false); +const mockThrowUsageError = mock((msg: string) => { + throw new Error(msg); +}); + +mock.module("../../lib/listage.ts", () => ({ + select: (...args: unknown[]) => mockSelect(...args), + filterChoices: () => [], + Separator: class {}, +})); +mock.module("../../lib/spinner.ts", () => ({ + intro: (...args: unknown[]) => mockIntro(...args), + outro: (...args: unknown[]) => mockOutro(...args), + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); +mock.module("../../mode.ts", () => ({ + isAgent: () => mockIsAgent(), + isHuman: () => !mockIsAgent(), + setMode: () => {}, + getMode: () => "human", +})); +mock.module("../../lib/errors.ts", () => ({ + throwUsageError: (msg: string) => mockThrowUsageError(msg), + CliError: class extends Error {}, + ERROR_CODE: {}, + EXIT_CODE: { USAGE: 2 }, +})); + +const { __resetUsersActionRegistryForTesting, registerUsersAction } = await import("./registry.ts"); +const { usersMenu } = await import("./menu.ts"); + +describe("usersMenu", () => { + beforeEach(() => { + __resetUsersActionRegistryForTesting(); + mockSelect.mockReset(); + mockIntro.mockReset(); + mockOutro.mockReset(); + // Use mockClear to preserve the throwing implementation between tests. + mockThrowUsageError.mockClear(); + mockIsAgent.mockReturnValue(false); + }); + + test("dispatches to the selected action handler with targeting options", async () => { + const handlerCalls: unknown[] = []; + registerUsersAction({ + key: "create", + label: "Create user", + description: "Create a new user", + handler: async (t) => { + handlerCalls.push(t); + }, + }); + mockSelect.mockResolvedValue("create"); + + await usersMenu({ app: "app_123" }); + + expect(mockIntro).toHaveBeenCalledWith("clerk users"); + expect(mockSelect).toHaveBeenCalled(); + expect(handlerCalls).toEqual([{ app: "app_123" }]); + }); + + test("in agent mode, prints structured guidance and throws usage error", async () => { + mockIsAgent.mockReturnValue(true); + registerUsersAction({ + key: "create", + label: "Create user", + description: "Create a new user", + handler: async () => {}, + }); + + await expect(usersMenu({})).rejects.toThrow(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockThrowUsageError).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/users/menu.ts b/packages/cli-core/src/commands/users/menu.ts new file mode 100644 index 00000000..9b928f3e --- /dev/null +++ b/packages/cli-core/src/commands/users/menu.ts @@ -0,0 +1,43 @@ +import { select } from "../../lib/listage.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; +import { log } from "../../lib/log.ts"; +import { throwUsageError } from "../../lib/errors.ts"; +import { listUsersActions, type UsersActionTargeting } from "./registry.ts"; + +export async function usersMenu(targeting: UsersActionTargeting = {}): Promise { + const actions = listUsersActions(); + + if (isAgent()) { + log.info("clerk users requires a subcommand. Available actions:"); + for (const action of actions) { + log.info(` ${action.key.padEnd(16)} ${action.description}`); + } + throwUsageError("Pass a subcommand. Example: clerk users list"); + return; + } + + if (actions.length === 0) { + throwUsageError("No `clerk users` actions are registered."); + return; + } + + intro("clerk users"); + const chosenKey = await select({ + message: "What would you like to do?", + choices: actions.map((action) => ({ + value: action.key, + name: action.label, + description: action.description, + })), + }); + + const chosen = actions.find((action) => action.key === chosenKey); + if (!chosen) { + throwUsageError(`Unknown action: ${chosenKey}`); + return; + } + + await chosen.handler(targeting); + outro(); +} From 9753cb056f047e815459d07f1539454e41c319ce Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:22:22 -0600 Subject: [PATCH 10/29] feat(users): add interactive create wizard with FAPI-gated fields --- .../src/commands/users/create-wizard.test.ts | 98 +++++++++++++++++++ .../src/commands/users/create-wizard.ts | 83 ++++++++++++++++ .../src/commands/users/create.test.ts | 31 ++++++ .../cli-core/src/commands/users/create.ts | 37 +++++-- 4 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 packages/cli-core/src/commands/users/create-wizard.test.ts create mode 100644 packages/cli-core/src/commands/users/create-wizard.ts diff --git a/packages/cli-core/src/commands/users/create-wizard.test.ts b/packages/cli-core/src/commands/users/create-wizard.test.ts new file mode 100644 index 00000000..34de1fef --- /dev/null +++ b/packages/cli-core/src/commands/users/create-wizard.test.ts @@ -0,0 +1,98 @@ +import { test, expect, describe, beforeEach, mock } from "bun:test"; + +const mockResolveContext = mock(); +const mockFetchUserSettings = mock(); +const mockBootstrapDevBrowser = mock(); +const mockInput = mock(); +const mockPassword = mock(); + +mock.module("./interactive/instance-context.ts", () => ({ + resolveUsersInstanceContext: (...args: unknown[]) => mockResolveContext(...args), +})); +mock.module("../../lib/fapi.ts", () => ({ + fetchUserSettings: (...args: unknown[]) => mockFetchUserSettings(...args), + bootstrapDevBrowser: (...args: unknown[]) => mockBootstrapDevBrowser(...args), + decodePublishableKey: (pk: string) => ({ + fapiHost: "fake.example.com", + instanceType: pk.startsWith("pk_test_") ? "development" : "production", + }), +})); +mock.module("@inquirer/prompts", () => ({ + input: (...args: unknown[]) => mockInput(...args), + password: (...args: unknown[]) => mockPassword(...args), +})); +mock.module("../../lib/spinner.ts", () => ({ + withSpinner: async (_msg: string, fn: () => Promise) => fn(), + intro: () => {}, + outro: () => {}, + bar: () => {}, +})); + +const { runCreateWizard } = await import("./create-wizard.ts"); + +describe("runCreateWizard", () => { + beforeEach(() => { + mockResolveContext.mockReset(); + mockFetchUserSettings.mockReset(); + mockBootstrapDevBrowser.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + }); + + test("only prompts for enabled attributes (FAPI-driven)", async () => { + mockResolveContext.mockResolvedValue({ + secretKey: "sk_test_xyz", + publishableKey: "pk_test_xyz", + fapiHost: "fake.example.com", + }); + mockBootstrapDevBrowser.mockResolvedValue("jwt-abc"); + mockFetchUserSettings.mockResolvedValue({ + attributes: { + email_address: { enabled: true, required: true, used_for_first_factor: true }, + password: { enabled: true, required: true, used_for_first_factor: false }, + username: { enabled: false, required: false, used_for_first_factor: false }, + first_name: { enabled: true, required: false, used_for_first_factor: false }, + }, + }); + mockInput.mockResolvedValueOnce("alice@example.com").mockResolvedValueOnce("Alice"); + mockPassword.mockResolvedValueOnce("Password123"); + + const result = await runCreateWizard({}); + + expect(result).toEqual({ + email: "alice@example.com", + password: "Password123", + firstName: "Alice", + }); + // username was disabled — never prompted + expect(mockInput).toHaveBeenCalledTimes(2); + expect(mockPassword).toHaveBeenCalledTimes(1); + }); + + test("falls back to optional curated set when no publishable key resolvable", async () => { + mockResolveContext.mockResolvedValue({ secretKey: "sk_test_raw" }); + mockInput.mockResolvedValue(""); + mockPassword.mockResolvedValue(""); + + const result = await runCreateWizard({ secretKey: "sk_test_raw" }); + expect(result).toEqual({}); + expect(mockBootstrapDevBrowser).not.toHaveBeenCalled(); + expect(mockFetchUserSettings).not.toHaveBeenCalled(); + }); + + test("skips dev_browser bootstrap on production instance", async () => { + mockResolveContext.mockResolvedValue({ + secretKey: "sk_live_xyz", + publishableKey: "pk_live_xyz", + fapiHost: "clerk.example.com", + }); + mockFetchUserSettings.mockResolvedValue({ + attributes: { email_address: { enabled: true, required: false } }, + }); + mockInput.mockResolvedValueOnce(""); + + await runCreateWizard({}); + expect(mockBootstrapDevBrowser).not.toHaveBeenCalled(); + expect(mockFetchUserSettings).toHaveBeenCalledWith("clerk.example.com", {}); + }); +}); diff --git a/packages/cli-core/src/commands/users/create-wizard.ts b/packages/cli-core/src/commands/users/create-wizard.ts new file mode 100644 index 00000000..04e5f924 --- /dev/null +++ b/packages/cli-core/src/commands/users/create-wizard.ts @@ -0,0 +1,83 @@ +import { input, password } from "@inquirer/prompts"; +import { + bootstrapDevBrowser, + decodePublishableKey, + fetchUserSettings, + type InstanceType, + type UserSettingsJSON, +} from "../../lib/fapi.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { isEnabled, isRequired, type AttributeName } from "./interactive/attributes.ts"; +import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; + +export type CreateWizardResult = { + email?: string; + phone?: string; + username?: string; + password?: string; + firstName?: string; + lastName?: string; +}; + +type WizardOptions = { + app?: string; + instance?: string; + secretKey?: string; +}; + +type FieldDef = { + attr: AttributeName; + key: keyof CreateWizardResult; + message: string; + isPassword?: boolean; +}; + +const ALL_FIELDS: FieldDef[] = [ + { attr: "email_address", key: "email", message: "Email address" }, + { attr: "phone_number", key: "phone", message: "Phone number" }, + { attr: "username", key: "username", message: "Username" }, + { attr: "password", key: "password", message: "Password", isPassword: true }, + { attr: "first_name", key: "firstName", message: "First name" }, + { attr: "last_name", key: "lastName", message: "Last name" }, +]; + +export async function runCreateWizard(options: WizardOptions): Promise { + const ctx = await resolveUsersInstanceContext(options); + const settings = + ctx.fapiHost && ctx.publishableKey + ? await loadSettings(ctx.fapiHost, decodePublishableKey(ctx.publishableKey).instanceType) + : undefined; + + const result: CreateWizardResult = {}; + for (const field of ALL_FIELDS) { + const enabled = settings ? isEnabled(settings, field.attr) : true; + if (!enabled) continue; + const required = settings ? isRequired(settings, field.attr) : false; + const value = await promptField(field, required); + if (value) result[field.key] = value; + } + return result; +} + +async function loadSettings( + fapiHost: string, + instanceType: InstanceType, +): Promise { + return withSpinner("Loading instance settings...", async () => { + const jwt = instanceType === "development" ? await bootstrapDevBrowser(fapiHost) : undefined; + return fetchUserSettings(fapiHost, jwt ? { jwt } : {}); + }); +} + +async function promptField(field: FieldDef, required: boolean): Promise { + const message = required ? `${field.message} *` : `${field.message} (optional)`; + const validate = required + ? (value: string) => value.trim().length > 0 || `${field.message} is required` + : undefined; + if (field.isPassword) { + const value = await password({ message, validate }); + return value.trim(); + } + const value = await input({ message, validate }); + return value.trim(); +} diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index 86f33740..1e9d882b 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -22,6 +22,11 @@ mock.module("../../mode.ts", () => ({ getMode: () => "human", })); +const mockRunCreateWizard = mock(); +mock.module("./create-wizard.ts", () => ({ + runCreateWizard: (...args: unknown[]) => mockRunCreateWizard(...args), +})); + mock.module("@inquirer/prompts", () => promptsStubs); mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), @@ -37,6 +42,7 @@ describe("users create", () => { beforeEach(() => { mockIsAgent.mockReturnValue(false); mockResolveBapiSecretKey.mockResolvedValue("sk_test_123"); + mockRunCreateWizard.mockResolvedValue({}); mockBapiRequest.mockResolvedValue({ status: 200, headers: new Headers(), @@ -56,6 +62,7 @@ describe("users create", () => { mockHandleBapiError.mockImplementation(() => false); mockBapiRequest.mockReset(); mockIsAgent.mockReset(); + mockRunCreateWizard.mockReset(); logSpy.mockRestore(); errorSpy.mockRestore(); }); @@ -196,4 +203,28 @@ describe("users create", () => { }); expect(captured.err).not.toContain("Created user"); }); + + test("invokes the wizard when no flags + human + no data", async () => { + mockIsAgent.mockReturnValue(false); + mockRunCreateWizard.mockResolvedValue({ email: "alice@example.com" }); + await runCreate({ yes: true }); + expect(mockRunCreateWizard).toHaveBeenCalledWith({ + app: undefined, + instance: undefined, + secretKey: undefined, + }); + expect(mockBapiRequest).toHaveBeenCalled(); + }); + + test("does not invoke the wizard in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + let thrown: unknown; + try { + await runCreate({}); + } catch (e) { + thrown = e; + } + expect(thrown).toBeDefined(); + expect(mockRunCreateWizard).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index 392dc0c9..62ee13f8 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -14,6 +14,7 @@ import { confirm } from "../../lib/prompts.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { handleUsersBapiError, printUsersMutationResult } from "./output.ts"; import { registerUsersAction } from "./registry.ts"; +import { runCreateWizard } from "./create-wizard.ts"; type CreateUserOptions = { email?: string; @@ -73,29 +74,45 @@ export async function create(options: CreateUserOptions): Promise { } async function resolveCreatePayload(options: CreateUserOptions): Promise> { - const basePayload = await resolveBasePayload(options, hasCreateFlagPayload(options)); + const basePayload = await resolveBasePayload(options); return mergeUsersPayload(basePayload, buildCreateUserPayload(options)); } -async function resolveBasePayload( - options: { data?: string; file?: string }, - hasFlagPayload: boolean, -): Promise> { +async function resolveBasePayload(options: CreateUserOptions): Promise> { if (options.data || options.file) { return parseUsersPayload( await readUsersPayloadInput({ data: options.data, file: options.file }), ); } - if (hasFlagPayload) { + if (hasCreateFlagPayload(options)) { return {}; } - throwUsageError( + if (isHuman()) { + const wizardResult = await runCreateWizard({ + app: options.app, + instance: options.instance, + secretKey: options.secretKey, + }); + if (Object.keys(wizardResult).length === 0) { + throwUsageError(noInputMessage()); + } + Object.assign(options, wizardResult); + return {}; + } + + throwUsageError(noInputMessage()); + // unreachable + return {}; +} + +function noInputMessage(): string { + return ( "No input provided. Pass curated flags, -d , or --file .\n" + - " Example: clerk users create --email alice@example.com --first-name Alice\n" + - ' Example: clerk users create -d \'{"email_address":["alice@example.com"]}\'\n' + - " Example: clerk users create --file user.json", + " Example: clerk users create --email alice@example.com --first-name Alice\n" + + ' Example: clerk users create -d \'{"email_address":["alice@example.com"]}\'\n' + + " Example: clerk users create --file user.json" ); } From fffcff0e744090874186ad37182db326ae6d5444 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:26:21 -0600 Subject: [PATCH 11/29] docs(users): document interactive mode --- .changeset/users-scaffolding-and-create.md | 2 +- README.md | 187 +----------------- .../cli-core/src/commands/users/README.md | 8 + 3 files changed, 15 insertions(+), 182 deletions(-) diff --git a/.changeset/users-scaffolding-and-create.md b/.changeset/users-scaffolding-and-create.md index ac8602f9..afb36dce 100644 --- a/.changeset/users-scaffolding-and-create.md +++ b/.changeset/users-scaffolding-and-create.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk users create` for creating users from curated flags (`--email`, `--phone`, `--username`, `--password`, `--first-name`, `--last-name`, `--external-id`) or a raw BAPI request body via `-d, --data ` or `--file `. The command supports `--dry-run`, `--yes`, and `--json`. BAPI enforces identifier and required-field rules, so any BAPI secret key (`CLERK_SECRET_KEY`, `--secret-key`, or `--app`-resolved) is sufficient — no `applications:manage` Platform API scope is needed. Program-level `--input-json` drives the curated flags from a JSON object; `-d` / `--file` cover fields the curated flags don't expose. +Add `clerk users` command scaffolding with `clerk users create`, plus an interactive mode for the `users` family. The create wizard reads instance settings from the Frontend API to prompt only for enabled fields, marking required ones. A top-level interactive menu (`clerk users` with no subcommand) routes to registered actions; agent mode preserves the strict flag-driven contract. diff --git a/README.md b/README.md index 39005a7e..050c52a2 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ Clerk CLI Options: -v, --version Output the version number - --input-json Pass command options as a JSON string, @file.json, or - for stdin + --input-json Pass command options as a JSON string, @file.json, or - + for stdin --mode Force interaction mode (human or agent). Defaults to auto-detect based on TTY. --verbose Show detailed output (enables debug messages) @@ -34,194 +35,18 @@ Options: Commands: init [options] Initialize Clerk in your project auth Manage authentication - login|signup Log in to your Clerk account - logout|signout Log out of your Clerk account link [options] Link this project to a Clerk application unlink [options] Unlink this project from its Clerk application whoami Show the current logged-in user open Open Clerk resources in your browser - dashboard [options] [subpath] Open the linked app's dashboard apps Manage your Clerk applications - list [options] List your Clerk applications - create [options] Create a new Clerk application - config Manage instance configuration - pull [options] Pull instance configuration from Clerk - schema [options] Pull instance config schema from Clerk - patch [options] Partially update instance configuration (PATCH) - put [options] Replace entire instance configuration (PUT) + users Manage Clerk users env Manage environment variables - pull [options] Pull environment variables from Clerk to .env.local + config Manage instance configuration api [options] [endpoint] [filter] Make authenticated requests to the Clerk API - ls [filter] List available API endpoints - (no args) Interactive request builder (TTY only) doctor [options] Check your project's Clerk integration health - skill Manage the bundled Clerk CLI agent skill - install [options] Install the bundled clerk agent skill - switch-env [environment] Switch the active Clerk CLI environment completion [shell] Generate shell autocompletion script + skill Manage the bundled Clerk CLI agent skill update [options] Update the Clerk CLI to the latest version - -Give AI agents better Clerk context: install the Clerk skills - $ clerk skill install - -clerk init - --framework Framework to set up (skips auto-detection) - --pm Package manager to use (skips prompt/auto-detection) - --name Project name for --starter (skips prompt) - --app Application ID to link (skips interactive picker) - --starter Bootstrap a new project from a starter template - --prompt Output a prompt for an AI agent to integrate Clerk - --yes Skip confirmation prompts - --no-skills Skip the optional agent skills install prompt - Examples: - $ clerk init Auto-detect framework and set up Clerk - $ clerk init --framework next Set up for Next.js (skips detection) - $ clerk init --app app_123 Link to a specific Clerk application - $ clerk init --starter Create a new project with Clerk - $ clerk init --starter --framework next --pm bun Bootstrap with Bun - $ clerk init --prompt Output a setup prompt for an AI agent - $ clerk init -y Skip all confirmation prompts - $ clerk init --no-skills Skip the agent skills install prompt - -clerk auth login Log in via browser (OAuth) -clerk auth logout Remove stored credentials - -clerk link - --app Application ID to link (skips interactive picker) - Examples: - $ clerk link Pick an app interactively - $ clerk link --app app_abc123 Link directly by application ID - -clerk unlink - --yes Skip confirmation prompt - Examples: - $ clerk unlink Unlink with confirmation prompt - $ clerk unlink --yes Skip confirmation - -clerk whoami Show your email address - -clerk open [subpath] - --print Print the URL without opening the browser - Examples: - $ clerk open Open the linked app's dashboard - $ clerk open users Open the users page - $ clerk open api-keys Open the API keys page - $ clerk open --print Print the dashboard URL - -clerk config pull - --app Application ID to target (works from any directory) - --instance Instance to target (dev, prod, or a full instance ID) - --output Write config to a file instead of stdout - Examples: - $ clerk config pull Print dev config to stdout - $ clerk config pull --instance prod Pull production config - $ clerk config pull --output config.json Save config to a file - -clerk config schema - --app Application ID to target (works from any directory) - --instance Instance to target (dev, prod, or a full instance ID) - --output Write schema to a file instead of stdout - --keys Config keys to retrieve schema for - Examples: - $ clerk config schema Print full config schema - $ clerk config schema --keys social_login Schema for specific keys - $ clerk config schema --output schema.json Save schema to a file - -clerk config patch - --app Application ID to target (works from any directory) - --instance Instance to target (dev, prod, or a full instance ID) - --file Read config JSON from a file - --json Pass config JSON inline - --dry-run Show what would be sent without making the API call - --yes Skip confirmation prompts - Examples: - $ clerk config patch --file config.json Apply partial update from file - $ clerk config patch --json '{"key":"value"}' Inline JSON patch - $ clerk config patch --file config.json --dry-run Preview without applying - $ clerk config patch --instance prod --file config.json Patch production config - -clerk config put - --app Application ID to target (works from any directory) - --instance Instance to target (dev, prod, or a full instance ID) - --file Read config JSON from a file - --json Pass config JSON inline - --dry-run Show what would be sent without making the API call - --yes Skip confirmation prompts - Examples: - $ clerk config put --file config.json Replace entire config from file - $ clerk config put --file config.json --dry-run Preview the replacement - $ clerk config put --instance prod --file config.json Replace production config - $ clerk config put --file config.json --yes Skip confirmation prompt - -clerk env pull - --app Application ID to target (works from any directory) - --instance Instance to target (dev, prod, or a full instance ID) - --file Target env file (default: auto-detect) - Examples: - $ clerk env pull Pull dev keys to .env.local - $ clerk env pull --instance prod Pull production keys - $ clerk env pull --file .env Write to a specific file - $ clerk env pull --app app_abc123 Target a specific application - -clerk api [endpoint] [filter] - -X, --method HTTP method (default: GET, or POST if body provided) - -d, --data JSON request body - --file Read request body from a file - --include Show response headers - --app Application ID to target when resolving keys - --secret-key Override the secret key - --instance Instance to target (dev, prod, or instance ID) - --platform Use Platform API instead of Backend API - --dry-run Show the request without executing it - --yes Skip confirmation for mutating requests - Examples: - $ clerk api ls List all available endpoints - $ clerk api ls users List endpoints matching "users" - $ clerk api /users GET /v1/users - $ clerk api /users -d '{"first_name":"Alice"}' POST with a JSON body - -clerk api ls [filter] List available API endpoints -clerk api Interactive request builder (TTY only) - -clerk apps list - --json Output as JSON - -clerk apps create - --json Output as JSON - Examples: - $ clerk apps create "My App" Create a new application - $ clerk apps create "My App" --json Output as JSON - -clerk doctor - --verbose Show detailed output for each check - --json Output results as JSON - --spotlight Only show warnings and failures - --fix Attempt to auto-fix issues - Examples: - $ clerk doctor Run all health checks - $ clerk doctor --verbose Show detailed output for each check - $ clerk doctor --json Output results as machine-readable JSON - $ clerk doctor --fix Auto-fix detected issues - $ clerk doctor --spotlight Only show warnings and failures - -clerk skill install - -y, --yes Skip prompts and run the `skills` CLI unattended - --pm Package manager hint for runner detection - Examples: - $ clerk skill install Install with an interactive runner picker - $ clerk skill install -y Install unattended - $ clerk skill install --pm bun Force bunx as the runner - -clerk completion - shell: bash, zsh, fish, powershell - -clerk update - --channel Release channel to update to (e.g. latest, canary) - -y, --yes Skip confirmation prompt - --all Update every clerk install found on PATH, not just the first - Examples: - $ clerk update Update to the latest stable release - $ clerk update --channel canary Update to the latest canary release - $ clerk update --yes Update without confirmation prompt - $ clerk update --all Update every clerk install on PATH + help [command] Display help for command ``` diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 881c430a..11ee9d82 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -23,6 +23,14 @@ Authentication is resolved in this order: The users commands talk to the instance's Backend API. Identifier and required-field rules are enforced by BAPI, so any BAPI secret key (via `CLERK_SECRET_KEY`, `--secret-key`, or `--app`-resolved) is enough — no `applications:manage` Platform API scope is required. +## Interactive mode + +In human mode (TTY), `clerk users` invoked with no subcommand opens an interactive menu that lists every registered action and dispatches to its handler. + +`clerk users create` invoked without curated flags or `--input-json` / `-d` / `--file` enters a guided wizard. The wizard fetches the instance's Frontend API configuration to prompt only for fields the instance accepts (and marks required fields). When run with `--secret-key` only (no app context), the wizard falls back to prompting the full curated-flag set as optional and lets the Backend API validate. + +In agent mode all interactive flows are disabled and the same invocations exit with a structured usage error. + ## Passing input as JSON Two complementary mechanisms for JSON input work across the users command family: From 1e7b4233c9abb0d0222a8e7e9b377f8531b63a45 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:37:31 -0600 Subject: [PATCH 12/29] fix(users): accept --app/--instance/--secret-key on parent users command The interactive menu (`clerk users` with no subcommand) needs to accept targeting flags so it can open against a specific app or instance instead of relying on a linked project. Without these options on the parent, `clerk users --app foo` errored with 'unknown option --app'. --- packages/cli-core/src/cli-program.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index e5f7f49d..8f2e6d47 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -263,6 +263,9 @@ Give AI agents better Clerk context: install the Clerk skills const users = program .command("users") .description("Manage Clerk users") + .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 create --email alice@example.com --first-name Alice --yes", From fcc44e4bba5bc4f974129261d4365de6178fc159 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 16:41:24 -0600 Subject: [PATCH 13/29] fix(users): pretty-print create payload preview inside wizard sidebar The 'About to POST /v1/users' confirmation used log.raw() for the JSON dump, which bypasses the bar-prefix that intro() pushes. The output visually escaped the wizard frame and showed dense JSON instead of human-friendly field listings. Now each top-level field renders as a single 'key: value' line through log.info(), so the sidebar wraps the preview cleanly. Single-element string arrays (BAPI's email_address shape) are unwrapped for display. --- .../cli-core/src/commands/users/create.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index 62ee13f8..0cb58f9c 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -136,8 +136,11 @@ async function confirmMutation( ): Promise { if (!isHuman() || options.yes) return; - log.info(`\nAbout to ${method} ${path}`); - log.raw(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); + log.info(`About to ${method} ${path}`); + const display = redactUsersDisplayPayload(payload); + for (const line of formatPayloadForDisplay(display)) { + log.info(line); + } const ok = await confirm({ message: "Proceed?" }); if (!ok) { @@ -145,6 +148,26 @@ async function confirmMutation( } } +function formatPayloadForDisplay(payload: unknown): string[] { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return [` ${formatPayloadValue(payload)}`]; + } + return Object.entries(payload as Record).map( + ([key, value]) => ` ${key}: ${formatPayloadValue(value)}`, + ); +} + +function formatPayloadValue(value: unknown): string { + if (value === null || value === undefined) return "(none)"; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value) && value.length === 1 && typeof value[0] === "string") { + return value[0]; + } + return JSON.stringify(value); +} + registerUsersAction({ key: "create", label: "Create user", From cfd2046f77cee8b1e157923fc3649029b7140553 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 17:07:13 -0600 Subject: [PATCH 14/29] fix(users): drop confirmation prompt for create Create is non-destructive: a mistyped field is recoverable by deleting and re-creating. The wizard already collected each value via prompts; re-asking 'Proceed?' immediately after is decision fatigue. The flag path goes straight to the spinner too, matching the wizard. --yes is preserved on the CLI surface but is now a no-op for create (scripts that pass it continue to work). Lifecycle commands (delete, ban, unban, lock, unlock) keep their confirmation gate where it genuinely earns its place. --- .../cli-core/src/commands/users/create.ts | 45 +------------------ 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index 0cb58f9c..052f983f 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -1,5 +1,5 @@ import { handleBapiError, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; -import { throwUsageError, throwUserAbort } from "../../lib/errors.ts"; +import { throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { buildCreateUserPayload, @@ -10,7 +10,6 @@ import { } from "../../lib/users.ts"; import { isHuman } from "../../mode.ts"; import { bapiRequest } from "../api/bapi.ts"; -import { confirm } from "../../lib/prompts.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { handleUsersBapiError, printUsersMutationResult } from "./output.ts"; import { registerUsersAction } from "./registry.ts"; @@ -43,8 +42,6 @@ export async function create(options: CreateUserOptions): Promise { return; } - await confirmMutation("POST", "/v1/users", payload, options); - const secretKey = await resolveBapiSecretKey({ secretKey: options.secretKey, app: options.app, @@ -128,46 +125,6 @@ function hasCreateFlagPayload(options: CreateUserOptions): boolean { ); } -async function confirmMutation( - method: string, - path: string, - payload: Record, - options: { yes?: boolean }, -): Promise { - if (!isHuman() || options.yes) return; - - log.info(`About to ${method} ${path}`); - const display = redactUsersDisplayPayload(payload); - for (const line of formatPayloadForDisplay(display)) { - log.info(line); - } - - const ok = await confirm({ message: "Proceed?" }); - if (!ok) { - throwUserAbort(); - } -} - -function formatPayloadForDisplay(payload: unknown): string[] { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return [` ${formatPayloadValue(payload)}`]; - } - return Object.entries(payload as Record).map( - ([key, value]) => ` ${key}: ${formatPayloadValue(value)}`, - ); -} - -function formatPayloadValue(value: unknown): string { - if (value === null || value === undefined) return "(none)"; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return String(value); - } - if (Array.isArray(value) && value.length === 1 && typeof value[0] === "string") { - return value[0]; - } - return JSON.stringify(value); -} - registerUsersAction({ key: "create", label: "Create user", From 9568b42a2c42e770886e99ef22cbf588a9ce45c9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 17:13:07 -0600 Subject: [PATCH 15/29] fix(users): resolve --app via parent command for create subcommand When --app/--instance/--secret-key were declared on both the parent 'users' command and the 'create' subcommand, Commander's option-parser attributed the value to the parent and the child action received undefined. That broke 'clerk users create --app foo' for the wizard path. Now the targeting flags live only on the parent. The create subcommand's action wrapper uses cmd.optsWithGlobals() to merge parent + child options before invoking the handler. Both invocation orders work: 'clerk users --app foo create' and 'clerk users create --app foo'. --- packages/cli-core/src/cli-program.test.ts | 11 ++++++++--- packages/cli-core/src/cli-program.ts | 7 +++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 57c926be..9dc3e6f3 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -37,15 +37,20 @@ test("users create exposes --json output, curated flags, and -d/--data for inlin "--external-id", "--data", "--file", - "--secret-key", - "--app", - "--instance", "--dry-run", "--yes", ]), ); }); +test("users parent command exposes targeting flags inherited by subcommands", () => { + const program = createProgram(); + const users = program.commands.find((command) => command.name() === "users")!; + const optionNames = users.options.map((option) => option.long); + + expect(optionNames).toEqual(expect.arrayContaining(["--secret-key", "--app", "--instance"])); +}); + test("users create documents -d and --file for raw BAPI request bodies", () => { const program = createProgram(); const users = program.commands.find((command) => command.name() === "users")!; diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 8f2e6d47..e41ce729 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -291,12 +291,11 @@ Give AI agents better Clerk context: install the Clerk skills .option("--external-id ", "External ID") .option("-d, --data ", "Inline BAPI request body") .option("--file ", "Read BAPI request body from a file") - .option("--secret-key ", "Backend API secret key to use") - .option("--app ", "Application ID to target (works from any directory)") - .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") .option("--dry-run", "Show the request without executing it") .option("--yes", "Skip confirmation prompt") - .action(usersHandlers.create); + .action((_opts, cmd) => + usersHandlers.create(cmd.optsWithGlobals() as Parameters[0]), + ); const env = program .command("env") From e899eb06d4898983df2e7f1a83b2b5e51ded2730 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 17:19:10 -0600 Subject: [PATCH 16/29] refactor(link): extract app picker into shared lib/app-picker Pull the 'pick existing or create new' interactive flow out of commands/link/index.ts into lib/app-picker.ts so other entry points can use the same UX. No behavior change for clerk link itself; the picker message and the auto-detect-from-keys handshake stay where they were. --- packages/cli-core/src/commands/link/index.ts | 77 ++----------------- packages/cli-core/src/lib/app-picker.ts | 78 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 70 deletions(-) create mode 100644 packages/cli-core/src/lib/app-picker.ts diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 316db732..796be87d 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -1,43 +1,25 @@ import { basename } from "node:path"; -import { input } from "@inquirer/prompts"; -import { search } from "../../lib/listage.ts"; import { confirm } from "../../lib/prompts.ts"; import { isAgent } from "../../mode.ts"; import { getToken } from "../../lib/credential-store.ts"; import { login } from "../auth/login.ts"; -import { - listApplications, - fetchApplication, - createApplication, - type Application, -} from "../../lib/plapi.ts"; +import { fetchApplication, type Application } from "../../lib/plapi.ts"; +import { appLabel, fetchAppsTolerantly, pickOrCreateApp } from "../../lib/app-picker.ts"; import { setProfile, resolveProfile, moveProfile } from "../../lib/config.ts"; import { autolink, findClerkKeys, matchKeyToApp } from "../../lib/autolink.ts"; import { getGitRepoIdentifier, getGitRepoRoot, getGitNormalizedRemote } from "../../lib/git.ts"; import { dim, cyan } from "../../lib/color.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; -import { - CliError, - PlapiError, - ERROR_CODE, - throwUsageError, - withApiContext, -} from "../../lib/errors.ts"; -import { intro, outro, withSpinner } from "../../lib/spinner.ts"; +import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; +import { intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; -const CREATE_NEW_APP = "__create_new__"; - interface LinkOptions { app?: string; skipIfLinked?: boolean; cwd?: string; } -function appLabel(app: Application): string { - return app.name ? `${app.name} (${app.application_id})` : app.application_id; -} - export async function link(options: LinkOptions = {}): Promise { const agent = isAgent(); const cwd = options.cwd ?? process.cwd(); @@ -125,11 +107,6 @@ async function ensureAuth() { } } -async function createAndFetchApp(name: string): Promise { - const created = await withApiContext(createApplication(name), "Failed to create application"); - return withApiContext(fetchApplication(created.application_id), "Failed to fetch application"); -} - function printExistingStatus( existing: Awaited> & {}, normalizedRemote: string | undefined, @@ -195,55 +172,15 @@ async function resolveApp( displayPath: string, detectKeys: boolean, ): Promise { - let apps: Application[]; - try { - apps = await withSpinner("Fetching applications...", () => - withApiContext(listApplications(), "Failed to fetch applications"), - ); - } catch (error) { - if (error instanceof PlapiError && error.status >= 500) { - log.info("Could not fetch your applications — you can still create a new one"); - apps = []; - } else { - throw error; - } - } + const apps = await fetchAppsTolerantly(); if (apps.length > 0 && detectKeys) { const detected = await tryDetectApp(cwd, apps); if (detected) return detected; } - return pickOrCreateApp(apps, displayPath); -} - -async function pickOrCreateApp(apps: Application[], displayPath: string): Promise { - const appChoices = apps.map((a) => ({ name: appLabel(a), value: a.application_id })); - const createChoice = { name: dim("+ Create a new application"), value: CREATE_NEW_APP }; - - const selectedId = await search({ + return pickOrCreateApp({ + apps, message: `Select a Clerk application to link ${dim(`(repo: ${basename(displayPath)})`)}`, - source: (term: string | undefined) => { - const filtered = term - ? appChoices.filter((c) => c.name.toLowerCase().includes(term.toLowerCase())) - : appChoices; - return [createChoice, ...filtered]; - }, }); - - if (selectedId === CREATE_NEW_APP) { - const name = await input({ - message: "Application name:", - validate: (v) => (v.trim() ? true : "Application name cannot be empty"), - }); - return createAndFetchApp(name.trim()); - } - - const found = apps.find((a) => a.application_id === selectedId); - if (!found) { - throw new CliError("Selected application not found", { - code: ERROR_CODE.APP_NOT_FOUND, - }); - } - return found; } diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts new file mode 100644 index 00000000..fe751227 --- /dev/null +++ b/packages/cli-core/src/lib/app-picker.ts @@ -0,0 +1,78 @@ +/** + * Shared interactive helpers for choosing or creating a Clerk application. + * Used by `clerk link` and by the `clerk users` wizard fallback when no + * project is linked and no --app was provided. + */ + +import { input } from "@inquirer/prompts"; +import { dim } from "./color.ts"; +import { CliError, ERROR_CODE, PlapiError, withApiContext } from "./errors.ts"; +import { search } from "./listage.ts"; +import { log } from "./log.ts"; +import { + type Application, + createApplication, + fetchApplication, + listApplications, +} from "./plapi.ts"; +import { withSpinner } from "./spinner.ts"; + +const CREATE_NEW_APP = "__create_new__"; + +export function appLabel(app: Application): string { + return app.name ? `${app.name} (${app.application_id})` : app.application_id; +} + +/** + * Fetch the user's applications. Returns an empty list when PLAPI is degraded + * (5xx) so the caller can still offer "create a new application". + */ +export async function fetchAppsTolerantly(): Promise { + try { + return await withSpinner("Fetching applications...", () => + withApiContext(listApplications(), "Failed to fetch applications"), + ); + } catch (error) { + if (error instanceof PlapiError && error.status >= 500) { + log.info("Could not fetch your applications, you can still create a new one"); + return []; + } + throw error; + } +} + +export async function pickOrCreateApp(opts: { + apps: Application[]; + message: string; +}): Promise { + const appChoices = opts.apps.map((a) => ({ name: appLabel(a), value: a.application_id })); + const createChoice = { name: dim("+ Create a new application"), value: CREATE_NEW_APP }; + + const selectedId = await search({ + message: opts.message, + source: (term) => { + const filtered = term + ? appChoices.filter((c) => c.name.toLowerCase().includes(term.toLowerCase())) + : appChoices; + return [createChoice, ...filtered]; + }, + }); + + if (selectedId === CREATE_NEW_APP) { + const name = await input({ + message: "Application name:", + validate: (v) => (v.trim() ? true : "Application name cannot be empty"), + }); + const created = await withApiContext( + createApplication(name.trim()), + "Failed to create application", + ); + return withApiContext(fetchApplication(created.application_id), "Failed to fetch application"); + } + + const found = opts.apps.find((a) => a.application_id === selectedId); + if (!found) { + throw new CliError("Selected application not found", { code: ERROR_CODE.APP_NOT_FOUND }); + } + return found; +} From 1579bc7e95121ced43ace8a40aadf4055bfe926d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 24 Apr 2026 17:19:13 -0600 Subject: [PATCH 17/29] feat(users): fall back to app picker when no project linked in human mode When the create wizard runs without --app and no project is linked, previously we threw NOT_LINKED with a 'Run clerk link or pass --app' message. Now we open the same picker (existing apps + 'create new') that clerk link uses, then resolve the chosen app's instance context. Agent mode keeps the original error: agents must pass --app explicitly. --- .../interactive/instance-context.test.ts | 52 +++++++++++++++++++ .../users/interactive/instance-context.ts | 20 +++++-- 2 files changed, 69 insertions(+), 3 deletions(-) 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 3949a553..030b6743 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,11 @@ import { test, expect, describe, beforeEach, mock } from "bun:test"; +import { CliError, ERROR_CODE } from "../../../lib/errors.ts"; const mockResolveAppContext = mock(); const mockFetchApplication = mock(); +const mockFetchAppsTolerantly = mock(); +const mockPickOrCreateApp = mock(); +const mockIsHuman = mock(() => true); mock.module("../../../lib/config.ts", () => ({ resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), @@ -31,6 +35,17 @@ mock.module("../../../lib/plapi.ts", () => ({ fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), validateKeyPrefix: () => {}, })); +mock.module("../../../lib/app-picker.ts", () => ({ + fetchAppsTolerantly: (...args: unknown[]) => mockFetchAppsTolerantly(...args), + pickOrCreateApp: (...args: unknown[]) => mockPickOrCreateApp(...args), + appLabel: (a: { application_id: string }) => a.application_id, +})); +mock.module("../../../mode.ts", () => ({ + isHuman: () => mockIsHuman(), + isAgent: () => !mockIsHuman(), + setMode: () => {}, + getMode: () => "human", +})); const { resolveUsersInstanceContext } = await import("./instance-context.ts"); @@ -38,6 +53,9 @@ describe("resolveUsersInstanceContext", () => { beforeEach(() => { mockResolveAppContext.mockReset(); mockFetchApplication.mockReset(); + mockFetchAppsTolerantly.mockReset(); + mockPickOrCreateApp.mockReset(); + mockIsHuman.mockReturnValue(true); }); test("returns publishable key when --app is provided", async () => { @@ -65,4 +83,38 @@ describe("resolveUsersInstanceContext", () => { expect(ctx.publishableKey).toBeUndefined(); expect(ctx.fapiHost).toBeUndefined(); }); + + test("falls back to picker when no project linked and human mode", async () => { + mockResolveAppContext.mockRejectedValue( + new CliError("No Clerk project linked", { code: ERROR_CODE.NOT_LINKED }), + ); + mockFetchAppsTolerantly.mockResolvedValue([{ application_id: "app_picked", name: "Picked" }]); + mockPickOrCreateApp.mockResolvedValue({ application_id: "app_picked", name: "Picked" }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_picked", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA", + secret_key: "sk_test_picked", + }, + ], + }); + + const ctx = await resolveUsersInstanceContext({}); + expect(mockPickOrCreateApp).toHaveBeenCalled(); + expect(ctx.secretKey).toBe("sk_test_picked"); + expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); + }); + + test("re-throws NOT_LINKED in agent mode without invoking picker", async () => { + mockIsHuman.mockReturnValue(false); + mockResolveAppContext.mockRejectedValue( + new CliError("No Clerk project linked", { code: ERROR_CODE.NOT_LINKED }), + ); + + await expect(resolveUsersInstanceContext({})).rejects.toThrow(CliError); + expect(mockPickOrCreateApp).not.toHaveBeenCalled(); + }); }); 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 a5881fd8..c47d9b89 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -1,7 +1,9 @@ +import { fetchAppsTolerantly, pickOrCreateApp } from "../../../lib/app-picker.ts"; import { resolveAppContext, resolveFetchedApplicationInstance } from "../../../lib/config.ts"; import { CliError, ERROR_CODE, withApiContext } from "../../../lib/errors.ts"; -import { fetchApplication, validateKeyPrefix } from "../../../lib/plapi.ts"; import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { fetchApplication, validateKeyPrefix } from "../../../lib/plapi.ts"; +import { isHuman } from "../../../mode.ts"; export type UsersInstanceContext = { secretKey: string; @@ -32,11 +34,23 @@ export async function resolveUsersInstanceContext( appId = ctx.appId; instanceHint = ctx.instanceId; } catch (error) { - if (error instanceof CliError && error.code === ERROR_CODE.NOT_LINKED && options.secretKey) { + if (!(error instanceof CliError) || error.code !== ERROR_CODE.NOT_LINKED) { + throw error; + } + if (options.secretKey) { validateKeyPrefix(options.secretKey, "sk_"); return { secretKey: options.secretKey }; } - throw error; + if (isHuman()) { + const apps = await fetchAppsTolerantly(); + const picked = await pickOrCreateApp({ + apps, + message: "Select a Clerk application to use:", + }); + appId = picked.application_id; + } else { + throw error; + } } } From 5eed076378081f9c660246d0b123f420fef3ac6b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 10:21:26 -0600 Subject: [PATCH 18/29] fix(users): propagate wizard-picked app + secret key to create call When no project is linked, the create wizard's resolveUsersInstanceContext falls back to the app picker. The picked appId and the secret key fetched from plapi were consumed only inside the wizard; resolveBapiSecretKey then ran again with no targeting and threw "No secret key found". Split the wizard return into fields (user input) and targeting (resolved app/instance/secretKey). The create command merges targeting onto its options before the BAPI call, so resolveBapiSecretKey short-circuits on the already-resolved key. The "no input provided" check now inspects only fields, since targeting is always populated when the picker fallback fires. --- .../src/commands/users/create-wizard.test.ts | 12 ++++++-- .../src/commands/users/create-wizard.ts | 27 ++++++++++++++---- .../src/commands/users/create.test.ts | 28 +++++++++++++++++-- .../cli-core/src/commands/users/create.ts | 6 ++-- .../interactive/instance-context.test.ts | 4 +++ .../users/interactive/instance-context.ts | 8 +++++- 6 files changed, 72 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/users/create-wizard.test.ts b/packages/cli-core/src/commands/users/create-wizard.test.ts index 34de1fef..bcc17a55 100644 --- a/packages/cli-core/src/commands/users/create-wizard.test.ts +++ b/packages/cli-core/src/commands/users/create-wizard.test.ts @@ -42,6 +42,8 @@ describe("runCreateWizard", () => { test("only prompts for enabled attributes (FAPI-driven)", async () => { mockResolveContext.mockResolvedValue({ secretKey: "sk_test_xyz", + appId: "app_xyz", + instanceId: "ins_dev", publishableKey: "pk_test_xyz", fapiHost: "fake.example.com", }); @@ -59,11 +61,16 @@ describe("runCreateWizard", () => { const result = await runCreateWizard({}); - expect(result).toEqual({ + expect(result.fields).toEqual({ email: "alice@example.com", password: "Password123", firstName: "Alice", }); + expect(result.targeting).toEqual({ + app: "app_xyz", + instance: "ins_dev", + secretKey: "sk_test_xyz", + }); // username was disabled — never prompted expect(mockInput).toHaveBeenCalledTimes(2); expect(mockPassword).toHaveBeenCalledTimes(1); @@ -75,7 +82,8 @@ describe("runCreateWizard", () => { mockPassword.mockResolvedValue(""); const result = await runCreateWizard({ secretKey: "sk_test_raw" }); - expect(result).toEqual({}); + expect(result.fields).toEqual({}); + expect(result.targeting).toEqual({ secretKey: "sk_test_raw" }); expect(mockBootstrapDevBrowser).not.toHaveBeenCalled(); expect(mockFetchUserSettings).not.toHaveBeenCalled(); }); diff --git a/packages/cli-core/src/commands/users/create-wizard.ts b/packages/cli-core/src/commands/users/create-wizard.ts index 04e5f924..35601d16 100644 --- a/packages/cli-core/src/commands/users/create-wizard.ts +++ b/packages/cli-core/src/commands/users/create-wizard.ts @@ -10,7 +10,7 @@ import { withSpinner } from "../../lib/spinner.ts"; import { isEnabled, isRequired, type AttributeName } from "./interactive/attributes.ts"; import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; -export type CreateWizardResult = { +export type CreateWizardFields = { email?: string; phone?: string; username?: string; @@ -19,6 +19,17 @@ export type CreateWizardResult = { lastName?: string; }; +export type CreateWizardTargeting = { + app?: string; + instance?: string; + secretKey?: string; +}; + +export type CreateWizardResult = { + fields: CreateWizardFields; + targeting: CreateWizardTargeting; +}; + type WizardOptions = { app?: string; instance?: string; @@ -27,7 +38,7 @@ type WizardOptions = { type FieldDef = { attr: AttributeName; - key: keyof CreateWizardResult; + key: keyof CreateWizardFields; message: string; isPassword?: boolean; }; @@ -48,15 +59,21 @@ export async function runCreateWizard(options: WizardOptions): Promise { beforeEach(() => { mockIsAgent.mockReturnValue(false); mockResolveBapiSecretKey.mockResolvedValue("sk_test_123"); - mockRunCreateWizard.mockResolvedValue({}); + mockRunCreateWizard.mockResolvedValue({ fields: {}, targeting: {} }); mockBapiRequest.mockResolvedValue({ status: 200, headers: new Headers(), @@ -206,7 +206,10 @@ describe("users create", () => { test("invokes the wizard when no flags + human + no data", async () => { mockIsAgent.mockReturnValue(false); - mockRunCreateWizard.mockResolvedValue({ email: "alice@example.com" }); + mockRunCreateWizard.mockResolvedValue({ + fields: { email: "alice@example.com" }, + targeting: {}, + }); await runCreate({ yes: true }); expect(mockRunCreateWizard).toHaveBeenCalledWith({ app: undefined, @@ -216,6 +219,27 @@ describe("users create", () => { expect(mockBapiRequest).toHaveBeenCalled(); }); + test("forwards wizard-resolved targeting to the secret key resolver", async () => { + mockIsAgent.mockReturnValue(false); + mockRunCreateWizard.mockResolvedValue({ + fields: { email: "alice@example.com", password: "Password123" }, + targeting: { + app: "app_picked", + instance: "ins_dev", + secretKey: "sk_test_picked", + }, + }); + + await runCreate({ yes: true }); + + expect(mockResolveBapiSecretKey).toHaveBeenCalledWith({ + secretKey: "sk_test_picked", + app: "app_picked", + instance: "ins_dev", + }); + expect(mockBapiRequest).toHaveBeenCalled(); + }); + test("does not invoke the wizard in agent mode", async () => { mockIsAgent.mockReturnValue(true); let thrown: unknown; diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index 052f983f..d86f4267 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -87,15 +87,15 @@ async function resolveBasePayload(options: CreateUserOptions): Promise { const ctx = await resolveUsersInstanceContext({ app: "app_123" }); expect(ctx.secretKey).toBe("sk_test_xyz"); + expect(ctx.appId).toBe("app_123"); + expect(ctx.instanceId).toBe("ins_dev"); expect(ctx.publishableKey).toBe("pk_test_aWRlYWwtbG91c2UtNjEuY2xlcmsuYWNjb3VudHMuZGV2JA"); expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); }); @@ -105,6 +107,8 @@ describe("resolveUsersInstanceContext", () => { const ctx = await resolveUsersInstanceContext({}); expect(mockPickOrCreateApp).toHaveBeenCalled(); expect(ctx.secretKey).toBe("sk_test_picked"); + expect(ctx.appId).toBe("app_picked"); + expect(ctx.instanceId).toBe("ins_dev"); expect(ctx.fapiHost).toBe("ideal-louse-61.clerk.accounts.dev"); }); 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 c47d9b89..f7aa71f4 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -7,6 +7,8 @@ import { isHuman } from "../../../mode.ts"; export type UsersInstanceContext = { secretKey: string; + appId?: string; + instanceId?: string; publishableKey?: string; fapiHost?: string; }; @@ -68,7 +70,11 @@ export async function resolveUsersInstanceContext( }); } - const ctx: UsersInstanceContext = { secretKey: options.secretKey ?? instance.secret_key }; + const ctx: UsersInstanceContext = { + secretKey: options.secretKey ?? instance.secret_key, + appId, + instanceId: resolved.instanceId, + }; if (instance.publishable_key) { ctx.publishableKey = instance.publishable_key; try { From 9da482daca8185cc288abda3f9b5d599dde79bb0 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 10:23:29 -0600 Subject: [PATCH 19/29] refactor(app-picker): move create option to bottom of picker The "+ Create a new application" entry sat at the top of the list, above the user's apps. For users with existing apps it makes the common case (pick an existing app) require an extra arrow-down. Place create at the bottom so it's the explicit fallback after scanning the list. --- packages/cli-core/src/commands/link/index.test.ts | 12 ++++++------ packages/cli-core/src/lib/app-picker.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 30614875..bbad9dda 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -469,9 +469,9 @@ describe("link", () => { source: (term: string | undefined) => { name: string; value: string }[]; }) => { const results = config.source("my"); - expect(results).toHaveLength(2); // create option + 1 match - expect(results[0]!.value).toBe("__create_new__"); - expect(results[1]!.value).toBe("app_a"); + expect(results).toHaveLength(2); // 1 match + create option + expect(results[0]!.value).toBe("app_a"); + expect(results[1]!.value).toBe("__create_new__"); const noMatch = config.source("zzz"); expect(noMatch).toHaveLength(1); // only create option @@ -508,9 +508,9 @@ describe("link", () => { source: (term: string | undefined) => { name: string; value: string }[]; }) => { const results = config.source("abc"); - expect(results).toHaveLength(2); // create option + 1 match - expect(results[0]!.value).toBe("__create_new__"); - expect(results[1]!.value).toBe("app_abc"); + expect(results).toHaveLength(2); // 1 match + create option + expect(results[0]!.value).toBe("app_abc"); + expect(results[1]!.value).toBe("__create_new__"); return "app_abc"; }, ); diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index fe751227..3da2189d 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -54,7 +54,7 @@ export async function pickOrCreateApp(opts: { const filtered = term ? appChoices.filter((c) => c.name.toLowerCase().includes(term.toLowerCase())) : appChoices; - return [createChoice, ...filtered]; + return [...filtered, createChoice]; }, }); From 511d8eebe6e119e32b849891eef7b8001acb9b4e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 10:26:30 -0600 Subject: [PATCH 20/29] refactor(app-picker): separate create option with separator and description The create option used dim styling, which made it nearly invisible at the bottom of the list. Drop the dim, add a Separator above it so the boundary between existing apps and the create action is unambiguous, and attach a short description that surfaces under the prompt when the row is highlighted. When no apps match the search, the list is just the create option (no leading separator). Refresh the branch changeset to mention the picker UX change since it also affects clerk link. --- .changeset/users-scaffolding-and-create.md | 2 +- packages/cli-core/src/commands/link/index.test.ts | 12 ++++++------ packages/cli-core/src/lib/app-picker.ts | 11 +++++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.changeset/users-scaffolding-and-create.md b/.changeset/users-scaffolding-and-create.md index afb36dce..9c72cb75 100644 --- a/.changeset/users-scaffolding-and-create.md +++ b/.changeset/users-scaffolding-and-create.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk users` command scaffolding with `clerk users create`, plus an interactive mode for the `users` family. The create wizard reads instance settings from the Frontend API to prompt only for enabled fields, marking required ones. A top-level interactive menu (`clerk users` with no subcommand) routes to registered actions; agent mode preserves the strict flag-driven contract. +Add `clerk users` command scaffolding with `clerk users create`, plus an interactive mode for the `users` family. The create wizard reads instance settings from the Frontend API to prompt only for enabled fields, marking required ones. A top-level interactive menu (`clerk users` with no subcommand) routes to registered actions; agent mode preserves the strict flag-driven contract. The application picker (used by `clerk link` and the `clerk users` wizard fallback) now lists the "Create a new application" option at the bottom, separated from your existing apps and with a description for clarity. diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index bbad9dda..8099bd6d 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -436,7 +436,7 @@ describe("link", () => { mockSearch.mockImplementation( async (config: { source: (term: string | undefined) => unknown[] }) => { const results = config.source(undefined); - expect(results).toHaveLength(3); // 2 apps + create option + expect(results).toHaveLength(4); // 2 apps + separator + create option return "app_a"; }, ); @@ -469,12 +469,12 @@ describe("link", () => { source: (term: string | undefined) => { name: string; value: string }[]; }) => { const results = config.source("my"); - expect(results).toHaveLength(2); // 1 match + create option + expect(results).toHaveLength(3); // 1 match + separator + create option expect(results[0]!.value).toBe("app_a"); - expect(results[1]!.value).toBe("__create_new__"); + expect(results[2]!.value).toBe("__create_new__"); const noMatch = config.source("zzz"); - expect(noMatch).toHaveLength(1); // only create option + expect(noMatch).toHaveLength(1); // only create option (no separator when list is empty) expect(noMatch[0]!.value).toBe("__create_new__"); return "app_a"; @@ -508,9 +508,9 @@ describe("link", () => { source: (term: string | undefined) => { name: string; value: string }[]; }) => { const results = config.source("abc"); - expect(results).toHaveLength(2); // 1 match + create option + expect(results).toHaveLength(3); // 1 match + separator + create option expect(results[0]!.value).toBe("app_abc"); - expect(results[1]!.value).toBe("__create_new__"); + expect(results[2]!.value).toBe("__create_new__"); return "app_abc"; }, ); diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index 3da2189d..bdb1d69b 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -5,9 +5,8 @@ */ import { input } from "@inquirer/prompts"; -import { dim } from "./color.ts"; import { CliError, ERROR_CODE, PlapiError, withApiContext } from "./errors.ts"; -import { search } from "./listage.ts"; +import { search, Separator } from "./listage.ts"; import { log } from "./log.ts"; import { type Application, @@ -46,7 +45,11 @@ export async function pickOrCreateApp(opts: { message: string; }): Promise { const appChoices = opts.apps.map((a) => ({ name: appLabel(a), value: a.application_id })); - const createChoice = { name: dim("+ Create a new application"), value: CREATE_NEW_APP }; + const createChoice = { + name: "+ Create a new application", + value: CREATE_NEW_APP, + description: "Provision a new Clerk application via the API", + }; const selectedId = await search({ message: opts.message, @@ -54,7 +57,7 @@ export async function pickOrCreateApp(opts: { const filtered = term ? appChoices.filter((c) => c.name.toLowerCase().includes(term.toLowerCase())) : appChoices; - return [...filtered, createChoice]; + return filtered.length > 0 ? [...filtered, new Separator(), createChoice] : [createChoice]; }, }); From 6a05cb9c5142b7494306afa15647210ba6348ea0 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 10:36:39 -0600 Subject: [PATCH 21/29] refactor(app-picker): mute create option when idle, highlight when active Add an optional per-choice style hook to the search prompt's SearchChoice (and forward it through normalizeChoices) so a single row can override the default highlight behavior. Use it in the application picker: the "+ Create a new application" row renders dim by default and cyan when the cursor lands on it, so it reads as a de-emphasized fallback rather than competing with the user's existing apps. Drop the description added in the previous commit; the muted-by-default treatment plus the separator are enough signal without a second line of helper text. --- .changeset/users-scaffolding-and-create.md | 2 +- packages/cli-core/src/lib/app-picker.ts | 3 ++- packages/cli-core/src/lib/listage.ts | 14 +++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.changeset/users-scaffolding-and-create.md b/.changeset/users-scaffolding-and-create.md index 9c72cb75..a09a9346 100644 --- a/.changeset/users-scaffolding-and-create.md +++ b/.changeset/users-scaffolding-and-create.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk users` command scaffolding with `clerk users create`, plus an interactive mode for the `users` family. The create wizard reads instance settings from the Frontend API to prompt only for enabled fields, marking required ones. A top-level interactive menu (`clerk users` with no subcommand) routes to registered actions; agent mode preserves the strict flag-driven contract. The application picker (used by `clerk link` and the `clerk users` wizard fallback) now lists the "Create a new application" option at the bottom, separated from your existing apps and with a description for clarity. +Add `clerk users` command scaffolding with `clerk users create`, plus an interactive mode for the `users` family. The create wizard reads instance settings from the Frontend API to prompt only for enabled fields, marking required ones. A top-level interactive menu (`clerk users` with no subcommand) routes to registered actions; agent mode preserves the strict flag-driven contract. The application picker (used by `clerk link` and the `clerk users` wizard fallback) now lists the "Create a new application" option at the bottom, separated from existing apps and de-emphasized until highlighted, so it reads as a fallback rather than a primary choice. diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index bdb1d69b..731d9cfa 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -5,6 +5,7 @@ */ import { input } from "@inquirer/prompts"; +import { cyan, dim } from "./color.ts"; import { CliError, ERROR_CODE, PlapiError, withApiContext } from "./errors.ts"; import { search, Separator } from "./listage.ts"; import { log } from "./log.ts"; @@ -48,7 +49,7 @@ export async function pickOrCreateApp(opts: { const createChoice = { name: "+ Create a new application", value: CREATE_NEW_APP, - description: "Provision a new Clerk application via the API", + style: (text: string, isActive: boolean) => (isActive ? cyan(text) : dim(text)), }; const selectedId = await search({ diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts index 17594f40..14b5a6c7 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -146,6 +146,7 @@ type NormalizedChoice = { short: string; disabled: boolean | string; description?: string; + style?: (text: string, isActive: boolean) => string; }; function normalizeChoices( @@ -157,7 +158,9 @@ function normalizeChoices( const name = String(choice); return { value: choice as Value, name, short: name, disabled: false }; } - const c = choice as SelectChoice; + const c = choice as SelectChoice & { + style?: (text: string, isActive: boolean) => string; + }; const name = c.name ?? String(c.value); const normalized: NormalizedChoice = { value: c.value, @@ -166,6 +169,7 @@ function normalizeChoices( disabled: c.disabled ?? false, }; if (c.description) normalized.description = c.description; + if (c.style) normalized.style = c.style; return normalized; }); } @@ -377,6 +381,8 @@ type SearchChoice = { description?: string; short?: string; disabled?: boolean | string; + /** Per-choice style hook. Receives `${cursor} ${name}` plus whether the row is active. */ + style?: (text: string, isActive: boolean) => string; }; export type SearchConfig = { @@ -527,9 +533,11 @@ const rawSearch = createPrompt>((config, done) => const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; return theme.style.disabled(`${item.name} ${disabledLabel}`); } - const color = isActive ? theme.style.highlight : (x: string) => x; const cursor = isActive ? theme.icon.cursor : " "; - return color(`${cursor} ${item.name}`); + const line = `${cursor} ${item.name}`; + if (item.style) return item.style(line, isActive); + const color = isActive ? theme.style.highlight : (x: string) => x; + return color(line); }, pageSize: effectivePageSize, loop: false, From 02e4a14a70cfb6adf5ca2cb5ee5518741836a2b3 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 10:45:19 -0600 Subject: [PATCH 22/29] refactor(app-picker): drop separator above create option The dim-by-default styling already differentiates the create row from the app list. The Separator added an extra blank line that contributed to the picker feeling cluttered without adding signal beyond what the muted color already provides. --- .changeset/users-scaffolding-and-create.md | 2 +- packages/cli-core/src/commands/link/index.test.ts | 12 ++++++------ packages/cli-core/src/lib/app-picker.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/users-scaffolding-and-create.md b/.changeset/users-scaffolding-and-create.md index a09a9346..659f2de0 100644 --- a/.changeset/users-scaffolding-and-create.md +++ b/.changeset/users-scaffolding-and-create.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk users` command scaffolding with `clerk users create`, plus an interactive mode for the `users` family. The create wizard reads instance settings from the Frontend API to prompt only for enabled fields, marking required ones. A top-level interactive menu (`clerk users` with no subcommand) routes to registered actions; agent mode preserves the strict flag-driven contract. The application picker (used by `clerk link` and the `clerk users` wizard fallback) now lists the "Create a new application" option at the bottom, separated from existing apps and de-emphasized until highlighted, so it reads as a fallback rather than a primary choice. +Add `clerk users` command scaffolding with `clerk users create`, plus an interactive mode for the `users` family. The create wizard reads instance settings from the Frontend API to prompt only for enabled fields, marking required ones. A top-level interactive menu (`clerk users` with no subcommand) routes to registered actions; agent mode preserves the strict flag-driven contract. The application picker (used by `clerk link` and the `clerk users` wizard fallback) now lists the "Create a new application" option at the bottom and de-emphasizes it until highlighted, so it reads as a fallback rather than a primary choice. diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 8099bd6d..bbad9dda 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -436,7 +436,7 @@ describe("link", () => { mockSearch.mockImplementation( async (config: { source: (term: string | undefined) => unknown[] }) => { const results = config.source(undefined); - expect(results).toHaveLength(4); // 2 apps + separator + create option + expect(results).toHaveLength(3); // 2 apps + create option return "app_a"; }, ); @@ -469,12 +469,12 @@ describe("link", () => { source: (term: string | undefined) => { name: string; value: string }[]; }) => { const results = config.source("my"); - expect(results).toHaveLength(3); // 1 match + separator + create option + expect(results).toHaveLength(2); // 1 match + create option expect(results[0]!.value).toBe("app_a"); - expect(results[2]!.value).toBe("__create_new__"); + expect(results[1]!.value).toBe("__create_new__"); const noMatch = config.source("zzz"); - expect(noMatch).toHaveLength(1); // only create option (no separator when list is empty) + expect(noMatch).toHaveLength(1); // only create option expect(noMatch[0]!.value).toBe("__create_new__"); return "app_a"; @@ -508,9 +508,9 @@ describe("link", () => { source: (term: string | undefined) => { name: string; value: string }[]; }) => { const results = config.source("abc"); - expect(results).toHaveLength(3); // 1 match + separator + create option + expect(results).toHaveLength(2); // 1 match + create option expect(results[0]!.value).toBe("app_abc"); - expect(results[2]!.value).toBe("__create_new__"); + expect(results[1]!.value).toBe("__create_new__"); return "app_abc"; }, ); diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index 731d9cfa..168d8ea0 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -7,7 +7,7 @@ import { input } from "@inquirer/prompts"; import { cyan, dim } from "./color.ts"; import { CliError, ERROR_CODE, PlapiError, withApiContext } from "./errors.ts"; -import { search, Separator } from "./listage.ts"; +import { search } from "./listage.ts"; import { log } from "./log.ts"; import { type Application, @@ -58,7 +58,7 @@ export async function pickOrCreateApp(opts: { const filtered = term ? appChoices.filter((c) => c.name.toLowerCase().includes(term.toLowerCase())) : appChoices; - return filtered.length > 0 ? [...filtered, new Separator(), createChoice] : [createChoice]; + return [...filtered, createChoice]; }, }); From ad46cd7ba7f3a2dc7171cbbde166834cd56f8a0f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 11:57:29 -0600 Subject: [PATCH 23/29] refactor(users): thread create resolution through return values resolveBasePayload mutated the caller's options object (`Object.assign( options, targeting, fields)`) so subsequent reads of options.app, options.secretKey, etc. would reflect the wizard's resolved targeting. That works today but quietly couples ordering: any future change that runs resolveBapiSecretKey before resolveCreatePayload would break without surfacing as a type error. Refactor so resolveBasePayload returns { basePayload, resolved } and resolveCreate returns { payload, resolved }. The create() function then reads from resolved instead of options for downstream targeting, payload printing, and BAPI error handling. The Commander-owned options object is no longer mutated. No behavior change. Existing unit tests and integration tests pass unchanged. --- .../cli-core/src/commands/users/create.ts | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index d86f4267..ac7f2eda 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -33,19 +33,24 @@ type CreateUserOptions = { yes?: boolean; }; +type ResolvedCreate = { + payload: Record; + resolved: CreateUserOptions; +}; + export async function create(options: CreateUserOptions): Promise { - const payload = await resolveCreatePayload(options); + const { payload, resolved } = await resolveCreate(options); - if (options.dryRun) { + if (resolved.dryRun) { log.info("[dry-run] POST /v1/users"); log.data(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); return; } const secretKey = await resolveBapiSecretKey({ - secretKey: options.secretKey, - app: options.app, - instance: options.instance, + secretKey: resolved.secretKey, + app: resolved.app, + instance: resolved.instance, }); try { @@ -58,9 +63,9 @@ export async function create(options: CreateUserOptions): Promise { }), ); - printUsersMutationResult("Created user", response.body, options); + printUsersMutationResult("Created user", response.body, resolved); } catch (error) { - if (handleUsersBapiError(error, "Failed to create user", options)) { + if (handleUsersBapiError(error, "Failed to create user", resolved)) { return; } if (handleBapiError(error)) { @@ -70,20 +75,29 @@ export async function create(options: CreateUserOptions): Promise { } } -async function resolveCreatePayload(options: CreateUserOptions): Promise> { - const basePayload = await resolveBasePayload(options); - return mergeUsersPayload(basePayload, buildCreateUserPayload(options)); +async function resolveCreate(options: CreateUserOptions): Promise { + const { basePayload, resolved } = await resolveBasePayload(options); + return { + payload: mergeUsersPayload(basePayload, buildCreateUserPayload(resolved)), + resolved, + }; } -async function resolveBasePayload(options: CreateUserOptions): Promise> { +async function resolveBasePayload(options: CreateUserOptions): Promise<{ + basePayload: Record; + resolved: CreateUserOptions; +}> { if (options.data || options.file) { - return parseUsersPayload( - await readUsersPayloadInput({ data: options.data, file: options.file }), - ); + return { + basePayload: parseUsersPayload( + await readUsersPayloadInput({ data: options.data, file: options.file }), + ), + resolved: options, + }; } if (hasCreateFlagPayload(options)) { - return {}; + return { basePayload: {}, resolved: options }; } if (isHuman()) { @@ -95,13 +109,10 @@ async function resolveBasePayload(options: CreateUserOptions): Promise Date: Mon, 27 Apr 2026 11:57:41 -0600 Subject: [PATCH 24/29] test(users): expand integration coverage for clerk users create Add integration tests that drive the full CLI program for paths the unit suite covers only with mocks: - raw -d/--data payload merging into BAPI body - --file payload reading from disk - --dry-run redacting password fields and skipping the BAPI call - --secret-key targeting BAPI directly with no platform API call - --app resolving the instance key without a linked project - wizard picker fallback when no project is linked. This exercises the resolveUsersInstanceContext picker-fallback path and asserts the secret key resolved by the wizard reaches the BAPI request. This is the regression test for 5eed0763, where the wizard resolved the key but dropped it before resolveBapiSecretKey ran. - agent mode without input returns a structured usage error and never prompts. The existing test.each leaves the harness's mocked _mode at "agent", so each new test pins --mode human (or agent) explicitly. --- .../test/integration/users-commands.test.ts | 238 ++++++++++++++++-- 1 file changed, 217 insertions(+), 21 deletions(-) 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 987514af..6fc61d9c 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -1,41 +1,58 @@ /** * Exercise primary users flows through the real CLI program. - * Covers create wired up against linked-project resolution. + * Covers create against linked-project, --app, --secret-key, raw -d/--data, + * --dry-run, and the wizard picker fallback when no project is linked. */ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { MOCK_APP, clerk, getInstance, http, + mockPrompts, setProfile, useIntegrationTestHarness, } from "./lib/harness.ts"; -useIntegrationTestHarness(); +const harness = useIntegrationTestHarness(); + +const CREATED_USER = { + id: "user_2", + email_addresses: [{ email_address: "alice@example.com" }], + first_name: "Alice", +}; + +const PLAPI_APP_ROUTE = "/v1/platform/applications/app_1?include_secret_keys=true"; +const BAPI_USERS_ROUTE = "https://test-bapi.clerk.dev/v1/users"; + +function findBapiCreateRequest() { + return http.requests.find( + (request) => request.method === "POST" && request.url.includes(BAPI_USERS_ROUTE), + ); +} describe("users commands", () => { const devInstance = getInstance(MOCK_APP, "development"); + async function linkDevProject() { + await setProfile("github.com/test/project", { + workspaceId: "", + appId: MOCK_APP.application_id, + appName: MOCK_APP.name, + instances: { development: devInstance.instance_id }, + }); + } + test.each([{ mode: "human" }, { mode: "agent" }])( "creates a user 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 }, - }); - - const createdUser = { - id: "user_2", - email_addresses: [{ email_address: "alice@example.com" }], - first_name: "Alice", - }; + await linkDevProject(); http.mock({ - "/v1/platform/applications/app_1?include_secret_keys=true": MOCK_APP, - "/v1/users": createdUser, + [PLAPI_APP_ROUTE]: MOCK_APP, + "/v1/users": CREATED_USER, }); const { stdout: createOutput, stderr: createStderr } = await clerk( @@ -55,13 +72,10 @@ describe("users commands", () => { expect(createStderr).toContain("Created user"); expect(createStderr).toContain("user_2"); } else { - expect(JSON.parse(createOutput)).toEqual(createdUser); + expect(JSON.parse(createOutput)).toEqual(CREATED_USER); } - const createRequest = http.requests.find( - (request) => - request.method === "POST" && request.url.includes("https://test-bapi.clerk.dev/v1/users"), - ); + const createRequest = findBapiCreateRequest(); expect(createRequest).toBeDefined(); expect(JSON.parse(createRequest!.body!)).toEqual({ email_address: ["alice@example.com"], @@ -69,4 +83,186 @@ describe("users commands", () => { }); }, ); + + test("creates a user from a raw -d/--data payload", async () => { + await linkDevProject(); + http.mock({ + [PLAPI_APP_ROUTE]: MOCK_APP, + "/v1/users": CREATED_USER, + }); + + await clerk( + "--mode", + "human", + "users", + "create", + "-d", + '{"email_address":["alice@example.com"],"first_name":"Alice","skip_password_requirement":true}', + "--yes", + ); + + const createRequest = findBapiCreateRequest(); + expect(createRequest).toBeDefined(); + expect(JSON.parse(createRequest!.body!)).toEqual({ + email_address: ["alice@example.com"], + first_name: "Alice", + skip_password_requirement: true, + }); + }); + + test("creates a user from a --file payload", async () => { + await linkDevProject(); + http.mock({ + [PLAPI_APP_ROUTE]: MOCK_APP, + "/v1/users": CREATED_USER, + }); + + const payloadPath = join(harness.tempDir, "user.json"); + await writeFile( + payloadPath, + JSON.stringify({ email_address: ["alice@example.com"], first_name: "Alice" }), + ); + + await clerk("--mode", "human", "users", "create", "--file", payloadPath, "--yes"); + + const createRequest = findBapiCreateRequest(); + expect(createRequest).toBeDefined(); + expect(JSON.parse(createRequest!.body!)).toEqual({ + email_address: ["alice@example.com"], + first_name: "Alice", + }); + }); + + test("--dry-run redacts the preview and skips the BAPI call", async () => { + await linkDevProject(); + // No /v1/users route: any POST would fail the test's "unmocked fetch" guard. + http.mock({}); + + const { stdout, stderr } = await clerk( + "--mode", + "human", + "users", + "create", + "--email", + "alice@example.com", + "--password", + "Sup3rSecret!", + "--dry-run", + ); + + expect(stderr).toContain("[dry-run] POST /v1/users"); + expect(JSON.parse(stdout)).toEqual({ + email_address: ["alice@example.com"], + password: "[REDACTED]", + }); + expect(findBapiCreateRequest()).toBeUndefined(); + }); + + test("--secret-key targets BAPI directly without resolving an app", async () => { + // No setProfile and no plapi mock: hitting platform API would fail. + http.mock({ + "/v1/users": CREATED_USER, + }); + + await clerk( + "--mode", + "human", + "users", + "create", + "--secret-key", + "sk_test_directkey", + "--email", + "alice@example.com", + "--yes", + ); + + const createRequest = findBapiCreateRequest(); + expect(createRequest).toBeDefined(); + const authHeader = createRequest!.url; // URL recorded in our mock; auth header lives on init + expect(authHeader).toContain("/v1/users"); + + const platformCall = http.requests.find((r) => r.url.includes("/v1/platform/applications")); + expect(platformCall).toBeUndefined(); + }); + + test("--app resolves the secret key via platform API without a linked project", async () => { + // No setProfile call: app must come from --app, not config. + http.mock({ + [PLAPI_APP_ROUTE]: MOCK_APP, + "/v1/users": CREATED_USER, + }); + + await clerk( + "--mode", + "human", + "users", + "create", + "--app", + "app_1", + "--email", + "alice@example.com", + "--yes", + ); + + const createRequest = findBapiCreateRequest(); + expect(createRequest).toBeDefined(); + expect(JSON.parse(createRequest!.body!)).toEqual({ + email_address: ["alice@example.com"], + }); + + const fetchAppCall = http.requests.find( + (r) => r.method === "GET" && r.url.includes("/v1/platform/applications/app_1"), + ); + expect(fetchAppCall).toBeDefined(); + }); + + test("wizard picker fallback resolves the secret key when no project is linked (regression for 5eed0763)", async () => { + // No setProfile: resolveAppContext throws NOT_LINKED, wizard falls back to picker. + http.mock({ + "/v1/platform/applications": [MOCK_APP], + [PLAPI_APP_ROUTE]: MOCK_APP, + "/v1/users": CREATED_USER, + }); + + // Picker returns app_1; wizard then prompts for the optional curated set + // because MOCK_APP's publishable key does not decode to a valid fapiHost + // (the wizard skips the FAPI fetch and falls back to optional curated fields). + mockPrompts.search("app_1"); + mockPrompts.input("alice@example.com"); // email + mockPrompts.input(""); // phone + mockPrompts.input(""); // username + mockPrompts.password(""); // password + mockPrompts.input(""); // first name + mockPrompts.input(""); // last name + + await clerk("--mode", "human", "users", "create", "--yes"); + + const createRequest = findBapiCreateRequest(); + expect(createRequest).toBeDefined(); + expect(JSON.parse(createRequest!.body!)).toEqual({ + email_address: ["alice@example.com"], + }); + + // The picker fetched the application list, then the wizard fetched the + // picked app to resolve the secret key. Both calls must have happened. + const listCall = http.requests.find( + (r) => r.method === "GET" && r.url.endsWith("/v1/platform/applications"), + ); + expect(listCall).toBeDefined(); + const fetchAppCall = http.requests.find( + (r) => r.method === "GET" && r.url.includes("/v1/platform/applications/app_1"), + ); + expect(fetchAppCall).toBeDefined(); + }); + + test("agent mode without flags or input throws a usage error and never prompts", async () => { + await linkDevProject(); + http.mock({}); + + const { stderr, stdout, exitCode } = await clerk.raw("--mode", "agent", "users", "create"); + + expect(exitCode).not.toBe(0); + expect(stderr + stdout).toContain("No input provided"); + expect(findBapiCreateRequest()).toBeUndefined(); + }); }); From a3ddd5642e9757ca7412b47aed5922bfac5214c9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 12:01:58 -0600 Subject: [PATCH 25/29] test(listage): cover style hook and normalizeChoices round-trip Extract the search prompt's per-row render logic into an exported renderSearchItem helper so it can be tested directly. The function is the load-bearing piece behind the app-picker UX (mute-when-idle / highlight-when-active); a future refactor that drops the `if (item.style) return ...` branch would silently regress styling across clerk link and the clerk users wizard fallback. Also export normalizeChoices and add a round-trip test confirming the style hook is preserved from input choice to normalized item. Tests cover: default highlight, plain inactive rendering, style hook overrides on both states, the cursor + name shape passed to the hook, separators, and disabled choices ignoring the hook. --- packages/cli-core/src/lib/listage.test.ts | 92 ++++++++++++++++++++++- packages/cli-core/src/lib/listage.ts | 48 ++++++++---- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/packages/cli-core/src/lib/listage.test.ts b/packages/cli-core/src/lib/listage.test.ts index 2e21c765..df5869ef 100644 --- a/packages/cli-core/src/lib/listage.test.ts +++ b/packages/cli-core/src/lib/listage.test.ts @@ -1,5 +1,13 @@ import { test, expect, describe, beforeEach } from "bun:test"; -import { scrollBounds, withScrollIndicators, filterChoices, ttyContext } from "./listage.ts"; +import { + filterChoices, + normalizeChoices, + renderSearchItem, + scrollBounds, + Separator, + ttyContext, + withScrollIndicators, +} from "./listage.ts"; describe("scrollBounds", () => { test("returns zeros when all items fit on page", () => { @@ -105,6 +113,88 @@ describe("filterChoices", () => { }); }); +describe("normalizeChoices", () => { + test("forwards style hook from choice to normalized item", () => { + const style = (text: string, isActive: boolean) => `[${isActive ? "on" : "off"}]${text}`; + // Cast through unknown: SelectChoice doesn't expose `style` at the type + // level, but normalizeChoices preserves it at runtime so SearchChoice + // callers can opt in. + const choices = [ + { value: "a", name: "A" }, + { value: "b", name: "B", style }, + ] as unknown as Parameters>[0]; + const result = normalizeChoices(choices); + const a = result[0] as Exclude<(typeof result)[number], Separator>; + const b = result[1] as Exclude<(typeof result)[number], Separator>; + expect(a.style).toBeUndefined(); + expect(b.style).toBe(style); + }); + + test("preserves separators", () => { + const sep = new Separator(); + const result = normalizeChoices([{ value: "a", name: "A" }, sep, { value: "b", name: "B" }]); + expect(Separator.isSeparator(result[0])).toBe(false); + expect(Separator.isSeparator(result[1])).toBe(true); + expect(Separator.isSeparator(result[2])).toBe(false); + }); +}); + +describe("renderSearchItem", () => { + const theme = { + icon: { cursor: ">" }, + style: { + disabled: (text: string) => `[disabled]${text}`, + highlight: (text: string) => `[highlight]${text}`, + }, + }; + const baseItem = { + value: "a", + name: "Choice A", + short: "A", + disabled: false as boolean | string, + }; + + test("uses default highlight when active and no style hook is set", () => { + expect(renderSearchItem(baseItem, true, theme)).toBe("[highlight]> Choice A"); + }); + + test("returns plain text when inactive and no style hook is set", () => { + expect(renderSearchItem(baseItem, false, theme)).toBe(" Choice A"); + }); + + test("invokes the style hook when set, bypassing the default highlight", () => { + const style = (text: string, isActive: boolean) => `[${isActive ? "on" : "off"}]${text}`; + const styled = { ...baseItem, style }; + expect(renderSearchItem(styled, true, theme)).toBe("[on]> Choice A"); + expect(renderSearchItem(styled, false, theme)).toBe("[off] Choice A"); + }); + + test("style hook receives cursor + name with no extra wrapping", () => { + let received: { text: string; isActive: boolean } | undefined; + const style = (text: string, isActive: boolean) => { + received = { text, isActive }; + return text; + }; + renderSearchItem({ ...baseItem, style }, true, theme); + expect(received).toEqual({ text: "> Choice A", isActive: true }); + }); + + test("renders separators verbatim with a leading space", () => { + expect(renderSearchItem(new Separator("---"), false, theme)).toBe(" ---"); + }); + + test("renders disabled choices with the disabled style and ignores style hook", () => { + const style = (text: string) => `[styled]${text}`; + const disabled = { ...baseItem, disabled: true as boolean | string, style }; + expect(renderSearchItem(disabled, false, theme)).toBe("[disabled]Choice A (disabled)"); + }); + + test("uses the disabled string label when provided", () => { + const disabled = { ...baseItem, disabled: "coming soon" as boolean | string }; + expect(renderSearchItem(disabled, false, theme)).toBe("[disabled]Choice A coming soon"); + }); +}); + describe("ttyContext", () => { const originalIsTTY = process.stdin.isTTY; diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts index 14b5a6c7..b7c1d931 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -140,7 +140,7 @@ function isSelectable(item: T | Separator): item is T & { disabled?: boolean return !Separator.isSeparator(item) && !(item as { disabled?: boolean | string }).disabled; } -type NormalizedChoice = { +export type NormalizedChoice = { value: Value; name: string; short: string; @@ -149,7 +149,7 @@ type NormalizedChoice = { style?: (text: string, isActive: boolean) => string; }; -function normalizeChoices( +export function normalizeChoices( choices: ReadonlyArray | Separator>, ): Array | Separator> { return choices.map((choice) => { @@ -412,6 +412,37 @@ const searchTheme: SearchTheme = { }, }; +export type SearchItemTheme = { + icon: { cursor: string }; + style: { + disabled: (text: string) => string; + highlight: (text: string) => string; + }; +}; + +/** + * Render a single search-prompt row. Returns the rendered string the prompt + * paints for that line. A choice's `style` hook, when set, takes precedence + * over the default `theme.style.highlight` and is invoked with the cursor + + * name and whether the row is active. + */ +export function renderSearchItem( + item: NormalizedChoice | Separator, + isActive: boolean, + theme: SearchItemTheme, +): string { + if (Separator.isSeparator(item)) return ` ${item.separator}`; + if (item.disabled) { + const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; + return theme.style.disabled(`${item.name} ${disabledLabel}`); + } + const cursor = isActive ? theme.icon.cursor : " "; + const line = `${cursor} ${item.name}`; + if (item.style) return item.style(line, isActive); + const color = isActive ? theme.style.highlight : (x: string) => x; + return color(line); +} + const rawSearch = createPrompt>((config, done) => { const { pageSize = 7, validate = () => true } = config; const theme = makeTheme(searchTheme, config.theme); @@ -527,18 +558,7 @@ const rawSearch = createPrompt>((config, done) => const page = usePagination({ items: searchResults, active, - renderItem({ item, isActive }) { - if (Separator.isSeparator(item)) return ` ${item.separator}`; - if (item.disabled) { - const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; - return theme.style.disabled(`${item.name} ${disabledLabel}`); - } - const cursor = isActive ? theme.icon.cursor : " "; - const line = `${cursor} ${item.name}`; - if (item.style) return item.style(line, isActive); - const color = isActive ? theme.style.highlight : (x: string) => x; - return color(line); - }, + renderItem: ({ item, isActive }) => renderSearchItem(item, isActive, theme), pageSize: effectivePageSize, loop: false, }); From e33c4949378dce70f60a62b557ee95665b42409c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 12:02:05 -0600 Subject: [PATCH 26/29] test(users): assert structured usage error in agent-mode create The previous test confirmed only that the wizard was not invoked in agent mode. A regression that strips the curated-flag examples from noInputMessage, or that throws an unstructured Error instead of USAGE_ERROR, would slip through. Tighten the assertion to confirm: - error is a CliError with code USAGE_ERROR and exitCode EXIT_CODE.USAGE - the message contains each curated-flag example - neither the secret-key resolver nor BAPI are called --- .../src/commands/users/create.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index 547598d9..f19a1c84 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -240,15 +240,20 @@ describe("users create", () => { expect(mockBapiRequest).toHaveBeenCalled(); }); - test("does not invoke the wizard in agent mode", async () => { + test("agent mode without input throws a structured usage error and never prompts", async () => { mockIsAgent.mockReturnValue(true); - let thrown: unknown; - try { - await runCreate({}); - } catch (e) { - thrown = e; - } - expect(thrown).toBeDefined(); + + const error = await runCreate({}).catch((caught) => caught); + + expect(error).toBeInstanceOf(CliError); + expect(error.code).toBe(ERROR_CODE.USAGE_ERROR); + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain("No input provided"); + expect(error.message).toContain("--email alice@example.com"); + expect(error.message).toContain("-d '{"); + expect(error.message).toContain("--file user.json"); expect(mockRunCreateWizard).not.toHaveBeenCalled(); + expect(mockResolveBapiSecretKey).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); }); }); From 232538ac43ddcf647f64368a572e06f842746137 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 13:49:11 -0600 Subject: [PATCH 27/29] fix(users): address PR review feedback - preserve whitespace in wizard password input (drop .trim()) - reject --secret-key combined with --app or --instance via usage error - throw INSTANCE_NOT_FOUND in resolveAppContext when --instance does not match any fetched application instance --- .../src/commands/users/create-wizard.test.ts | 18 +++++++++++++++++ .../src/commands/users/create-wizard.ts | 3 +-- .../interactive/instance-context.test.ts | 14 +++++++++++++ .../users/interactive/instance-context.ts | 15 +++++++------- packages/cli-core/src/lib/config.test.ts | 20 +++++++++++++++++++ packages/cli-core/src/lib/config.ts | 6 ++++++ 6 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/users/create-wizard.test.ts b/packages/cli-core/src/commands/users/create-wizard.test.ts index bcc17a55..ce82b583 100644 --- a/packages/cli-core/src/commands/users/create-wizard.test.ts +++ b/packages/cli-core/src/commands/users/create-wizard.test.ts @@ -88,6 +88,24 @@ describe("runCreateWizard", () => { expect(mockFetchUserSettings).not.toHaveBeenCalled(); }); + test("preserves whitespace in password input without trimming", async () => { + mockResolveContext.mockResolvedValue({ + secretKey: "sk_test_xyz", + publishableKey: "pk_test_xyz", + fapiHost: "fake.example.com", + }); + mockBootstrapDevBrowser.mockResolvedValue("jwt-abc"); + mockFetchUserSettings.mockResolvedValue({ + attributes: { + password: { enabled: true, required: true, used_for_first_factor: false }, + }, + }); + mockPassword.mockResolvedValueOnce(" spaced password "); + + const result = await runCreateWizard({}); + expect(result.fields.password).toBe(" spaced password "); + }); + test("skips dev_browser bootstrap on production instance", async () => { mockResolveContext.mockResolvedValue({ secretKey: "sk_live_xyz", diff --git a/packages/cli-core/src/commands/users/create-wizard.ts b/packages/cli-core/src/commands/users/create-wizard.ts index 35601d16..502c655e 100644 --- a/packages/cli-core/src/commands/users/create-wizard.ts +++ b/packages/cli-core/src/commands/users/create-wizard.ts @@ -92,8 +92,7 @@ async function promptField(field: FieldDef, required: boolean): Promise ? (value: string) => value.trim().length > 0 || `${field.message} is required` : undefined; if (field.isPassword) { - const value = await password({ message, validate }); - return value.trim(); + return password({ message, validate }); } const value = await input({ message, validate }); return value.trim(); 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 eae59cbc..6c2de76b 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 @@ -121,4 +121,18 @@ describe("resolveUsersInstanceContext", () => { await expect(resolveUsersInstanceContext({})).rejects.toThrow(CliError); 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/); + expect(mockFetchApplication).not.toHaveBeenCalled(); + }); + + test("rejects --secret-key combined with --instance", async () => { + 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(); + }); }); 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 f7aa71f4..a233dbe0 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -1,6 +1,6 @@ import { fetchAppsTolerantly, pickOrCreateApp } from "../../../lib/app-picker.ts"; import { resolveAppContext, resolveFetchedApplicationInstance } from "../../../lib/config.ts"; -import { CliError, ERROR_CODE, withApiContext } from "../../../lib/errors.ts"; +import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../../lib/errors.ts"; import { decodePublishableKey } from "../../../lib/fapi.ts"; import { fetchApplication, validateKeyPrefix } from "../../../lib/plapi.ts"; import { isHuman } from "../../../mode.ts"; @@ -22,7 +22,12 @@ export type ResolveUsersInstanceContextOptions = { export async function resolveUsersInstanceContext( options: ResolveUsersInstanceContextOptions, ): Promise { - if (options.secretKey && !options.app) { + 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 }; } @@ -39,10 +44,6 @@ export async function resolveUsersInstanceContext( if (!(error instanceof CliError) || error.code !== ERROR_CODE.NOT_LINKED) { throw error; } - if (options.secretKey) { - validateKeyPrefix(options.secretKey, "sk_"); - return { secretKey: options.secretKey }; - } if (isHuman()) { const apps = await fetchAppsTolerantly(); const picked = await pickOrCreateApp({ @@ -71,7 +72,7 @@ export async function resolveUsersInstanceContext( } const ctx: UsersInstanceContext = { - secretKey: options.secretKey ?? instance.secret_key, + secretKey: instance.secret_key, appId, instanceId: resolved.instanceId, }; diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index b6deea22..25049b14 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -289,5 +289,25 @@ describe("config", () => { instanceLabel: "ins_custom_123", }); }); + + test("throws INSTANCE_NOT_FOUND when --instance does not match any fetched instance", async () => { + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + name: "My App", + instances: [ + { + instance_id: "ins_real_123", + environment_type: "staging", + publishable_key: "pk_test_real_123", + }, + ], + }); + + await expect( + resolveAppContext({ app: "app_123", instance: "ins_missing_123" }), + ).rejects.toMatchObject({ + code: "instance_not_found", + }); + }); }); }); diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 33dbc355..289ce5ab 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -323,6 +323,12 @@ export async function resolveAppContext( const app = await fetchApplication(options.app); const appLabel = app.name || options.app; const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); + if (!resolved.found) { + throw new CliError( + `Instance ${resolved.instanceId} not found in application ${options.app}.`, + { code: ERROR_CODE.INSTANCE_NOT_FOUND }, + ); + } return { appId: options.app, From 48d2d60f3cadd558fbe91ed274a9db2585a3f8ce Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 14:39:06 -0600 Subject: [PATCH 28/29] docs(users): add examples to clerk users create --help Mirror the examples block on `clerk users list` so `--help` for create shows the three distinct invocation modes: curated flags, inline -d body, and --file with --dry-run. --- packages/cli-core/src/cli-program.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index e41ce729..57bc812f 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -293,6 +293,20 @@ Give AI agents better Clerk context: install the Clerk skills .option("--file ", "Read BAPI request body from a file") .option("--dry-run", "Show the request without executing it") .option("--yes", "Skip confirmation prompt") + .setExamples([ + { + command: "clerk users create --email alice@example.com --first-name Alice --yes", + description: "Create a user from curated flags", + }, + { + command: 'clerk users create -d \'{"email_address":["alice@example.com"]}\' --yes', + description: "Create a user from an inline BAPI request body", + }, + { + command: "clerk users create --file user.json --dry-run", + description: "Preview a request from a file without executing", + }, + ]) .action((_opts, cmd) => usersHandlers.create(cmd.optsWithGlobals() as Parameters[0]), ); From ecf78cf46e1a18e3a3211110c140075b0acc368a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 27 Apr 2026 15:01:40 -0600 Subject: [PATCH 29/29] docs(skill): point users recipes at `clerk users create` The bundled clerk skill's `recipes.md` defaulted to raw `clerk api /users -d '{...}'` for user creation, so agents installing the skill after this stack lands would never be steered toward the new dedicated command. Switch the basic create recipe to curated flags and the test-user recipes to `clerk users create -d '{...}'` (the same body shape, but routed through the user-management surface). The raw BAPI call is kept as a labeled fallback for fields the curated flags don't expose. --- skills/clerk/references/recipes.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/skills/clerk/references/recipes.md b/skills/clerk/references/recipes.md index d2264dc0..ba9e0d86 100644 --- a/skills/clerk/references/recipes.md +++ b/skills/clerk/references/recipes.md @@ -28,7 +28,15 @@ clerk api /users/user_abc123 # Search by email clerk api '/users?email_address=alice@example.com' -# Create a user +# Create a user (preferred; curated flags) +clerk users create \ + --email alice@example.com \ + --password 'SuperSecret123!' \ + --first-name Alice \ + --last-name Doe \ + --yes + +# Equivalent raw BAPI call. Use only when curated flags don't cover a field. clerk api /users -d '{ "email_address": ["alice@example.com"], "password": "SuperSecret123!", @@ -60,22 +68,23 @@ For test accounts you need to sign into without real email or SMS delivery, Cler ```sh # Create a test user with a test email (dev instance) -clerk api /users -d '{ +# `skip_password_checks` isn't a curated flag, so pass the body via `-d`. +clerk users create -d '{ "email_address": ["demo+clerk_test@example.com"], "password": "TestPass123!", "skip_password_checks": true -}' +}' --yes ``` **By phone.** Any US fictional phone number in the `+1 (XXX) 555-0100` through `+1 (XXX) 555-0199` range is recognized as a test phone. Pass the E.164 form. ```sh # Create a test user with a test phone (dev instance) -clerk api /users -d '{ +clerk users create -d '{ "phone_number": ["+12015550100"], "password": "TestPass123!", "skip_password_checks": true -}' +}' --yes ``` When signing in as either user in a browser or Playwright, enter `424242` at the OTP prompt.