feat(cli-core): add clerk orgs and clerk billing shortcut commands#219
feat(cli-core): add clerk orgs and clerk billing shortcut commands#219nicolas-angelo wants to merge 4 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: a87ed32 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 |
📝 WalkthroughWalkthroughThis PR introduces CLI support for managing Clerk Organizations and Billing configuration. It adds two new top-level command groups— Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes 🚥 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. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/cli-core/src/commands/billing/index.ts`:
- Around line 109-120: Validate numeric CLI flags before assigning to plan: for
options.amount, options.annualAmount, and options.trialDays parseInt results
must be checked for NaN and integer-ness (e.g., const amt =
parseInt(options.amount,10); if (Number.isNaN(amt) || !Number.isFinite(amt))
return error/exit), and only then set plan.amount, plan.annual_monthly_amount,
and plan.free_trial_days; for trialDays also ensure it's a non-negative integer
and set plan.free_trial_enabled true only when validation passes. Apply the same
checks for the other numeric flags referenced later (the block noted at lines
~201-210) so no invalid or partial numeric input (like "12abc" → 12 or NaN) is
sent to the API.
In `@packages/cli-core/src/commands/orgs/index.ts`:
- Around line 20-24: The patch payload construction uses
parseInt(options.maxMembers, 10) which allows invalid or non-positive values
(NaN or truncated numbers) to be sent to patchInstanceConfig; validate
options.maxMembers before adding to patch: ensure it's present, is an integer
string with no trailing chars, and is > 0 (e.g., via Number.isInteger(parsed)
and parsed > 0) and only then set patch.max_allowed_memberships to the parsed
integer; if validation fails, return/throw a clear user-facing error or CLI exit
indicating the --max-members value is invalid.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9b25373c-146e-4858-b48e-3f4ebe70879f
📒 Files selected for processing (9)
.changeset/billing-orgs-shortcut-commands.mdREADME.mdpackages/cli-core/src/cli-program.tspackages/cli-core/src/commands/billing/README.mdpackages/cli-core/src/commands/billing/index.test.tspackages/cli-core/src/commands/billing/index.tspackages/cli-core/src/commands/orgs/README.mdpackages/cli-core/src/commands/orgs/index.test.tspackages/cli-core/src/commands/orgs/index.ts
| amount: parseInt(options.amount, 10), | ||
| payer_type: options.payer, | ||
| is_recurring: true, | ||
| publicly_visible: !options.hidden, | ||
| }; | ||
|
|
||
| if (options.currency) plan.currency = options.currency; | ||
| if (options.description) plan.description = options.description; | ||
| if (options.annualAmount) plan.annual_monthly_amount = parseInt(options.annualAmount, 10); | ||
| if (options.trialDays) { | ||
| plan.free_trial_enabled = true; | ||
| plan.free_trial_days = parseInt(options.trialDays, 10); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Demonstrates why parseInt is unsafe for CLI numeric flags.
bun -e 'console.log(parseInt("12abc", 10)); console.log(JSON.stringify({ amount: Number.NaN }))'Repository: clerk/cli
Length of output: 96
🏁 Script executed:
# First, locate the file and check its structure
fd -t f "index.ts" | grep -E "billing|commands"Repository: clerk/cli
Length of output: 759
🏁 Script executed:
# Read the file to see the actual code context
cat -n packages/cli-core/src/commands/billing/index.ts | head -250Repository: clerk/cli
Length of output: 9504
🏁 Script executed:
# Check if there's any input validation or CLI option type definitions
rg -A 5 -B 5 "PlansCreateOptions|PlansUpdateOptions" packages/cli-core/src/commands/billing/Repository: clerk/cli
Length of output: 3419
🏁 Script executed:
# Find where plansCreate and plansUpdate are called/registered
rg -B 5 -A 5 "plansCreate|plansUpdate" --type ts packages/cli-core/srcRepository: clerk/cli
Length of output: 13564
🏁 Script executed:
# Check for CLI argument parsing (commander, yargs, etc.)
find packages/cli-core/src -name "*.ts" | xargs grep -l "commander\|yargs\|option\|argument" | head -5Repository: clerk/cli
Length of output: 278
🏁 Script executed:
# Check the cli-program.ts to see how the plans create/update options are defined
rg -B 10 -A 3 "plans\.command.*create" packages/cli-core/src/cli-program.tsRepository: clerk/cli
Length of output: 35
🏁 Script executed:
# Look for the full option definitions for plans create and update
rg -B 20 "\.action\(plansCreate\)|\.action\(plansUpdate\)" packages/cli-core/src/cli-program.ts | head -80Repository: clerk/cli
Length of output: 2080
🏁 Script executed:
# Check if there's any validation happening in the CLI framework itself
# Look at the Command class and createOption to see if they validate
rg -A 10 "createOption.*amount" packages/cli-core/src/cli-program.tsRepository: clerk/cli
Length of output: 35
🏁 Script executed:
# Check if there are any tests that validate invalid numeric input
grep -r "parseInt\|NaN" packages/cli-core/src/commands/billing/Repository: clerk/cli
Length of output: 754
🏁 Script executed:
# Verify that JSON.stringify converts NaN to null (standard JS behavior)
node -e "console.log(JSON.stringify({amount: NaN, value: parseInt('abc', 10)}))"Repository: clerk/cli
Length of output: 81
🏁 Script executed:
# Check if there are any tests for invalid numeric input
cat packages/cli-core/src/commands/billing/index.test.ts | grep -A 10 -B 2 "invalid\|NaN\|abc\|parse"Repository: clerk/cli
Length of output: 6224
🏁 Script executed:
# Check if there's any validation or error handling after the API call
rg -A 5 "patchInstanceConfig" packages/cli-core/src/commands/billing/index.ts | head -20Repository: clerk/cli
Length of output: 670
🏁 Script executed:
# Look at the complete test file to see if there are edge cases tested
wc -l packages/cli-core/src/commands/billing/index.test.tsRepository: clerk/cli
Length of output: 109
Add validation for numeric CLI flags before patching billing plans.
parseInt silently accepts partial input ("12abc" → 12) and converts invalid input to NaN, which serializes as null in JSON. This allows corrupted plan amounts and trial days to be sent to the API. The code needs explicit numeric validation.
Suggested fix
function titleCase(slug: string): string {
return slug
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
+
+function parseNonNegativeInteger(value: string | undefined, flagName: string): number {
+ if (typeof value !== "string" || !/^\d+$/.test(value)) {
+ throwUsageError(`${flagName} must be a non-negative integer.`);
+ }
+
+ const parsed = Number(value);
+ if (!Number.isSafeInteger(parsed)) {
+ throwUsageError(`${flagName} must be a safe integer.`);
+ }
+
+ return parsed;
+}
export async function plansCreate(slug: string, options: PlansCreateOptions): Promise<void> {
const ctx = await resolveAppContext(options);
const plan: Record<string, unknown> = {
name: options.name || titleCase(slug),
- amount: parseInt(options.amount, 10),
+ amount: parseNonNegativeInteger(options.amount, "--amount"),
payer_type: options.payer,
is_recurring: true,
publicly_visible: !options.hidden,
};
if (options.currency) plan.currency = options.currency;
if (options.description) plan.description = options.description;
- if (options.annualAmount) plan.annual_monthly_amount = parseInt(options.annualAmount, 10);
- if (options.trialDays) {
+ if (options.annualAmount !== undefined) {
+ plan.annual_monthly_amount = parseNonNegativeInteger(options.annualAmount, "--annual-amount");
+ }
+ if (options.trialDays !== undefined) {
plan.free_trial_enabled = true;
- plan.free_trial_days = parseInt(options.trialDays, 10);
+ plan.free_trial_days = parseNonNegativeInteger(options.trialDays, "--trial-days");
} const plan: Record<string, unknown> = {};
if (options.name) plan.name = options.name;
- if (options.amount) plan.amount = parseInt(options.amount, 10);
+ if (options.amount !== undefined) plan.amount = parseNonNegativeInteger(options.amount, "--amount");
if (options.currency) plan.currency = options.currency;
if (options.description) plan.description = options.description;
- if (options.annualAmount) plan.annual_monthly_amount = parseInt(options.annualAmount, 10);
+ if (options.annualAmount !== undefined) {
+ plan.annual_monthly_amount = parseNonNegativeInteger(options.annualAmount, "--annual-amount");
+ }
if (options.hidden) plan.publicly_visible = false;
if (options.visible) plan.publicly_visible = true;
- if (options.trialDays) {
+ if (options.trialDays !== undefined) {
plan.free_trial_enabled = true;
- plan.free_trial_days = parseInt(options.trialDays, 10);
+ plan.free_trial_days = parseNonNegativeInteger(options.trialDays, "--trial-days");
}Also applies to: 201-210
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/cli-core/src/commands/billing/index.ts` around lines 109 - 120,
Validate numeric CLI flags before assigning to plan: for options.amount,
options.annualAmount, and options.trialDays parseInt results must be checked for
NaN and integer-ness (e.g., const amt = parseInt(options.amount,10); if
(Number.isNaN(amt) || !Number.isFinite(amt)) return error/exit), and only then
set plan.amount, plan.annual_monthly_amount, and plan.free_trial_days; for
trialDays also ensure it's a non-negative integer and set
plan.free_trial_enabled true only when validation passes. Apply the same checks
for the other numeric flags referenced later (the block noted at lines ~201-210)
so no invalid or partial numeric input (like "12abc" → 12 or NaN) is sent to the
API.
| const patch: Record<string, unknown> = { enabled: true }; | ||
| if (options.forceSelection) patch.force_organization_selection = true; | ||
| if (options.domains) patch.domains_enabled = true; | ||
| if (options.maxMembers) patch.max_allowed_memberships = parseInt(options.maxMembers, 10); | ||
| if (options.autoCreate) { |
There was a problem hiding this comment.
Validate --max-members before patching config.
Line 23 turns invalid input into unsafe payloads: parseInt("abc", 10) becomes NaN, and JSON.stringify sends that as null; parseInt("10abc", 10) silently sends 10. Reject non-positive/non-integer values before calling patchInstanceConfig.
Proposed fix
-import { withApiContext } from "../../lib/errors.ts";
+import { throwUsageError, withApiContext } from "../../lib/errors.ts";
@@
- if (options.maxMembers) patch.max_allowed_memberships = parseInt(options.maxMembers, 10);
+ if (options.maxMembers !== undefined) {
+ const maxMembers = Number(options.maxMembers);
+ if (!Number.isInteger(maxMembers) || maxMembers < 1) {
+ throwUsageError("--max-members must be a positive integer.");
+ }
+ patch.max_allowed_memberships = maxMembers;
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/cli-core/src/commands/orgs/index.ts` around lines 20 - 24, The patch
payload construction uses parseInt(options.maxMembers, 10) which allows invalid
or non-positive values (NaN or truncated numbers) to be sent to
patchInstanceConfig; validate options.maxMembers before adding to patch: ensure
it's present, is an integer string with no trailing chars, and is > 0 (e.g., via
Number.isInteger(parsed) and parsed > 0) and only then set
patch.max_allowed_memberships to the parsed integer; if validation fails,
return/throw a clear user-facing error or CLI exit indicating the --max-members
value is invalid.
| .option("--auto-create", "Auto-create an organization for new users") | ||
| .option("--max-members <n>", "Maximum members per organization") | ||
| .option("--domains", "Enable verified domains") | ||
| .option("--yes", "Skip confirmation prompts") |
There was a problem hiding this comment.
--yes is advertised on orgs enable/disable, billing enable/disable, and plans create/update/remove, but no implementation consults options.yes or calls confirm(). The commands always mutate silently. Users who see --yes will reasonably assume there is a prompt to skip.
Compare with config patch, which these shortcuts wrap: packages/cli-core/src/commands/config/push.ts:94-102 does if (!options.dryRun && isHuman() && !options.yes) { await confirm(...) }. Without an equivalent branch here, the shortcut is strictly more dangerous than the command it's supposed to replace.
Fix: either remove --yes and the yes?: boolean fields until a prompt exists, or add the real confirmation branch for every mutating command. Option two is the right call, these are production config edits.
| `Removing plan ${cyan(slug)} on ${ctx.appLabel} (${ctx.instanceLabel})...`, | ||
| () => | ||
| withApiContext( | ||
| patchInstanceConfig(ctx.appId, ctx.instanceId, config, { destructive: true }), |
There was a problem hiding this comment.
plansRemove fetches the current config, deletes the slug in memory, and PATCHes with { destructive: true }. No prompt, no diff, no dry-run. A typo in <slug> or the active instance silently wipes a live subscription plan.
The equivalent clerk config patch path would print a diff, warn, and ask "Proceed?". The shortcut regresses this safety.
Fix: gate the mutation behind isHuman() && !options.yes confirmation, support --dry-run, and show the plan (name, price, payer) being removed before confirming.
| .argument("<slug>", "Plan slug (display name auto-derived via title case)") | ||
| .option("--name <name>", "Override display name") | ||
| .requiredOption("--amount <cents>", "Monthly price in cents") | ||
| .addOption(createOption("--payer <type>", "Who pays").choices(["org", "user"])) |
There was a problem hiding this comment.
Line 527 uses .requiredOption("--amount <cents>", …). Line 528 uses .addOption(createOption("--payer <type>", "Who pays").choices(["org", "user"])) with no .makeOptionMandatory(). packages/cli-core/src/commands/billing/README.md:18 claims --payer is (required).
When omitted, options.payer === undefined, so billing/index.ts:110 writes payer_type: undefined. JSON.stringify drops the field, sending a malformed plan to the API.
Fix:
.addOption(
createOption("--payer <type>", "Who pays")
.choices(["org", "user"])
.makeOptionMandatory(),
)Add a test covering the missing---payer rejection.
| .description("Create a subscription plan") | ||
| .argument("<slug>", "Plan slug (display name auto-derived via title case)") | ||
| .option("--name <name>", "Override display name") | ||
| .requiredOption("--amount <cents>", "Monthly price in cents") |
There was a problem hiding this comment.
patchInstanceConfig() accepts dryRun (see lib/plapi.ts:156-184). clerk config patch exposes --dry-run at cli-program.ts:367. None of the new shortcuts plumb it through.
A maintainer who wants to preview billing enable --for org or plans remove pro on production has to fall back to the raw config patch command, defeating the point of the wrappers. These are the exact operations you most want to dry-run.
Fix: add --dry-run to every mutating subcommand and pass { dryRun: options.dryRun } into patchInstanceConfig, matching config/push.ts:107-115.
|
|
||
| const plan: Record<string, unknown> = { | ||
| name: options.name || titleCase(slug), | ||
| amount: parseInt(options.amount, 10), |
There was a problem hiding this comment.
Every numeric option (--amount, --max-members, --trial-days, --annual-amount) goes through raw parseInt(value, 10) with no validation, here and at billing/index.ts:117, 120, 201, 204, 209, plus orgs/index.ts:23.
parseInt("abc", 10) returns NaN, which JSON.stringify emits as null. So clerk billing plans create pro --amount abc --payer org sends amount: null to the API, and --max-members abc sends max_allowed_memberships: null. Neither failure mode is user-friendly.
Fix: use Commander's option argParser at the registration site:
.option("--amount <cents>", "Monthly price in cents", (v) => {
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n) || n < 0) {
throwUsageError(`Invalid --amount: "${v}". Must be a non-negative integer.`);
}
return n;
})Or extract a shared parseNonNegativeInt helper.
|
|
||
| const billing = current.billing as Record<string, unknown> | undefined; | ||
| if (billing?.organization_enabled) { | ||
| log.warn( |
There was a problem hiding this comment.
orgsDisable fetches billing config and calls log.warn("…Disabling organizations will also disable org billing."), then unconditionally issues the PATCH.
Emitting a warning and then doing the thing the warning describes is worse than not warning at all, in a CI log it looks like the warning was heeded. If the dependency is worth detecting (it is), it's worth gating behind confirmation.
Fix: when billing.organization_enabled is true, require explicit confirmation in human mode or a --force / --yes flag to proceed. In non-human mode, consider failing with a usage error directing the user to pass --force.
|
|
||
| const config = { billing: patch }; | ||
|
|
||
| const result = await withSpinner( |
There was a problem hiding this comment.
config push.ts:75-92 fetches the current config, computes a diff with hasConfigChanges / printDiff, prints it, then prompts. The shortcut commands send blind.
If confirmation is added per the critical-priority findings, pair it with the diff, otherwise the prompt just says "Proceed?" with no context about what's about to change.
Fix: reuse hasConfigChanges / printDiff from config/push.ts. They already handle partial-payload patch mode, which is what these wrappers generate.
|
|
||
| log.data(JSON.stringify(result, null, 2)); | ||
| log.success(`Billing enabled for ${target === "org" ? "organizations" : "users"}`); | ||
| } |
There was a problem hiding this comment.
log.data(JSON.stringify(result, null, 2)) emits the full server response on stdout after every mutation, here and at billing/index.ts:80, 128, 229, 267, plus orgs/index.ts:41, 73.
Per .claude/rules/logging.md, log.data is stdout for pipeable output. config patch does this because the user opted into a low-level config operation. For clerk orgs enable the user asked a single-intent question, piping a ~200-line config blob out every time is noise and will trip up shell pipelines.
Fix: demote to log.debug(...) for routine success, or emit only when --json / --verbose is set. Keep the log.success(...) line as the human-facing signal.
| const patch: Record<string, unknown> = { enabled: true }; | ||
| if (options.forceSelection) patch.force_organization_selection = true; | ||
| if (options.domains) patch.domains_enabled = true; | ||
| if (options.maxMembers) patch.max_allowed_memberships = parseInt(options.maxMembers, 10); |
There was a problem hiding this comment.
if (options.forceSelection) patch.force_organization_selection = true;
if (options.domains) patch.domains_enabled = true;
if (options.autoCreate) { … enabled: true }There's no --no-force-selection or --force-selection=false. To turn a flag off while keeping orgs enabled, the user has to fall back to clerk config patch, partially defeating the wrapper.
Fix: either (a) document in the README that these flags are one-way and point users to config patch for the inverse, or (b) accept Commander's boolean negation (--no-force-selection) and write false when it's set.
| const currency = (plan.currency as string) || "usd"; | ||
| const price = amount === 0 ? "Free" : `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`; | ||
| const payer = plan.payer_type as string; | ||
| const visible = plan.publicly_visible !== false; |
There was a problem hiding this comment.
cyan(plan.name as string) casts without checking. If the config contains a plan with no name field (legacy or malformed), output reads undefined (slug) — $X.XX/mo ….
Fix: cyan((plan.name as string) ?? slug).
Summary
Managing organizations and billing currently requires constructing raw JSON payloads with
clerk config patch. This is error-prone, hard to remember, and not discoverable. These new shortcut commands provide an ergonomic CLI interface for the most common org and billing configuration tasks.clerk orgs enable/disable— toggle organizations with optional configurationclerk billing enable/disable— toggle billing for orgs or usersclerk billing plans create/list/update/remove— full plan lifecycle managementAll commands are thin wrappers around the existing
config patchAPI endpoint.What changed
clerk orgscommand (commands/orgs/) —enableanddisablesubcommands that patchorganization_settings.enablesupports--force-selection,--auto-create,--max-members, and--domains.disablechecks if org billing is active and warns before proceeding. Aliased asclerk organizations.clerk billingcommand (commands/billing/) —enableanddisablesubcommands that patchbilling.organization_enabledorbilling.user_enabledbased on the required--for org|userflag.clerk billing planssubcommand —create,list,update, andremovefor managing subscription plans.createtakes a positional slug and auto-derives the display name via title case (overridable with--name).removeuses?destructive=trueto delete the plan from the config.Usage
Organizations
Billing
Plans
(App | Instance) Targeting
All commands resolve the linked app from the current project directory by default (via clerk link). Use
--appand--instanceto override:clerk orgs enable --app app_abc123 clerk billing plans list --app app_abc123 --instance prodBefore / After
Before (manual JSON):
After (shortcut commands):