feat(users): add users command scaffolding and clerk users create#240
feat(users): add users command scaffolding and clerk users create#240
clerk users create#240Conversation
… users c…" This reverts commit e4778c0.
🦋 Changeset detectedLatest commit: 231c3b7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
!snapshot |
Snapshot publishednpm install -g clerk@1.1.1-snapshot.231c3b7
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (45)
📝 WalkthroughWalkthroughThis pull request introduces a new Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the Comment |
| }) { | ||
| const payload: Record<string, unknown> = {}; | ||
|
|
||
| if (options.email) payload.email_address = [options.email]; |
There was a problem hiding this comment.
These truthiness checks (if (options.email)) will silently drop empty strings, which matters for buildUpdateUserPayload where "" could mean "clear this field". Using != null preserves the "omit if not provided" semantics while allowing explicit empty strings:
| if (options.email) payload.email_address = [options.email]; | |
| if (options.email != null) payload.email_address = [options.email]; | |
| if (options.phone != null) payload.phone_number = [options.phone]; | |
| if (options.username != null) payload.username = options.username; | |
| if (options.password != null) payload.password = options.password; | |
| if (options.firstName != null) payload.first_name = options.firstName; | |
| if (options.lastName != null) payload.last_name = options.lastName; | |
| if (options.externalId != null) payload.external_id = options.externalId; |
| } | ||
|
|
||
| function getLifecycleSuccessMessage(path: string): string { | ||
| const userId = getUserIdFromPath(path); |
There was a problem hiding this comment.
These two parallel if-chains (getLifecycleSuccessMessage and getLifecycleErrorMessage) duplicate the same path.endsWith logic across ~60 lines. A lookup map would cut this down significantly:
| const userId = getUserIdFromPath(path); | |
| const LIFECYCLE_LABELS: Record<string, { success: string; error: string }> = { | |
| "/ban": { success: "Banned user", error: "Failed to ban user" }, | |
| "/unban": { success: "Unbanned user", error: "Failed to unban user" }, | |
| "/lock": { success: "Locked user", error: "Failed to lock user" }, | |
| "/unlock": { success: "Unlocked user", error: "Failed to unlock user" }, | |
| "/profile_image": { success: "Removed profile image for user", error: "Failed to remove profile image for user" }, | |
| "/mfa": { success: "Disabled MFA for user", error: "Failed to disable MFA for user" }, | |
| "/totp": { success: "Removed TOTP for user", error: "Failed to remove TOTP for user" }, | |
| "/backup_code": { success: "Removed backup codes for user", error: "Failed to remove backup codes for user" }, | |
| }; | |
| function getLifecycleLabel(path: string, type: "success" | "error"): string { | |
| const userId = getUserIdFromPath(path); | |
| const suffix = "/" + (path.split("/").pop() ?? ""); | |
| const labels = LIFECYCLE_LABELS[suffix]; | |
| const fallback = type === "success" ? "Updated user" : "Failed to update user"; | |
| return `${labels?.[type] ?? fallback} ${userId}`; | |
| } |
| export async function runCreateWizard(options: WizardOptions): Promise<CreateWizardResult> { | ||
| const ctx = await resolveUsersInstanceContext(options); | ||
| const settings = | ||
| ctx.fapiHost && ctx.publishableKey |
There was a problem hiding this comment.
If FAPI is down or the request fails, loadSettings throws FapiError and the entire wizard crashes. This contrasts with fetchAppsTolerantly in app-picker.ts which gracefully degrades on 5xx. Wrapping this in a try-catch would let the wizard fall back to showing all fields as optional (which it already does when fapiHost is undefined):
| ctx.fapiHost && ctx.publishableKey | |
| let settings: UserSettingsJSON | undefined; | |
| if (ctx.fapiHost && ctx.publishableKey) { | |
| try { | |
| settings = await loadSettings(ctx.fapiHost, decodePublishableKey(ctx.publishableKey).instanceType); | |
| } catch { | |
| // Fall back to prompting the full curated set when FAPI is unreachable. | |
| } | |
| } |
Summary
Adds the
clerk userscommand family with the first subcommand,clerk users create. The top-leveluserscommand exposes shared targeting (--app,--secret-key,--instance,--dry-run,--yes,--json) that upcoming subcommands will reuse.clerk users createaccepts curated flags (--email,--phone,--username,--password,--first-name,--last-name,--external-id) for the common path, and-d, --data <json>/--file <path>for raw BAPI bodies when a field the curated flags don't expose (primary_email_address_id,skip_password_checks,web3_wallets) is needed. Program-level--input-jsoncomposes cleanly so agents can drive curated flags from a JSON object. BAPI enforces identifier and required-field rules server-side, so the command only needs a BAPI secret key, not theapplications:managePlatform API scope.In human mode,
clerk usersinvoked with no subcommand opens an interactive menu that dispatches to registered actions, andclerk users createinvoked without curated flags or-d/--fileenters a guided wizard. The wizard reads the instance's Frontend API user_settings and prompts only for fields the instance accepts, marking required ones. When no project is linked the wizard falls back to a shared application picker (also used byclerk link), so users don't have to runclerk linkfirst. Agent mode disables every interactive flow and exits with a structured usage error instead.The application picker now lists "Create a new application" at the bottom and de-emphasizes it until highlighted, so it reads as a fallback rather than a primary choice.
lib/listage.tsgained a per-choicestylehook to support that without baking dim styling into the prompt theme.Alongside
create, this PR lands shared scaffolding upcoming subcommands will build on:lib/bapi-command.ts(secret-key resolution and error formatting),lib/users.ts(payload builders, JSON input parsing),commands/users/output.ts(mutation print helpers), andcommands/users/lifecycle-runner.ts(shared runner for direct state transitions).Test plan
bun run format:checkbun run lintbun run typecheckbun run testbun run test:e2e:op(every framework fixture's test-user creation now runs throughclerk users create -d)clerk users create --helpand the curated-flags +-dinteractionclerk users createwizard with no project linked, confirm picker → wizard → BAPI handoff