From 239e21dd7247424007e0964467d9bd5c30abeb06 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 28 Apr 2026 09:06:31 -0600 Subject: [PATCH 1/3] =?UTF-8?q?Revert=20"Revert=20"feat(users):=20add=20us?= =?UTF-8?q?ers=20command=20scaffolding=20and=20`clerk=20users=20c=E2=80=A6?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e4778c02b1ea41a5e7034ff711ea66c25dd99838. --- .changeset/users-scaffolding-and-create.md | 5 + .gitignore | 3 + README.md | 187 +---------- bun.lock | 9 +- packages/cli-core/package.json | 1 + packages/cli-core/src/cli-program.test.ts | 56 ++++ packages/cli-core/src/cli-program.ts | 55 +++- 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/link/index.test.ts | 12 +- packages/cli-core/src/commands/link/index.ts | 77 +---- .../cli-core/src/commands/users/README.md | 78 +++++ .../src/commands/users/create-wizard.test.ts | 124 +++++++ .../src/commands/users/create-wizard.ts | 99 ++++++ .../src/commands/users/create.test.ts | 259 +++++++++++++++ .../cli-core/src/commands/users/create.ts | 146 +++++++++ .../cli-core/src/commands/users/index.test.ts | 22 ++ packages/cli-core/src/commands/users/index.ts | 14 + .../users/interactive/attributes.test.ts | 32 ++ .../commands/users/interactive/attributes.ts | 25 ++ .../interactive/instance-context.test.ts | 138 ++++++++ .../users/interactive/instance-context.ts | 88 +++++ .../users/interactive/pick-user.test.ts | 76 +++++ .../commands/users/interactive/pick-user.ts | 47 +++ .../src/commands/users/lifecycle-runner.ts | 156 +++++++++ .../cli-core/src/commands/users/menu.test.ts | 81 +++++ packages/cli-core/src/commands/users/menu.ts | 43 +++ .../cli-core/src/commands/users/output.ts | 134 ++++++++ .../cli-core/src/commands/users/registry.ts | 27 ++ .../cli-core/src/commands/users/shared.ts | 13 + packages/cli-core/src/lib/app-picker.ts | 82 +++++ .../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 | 107 +++++- packages/cli-core/src/lib/config.ts | 100 ++++-- packages/cli-core/src/lib/errors.ts | 25 ++ packages/cli-core/src/lib/fapi.test.ts | 128 ++++++++ packages/cli-core/src/lib/fapi.ts | 123 +++++++ packages/cli-core/src/lib/listage.test.ts | 92 +++++- packages/cli-core/src/lib/listage.ts | 54 +++- packages/cli-core/src/lib/users.test.ts | 101 ++++++ packages/cli-core/src/lib/users.ts | 124 +++++++ .../test/integration/users-commands.test.ts | 268 +++++++++++++++ skills/clerk-cli/references/recipes.md | 19 +- test/e2e/lib/test-user.ts | 8 +- 46 files changed, 3395 insertions(+), 395 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-wizard.test.ts create mode 100644 packages/cli-core/src/commands/users/create-wizard.ts 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.test.ts create mode 100644 packages/cli-core/src/commands/users/index.ts 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 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 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 create mode 100644 packages/cli-core/src/commands/users/lifecycle-runner.ts create mode 100644 packages/cli-core/src/commands/users/menu.test.ts create mode 100644 packages/cli-core/src/commands/users/menu.ts create mode 100644 packages/cli-core/src/commands/users/output.ts create mode 100644 packages/cli-core/src/commands/users/registry.ts create mode 100644 packages/cli-core/src/commands/users/shared.ts create mode 100644 packages/cli-core/src/lib/app-picker.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/fapi.test.ts create mode 100644 packages/cli-core/src/lib/fapi.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..659f2de0 --- /dev/null +++ b/.changeset/users-scaffolding-and-create.md @@ -0,0 +1,5 @@ +--- +"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 and de-emphasizes it until highlighted, so it reads as a fallback rather than a primary choice. 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/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/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" } } diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 50ec753f..d7850168 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -5,6 +5,62 @@ 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", + "--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")!; + 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 30e16d50..e3975c36 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"; @@ -34,6 +35,7 @@ import { UserAbortError, ApiError, PlapiError, + FapiError, EXIT_CODE, throwUsageError, } from "./lib/errors.ts"; @@ -256,6 +258,57 @@ Give AI agents better Clerk context: install the Clerk skills ]) .action(appsHandlers.create); + 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", + 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", + }, + ]) + .action(usersHandlers.menu); + + 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("--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]), + ); + const env = program .command("env") .description("Manage environment variables") @@ -697,7 +750,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/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/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/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/commands/users/README.md b/packages/cli-core/src/commands/users/README.md new file mode 100644 index 00000000..11ee9d82 --- /dev/null +++ b/packages/cli-core/src/commands/users/README.md @@ -0,0 +1,78 @@ +# `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. + +## 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: + +- **`--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-wizard.test.ts b/packages/cli-core/src/commands/users/create-wizard.test.ts new file mode 100644 index 00000000..ce82b583 --- /dev/null +++ b/packages/cli-core/src/commands/users/create-wizard.test.ts @@ -0,0 +1,124 @@ +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", + appId: "app_xyz", + instanceId: "ins_dev", + 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.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); + }); + + 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.fields).toEqual({}); + expect(result.targeting).toEqual({ secretKey: "sk_test_raw" }); + expect(mockBootstrapDevBrowser).not.toHaveBeenCalled(); + 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", + 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..502c655e --- /dev/null +++ b/packages/cli-core/src/commands/users/create-wizard.ts @@ -0,0 +1,99 @@ +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 CreateWizardFields = { + email?: string; + phone?: string; + username?: string; + password?: string; + firstName?: string; + lastName?: string; +}; + +export type CreateWizardTargeting = { + app?: string; + instance?: string; + secretKey?: string; +}; + +export type CreateWizardResult = { + fields: CreateWizardFields; + targeting: CreateWizardTargeting; +}; + +type WizardOptions = { + app?: string; + instance?: string; + secretKey?: string; +}; + +type FieldDef = { + attr: AttributeName; + key: keyof CreateWizardFields; + 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 fields: CreateWizardFields = {}; + 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) fields[field.key] = value; + } + + const targeting: CreateWizardTargeting = {}; + if (ctx.appId) targeting.app = ctx.appId; + if (ctx.instanceId) targeting.instance = ctx.instanceId; + if (ctx.secretKey) targeting.secretKey = ctx.secretKey; + + return { fields, targeting }; +} + +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) { + return password({ message, validate }); + } + 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 new file mode 100644 index 00000000..f19a1c84 --- /dev/null +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -0,0 +1,259 @@ +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", +})); + +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(), +})); + +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"); + mockRunCreateWizard.mockResolvedValue({ fields: {}, targeting: {} }); + 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(); + mockRunCreateWizard.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"); + }); + + test("invokes the wizard when no flags + human + no data", async () => { + mockIsAgent.mockReturnValue(false); + mockRunCreateWizard.mockResolvedValue({ + fields: { email: "alice@example.com" }, + targeting: {}, + }); + await runCreate({ yes: true }); + expect(mockRunCreateWizard).toHaveBeenCalledWith({ + app: undefined, + instance: undefined, + secretKey: undefined, + }); + 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("agent mode without input throws a structured usage error and never prompts", async () => { + mockIsAgent.mockReturnValue(true); + + 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(); + }); +}); 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..ac7f2eda --- /dev/null +++ b/packages/cli-core/src/commands/users/create.ts @@ -0,0 +1,146 @@ +import { handleBapiError, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; +import { throwUsageError } 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 { 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; + 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; +}; + +type ResolvedCreate = { + payload: Record; + resolved: CreateUserOptions; +}; + +export async function create(options: CreateUserOptions): Promise { + const { payload, resolved } = await resolveCreate(options); + + if (resolved.dryRun) { + log.info("[dry-run] POST /v1/users"); + log.data(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); + return; + } + + const secretKey = await resolveBapiSecretKey({ + secretKey: resolved.secretKey, + app: resolved.app, + instance: resolved.instance, + }); + + try { + const response = await withSpinner("Creating user...", () => + bapiRequest({ + method: "POST", + path: "/users", + secretKey, + body: JSON.stringify(payload), + }), + ); + + printUsersMutationResult("Created user", response.body, resolved); + } catch (error) { + if (handleUsersBapiError(error, "Failed to create user", resolved)) { + return; + } + if (handleBapiError(error)) { + return; + } + throw error; + } +} + +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<{ + basePayload: Record; + resolved: CreateUserOptions; +}> { + if (options.data || options.file) { + return { + basePayload: parseUsersPayload( + await readUsersPayloadInput({ data: options.data, file: options.file }), + ), + resolved: options, + }; + } + + if (hasCreateFlagPayload(options)) { + return { basePayload: {}, resolved: options }; + } + + if (isHuman()) { + const { fields, targeting } = await runCreateWizard({ + app: options.app, + instance: options.instance, + secretKey: options.secretKey, + }); + if (Object.keys(fields).length === 0) { + throwUsageError(noInputMessage()); + } + return { basePayload: {}, resolved: { ...options, ...targeting, ...fields } }; + } + + throwUsageError(noInputMessage()); +} + +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" + ); +} + +function hasCreateFlagPayload(options: CreateUserOptions): boolean { + return Boolean( + options.email || + options.phone || + options.username || + options.password || + options.firstName || + options.lastName || + options.externalId, + ); +} + +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 new file mode 100644 index 00000000..051760e0 --- /dev/null +++ b/packages/cli-core/src/commands/users/index.ts @@ -0,0 +1,14 @@ +import { create } from "./create.ts"; +import { usersMenu } from "./menu.ts"; + +export type { UsersActionTargeting, UsersAction } from "./registry.ts"; +export { + registerUsersAction, + listUsersActions, + __resetUsersActionRegistryForTesting, +} from "./registry.ts"; + +export const users = { + create, + menu: usersMenu, +}; 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); +} 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..6c2de76b --- /dev/null +++ b/packages/cli-core/src/commands/users/interactive/instance-context.test.ts @@ -0,0 +1,138 @@ +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), + 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: () => {}, +})); +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"); + +describe("resolveUsersInstanceContext", () => { + beforeEach(() => { + mockResolveAppContext.mockReset(); + mockFetchApplication.mockReset(); + mockFetchAppsTolerantly.mockReset(); + mockPickOrCreateApp.mockReset(); + mockIsHuman.mockReturnValue(true); + }); + + 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.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"); + }); + + 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(); + }); + + 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.appId).toBe("app_picked"); + expect(ctx.instanceId).toBe("ins_dev"); + 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(); + }); + + 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 new file mode 100644 index 00000000..a233dbe0 --- /dev/null +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -0,0 +1,88 @@ +import { fetchAppsTolerantly, pickOrCreateApp } from "../../../lib/app-picker.ts"; +import { resolveAppContext, resolveFetchedApplicationInstance } from "../../../lib/config.ts"; +import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../../lib/errors.ts"; +import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { fetchApplication, validateKeyPrefix } from "../../../lib/plapi.ts"; +import { isHuman } from "../../../mode.ts"; + +export type UsersInstanceContext = { + secretKey: string; + appId?: string; + instanceId?: string; + publishableKey?: string; + fapiHost?: string; +}; + +export type ResolveUsersInstanceContextOptions = { + app?: string; + instance?: string; + secretKey?: string; +}; + +export async function resolveUsersInstanceContext( + options: ResolveUsersInstanceContextOptions, +): Promise { + if (options.secretKey) { + if (options.app || options.instance) { + throwUsageError( + "--secret-key cannot be combined with --app or --instance. The secret key already targets a specific instance.", + ); + } + validateKeyPrefix(options.secretKey, "sk_"); + return { secretKey: options.secretKey }; + } + + 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) { + 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; + } + } + } + + 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: instance.secret_key, + appId, + instanceId: resolved.instanceId, + }; + 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; +} 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), + })); + }, + }); +} 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/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(); +} 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/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; +} 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/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts new file mode 100644 index 00000000..168d8ea0 --- /dev/null +++ b/packages/cli-core/src/lib/app-picker.ts @@ -0,0 +1,82 @@ +/** + * 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 { cyan, 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: "+ Create a new application", + value: CREATE_NEW_APP, + style: (text: string, isActive: boolean) => (isActive ? cyan(text) : dim(text)), + }; + + const selectedId = await search({ + message: opts.message, + source: (term) => { + const filtered = term + ? appChoices.filter((c) => c.name.toLowerCase().includes(term.toLowerCase())) + : appChoices; + return [...filtered, createChoice]; + }, + }); + + 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; +} 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..25049b14 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,90 @@ 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", + }); + }); + + 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 38af0c16..289ce5ab 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,19 @@ 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); + 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, appLabel, - instanceId: development.instance_id, - instanceLabel: "development", + instanceId: resolved.instanceId, + instanceLabel: resolved.instanceLabel, }; } diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 48d1c821..ef577503 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]; @@ -195,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 new file mode 100644 index 00000000..2e7c4291 --- /dev/null +++ b/packages/cli-core/src/lib/fapi.test.ts @@ -0,0 +1,128 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { decodePublishableKey, bootstrapDevBrowser, fetchUserSettings } from "./fapi.ts"; +import { CliError, FapiError } 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); + }); + + 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", () => { + 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 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); + }); +}); + +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 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 new file mode 100644 index 00000000..abc56b65 --- /dev/null +++ b/packages/cli-core/src/lib/fapi.ts @@ -0,0 +1,123 @@ +/** + * 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, 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 = { + 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, + }); + } + + 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: host, + instanceType, + }; +} + +export type { UserSettingsJSON }; + +async function fapiFetch(method: "GET" | "POST", url: URL): Promise { + const response = await loggedFetch(url, { tag: "fapi", method }); + if (!response.ok) { + const body = await response.text(); + throw new FapiError(response.status, body, url.toString()); + } + 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, + }); + } + 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", CLERK_JS_API_VERSION); + if (opts.jwt) { + url.searchParams.set("__clerk_db_jwt", opts.jwt); + } + 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, + }); + } + return body.user_settings; +} 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 17594f40..b7c1d931 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -140,15 +140,16 @@ 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; disabled: boolean | string; description?: string; + style?: (text: string, isActive: boolean) => string; }; -function normalizeChoices( +export function normalizeChoices( choices: ReadonlyArray | Separator>, ): Array | Separator> { return choices.map((choice) => { @@ -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 = { @@ -406,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); @@ -521,16 +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 color = isActive ? theme.style.highlight : (x: string) => x; - const cursor = isActive ? theme.icon.cursor : " "; - return color(`${cursor} ${item.name}`); - }, + renderItem: ({ item, isActive }) => renderSearchItem(item, isActive, theme), pageSize: effectivePageSize, loop: false, }); 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..6fc61d9c --- /dev/null +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -0,0 +1,268 @@ +/** + * Exercise primary users flows through the real CLI program. + * Covers create against linked-project, --app, --secret-key, raw -d/--data, + * --dry-run, and the wizard picker fallback when no project is linked. + */ + +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"; + +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 linkDevProject(); + http.mock({ + [PLAPI_APP_ROUTE]: MOCK_APP, + "/v1/users": CREATED_USER, + }); + + 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(CREATED_USER); + } + + const createRequest = findBapiCreateRequest(); + expect(createRequest).toBeDefined(); + expect(JSON.parse(createRequest!.body!)).toEqual({ + email_address: ["alice@example.com"], + first_name: "Alice", + }); + }, + ); + + 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(); + }); +}); diff --git a/skills/clerk-cli/references/recipes.md b/skills/clerk-cli/references/recipes.md index d2264dc0..ba9e0d86 100644 --- a/skills/clerk-cli/references/recipes.md +++ b/skills/clerk-cli/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. 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: Thu, 30 Apr 2026 09:55:05 -0600 Subject: [PATCH 2/3] refactor(users): collapse lifecycle labels and harden wizard FAPI fallback Replaces the duplicated `path.endsWith` if-chains in `lifecycle-runner` with a single `LIFECYCLE_LABELS` lookup map plus one `getLifecycleLabel(path, type)` helper. Wraps `loadSettings` in `create-wizard` with try/catch so a FAPI 5xx falls back to the all-fields-optional path that already runs when `fapiHost` is undefined, with a `log.debug` line surfacing the error under `--verbose`. Addresses inline review feedback on #240. --- .../src/commands/users/create-wizard.ts | 20 ++++- .../src/commands/users/lifecycle-runner.ts | 81 ++++++------------- 2 files changed, 42 insertions(+), 59 deletions(-) diff --git a/packages/cli-core/src/commands/users/create-wizard.ts b/packages/cli-core/src/commands/users/create-wizard.ts index 502c655e..6e952a09 100644 --- a/packages/cli-core/src/commands/users/create-wizard.ts +++ b/packages/cli-core/src/commands/users/create-wizard.ts @@ -6,6 +6,7 @@ import { type InstanceType, type UserSettingsJSON, } from "../../lib/fapi.ts"; +import { log } from "../../lib/log.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { isEnabled, isRequired, type AttributeName } from "./interactive/attributes.ts"; import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; @@ -54,10 +55,21 @@ const ALL_FIELDS: FieldDef[] = [ 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; + let settings: UserSettingsJSON | undefined; + if (ctx.fapiHost && ctx.publishableKey) { + try { + settings = await loadSettings( + ctx.fapiHost, + decodePublishableKey(ctx.publishableKey).instanceType, + ); + } catch (error) { + log.debug( + `create-wizard: failed to load FAPI user settings, prompting full curated set: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } const fields: CreateWizardFields = {}; for (const field of ALL_FIELDS) { diff --git a/packages/cli-core/src/commands/users/lifecycle-runner.ts b/packages/cli-core/src/commands/users/lifecycle-runner.ts index 4f22ac26..8894def2 100644 --- a/packages/cli-core/src/commands/users/lifecycle-runner.ts +++ b/packages/cli-core/src/commands/users/lifecycle-runner.ts @@ -89,66 +89,37 @@ export async function runUserLifecycleCommand( } } -function getLifecycleSuccessMessage(path: string): string { - const userId = getUserIdFromPath(path); +const LIFECYCLE_LABELS: Record = { + "/ban": { success: "Banned user", error: "Failed to ban user" }, + "/unban": { success: "Unbanned user", error: "Failed to unban user" }, + "/lock": { success: "Locked user", error: "Failed to lock user" }, + "/unlock": { success: "Unlocked user", error: "Failed to unlock user" }, + "/profile_image": { + success: "Removed profile image for user", + error: "Failed to remove profile image for user", + }, + "/mfa": { success: "Disabled MFA for user", error: "Failed to disable MFA for user" }, + "/totp": { success: "Removed TOTP for user", error: "Failed to remove TOTP for user" }, + "/backup_code": { + success: "Removed backup codes for user", + error: "Failed to remove backup codes for user", + }, +}; - 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}`; - } +function getLifecycleLabel(path: string, type: "success" | "error"): string { + const userId = getUserIdFromPath(path); + const suffix = "/" + (path.split("/").pop() ?? ""); + const labels = LIFECYCLE_LABELS[suffix]; + const fallback = type === "success" ? "Updated user" : "Failed to update user"; + return `${labels?.[type] ?? fallback} ${userId}`; +} - return `Updated user ${userId}`; +function getLifecycleSuccessMessage(path: string): string { + return getLifecycleLabel(path, "success"); } 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}`; + return getLifecycleLabel(path, "error"); } function getUserIdFromPath(path: string): string { From ec9323f9f186a46223d7b8b9971f73b7299e9196 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 30 Apr 2026 10:10:20 -0600 Subject: [PATCH 3/3] fix(bapi): handle bare /v1 path in normalizeBapiPath normalizeBapiPath previously prepended "/v1" to any input that didn't start with "/v1/", producing "/v1/v1" for inputs of "v1" or "/v1". Match "/v1" exactly or "/v1/..." instead. --- packages/cli-core/src/lib/bapi-command.test.ts | 2 ++ packages/cli-core/src/lib/bapi-command.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/lib/bapi-command.test.ts b/packages/cli-core/src/lib/bapi-command.test.ts index 74f86bb7..af421b2a 100644 --- a/packages/cli-core/src/lib/bapi-command.test.ts +++ b/packages/cli-core/src/lib/bapi-command.test.ts @@ -35,6 +35,8 @@ describe("bapi-command", () => { expect(normalizeBapiPath("users")).toBe("/v1/users"); expect(normalizeBapiPath("/users")).toBe("/v1/users"); expect(normalizeBapiPath("/v1/users")).toBe("/v1/users"); + expect(normalizeBapiPath("v1")).toBe("/v1"); + expect(normalizeBapiPath("/v1")).toBe("/v1"); }); test("prints raw BAPI error bodies for machine use", async () => { diff --git a/packages/cli-core/src/lib/bapi-command.ts b/packages/cli-core/src/lib/bapi-command.ts index 7f26a5d2..22f22630 100644 --- a/packages/cli-core/src/lib/bapi-command.ts +++ b/packages/cli-core/src/lib/bapi-command.ts @@ -6,7 +6,7 @@ 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}`; + if (!/^\/v1(?:\/|$)/.test(normalized)) normalized = `/v1${normalized}`; return normalized; }