diff --git a/.changeset/users-scaffolding-and-create.md b/.changeset/users-scaffolding-and-create.md deleted file mode 100644 index 659f2de0..00000000 --- a/.changeset/users-scaffolding-and-create.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"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 91df7469..7afe5007 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,3 @@ 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 050c52a2..39005a7e 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,7 @@ 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) @@ -35,18 +34,194 @@ 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 - users Manage Clerk users - env Manage environment variables + 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) + env Manage environment variables + pull [options] Pull environment variables from Clerk to .env.local 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 - completion [shell] Generate shell autocompletion script 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 update [options] Update the Clerk CLI to the latest version - help [command] Display help for command + +Give AI agents better Clerk context: install the Clerk skills + $ clerk skill install + +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 ``` diff --git a/bun.lock b/bun.lock index 690bbb9a..766246d0 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "packages/cli": { "name": "clerk", - "version": "1.0.3", + "version": "0.8.4", "bin": { "clerk": "./bin/clerk", }, @@ -42,7 +42,6 @@ "yaml": "^2.8.3", }, "devDependencies": { - "@clerk/shared": "^4.8.3", "@types/semver": "^7.7.1", }, }, @@ -99,7 +98,7 @@ "@clerk/cli-core": ["@clerk/cli-core@workspace:packages/cli-core"], - "@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/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/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=="], @@ -461,10 +460,6 @@ "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 60e1ecdf..deb230b6 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -29,7 +29,6 @@ "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 9dc3e6f3..e1ee982d 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -5,62 +5,6 @@ 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 57bc812f..6266fb01 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -14,7 +14,6 @@ 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"; @@ -35,7 +34,6 @@ import { UserAbortError, ApiError, PlapiError, - FapiError, EXIT_CODE, throwUsageError, } from "./lib/errors.ts"; @@ -260,57 +258,6 @@ 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") @@ -752,7 +699,7 @@ export async function runProgram( ); } else { log.error(`${prefix} (${error.status}): ${detail}`); - if (verbose && (error instanceof PlapiError || error instanceof FapiError) && error.url) { + if (verbose && error instanceof PlapiError && 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 a4860a6f..35e865d1 100644 --- a/packages/cli-core/src/commands/api/bapi.ts +++ b/packages/cli-core/src/commands/api/bapi.ts @@ -4,7 +4,6 @@ */ 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"; @@ -23,7 +22,11 @@ export async function bapiRequest(options: { baseUrl?: string; }): Promise { const base = options.baseUrl ?? getBapiBaseUrl(); - const path = normalizeBapiPath(options.path); + + // 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 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 d66b3613..00b7ec95 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -2,7 +2,6 @@ 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, @@ -56,57 +55,6 @@ 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 = { @@ -141,11 +89,7 @@ mock.module("../../lib/config.ts", () => ({ } const profile = _profiles[process.cwd()]; - if (!profile) { - throw new CliError("No Clerk project linked to this directory.", { - code: ERROR_CODE.NOT_LINKED, - }); - } + if (!profile) throw new Error("No Clerk project 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 1bdf7624..d1320d54 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -1,8 +1,15 @@ -import { getAuthToken } from "../../lib/plapi.ts"; +import { resolveAppContext } from "../../lib/config.ts"; +import { fetchApplication, getAuthToken, validateKeyPrefix } 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, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; +import { + BapiError, + CliError, + ERROR_CODE, + throwUsageError, + throwUserAbort, + withApiContext, +} from "../../lib/errors.ts"; import { isHuman } from "../../mode.ts"; import { confirm } from "../../lib/prompts.ts"; import { withSpinner } from "../../lib/spinner.ts"; @@ -54,13 +61,13 @@ export async function api( secretKey = await getAuthToken(); baseUrl = getPlapiBaseUrl(); } else { - secretKey = await resolveBapiSecretKey(options); + secretKey = await resolveSecretKey(options); baseUrl = getBapiBaseUrl(); } // 4. Dry run if (options.dryRun) { - log.info(`[dry-run] ${method} ${baseUrl}${normalizeBapiPath(endpoint)}`); + log.info(`[dry-run] ${method} ${baseUrl}${normalizePath(endpoint)}`); if (body) { prettyPrint(body); } @@ -110,6 +117,52 @@ 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; @@ -134,6 +187,13 @@ 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 bbad9dda..30614875 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); // 1 match + create option - expect(results[0]!.value).toBe("app_a"); - expect(results[1]!.value).toBe("__create_new__"); + expect(results).toHaveLength(2); // create option + 1 match + expect(results[0]!.value).toBe("__create_new__"); + expect(results[1]!.value).toBe("app_a"); 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); // 1 match + create option - expect(results[0]!.value).toBe("app_abc"); - expect(results[1]!.value).toBe("__create_new__"); + expect(results).toHaveLength(2); // create option + 1 match + expect(results[0]!.value).toBe("__create_new__"); + expect(results[1]!.value).toBe("app_abc"); return "app_abc"; }, ); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 796be87d..316db732 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -1,25 +1,43 @@ 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 { fetchApplication, type Application } from "../../lib/plapi.ts"; -import { appLabel, fetchAppsTolerantly, pickOrCreateApp } from "../../lib/app-picker.ts"; +import { + listApplications, + fetchApplication, + createApplication, + type Application, +} from "../../lib/plapi.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, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; -import { intro, outro } from "../../lib/spinner.ts"; +import { + CliError, + PlapiError, + ERROR_CODE, + throwUsageError, + withApiContext, +} from "../../lib/errors.ts"; +import { intro, outro, withSpinner } 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(); @@ -107,6 +125,11 @@ 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, @@ -172,15 +195,55 @@ async function resolveApp( displayPath: string, detectKeys: boolean, ): Promise { - const apps = await fetchAppsTolerantly(); + 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; + } + } if (apps.length > 0 && detectKeys) { const detected = await tryDetectApp(cwd, apps); if (detected) return detected; } - return pickOrCreateApp({ - apps, + 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({ 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 deleted file mode 100644 index 11ee9d82..00000000 --- a/packages/cli-core/src/commands/users/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# `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 deleted file mode 100644 index ce82b583..00000000 --- a/packages/cli-core/src/commands/users/create-wizard.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 502c655e..00000000 --- a/packages/cli-core/src/commands/users/create-wizard.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index f19a1c84..00000000 --- a/packages/cli-core/src/commands/users/create.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -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 deleted file mode 100644 index ac7f2eda..00000000 --- a/packages/cli-core/src/commands/users/create.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 20d4779a..00000000 --- a/packages/cli-core/src/commands/users/index.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 051760e0..00000000 --- a/packages/cli-core/src/commands/users/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 4c104ac1..00000000 --- a/packages/cli-core/src/commands/users/interactive/attributes.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 7e514879..00000000 --- a/packages/cli-core/src/commands/users/interactive/attributes.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 6c2de76b..00000000 --- a/packages/cli-core/src/commands/users/interactive/instance-context.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -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 deleted file mode 100644 index a233dbe0..00000000 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 7a374685..00000000 --- a/packages/cli-core/src/commands/users/interactive/pick-user.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index 0205ab19..00000000 --- a/packages/cli-core/src/commands/users/interactive/pick-user.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 4f22ac26..00000000 --- a/packages/cli-core/src/commands/users/lifecycle-runner.ts +++ /dev/null @@ -1,156 +0,0 @@ -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 deleted file mode 100644 index caa8bc9e..00000000 --- a/packages/cli-core/src/commands/users/menu.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 9b928f3e..00000000 --- a/packages/cli-core/src/commands/users/menu.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index b60cf999..00000000 --- a/packages/cli-core/src/commands/users/output.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index e2aef5a7..00000000 --- a/packages/cli-core/src/commands/users/registry.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 5d201d35..00000000 --- a/packages/cli-core/src/commands/users/shared.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 168d8ea0..00000000 --- a/packages/cli-core/src/lib/app-picker.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 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 deleted file mode 100644 index 74f86bb7..00000000 --- a/packages/cli-core/src/lib/bapi-command.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -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 deleted file mode 100644 index 7f26a5d2..00000000 --- a/packages/cli-core/src/lib/bapi-command.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 25049b14..c1805e1f 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -1,9 +1,5 @@ -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 { +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { readConfig, writeConfig, getAuth, @@ -15,26 +11,23 @@ const { resolveProfile, resolveInstanceId, resolveAppContext, - resolveFetchedApplicationInstance, _setConfigDir, -} = await import("./config.ts"); -type Profile = - Awaited> extends infer T ? Exclude : never; -const plapiModule = await import("./plapi.ts"); + type Profile, +} from "./config.ts"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; 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 }); }); @@ -224,90 +217,4 @@ 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 289ce5ab..38af0c16 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -11,7 +11,6 @@ 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; @@ -252,62 +251,6 @@ 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: @@ -322,19 +265,46 @@ export async function resolveAppContext( const { fetchApplication } = await import("./plapi.ts"); const app = await fetchApplication(options.app); const appLabel = app.name || options.app; - const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); - if (!resolved.found) { - throw new CliError( - `Instance ${resolved.instanceId} not found in application ${options.app}.`, - { code: ERROR_CODE.INSTANCE_NOT_FOUND }, - ); + + 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, + }); } return { appId: options.app, appLabel, - instanceId: resolved.instanceId, - instanceLabel: resolved.instanceLabel, + instanceId: development.instance_id, + instanceLabel: "development", }; } diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index ef577503..48d1c821 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -43,8 +43,6 @@ 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]; @@ -197,29 +195,6 @@ 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 deleted file mode 100644 index 2e7c4291..00000000 --- a/packages/cli-core/src/lib/fapi.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index abc56b65..00000000 --- a/packages/cli-core/src/lib/fapi.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * 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 df5869ef..2e21c765 100644 --- a/packages/cli-core/src/lib/listage.test.ts +++ b/packages/cli-core/src/lib/listage.test.ts @@ -1,13 +1,5 @@ import { test, expect, describe, beforeEach } from "bun:test"; -import { - filterChoices, - normalizeChoices, - renderSearchItem, - scrollBounds, - Separator, - ttyContext, - withScrollIndicators, -} from "./listage.ts"; +import { scrollBounds, withScrollIndicators, filterChoices, ttyContext } from "./listage.ts"; describe("scrollBounds", () => { test("returns zeros when all items fit on page", () => { @@ -113,88 +105,6 @@ 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 b7c1d931..17594f40 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -140,16 +140,15 @@ function isSelectable(item: T | Separator): item is T & { disabled?: boolean return !Separator.isSeparator(item) && !(item as { disabled?: boolean | string }).disabled; } -export type NormalizedChoice = { +type NormalizedChoice = { value: Value; name: string; short: string; disabled: boolean | string; description?: string; - style?: (text: string, isActive: boolean) => string; }; -export function normalizeChoices( +function normalizeChoices( choices: ReadonlyArray | Separator>, ): Array | Separator> { return choices.map((choice) => { @@ -158,9 +157,7 @@ export function normalizeChoices( const name = String(choice); return { value: choice as Value, name, short: name, disabled: false }; } - const c = choice as SelectChoice & { - style?: (text: string, isActive: boolean) => string; - }; + const c = choice as SelectChoice; const name = c.name ?? String(c.value); const normalized: NormalizedChoice = { value: c.value, @@ -169,7 +166,6 @@ export function normalizeChoices( disabled: c.disabled ?? false, }; if (c.description) normalized.description = c.description; - if (c.style) normalized.style = c.style; return normalized; }); } @@ -381,8 +377,6 @@ 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 = { @@ -412,37 +406,6 @@ 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); @@ -558,7 +521,16 @@ const rawSearch = createPrompt>((config, done) => const page = usePagination({ items: searchResults, active, - renderItem: ({ item, isActive }) => renderSearchItem(item, isActive, theme), + 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}`); + }, pageSize: effectivePageSize, loop: false, }); diff --git a/packages/cli-core/src/lib/users.test.ts b/packages/cli-core/src/lib/users.test.ts deleted file mode 100644 index 302a8327..00000000 --- a/packages/cli-core/src/lib/users.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 6a4d4641..00000000 --- a/packages/cli-core/src/lib/users.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 6fc61d9c..00000000 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * 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/references/recipes.md b/skills/clerk/references/recipes.md index ba9e0d86..d2264dc0 100644 --- a/skills/clerk/references/recipes.md +++ b/skills/clerk/references/recipes.md @@ -28,15 +28,7 @@ clerk api /users/user_abc123 # Search by email clerk api '/users?email_address=alice@example.com' -# 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. +# Create a user clerk api /users -d '{ "email_address": ["alice@example.com"], "password": "SuperSecret123!", @@ -68,23 +60,22 @@ 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) -# `skip_password_checks` isn't a curated flag, so pass the body via `-d`. -clerk users create -d '{ +clerk api /users -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 users create -d '{ +clerk api /users -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 300e26a5..3bbebaac 100644 --- a/test/e2e/lib/test-user.ts +++ b/test/e2e/lib/test-user.ts @@ -20,10 +20,8 @@ function clerkEnv(configDir: string, secretKey: string): Record