From e81ba33238a8fbecb5bf96aec26a8bcd94786bfd Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 23 Apr 2026 09:35:24 -0400 Subject: [PATCH 1/8] feat(cli-core): add clerk orgs and clerk billing shortcut commands --- packages/cli-core/src/cli-program.ts | 178 ++++++++++++ .../cli-core/src/commands/billing/README.md | 80 ++++++ .../cli-core/src/commands/billing/index.ts | 270 ++++++++++++++++++ packages/cli-core/src/commands/orgs/README.md | 39 +++ packages/cli-core/src/commands/orgs/index.ts | 76 +++++ 5 files changed, 643 insertions(+) create mode 100644 packages/cli-core/src/commands/billing/README.md create mode 100644 packages/cli-core/src/commands/billing/index.ts create mode 100644 packages/cli-core/src/commands/orgs/README.md create mode 100644 packages/cli-core/src/commands/orgs/index.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 1d6b18ef..cc5240ae 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -43,6 +43,15 @@ import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { update } from "./commands/update/index.ts"; import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; +import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; +import { + billingEnable, + billingDisable, + plansCreate, + plansList, + plansUpdate, + plansRemove, +} from "./commands/billing/index.ts"; export function createProgram() { const program = new Command() @@ -414,6 +423,175 @@ Give AI agents better Clerk context: install the Clerk skills ]) .action(configPut); + // --- clerk orgs --- + const orgs = program + .command("orgs") + .alias("organizations") + .description("Manage Clerk Organizations") + .setExamples([ + { command: "clerk orgs enable", description: "Enable organizations" }, + { + command: "clerk orgs enable --force-selection --max-members 10", + description: "Enable with options", + }, + { command: "clerk orgs disable", description: "Disable organizations" }, + ]); + + orgs + .command("enable") + .description("Enable organizations on the linked instance") + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--force-selection", "Force organization selection on login") + .option("--auto-create", "Auto-create an organization for new users") + .option("--max-members ", "Maximum members per organization") + .option("--domains", "Enable verified domains") + .option("--yes", "Skip confirmation prompts") + .setExamples([ + { command: "clerk orgs enable", description: "Enable organizations" }, + { + command: "clerk orgs enable --force-selection", + description: "Enable and force org selection", + }, + { + command: "clerk orgs enable --auto-create --max-members 10", + description: "Enable with auto-creation and member limit", + }, + ]) + .action(orgsEnable); + + orgs + .command("disable") + .description("Disable organizations on the linked instance") + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--yes", "Skip confirmation prompts") + .setExamples([{ command: "clerk orgs disable", description: "Disable organizations" }]) + .action(orgsDisable); + + // --- clerk billing --- + const billing = program + .command("billing") + .description("Manage billing and subscription plans") + .setExamples([ + { command: "clerk billing enable --for org", description: "Enable org billing" }, + { + command: "clerk billing plans create pro --amount 1999 --payer org", + description: "Create a Pro plan", + }, + { command: "clerk billing plans list", description: "List all plans" }, + ]); + + billing + .command("enable") + .description("Enable billing on the linked instance") + .addOption(createOption("--for ", "Billing target type").choices(["org", "user"])) + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--require-payment-method", "Require payment method for free trials") + .option("--yes", "Skip confirmation prompts") + .setExamples([ + { command: "clerk billing enable --for org", description: "Enable org billing" }, + { command: "clerk billing enable --for user", description: "Enable user billing" }, + ]) + .action(billingEnable); + + billing + .command("disable") + .description("Disable billing on the linked instance") + .addOption(createOption("--for ", "Billing target type").choices(["org", "user"])) + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--yes", "Skip confirmation prompts") + .setExamples([ + { command: "clerk billing disable --for org", description: "Disable org billing" }, + ]) + .action(billingDisable); + + const plans = billing + .command("plans") + .description("Manage subscription plans") + .setExamples([ + { command: "clerk billing plans list", description: "List all plans" }, + { + command: "clerk billing plans create pro --amount 1999 --payer org", + description: "Create a Pro plan at $19.99/mo", + }, + ]); + + plans + .command("create") + .description("Create a subscription plan") + .argument("", "Plan slug (display name auto-derived via title case)") + .option("--name ", "Override display name") + .requiredOption("--amount ", "Monthly price in cents") + .addOption(createOption("--payer ", "Who pays").choices(["org", "user"])) + .option("--currency ", "Currency code (default: usd)") + .option("--description ", "Plan description") + .option("--trial-days ", "Free trial length in days") + .option("--annual-amount ", "Monthly equivalent when billed annually, in cents") + .option("--hidden", "Hide plan from end users") + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--yes", "Skip confirmation prompts") + .setExamples([ + { + command: "clerk billing plans create pro --amount 1999 --payer org", + description: "Create a Pro plan at $19.99/mo for orgs", + }, + { + command: + 'clerk billing plans create enterprise --name "Enterprise Plus" --amount 9999 --payer org --trial-days 14', + description: "Create an Enterprise plan with a 14-day trial", + }, + ]) + .action(plansCreate); + + plans + .command("list") + .description("List subscription plans") + .option("--json", "Output as JSON") + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .setExamples([ + { command: "clerk billing plans list", description: "List all plans" }, + { command: "clerk billing plans list --json", description: "Output as JSON" }, + ]) + .action(plansList); + + plans + .command("update") + .description("Update a subscription plan") + .argument("", "Plan slug to update") + .option("--name ", "Update display name") + .option("--amount ", "Update monthly price in cents") + .option("--currency ", "Update currency") + .option("--description ", "Update description") + .option("--trial-days ", "Update free trial days") + .option("--annual-amount ", "Update annual amount") + .option("--hidden", "Hide plan from end users") + .option("--visible", "Show plan to end users") + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--yes", "Skip confirmation prompts") + .setExamples([ + { command: "clerk billing plans update pro --amount 2999", description: "Update price" }, + { command: "clerk billing plans update pro --hidden", description: "Hide plan" }, + ]) + .action(plansUpdate); + + plans + .command("remove") + .description("Remove a subscription plan") + .argument("", "Plan slug to remove") + .option("--app ", "Application ID to target") + .option("--instance ", "Instance to target (dev, prod, or instance ID)") + .option("--yes", "Skip confirmation prompts") + .setExamples([ + { command: "clerk billing plans remove pro", description: "Remove the Pro plan" }, + ]) + .action(plansRemove); + program .command("api") .description("Make authenticated requests to the Clerk API") diff --git a/packages/cli-core/src/commands/billing/README.md b/packages/cli-core/src/commands/billing/README.md new file mode 100644 index 00000000..8fe45c9b --- /dev/null +++ b/packages/cli-core/src/commands/billing/README.md @@ -0,0 +1,80 @@ +# clerk billing + +Enable/disable billing and manage subscription plans on the linked instance. + +## Usage + +``` +clerk billing enable --for [options] +clerk billing disable --for [options] +clerk billing plans create [options] +clerk billing plans list [options] +clerk billing plans update [options] +clerk billing plans remove [options] +``` + +## Options + +### `enable` / `disable` + +| Flag | Description | +| -------------------------- | ---------------------------------------------------- | +| `--for ` | **(required)** Target billing type | +| `--require-payment-method` | Require payment method for free trials (enable only) | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | + +### `plans create` + +| Flag | Description | +| ------------------------- | ------------------------------------------------------------------- | +| `` | Plan slug (positional). Display name is auto-derived via title case | +| `--name ` | Override display name (default: title-cased slug) | +| `--amount ` | **(required)** Monthly price in cents | +| `--payer ` | **(required)** Who pays | +| `--currency ` | Currency code (default: usd) | +| `--description ` | Plan description | +| `--trial-days ` | Free trial length in days | +| `--annual-amount ` | Monthly equivalent when billed annually, in cents | +| `--hidden` | Hide plan from end users | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | + +### `plans list` + +| Flag | Description | +| ----------------- | -------------------------------------- | +| `--json` | Output as JSON | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | + +### `plans update` + +| Flag | Description | +| ------------------------- | -------------------------------------- | +| `` | Plan slug to update (positional) | +| `--name ` | Update display name | +| `--amount ` | Update monthly price | +| `--currency ` | Update currency | +| `--description ` | Update description | +| `--trial-days ` | Update free trial days | +| `--annual-amount ` | Update annual amount | +| `--hidden` | Hide plan | +| `--visible` | Show plan | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | + +### `plans remove` + +| Flag | Description | +| ----------------- | -------------------------------------- | +| `` | Plan slug to remove (positional) | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | + +## Clerk API endpoints + +| Method | Endpoint | Description | +| ------ | ----------------------------------------------------------------- | -------------------------------------------- | +| GET | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Fetch current config (for plans list/remove) | +| PATCH | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Patch billing and plans config | diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts new file mode 100644 index 00000000..eb72297c --- /dev/null +++ b/packages/cli-core/src/commands/billing/index.ts @@ -0,0 +1,270 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { fetchInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; +import { throwUsageError, withApiContext } from "../../lib/errors.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { log } from "../../lib/log.ts"; +import { cyan, dim } from "../../lib/color.ts"; + +interface BillingOptions { + app?: string; + instance?: string; + for?: string; + requirePaymentMethod?: boolean; + yes?: boolean; +} + +function validateFor(value: string | undefined): "org" | "user" { + if (!value) { + throwUsageError("--for is required. Use --for org or --for user."); + } + if (value !== "org" && value !== "user") { + throwUsageError(`Invalid --for value: "${value}". Must be "org" or "user".`); + } + return value; +} + +export async function billingEnable(options: BillingOptions): Promise { + const target = validateFor(options.for); + const ctx = await resolveAppContext(options); + + const patch: Record = {}; + if (target === "org") { + patch.organization_enabled = true; + } else { + patch.user_enabled = true; + } + if (options.requirePaymentMethod !== undefined) { + patch.free_trial_requires_payment_method = options.requirePaymentMethod; + } + + const config = { billing: patch }; + + const result = await withSpinner( + `Enabling ${target} billing on ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + patchInstanceConfig(ctx.appId, ctx.instanceId, config), + "Failed to enable billing", + ), + ); + + log.data(JSON.stringify(result, null, 2)); + log.success(`Billing enabled for ${target === "org" ? "organizations" : "users"}`); +} + +export async function billingDisable(options: BillingOptions): Promise { + const target = validateFor(options.for); + const ctx = await resolveAppContext(options); + + const patch: Record = {}; + if (target === "org") { + patch.organization_enabled = false; + } else { + patch.user_enabled = false; + } + + const config = { billing: patch }; + + const result = await withSpinner( + `Disabling ${target} billing on ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + patchInstanceConfig(ctx.appId, ctx.instanceId, config), + "Failed to disable billing", + ), + ); + + log.data(JSON.stringify(result, null, 2)); + log.success(`Billing disabled for ${target === "org" ? "organizations" : "users"}`); +} + +// --- Plans subcommand --- + +interface PlansCreateOptions { + app?: string; + instance?: string; + name?: string; + amount: string; + currency?: string; + payer?: string; + description?: string; + trialDays?: string; + hidden?: boolean; + annualAmount?: string; + yes?: boolean; +} + +function titleCase(slug: string): string { + return slug + .split(/[-_]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export async function plansCreate(slug: string, options: PlansCreateOptions): Promise { + const ctx = await resolveAppContext(options); + + const plan: Record = { + name: options.name || titleCase(slug), + 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); + } + + const config = { billing: { plans: { [slug]: plan } } }; + + const result = await withSpinner( + `Creating plan ${cyan(options.name || titleCase(slug))} on ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + patchInstanceConfig(ctx.appId, ctx.instanceId, config), + "Failed to create plan", + ), + ); + + log.data(JSON.stringify(result, null, 2)); + log.success(`Plan ${cyan(options.name || titleCase(slug))} ${dim(`(${slug})`)} created`); +} + +interface PlansListOptions { + app?: string; + instance?: string; + json?: boolean; +} + +export async function plansList(options: PlansListOptions): Promise { + const ctx = await resolveAppContext(options); + + const current = await withSpinner("Fetching billing config...", () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing"]), + "Failed to fetch config", + ), + ); + + const billing = current.billing as Record | undefined; + const plans = (billing?.plans as Record>) ?? {}; + + if (options.json) { + log.data(JSON.stringify(plans, null, 2)); + return; + } + + const entries = Object.entries(plans); + if (entries.length === 0) { + log.info("No plans configured. Use `clerk billing plans create` to add one."); + return; + } + + for (const [slug, plan] of entries) { + const amount = plan.amount as number; + 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; + const trial = plan.free_trial_enabled ? ` (${plan.free_trial_days}d trial)` : ""; + + log.info( + `${cyan(plan.name as string)} ${dim(`(${slug})`)} — ${price}/mo — ${payer}${trial}${!visible ? dim(" [hidden]") : ""}`, + ); + } +} + +interface PlansUpdateOptions { + app?: string; + instance?: string; + name?: string; + amount?: string; + currency?: string; + description?: string; + trialDays?: string; + hidden?: boolean; + visible?: boolean; + annualAmount?: string; + yes?: boolean; +} + +export async function plansUpdate(slug: string, options: PlansUpdateOptions): Promise { + const ctx = await resolveAppContext(options); + + const plan: Record = {}; + if (options.name) plan.name = options.name; + if (options.amount) plan.amount = parseInt(options.amount, 10); + 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.hidden) plan.publicly_visible = false; + if (options.visible) plan.publicly_visible = true; + if (options.trialDays) { + plan.free_trial_enabled = true; + plan.free_trial_days = parseInt(options.trialDays, 10); + } + + if (Object.keys(plan).length === 0) { + throwUsageError("No update options provided. Use --name, --amount, --hidden, etc."); + } + + const config = { billing: { plans: { [slug]: plan } } }; + + const result = await withSpinner( + `Updating plan ${cyan(slug)} on ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + patchInstanceConfig(ctx.appId, ctx.instanceId, config), + "Failed to update plan", + ), + ); + + log.data(JSON.stringify(result, null, 2)); + log.success(`Plan ${cyan(slug)} updated`); +} + +interface PlansRemoveOptions { + app?: string; + instance?: string; + yes?: boolean; +} + +export async function plansRemove(slug: string, options: PlansRemoveOptions): Promise { + const ctx = await resolveAppContext(options); + + // Fetch current config so we can PUT without the plan + const current = await withSpinner("Fetching current config...", () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing"]), + "Failed to fetch config", + ), + ); + + const billing = current.billing as Record | undefined; + const plans = { ...((billing?.plans as Record) ?? {}) }; + + if (!(slug in plans)) { + throwUsageError(`Plan "${slug}" not found.`); + } + + delete plans[slug]; + + const config = { billing: { plans } }; + + const result = await withSpinner( + `Removing plan ${cyan(slug)} on ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + patchInstanceConfig(ctx.appId, ctx.instanceId, config, { destructive: true }), + "Failed to remove plan", + ), + ); + + log.data(JSON.stringify(result, null, 2)); + log.success(`Plan ${cyan(slug)} removed`); +} diff --git a/packages/cli-core/src/commands/orgs/README.md b/packages/cli-core/src/commands/orgs/README.md new file mode 100644 index 00000000..fb9b29b1 --- /dev/null +++ b/packages/cli-core/src/commands/orgs/README.md @@ -0,0 +1,39 @@ +# clerk orgs + +Enable or disable Clerk Organizations on the linked instance. + +## Usage + +``` +clerk orgs enable [options] +clerk orgs disable [options] +``` + +## Options + +### `enable` + +| Flag | Description | +| ------------------- | ----------------------------------------- | +| `--force-selection` | Force organization selection on login | +| `--auto-create` | Auto-create an organization for new users | +| `--max-members ` | Maximum members per organization | +| `--domains` | Enable verified domains | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | +| `--yes` | Skip confirmation prompts | + +### `disable` + +| Flag | Description | +| ----------------- | -------------------------------------- | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | +| `--yes` | Skip confirmation prompts | + +## Clerk API endpoints + +| Method | Endpoint | Description | +| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------ | +| GET | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Fetch current config (used to check billing dependency on disable) | +| PATCH | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Patch `organization_settings` | diff --git a/packages/cli-core/src/commands/orgs/index.ts b/packages/cli-core/src/commands/orgs/index.ts new file mode 100644 index 00000000..d8689487 --- /dev/null +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -0,0 +1,76 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { fetchInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { log } from "../../lib/log.ts"; + +interface OrgsOptions { + app?: string; + instance?: string; + forceSelection?: boolean; + autoCreate?: boolean; + maxMembers?: string; + domains?: boolean; + yes?: boolean; +} + +export async function orgsEnable(options: OrgsOptions): Promise { + const ctx = await resolveAppContext(options); + + const patch: Record = { 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) { + patch.organization_creation_defaults = { + automatic_organization_creation: { enabled: true }, + }; + } + + const config = { organization_settings: patch }; + + const result = await withSpinner( + `Enabling organizations on ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + patchInstanceConfig(ctx.appId, ctx.instanceId, config), + "Failed to enable organizations", + ), + ); + + log.data(JSON.stringify(result, null, 2)); + log.success("Organizations enabled"); +} + +export async function orgsDisable(options: OrgsOptions): Promise { + const ctx = await resolveAppContext(options); + + // Check if billing depends on orgs + const current = await withSpinner("Checking current config...", () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing"]), + "Failed to fetch config", + ), + ); + + const billing = current.billing as Record | undefined; + if (billing?.organization_enabled) { + log.warn( + "Organization billing is enabled. Disabling organizations will also disable org billing.", + ); + } + + const config = { organization_settings: { enabled: false } }; + + const result = await withSpinner( + `Disabling organizations on ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + patchInstanceConfig(ctx.appId, ctx.instanceId, config), + "Failed to disable organizations", + ), + ); + + log.data(JSON.stringify(result, null, 2)); + log.success("Organizations disabled"); +} From 8583f13d3fc67d64f4c28c349169f9732694252b Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 23 Apr 2026 09:41:35 -0400 Subject: [PATCH 2/8] test(cli-core): add tests for orgs and billing shortcut commands --- .../src/commands/billing/index.test.ts | 383 ++++++++++++++++++ .../cli-core/src/commands/orgs/index.test.ts | 204 ++++++++++ 2 files changed, 587 insertions(+) create mode 100644 packages/cli-core/src/commands/billing/index.test.ts create mode 100644 packages/cli-core/src/commands/orgs/index.test.ts diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts new file mode 100644 index 00000000..5af918f8 --- /dev/null +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -0,0 +1,383 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { _setConfigDir, setProfile } from "../../lib/config.ts"; +import { + captureLog, + credentialStoreStubs, + gitStubs, + promptsStubs, + stubFetch, +} from "../../test/lib/stubs.ts"; + +mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); +mock.module("../../lib/git.ts", () => gitStubs); +mock.module("@inquirer/prompts", () => promptsStubs); +mock.module("../../lib/spinner.ts", () => ({ + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + +describe("clerk billing", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + let tempDir: string; + let logSpy: ReturnType; + let errorSpy: ReturnType; + let captured: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-billing-test-")); + _setConfigDir(tempDir); + process.env.CLERK_PLATFORM_API_KEY = "test_key"; + process.env.CLERK_PLATFORM_API_URL = "https://test-api.clerk.com"; + + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + captured = captureLog(); + + stubFetch(async () => { + return new Response(JSON.stringify({}), { status: 200 }); + }); + }); + + afterEach(async () => { + captured.teardown(); + _setConfigDir(undefined); + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + logSpy.mockRestore(); + errorSpy.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + }); + + async function setupProfile() { + await setProfile(process.cwd(), { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + } + + // --- billing enable --- + + test("enable --for org sends organization_enabled = true", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: "org" })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.organization_enabled).toBe(true); + }); + + test("enable --for user sends user_enabled = true", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: "user" })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.user_enabled).toBe(true); + }); + + test("enable errors without --for", async () => { + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await expect(captured.run(() => billingEnable({}))).rejects.toThrow("--for is required"); + }); + + test("enable errors with invalid --for value", async () => { + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await expect(captured.run(() => billingEnable({ for: "invalid" }))).rejects.toThrow( + 'Must be "org" or "user"', + ); + }); + + test("enable shows success message", async () => { + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: "org" })); + + expect(captured.err).toContain("Billing enabled for organizations"); + }); + + // --- billing disable --- + + test("disable --for org sends organization_enabled = false", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ for: "org" })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.organization_enabled).toBe(false); + }); + + // --- plans create --- + + test("plans create sends correct plan config", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansCreate } = await import("./index.ts"); + await captured.run(() => plansCreate("pro", { amount: "1999", payer: "org" })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.plans.pro.name).toBe("Pro"); + expect(parsed.billing.plans.pro.amount).toBe(1999); + expect(parsed.billing.plans.pro.payer_type).toBe("org"); + expect(parsed.billing.plans.pro.is_recurring).toBe(true); + }); + + test("plans create auto-derives name from slug", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansCreate } = await import("./index.ts"); + await captured.run(() => plansCreate("enterprise-plus", { amount: "9999", payer: "org" })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.plans["enterprise-plus"].name).toBe("Enterprise Plus"); + }); + + test("plans create uses --name override", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansCreate } = await import("./index.ts"); + await captured.run(() => + plansCreate("pro", { amount: "1999", payer: "org", name: "Pro Plus" }), + ); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.plans.pro.name).toBe("Pro Plus"); + }); + + test("plans create with --trial-days enables trial", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansCreate } = await import("./index.ts"); + await captured.run(() => plansCreate("pro", { amount: "1999", payer: "org", trialDays: "14" })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.plans.pro.free_trial_enabled).toBe(true); + expect(parsed.billing.plans.pro.free_trial_days).toBe(14); + }); + + test("plans create with --hidden sets publicly_visible = false", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansCreate } = await import("./index.ts"); + await captured.run(() => plansCreate("pro", { amount: "1999", payer: "org", hidden: true })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.plans.pro.publicly_visible).toBe(false); + }); + + test("plans create shows success message", async () => { + await setupProfile(); + const { plansCreate } = await import("./index.ts"); + await captured.run(() => plansCreate("pro", { amount: "1999", payer: "org" })); + + expect(captured.err).toContain("created"); + }); + + // --- plans list --- + + test("plans list outputs plans", async () => { + stubFetch(async () => { + return new Response( + JSON.stringify({ + billing: { + plans: { + free_org: { name: "Free", amount: 0, payer_type: "org", publicly_visible: true }, + pro: { + name: "Pro", + amount: 1999, + currency: "usd", + payer_type: "org", + publicly_visible: true, + }, + }, + }, + }), + { status: 200 }, + ); + }); + + await setupProfile(); + const { plansList } = await import("./index.ts"); + await captured.run(() => plansList({})); + + expect(captured.err).toContain("Free"); + expect(captured.err).toContain("Pro"); + }); + + test("plans list --json outputs JSON", async () => { + const plansData = { + free_org: { name: "Free", amount: 0, payer_type: "org" }, + }; + stubFetch(async () => { + return new Response(JSON.stringify({ billing: { plans: plansData } }), { status: 200 }); + }); + + await setupProfile(); + const { plansList } = await import("./index.ts"); + await captured.run(() => plansList({ json: true })); + + expect(captured.out).toContain('"Free"'); + }); + + test("plans list shows message when no plans", async () => { + stubFetch(async () => { + return new Response(JSON.stringify({ billing: { plans: {} } }), { status: 200 }); + }); + + await setupProfile(); + const { plansList } = await import("./index.ts"); + await captured.run(() => plansList({})); + + expect(captured.err).toContain("No plans configured"); + }); + + // --- plans update --- + + test("plans update sends partial plan config", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansUpdate } = await import("./index.ts"); + await captured.run(() => plansUpdate("pro", { amount: "2999" })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.plans.pro.amount).toBe(2999); + }); + + test("plans update --hidden sets publicly_visible = false", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansUpdate } = await import("./index.ts"); + await captured.run(() => plansUpdate("pro", { hidden: true })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.plans.pro.publicly_visible).toBe(false); + }); + + test("plans update errors with no options", async () => { + await setupProfile(); + const { plansUpdate } = await import("./index.ts"); + await expect(captured.run(() => plansUpdate("pro", {}))).rejects.toThrow( + "No update options provided", + ); + }); + + // --- plans remove --- + + test("plans remove sends config without the plan", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (!init?.method || init.method === "GET") { + return new Response( + JSON.stringify({ + billing: { + plans: { + free_org: { name: "Free", amount: 0 }, + pro: { name: "Pro", amount: 1999 }, + }, + }, + }), + { status: 200 }, + ); + } + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansRemove } = await import("./index.ts"); + await captured.run(() => plansRemove("pro", {})); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.plans).not.toHaveProperty("pro"); + expect(parsed.billing.plans).toHaveProperty("free_org"); + }); + + test("plans remove sends ?destructive=true", async () => { + let capturedUrl = ""; + stubFetch(async (input, init) => { + if (!init?.method || init.method === "GET") { + return new Response(JSON.stringify({ billing: { plans: { pro: { name: "Pro" } } } }), { + status: 200, + }); + } + if (init?.method === "PATCH") capturedUrl = input.toString(); + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { plansRemove } = await import("./index.ts"); + await captured.run(() => plansRemove("pro", {})); + + expect(capturedUrl).toContain("destructive=true"); + }); + + test("plans remove errors when plan not found", async () => { + stubFetch(async () => { + return new Response(JSON.stringify({ billing: { plans: {} } }), { status: 200 }); + }); + + await setupProfile(); + const { plansRemove } = await import("./index.ts"); + await expect(captured.run(() => plansRemove("nonexistent", {}))).rejects.toThrow( + 'Plan "nonexistent" not found', + ); + }); +}); diff --git a/packages/cli-core/src/commands/orgs/index.test.ts b/packages/cli-core/src/commands/orgs/index.test.ts new file mode 100644 index 00000000..b64dcc71 --- /dev/null +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -0,0 +1,204 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { _setConfigDir, setProfile } from "../../lib/config.ts"; +import { + captureLog, + credentialStoreStubs, + gitStubs, + promptsStubs, + stubFetch, +} from "../../test/lib/stubs.ts"; + +mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); +mock.module("../../lib/git.ts", () => gitStubs); +mock.module("@inquirer/prompts", () => promptsStubs); +mock.module("../../lib/spinner.ts", () => ({ + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + +describe("clerk orgs", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + let tempDir: string; + let logSpy: ReturnType; + let errorSpy: ReturnType; + let captured: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-orgs-test-")); + _setConfigDir(tempDir); + process.env.CLERK_PLATFORM_API_KEY = "test_key"; + process.env.CLERK_PLATFORM_API_URL = "https://test-api.clerk.com"; + + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + captured = captureLog(); + + stubFetch(async () => { + return new Response(JSON.stringify({}), { status: 200 }); + }); + }); + + afterEach(async () => { + captured.teardown(); + _setConfigDir(undefined); + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + logSpy.mockRestore(); + errorSpy.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + }); + + async function setupProfile() { + await setProfile(process.cwd(), { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + } + + // --- orgs enable --- + + test("enable sends PATCH with organization_settings.enabled = true", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await captured.run(() => orgsEnable({})); + + const parsed = JSON.parse(capturedBody); + expect(parsed.organization_settings.enabled).toBe(true); + }); + + test("enable passes --force-selection flag", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await captured.run(() => orgsEnable({ forceSelection: true })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.organization_settings.force_organization_selection).toBe(true); + }); + + test("enable passes --max-members flag", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await captured.run(() => orgsEnable({ maxMembers: "10" })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.organization_settings.max_allowed_memberships).toBe(10); + }); + + test("enable passes --domains flag", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await captured.run(() => orgsEnable({ domains: true })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.organization_settings.domains_enabled).toBe(true); + }); + + test("enable passes --auto-create flag", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await captured.run(() => orgsEnable({ autoCreate: true })); + + const parsed = JSON.parse(capturedBody); + expect( + parsed.organization_settings.organization_creation_defaults.automatic_organization_creation + .enabled, + ).toBe(true); + }); + + test("enable shows success message", async () => { + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await captured.run(() => orgsEnable({})); + + expect(captured.err).toContain("Organizations enabled"); + }); + + test("enable errors when no profile is linked", async () => { + const { orgsEnable } = await import("./index.ts"); + await expect(captured.run(() => orgsEnable({}))).rejects.toThrow("No Clerk project linked"); + }); + + // --- orgs disable --- + + test("disable sends PATCH with organization_settings.enabled = false", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({ billing: { organization_enabled: false } }), { + status: 200, + }); + }); + + await setupProfile(); + const { orgsDisable } = await import("./index.ts"); + await captured.run(() => orgsDisable({})); + + const parsed = JSON.parse(capturedBody); + expect(parsed.organization_settings.enabled).toBe(false); + }); + + test("disable warns when org billing is enabled", async () => { + stubFetch(async (_input, init) => { + if (!init?.method || init.method === "GET") { + return new Response(JSON.stringify({ billing: { organization_enabled: true } }), { + status: 200, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { orgsDisable } = await import("./index.ts"); + await captured.run(() => orgsDisable({})); + + expect(captured.err).toContain("Organization billing is enabled"); + }); + + test("disable shows success message", async () => { + stubFetch(async () => { + return new Response(JSON.stringify({ billing: { organization_enabled: false } }), { + status: 200, + }); + }); + + await setupProfile(); + const { orgsDisable } = await import("./index.ts"); + await captured.run(() => orgsDisable({})); + + expect(captured.err).toContain("Organizations disabled"); + }); +}); From 8198e4098e3424d2fb982d73c63af88f4a6cbb6c Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 23 Apr 2026 09:44:29 -0400 Subject: [PATCH 3/8] docs(changeset): add clerk orgs and clerk billing shortcut commands --- .changeset/billing-orgs-shortcut-commands.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/billing-orgs-shortcut-commands.md diff --git a/.changeset/billing-orgs-shortcut-commands.md b/.changeset/billing-orgs-shortcut-commands.md new file mode 100644 index 00000000..7e027225 --- /dev/null +++ b/.changeset/billing-orgs-shortcut-commands.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk orgs enable/disable` and `clerk billing enable/disable/plans` shortcut commands for managing organizations, billing, and subscription plans from the CLI. From a87ed329be64d351ae7b18592539a2af6e112e9d Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 23 Apr 2026 09:49:45 -0400 Subject: [PATCH 4/8] docs: update README with orgs and billing commands --- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a239cae0..5114fcbe 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Options: -v, --version Output the version number --mode Force interaction mode (human or agent). Defaults to auto-detect based on TTY. - --verbose Show detailed error output + --verbose Show detailed output (enables debug messages) -h, --help Display help for command Commands: @@ -48,6 +48,17 @@ Commands: schema [options] Pull instance config schema from Clerk patch [options] Partially update instance configuration (PATCH) put [options] Replace entire instance configuration (PUT) + orgs|organizations Manage Clerk Organizations + enable [options] Enable organizations on the linked instance + disable [options] Disable organizations on the linked instance + billing Manage billing and subscription plans + enable [options] Enable billing on the linked instance + disable [options] Disable billing on the linked instance + plans Manage subscription plans + create [options] Create a subscription plan + list [options] List subscription plans + update [options] Update a subscription plan + remove [options] Remove a subscription plan 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 @@ -152,6 +163,71 @@ clerk config put $ clerk config put --instance prod --file config.json Replace production config $ clerk config put --file config.json --yes Skip confirmation prompt +clerk orgs enable + --force-selection Force organization selection on login + --auto-create Auto-create an organization for new users + --max-members Maximum members per organization + --domains Enable verified domains + --app Application ID to target + --instance Instance to target (dev, prod, or instance ID) + Examples: + $ clerk orgs enable Enable organizations + $ clerk orgs enable --force-selection Enable and force org selection + $ clerk orgs enable --auto-create --max-members 10 Enable with auto-creation and member limit + +clerk orgs disable + --app Application ID to target + --instance Instance to target (dev, prod, or instance ID) + +clerk billing enable + --for (required) Billing target type + --require-payment-method Require payment method for free trials + --app Application ID to target + --instance Instance to target (dev, prod, or instance ID) + Examples: + $ clerk billing enable --for org Enable org billing + $ clerk billing enable --for user Enable user billing + +clerk billing disable + --for (required) Billing target type + --app Application ID to target + --instance Instance to target (dev, prod, or instance ID) + +clerk billing plans create + --name Override display name (default: title-cased slug) + --amount (required) Monthly price in cents + --payer Who pays + --currency Currency code (default: usd) + --description Plan description + --trial-days Free trial length in days + --annual-amount Monthly equivalent when billed annually, in cents + --hidden Hide plan from end users + --app Application ID to target + --instance Instance to target (dev, prod, or instance ID) + Examples: + $ clerk billing plans create pro --amount 1999 --payer org + $ clerk billing plans create enterprise --name "Enterprise Plus" --amount 9999 --payer org --trial-days 14 + +clerk billing plans list + --json Output as JSON + --app Application ID to target + --instance Instance to target (dev, prod, or instance ID) + +clerk billing plans update + --name Update display name + --amount Update monthly price + --hidden Hide plan from end users + --visible Show plan to end users + --app Application ID to target + --instance Instance to target (dev, prod, or instance ID) + Examples: + $ clerk billing plans update pro --amount 2999 + $ clerk billing plans update pro --hidden + +clerk billing plans remove + --app Application ID to target + --instance Instance to target (dev, prod, or instance ID) + clerk env pull --app Application ID to target (works from any directory) --instance Instance to target (dev, prod, or a full instance ID) From 7ea0381b17017adb55e9e20f01e65f431bd546af Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 30 Apr 2026 10:42:15 -0400 Subject: [PATCH 5/8] feat(cli-core): rework orgs/billing toggles as top-level clerk enable/disable commands Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/billing-orgs-shortcut-commands.md | 7 +- README.md | 90 ++--- packages/cli-core/src/cli-program.ts | 215 +++++------- .../cli-core/src/commands/billing/README.md | 104 +++--- .../src/commands/billing/index.test.ts | 293 ++++++---------- .../cli-core/src/commands/billing/index.ts | 316 ++++-------------- .../src/commands/completion/__complete.ts | 5 + .../src/commands/config/apply-patch.ts | 76 +++++ packages/cli-core/src/commands/orgs/README.md | 49 ++- .../cli-core/src/commands/orgs/index.test.ts | 109 +++++- packages/cli-core/src/commands/orgs/index.ts | 97 +++--- 11 files changed, 602 insertions(+), 759 deletions(-) create mode 100644 packages/cli-core/src/commands/config/apply-patch.ts diff --git a/.changeset/billing-orgs-shortcut-commands.md b/.changeset/billing-orgs-shortcut-commands.md index 7e027225..6580eec4 100644 --- a/.changeset/billing-orgs-shortcut-commands.md +++ b/.changeset/billing-orgs-shortcut-commands.md @@ -2,4 +2,9 @@ "clerk": minor --- -Add `clerk orgs enable/disable` and `clerk billing enable/disable/plans` shortcut commands for managing organizations, billing, and subscription plans from the CLI. +Add `clerk enable` and `clerk disable` top-level commands for toggling features on the linked instance: + +- `clerk enable orgs` / `clerk disable orgs` — toggle organizations, with optional `--force-selection`, `--auto-create`, `--max-members `, and `--domains` configuration on enable. +- `clerk enable billing [--for org,user]` / `clerk disable billing [--for org,user]` — toggle billing for organizations and/or users. `--for` defaults to both targets when omitted; enabling for `org` also enables organizations. + +All commands share the diff-and-confirm safety flow used by `clerk config patch`, including `--dry-run` and `--yes` flags. diff --git a/README.md b/README.md index 5114fcbe..3eb69e07 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,12 @@ Commands: schema [options] Pull instance config schema from Clerk patch [options] Partially update instance configuration (PATCH) put [options] Replace entire instance configuration (PUT) - orgs|organizations Manage Clerk Organizations - enable [options] Enable organizations on the linked instance - disable [options] Disable organizations on the linked instance - billing Manage billing and subscription plans - enable [options] Enable billing on the linked instance - disable [options] Disable billing on the linked instance - plans Manage subscription plans - create [options] Create a subscription plan - list [options] List subscription plans - update [options] Update a subscription plan - remove [options] Remove a subscription plan + enable Enable Clerk features on the linked instance + orgs|organizations [options] Enable organizations + billing [options] Enable billing for organizations and/or users + disable Disable Clerk features on the linked instance + orgs|organizations [options] Disable organizations + billing [options] Disable billing for organizations and/or users 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 @@ -163,70 +158,51 @@ clerk config put $ clerk config put --instance prod --file config.json Replace production config $ clerk config put --file config.json --yes Skip confirmation prompt -clerk orgs enable +clerk enable orgs --force-selection Force organization selection on login --auto-create Auto-create an organization for new users - --max-members Maximum members per organization + --max-members Maximum members per organization (positive integer) --domains Enable verified domains --app Application ID to target --instance Instance to target (dev, prod, or instance ID) + --yes Skip the confirmation prompt + --dry-run Preview the patch without applying it Examples: - $ clerk orgs enable Enable organizations - $ clerk orgs enable --force-selection Enable and force org selection - $ clerk orgs enable --auto-create --max-members 10 Enable with auto-creation and member limit + $ clerk enable orgs Enable organizations + $ clerk enable orgs --force-selection Enable and force organization selection + $ clerk enable orgs --auto-create --max-members 10 Enable with auto-creation and member limit + $ clerk enable orgs --dry-run Preview the patch without applying it -clerk orgs disable +clerk disable orgs --app Application ID to target --instance Instance to target (dev, prod, or instance ID) + --yes Skip the confirmation prompt + --dry-run Preview the patch without applying it -clerk billing enable - --for (required) Billing target type - --require-payment-method Require payment method for free trials - --app Application ID to target - --instance Instance to target (dev, prod, or instance ID) - Examples: - $ clerk billing enable --for org Enable org billing - $ clerk billing enable --for user Enable user billing + Note: when org billing is currently enabled, `disable` warns and confirms in + human mode. In agent mode (no TTY), `--yes` is required to override. -clerk billing disable - --for (required) Billing target type - --app Application ID to target - --instance Instance to target (dev, prod, or instance ID) - -clerk billing plans create - --name Override display name (default: title-cased slug) - --amount (required) Monthly price in cents - --payer Who pays - --currency Currency code (default: usd) - --description Plan description - --trial-days Free trial length in days - --annual-amount Monthly equivalent when billed annually, in cents - --hidden Hide plan from end users +clerk enable billing + --for Billing targets (org and/or user), separated by spaces or commas (e.g. org user). Defaults to both when omitted. --app Application ID to target --instance Instance to target (dev, prod, or instance ID) + --yes Skip the confirmation prompt + --dry-run Preview the patch without applying it Examples: - $ clerk billing plans create pro --amount 1999 --payer org - $ clerk billing plans create enterprise --name "Enterprise Plus" --amount 9999 --payer org --trial-days 14 + $ clerk enable billing Enable billing for organizations and users + $ clerk enable billing --for org Enable billing for organizations only + $ clerk enable billing --for user Enable billing for users only + $ clerk enable billing --for org user Enable billing for both targets -clerk billing plans list - --json Output as JSON +clerk disable billing + --for Billing targets (org and/or user), separated by spaces or commas (e.g. org user). Defaults to both when omitted. --app Application ID to target --instance Instance to target (dev, prod, or instance ID) + --yes Skip the confirmation prompt + --dry-run Preview the patch without applying it -clerk billing plans update - --name Update display name - --amount Update monthly price - --hidden Hide plan from end users - --visible Show plan to end users - --app Application ID to target - --instance Instance to target (dev, prod, or instance ID) - Examples: - $ clerk billing plans update pro --amount 2999 - $ clerk billing plans update pro --hidden - -clerk billing plans remove - --app Application ID to target - --instance Instance to target (dev, prod, or instance ID) + Note: `disable billing` never disables organizations themselves — run + `clerk disable orgs` separately if that's what you intend. clerk env pull --app Application ID to target (works from any directory) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index cc5240ae..b8f71bae 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -44,14 +44,7 @@ import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { update } from "./commands/update/index.ts"; import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; -import { - billingEnable, - billingDisable, - plansCreate, - plansList, - plansUpdate, - plansRemove, -} from "./commands/billing/index.ts"; +import { billingEnable, billingDisable } from "./commands/billing/index.ts"; export function createProgram() { const program = new Command() @@ -423,22 +416,29 @@ Give AI agents better Clerk context: install the Clerk skills ]) .action(configPut); - // --- clerk orgs --- - const orgs = program - .command("orgs") - .alias("organizations") - .description("Manage Clerk Organizations") + // --- clerk enable / disable --- + const enable = program + .command("enable") + .description("Enable Clerk features on the linked instance") .setExamples([ - { command: "clerk orgs enable", description: "Enable organizations" }, + { command: "clerk enable orgs", description: "Enable organizations" }, + { + command: "clerk enable orgs --force-selection --max-members 10", + description: "Enable organizations with options", + }, + { + command: "clerk enable billing --for org", + description: "Enable billing for organizations only", + }, { - command: "clerk orgs enable --force-selection --max-members 10", - description: "Enable with options", + command: "clerk enable billing", + description: "Enable billing for organizations and users", }, - { command: "clerk orgs disable", description: "Disable organizations" }, ]); - orgs - .command("enable") + enable + .command("orgs") + .alias("organizations") .description("Enable organizations on the linked instance") .option("--app ", "Application ID to target") .option("--instance ", "Instance to target (dev, prod, or instance ID)") @@ -447,150 +447,115 @@ Give AI agents better Clerk context: install the Clerk skills .option("--max-members ", "Maximum members per organization") .option("--domains", "Enable verified domains") .option("--yes", "Skip confirmation prompts") + .option("--dry-run", "Show the patch that would be sent without applying it") .setExamples([ - { command: "clerk orgs enable", description: "Enable organizations" }, + { command: "clerk enable orgs", description: "Enable organizations" }, { - command: "clerk orgs enable --force-selection", + command: "clerk enable orgs --force-selection", description: "Enable and force org selection", }, { - command: "clerk orgs enable --auto-create --max-members 10", + command: "clerk enable orgs --auto-create --max-members 10", description: "Enable with auto-creation and member limit", }, + { + command: "clerk enable orgs --dry-run", + description: "Preview the patch without applying it", + }, ]) .action(orgsEnable); - orgs - .command("disable") - .description("Disable organizations on the linked instance") + enable + .command("billing") + .description("Enable billing for organizations and/or users") + .option( + "--for ", + "Billing targets (org and/or user), separated by spaces or commas (e.g. org user). Defaults to both when omitted.", + ) .option("--app ", "Application ID to target") .option("--instance ", "Instance to target (dev, prod, or instance ID)") .option("--yes", "Skip confirmation prompts") - .setExamples([{ command: "clerk orgs disable", description: "Disable organizations" }]) - .action(orgsDisable); - - // --- clerk billing --- - const billing = program - .command("billing") - .description("Manage billing and subscription plans") + .option("--dry-run", "Show the patch that would be sent without applying it") .setExamples([ - { command: "clerk billing enable --for org", description: "Enable org billing" }, { - command: "clerk billing plans create pro --amount 1999 --payer org", - description: "Create a Pro plan", + command: "clerk enable billing", + description: "Enable billing for organizations and users", + }, + { + command: "clerk enable billing --for org", + description: "Enable billing for organizations only", + }, + { + command: "clerk enable billing --for user", + description: "Enable billing for users only", + }, + { + command: "clerk enable billing --for org user", + description: "Enable billing for both targets", }, - { command: "clerk billing plans list", description: "List all plans" }, - ]); - - billing - .command("enable") - .description("Enable billing on the linked instance") - .addOption(createOption("--for ", "Billing target type").choices(["org", "user"])) - .option("--app ", "Application ID to target") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .option("--require-payment-method", "Require payment method for free trials") - .option("--yes", "Skip confirmation prompts") - .setExamples([ - { command: "clerk billing enable --for org", description: "Enable org billing" }, - { command: "clerk billing enable --for user", description: "Enable user billing" }, ]) .action(billingEnable); - billing + const disable = program .command("disable") - .description("Disable billing on the linked instance") - .addOption(createOption("--for ", "Billing target type").choices(["org", "user"])) - .option("--app ", "Application ID to target") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .option("--yes", "Skip confirmation prompts") - .setExamples([ - { command: "clerk billing disable --for org", description: "Disable org billing" }, - ]) - .action(billingDisable); - - const plans = billing - .command("plans") - .description("Manage subscription plans") + .description("Disable Clerk features on the linked instance") .setExamples([ - { command: "clerk billing plans list", description: "List all plans" }, + { command: "clerk disable orgs", description: "Disable organizations" }, + { + command: "clerk disable billing --for org", + description: "Disable billing for organizations only (leaves organizations enabled)", + }, { - command: "clerk billing plans create pro --amount 1999 --payer org", - description: "Create a Pro plan at $19.99/mo", + command: "clerk disable billing", + description: "Disable billing for organizations and users", }, ]); - plans - .command("create") - .description("Create a subscription plan") - .argument("", "Plan slug (display name auto-derived via title case)") - .option("--name ", "Override display name") - .requiredOption("--amount ", "Monthly price in cents") - .addOption(createOption("--payer ", "Who pays").choices(["org", "user"])) - .option("--currency ", "Currency code (default: usd)") - .option("--description ", "Plan description") - .option("--trial-days ", "Free trial length in days") - .option("--annual-amount ", "Monthly equivalent when billed annually, in cents") - .option("--hidden", "Hide plan from end users") + disable + .command("orgs") + .alias("organizations") + .description("Disable organizations on the linked instance") .option("--app ", "Application ID to target") .option("--instance ", "Instance to target (dev, prod, or instance ID)") .option("--yes", "Skip confirmation prompts") + .option("--dry-run", "Show the patch that would be sent without applying it") .setExamples([ + { command: "clerk disable orgs", description: "Disable organizations" }, { - command: "clerk billing plans create pro --amount 1999 --payer org", - description: "Create a Pro plan at $19.99/mo for orgs", - }, - { - command: - 'clerk billing plans create enterprise --name "Enterprise Plus" --amount 9999 --payer org --trial-days 14', - description: "Create an Enterprise plan with a 14-day trial", + command: "clerk disable orgs --dry-run", + description: "Preview without applying", }, ]) - .action(plansCreate); - - plans - .command("list") - .description("List subscription plans") - .option("--json", "Output as JSON") - .option("--app ", "Application ID to target") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .setExamples([ - { command: "clerk billing plans list", description: "List all plans" }, - { command: "clerk billing plans list --json", description: "Output as JSON" }, - ]) - .action(plansList); - - plans - .command("update") - .description("Update a subscription plan") - .argument("", "Plan slug to update") - .option("--name ", "Update display name") - .option("--amount ", "Update monthly price in cents") - .option("--currency ", "Update currency") - .option("--description ", "Update description") - .option("--trial-days ", "Update free trial days") - .option("--annual-amount ", "Update annual amount") - .option("--hidden", "Hide plan from end users") - .option("--visible", "Show plan to end users") - .option("--app ", "Application ID to target") - .option("--instance ", "Instance to target (dev, prod, or instance ID)") - .option("--yes", "Skip confirmation prompts") - .setExamples([ - { command: "clerk billing plans update pro --amount 2999", description: "Update price" }, - { command: "clerk billing plans update pro --hidden", description: "Hide plan" }, - ]) - .action(plansUpdate); + .action(orgsDisable); - plans - .command("remove") - .description("Remove a subscription plan") - .argument("", "Plan slug to remove") + disable + .command("billing") + .description( + "Disable billing for organizations and/or users (does not disable organizations themselves)", + ) + .option( + "--for ", + "Billing targets (org and/or user), separated by spaces or commas (e.g. org user). Defaults to both when omitted.", + ) .option("--app ", "Application ID to target") .option("--instance ", "Instance to target (dev, prod, or instance ID)") .option("--yes", "Skip confirmation prompts") + .option("--dry-run", "Show the patch that would be sent without applying it") .setExamples([ - { command: "clerk billing plans remove pro", description: "Remove the Pro plan" }, + { + command: "clerk disable billing", + description: "Disable billing for organizations and users", + }, + { + command: "clerk disable billing --for org", + description: "Disable billing for organizations only", + }, + { + command: "clerk disable billing --for user", + description: "Disable billing for users only", + }, ]) - .action(plansRemove); + .action(billingDisable); program .command("api") diff --git a/packages/cli-core/src/commands/billing/README.md b/packages/cli-core/src/commands/billing/README.md index 8fe45c9b..947b8a51 100644 --- a/packages/cli-core/src/commands/billing/README.md +++ b/packages/cli-core/src/commands/billing/README.md @@ -1,80 +1,54 @@ -# clerk billing +# clerk billing (enable/disable) -Enable/disable billing and manage subscription plans on the linked instance. +Toggle Clerk billing for organizations and/or users on the linked instance. +The handlers are wired to top-level `clerk enable billing` and `clerk disable +billing` commands. + +For arbitrary billing config edits (plans, trials, payment-method requirements) +use `clerk config patch --json '{"billing":{...}}'` until a dedicated +`clerk billing settings` command lands. ## Usage ``` -clerk billing enable --for [options] -clerk billing disable --for [options] -clerk billing plans create [options] -clerk billing plans list [options] -clerk billing plans update [options] -clerk billing plans remove [options] +clerk enable billing [--for ] [options] +clerk disable billing [--for ] [options] ``` -## Options - -### `enable` / `disable` - -| Flag | Description | -| -------------------------- | ---------------------------------------------------- | -| `--for ` | **(required)** Target billing type | -| `--require-payment-method` | Require payment method for free trials (enable only) | -| `--app ` | Target a specific application | -| `--instance ` | Target a specific instance (dev, prod) | - -### `plans create` +`` is `org` and/or `user`, accepted as space-separated, comma-separated, +or repeated `--for` flags (matching `clerk config pull --keys`). When omitted, +the command targets both: -| Flag | Description | -| ------------------------- | ------------------------------------------------------------------- | -| `` | Plan slug (positional). Display name is auto-derived via title case | -| `--name ` | Override display name (default: title-cased slug) | -| `--amount ` | **(required)** Monthly price in cents | -| `--payer ` | **(required)** Who pays | -| `--currency ` | Currency code (default: usd) | -| `--description ` | Plan description | -| `--trial-days ` | Free trial length in days | -| `--annual-amount ` | Monthly equivalent when billed annually, in cents | -| `--hidden` | Hide plan from end users | -| `--app ` | Target a specific application | -| `--instance ` | Target a specific instance (dev, prod) | - -### `plans list` - -| Flag | Description | -| ----------------- | -------------------------------------- | -| `--json` | Output as JSON | -| `--app ` | Target a specific application | -| `--instance ` | Target a specific instance (dev, prod) | +```sh +clerk enable billing --for org user +clerk enable billing --for org,user +clerk enable billing --for org --for user +clerk enable billing # defaults to both +``` -### `plans update` +## Options -| Flag | Description | -| ------------------------- | -------------------------------------- | -| `` | Plan slug to update (positional) | -| `--name ` | Update display name | -| `--amount ` | Update monthly price | -| `--currency ` | Update currency | -| `--description ` | Update description | -| `--trial-days ` | Update free trial days | -| `--annual-amount ` | Update annual amount | -| `--hidden` | Hide plan | -| `--visible` | Show plan | -| `--app ` | Target a specific application | -| `--instance ` | Target a specific instance (dev, prod) | +| Flag | Description | +| ----------------- | ------------------------------------------------------------------------------- | +| `--for ` | Targets (`org` and/or `user`), separated by spaces or commas. Defaults to both. | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | +| `--yes` | Skip the confirmation prompt | +| `--dry-run` | Preview the patch without applying it | -### `plans remove` +## Cascade behavior -| Flag | Description | -| ----------------- | -------------------------------------- | -| `` | Plan slug to remove (positional) | -| `--app ` | Target a specific application | -| `--instance ` | Target a specific instance (dev, prod) | +- `enable billing --for org` (or `org,user`, or no `--for`) **also** sets + `organization_settings.enabled = true`. Billing for organizations requires + organizations enabled, so this saves a separate command. The cascade is + idempotent — if organizations are already on, the diff is empty for that + field. +- `disable billing` **never** touches `organization_settings`. To disable + organizations themselves, run `clerk disable orgs` separately. ## Clerk API endpoints -| Method | Endpoint | Description | -| ------ | ----------------------------------------------------------------- | -------------------------------------------- | -| GET | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Fetch current config (for plans list/remove) | -| PATCH | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Patch billing and plans config | +| Method | Endpoint | Description | +| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------- | +| GET | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Fetch current config for diff before mutation | +| PATCH | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Patch `billing.*` (with `?dry_run=true` when `--dry-run` set) | diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts index 5af918f8..832612ab 100644 --- a/packages/cli-core/src/commands/billing/index.test.ts +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -18,7 +18,7 @@ mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); -describe("clerk billing", () => { +describe("clerk enable/disable billing", () => { const originalEnv = { ...process.env }; const originalFetch = globalThis.fetch; let tempDir: string; @@ -59,9 +59,9 @@ describe("clerk billing", () => { }); } - // --- billing enable --- + // --- enable --- - test("enable --for org sends organization_enabled = true", async () => { + test("enable --for org sends organization_enabled = true and cascades organization_settings.enabled", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -70,13 +70,15 @@ describe("clerk billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: "org" })); + await captured.run(() => billingEnable({ for: ["org"] })); const parsed = JSON.parse(capturedBody); expect(parsed.billing.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBeUndefined(); + expect(parsed.organization_settings.enabled).toBe(true); }); - test("enable --for user sends user_enabled = true", async () => { + test("enable --for user sends user_enabled = true and does NOT cascade orgs", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -85,37 +87,15 @@ describe("clerk billing", () => { await setupProfile(); const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: "user" })); + await captured.run(() => billingEnable({ for: ["user"] })); const parsed = JSON.parse(capturedBody); expect(parsed.billing.user_enabled).toBe(true); + expect(parsed.billing.organization_enabled).toBeUndefined(); + expect(parsed.organization_settings).toBeUndefined(); }); - test("enable errors without --for", async () => { - await setupProfile(); - const { billingEnable } = await import("./index.ts"); - await expect(captured.run(() => billingEnable({}))).rejects.toThrow("--for is required"); - }); - - test("enable errors with invalid --for value", async () => { - await setupProfile(); - const { billingEnable } = await import("./index.ts"); - await expect(captured.run(() => billingEnable({ for: "invalid" }))).rejects.toThrow( - 'Must be "org" or "user"', - ); - }); - - test("enable shows success message", async () => { - await setupProfile(); - const { billingEnable } = await import("./index.ts"); - await captured.run(() => billingEnable({ for: "org" })); - - expect(captured.err).toContain("Billing enabled for organizations"); - }); - - // --- billing disable --- - - test("disable --for org sends organization_enabled = false", async () => { + test("enable --for org,user (CSV form) sets both billing fields and cascades orgs", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -123,16 +103,16 @@ describe("clerk billing", () => { }); await setupProfile(); - const { billingDisable } = await import("./index.ts"); - await captured.run(() => billingDisable({ for: "org" })); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org,user"] })); const parsed = JSON.parse(capturedBody); - expect(parsed.billing.organization_enabled).toBe(false); + expect(parsed.billing.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBe(true); + expect(parsed.organization_settings.enabled).toBe(true); }); - // --- plans create --- - - test("plans create sends correct plan config", async () => { + test("enable --for org user (variadic form) sets both billing fields and cascades orgs", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -140,17 +120,18 @@ describe("clerk billing", () => { }); await setupProfile(); - const { plansCreate } = await import("./index.ts"); - await captured.run(() => plansCreate("pro", { amount: "1999", payer: "org" })); + const { billingEnable } = await import("./index.ts"); + // Commander variadic produces a string[] when the user writes + // `--for org user` or `--for org --for user`. + await captured.run(() => billingEnable({ for: ["org", "user"] })); const parsed = JSON.parse(capturedBody); - expect(parsed.billing.plans.pro.name).toBe("Pro"); - expect(parsed.billing.plans.pro.amount).toBe(1999); - expect(parsed.billing.plans.pro.payer_type).toBe("org"); - expect(parsed.billing.plans.pro.is_recurring).toBe(true); + expect(parsed.billing.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBe(true); + expect(parsed.organization_settings.enabled).toBe(true); }); - test("plans create auto-derives name from slug", async () => { + test("enable with no --for defaults to both targets and cascades orgs", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -158,47 +139,32 @@ describe("clerk billing", () => { }); await setupProfile(); - const { plansCreate } = await import("./index.ts"); - await captured.run(() => plansCreate("enterprise-plus", { amount: "9999", payer: "org" })); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({})); const parsed = JSON.parse(capturedBody); - expect(parsed.billing.plans["enterprise-plus"].name).toBe("Enterprise Plus"); + expect(parsed.billing.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBe(true); + expect(parsed.organization_settings.enabled).toBe(true); }); - test("plans create uses --name override", async () => { - let capturedBody = ""; - stubFetch(async (_input, init) => { - if (init?.method === "PATCH") capturedBody = init.body as string; - return new Response(JSON.stringify({}), { status: 200 }); - }); - + test("enable rejects invalid --for token", async () => { await setupProfile(); - const { plansCreate } = await import("./index.ts"); - await captured.run(() => - plansCreate("pro", { amount: "1999", payer: "org", name: "Pro Plus" }), + const { billingEnable } = await import("./index.ts"); + await expect(captured.run(() => billingEnable({ for: ["foo"] }))).rejects.toThrow( + 'Invalid --for value: "foo"', ); - - const parsed = JSON.parse(capturedBody); - expect(parsed.billing.plans.pro.name).toBe("Pro Plus"); }); - test("plans create with --trial-days enables trial", async () => { - let capturedBody = ""; - stubFetch(async (_input, init) => { - if (init?.method === "PATCH") capturedBody = init.body as string; - return new Response(JSON.stringify({}), { status: 200 }); - }); - + test("enable rejects empty --for value", async () => { await setupProfile(); - const { plansCreate } = await import("./index.ts"); - await captured.run(() => plansCreate("pro", { amount: "1999", payer: "org", trialDays: "14" })); - - const parsed = JSON.parse(capturedBody); - expect(parsed.billing.plans.pro.free_trial_enabled).toBe(true); - expect(parsed.billing.plans.pro.free_trial_days).toBe(14); + const { billingEnable } = await import("./index.ts"); + await expect(captured.run(() => billingEnable({ for: [","] }))).rejects.toThrow( + "--for must include at least one of", + ); }); - test("plans create with --hidden sets publicly_visible = false", async () => { + test("enable trims whitespace and dedupes --for tokens", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -206,82 +172,40 @@ describe("clerk billing", () => { }); await setupProfile(); - const { plansCreate } = await import("./index.ts"); - await captured.run(() => plansCreate("pro", { amount: "1999", payer: "org", hidden: true })); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: [" org , org , user "] })); const parsed = JSON.parse(capturedBody); - expect(parsed.billing.plans.pro.publicly_visible).toBe(false); - }); - - test("plans create shows success message", async () => { - await setupProfile(); - const { plansCreate } = await import("./index.ts"); - await captured.run(() => plansCreate("pro", { amount: "1999", payer: "org" })); - - expect(captured.err).toContain("created"); - }); - - // --- plans list --- - - test("plans list outputs plans", async () => { - stubFetch(async () => { - return new Response( - JSON.stringify({ - billing: { - plans: { - free_org: { name: "Free", amount: 0, payer_type: "org", publicly_visible: true }, - pro: { - name: "Pro", - amount: 1999, - currency: "usd", - payer_type: "org", - publicly_visible: true, - }, - }, - }, - }), - { status: 200 }, - ); - }); - - await setupProfile(); - const { plansList } = await import("./index.ts"); - await captured.run(() => plansList({})); - - expect(captured.err).toContain("Free"); - expect(captured.err).toContain("Pro"); + expect(parsed.billing.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBe(true); }); - test("plans list --json outputs JSON", async () => { - const plansData = { - free_org: { name: "Free", amount: 0, payer_type: "org" }, - }; - stubFetch(async () => { - return new Response(JSON.stringify({ billing: { plans: plansData } }), { status: 200 }); - }); - + test("enable shows success message", async () => { await setupProfile(); - const { plansList } = await import("./index.ts"); - await captured.run(() => plansList({ json: true })); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org"] })); - expect(captured.out).toContain('"Free"'); + expect(captured.err).toContain("Billing enabled for organizations"); }); - test("plans list shows message when no plans", async () => { - stubFetch(async () => { - return new Response(JSON.stringify({ billing: { plans: {} } }), { status: 200 }); + test("enable --dry-run plumbs dry_run=true to the API", async () => { + let capturedUrl = ""; + stubFetch(async (input, init) => { + if (init?.method === "PATCH") capturedUrl = input.toString(); + return new Response(JSON.stringify({}), { status: 200 }); }); await setupProfile(); - const { plansList } = await import("./index.ts"); - await captured.run(() => plansList({})); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org"], dryRun: true })); - expect(captured.err).toContain("No plans configured"); + expect(capturedUrl).toContain("dry_run=true"); + expect(captured.err).toContain("[dry-run]"); }); - // --- plans update --- + // --- disable --- - test("plans update sends partial plan config", async () => { + test("disable --for org sets organization_enabled = false and never touches organization_settings", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -289,14 +213,16 @@ describe("clerk billing", () => { }); await setupProfile(); - const { plansUpdate } = await import("./index.ts"); - await captured.run(() => plansUpdate("pro", { amount: "2999" })); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ for: ["org"] })); const parsed = JSON.parse(capturedBody); - expect(parsed.billing.plans.pro.amount).toBe(2999); + expect(parsed.billing.organization_enabled).toBe(false); + expect(parsed.billing.user_enabled).toBeUndefined(); + expect(parsed.organization_settings).toBeUndefined(); }); - test("plans update --hidden sets publicly_visible = false", async () => { + test("disable --for user sets user_enabled = false and never touches organization_settings", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -304,80 +230,61 @@ describe("clerk billing", () => { }); await setupProfile(); - const { plansUpdate } = await import("./index.ts"); - await captured.run(() => plansUpdate("pro", { hidden: true })); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ for: ["user"] })); const parsed = JSON.parse(capturedBody); - expect(parsed.billing.plans.pro.publicly_visible).toBe(false); - }); - - test("plans update errors with no options", async () => { - await setupProfile(); - const { plansUpdate } = await import("./index.ts"); - await expect(captured.run(() => plansUpdate("pro", {}))).rejects.toThrow( - "No update options provided", - ); + expect(parsed.billing.user_enabled).toBe(false); + expect(parsed.billing.organization_enabled).toBeUndefined(); + expect(parsed.organization_settings).toBeUndefined(); }); - // --- plans remove --- - - test("plans remove sends config without the plan", async () => { + test("disable with no --for defaults to both targets and never cascades to orgs", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { - if (!init?.method || init.method === "GET") { - return new Response( - JSON.stringify({ - billing: { - plans: { - free_org: { name: "Free", amount: 0 }, - pro: { name: "Pro", amount: 1999 }, - }, - }, - }), - { status: 200 }, - ); - } if (init?.method === "PATCH") capturedBody = init.body as string; - return new Response(JSON.stringify({}), { status: 200 }); + return new Response( + JSON.stringify({ + billing: { organization_enabled: true, user_enabled: true }, + organization_settings: { enabled: true }, + }), + { status: 200 }, + ); }); await setupProfile(); - const { plansRemove } = await import("./index.ts"); - await captured.run(() => plansRemove("pro", {})); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({})); const parsed = JSON.parse(capturedBody); - expect(parsed.billing.plans).not.toHaveProperty("pro"); - expect(parsed.billing.plans).toHaveProperty("free_org"); + expect(parsed.billing.organization_enabled).toBe(false); + expect(parsed.billing.user_enabled).toBe(false); + expect(parsed.organization_settings).toBeUndefined(); }); - test("plans remove sends ?destructive=true", async () => { - let capturedUrl = ""; - stubFetch(async (input, init) => { - if (!init?.method || init.method === "GET") { - return new Response(JSON.stringify({ billing: { plans: { pro: { name: "Pro" } } } }), { - status: 200, - }); - } - if (init?.method === "PATCH") capturedUrl = input.toString(); - return new Response(JSON.stringify({}), { status: 200 }); - }); - + test("disable shows success message", async () => { await setupProfile(); - const { plansRemove } = await import("./index.ts"); - await captured.run(() => plansRemove("pro", {})); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ for: ["org"] })); - expect(capturedUrl).toContain("destructive=true"); + expect(captured.err).toContain("Billing disabled for organizations"); }); - test("plans remove errors when plan not found", async () => { - stubFetch(async () => { - return new Response(JSON.stringify({ billing: { plans: {} } }), { status: 200 }); + test("disable --dry-run plumbs dry_run=true", async () => { + let capturedUrl = ""; + stubFetch(async (input, init) => { + if (init?.method === "PATCH") capturedUrl = input.toString(); + return new Response( + JSON.stringify({ billing: { organization_enabled: true, user_enabled: true } }), + { status: 200 }, + ); }); await setupProfile(); - const { plansRemove } = await import("./index.ts"); - await expect(captured.run(() => plansRemove("nonexistent", {}))).rejects.toThrow( - 'Plan "nonexistent" not found', - ); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ dryRun: true })); + + expect(capturedUrl).toContain("dry_run=true"); + expect(captured.err).toContain("[dry-run]"); }); }); diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts index eb72297c..5ab4e507 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -1,270 +1,94 @@ import { resolveAppContext } from "../../lib/config.ts"; -import { fetchInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; -import { throwUsageError, withApiContext } from "../../lib/errors.ts"; -import { withSpinner } from "../../lib/spinner.ts"; -import { log } from "../../lib/log.ts"; -import { cyan, dim } from "../../lib/color.ts"; +import { throwUsageError } from "../../lib/errors.ts"; +import { applyConfigPatch } from "../config/apply-patch.ts"; interface BillingOptions { app?: string; instance?: string; - for?: string; - requirePaymentMethod?: boolean; + for?: string[]; yes?: boolean; + dryRun?: boolean; } -function validateFor(value: string | undefined): "org" | "user" { - if (!value) { - throwUsageError("--for is required. Use --for org or --for user."); +type Target = "org" | "user"; + +/** + * Parse the `--for` option, which accepts both variadic and comma-separated + * forms (mirroring `--keys` on `clerk config pull`): + * + * --for org user → ["org", "user"] + * --for org,user → ["org", "user"] + * --for org --for user → ["org", "user"] + * (omitted) → ["org", "user"] (default to both) + */ +function parseForTargets(values: string[] | undefined): Target[] { + if (!values?.length) return ["org", "user"]; + const seen = new Set(); + for (const value of values) { + for (const part of value.split(",")) { + const trimmed = part.trim(); + if (!trimmed) continue; + if (trimmed !== "org" && trimmed !== "user") { + throwUsageError(`Invalid --for value: "${trimmed}". Expected "org" and/or "user".`); + } + seen.add(trimmed); + } } - if (value !== "org" && value !== "user") { - throwUsageError(`Invalid --for value: "${value}". Must be "org" or "user".`); + if (seen.size === 0) { + throwUsageError('--for must include at least one of: "org", "user".'); } - return value; + return [...seen]; } -export async function billingEnable(options: BillingOptions): Promise { - const target = validateFor(options.for); - const ctx = await resolveAppContext(options); - - const patch: Record = {}; - if (target === "org") { - patch.organization_enabled = true; - } else { - patch.user_enabled = true; - } - if (options.requirePaymentMethod !== undefined) { - patch.free_trial_requires_payment_method = options.requirePaymentMethod; - } - - const config = { billing: patch }; - - const result = await withSpinner( - `Enabling ${target} billing on ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - patchInstanceConfig(ctx.appId, ctx.instanceId, config), - "Failed to enable billing", - ), - ); - - log.data(JSON.stringify(result, null, 2)); - log.success(`Billing enabled for ${target === "org" ? "organizations" : "users"}`); -} - -export async function billingDisable(options: BillingOptions): Promise { - const target = validateFor(options.for); - const ctx = await resolveAppContext(options); - - const patch: Record = {}; - if (target === "org") { - patch.organization_enabled = false; - } else { - patch.user_enabled = false; - } - - const config = { billing: patch }; - - const result = await withSpinner( - `Disabling ${target} billing on ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - patchInstanceConfig(ctx.appId, ctx.instanceId, config), - "Failed to disable billing", - ), - ); - - log.data(JSON.stringify(result, null, 2)); - log.success(`Billing disabled for ${target === "org" ? "organizations" : "users"}`); -} - -// --- Plans subcommand --- - -interface PlansCreateOptions { - app?: string; - instance?: string; - name?: string; - amount: string; - currency?: string; - payer?: string; - description?: string; - trialDays?: string; - hidden?: boolean; - annualAmount?: string; - yes?: boolean; -} - -function titleCase(slug: string): string { - return slug - .split(/[-_]/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -} - -export async function plansCreate(slug: string, options: PlansCreateOptions): Promise { - const ctx = await resolveAppContext(options); - - const plan: Record = { - name: options.name || titleCase(slug), - 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); - } - - const config = { billing: { plans: { [slug]: plan } } }; - - const result = await withSpinner( - `Creating plan ${cyan(options.name || titleCase(slug))} on ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - patchInstanceConfig(ctx.appId, ctx.instanceId, config), - "Failed to create plan", - ), - ); - - log.data(JSON.stringify(result, null, 2)); - log.success(`Plan ${cyan(options.name || titleCase(slug))} ${dim(`(${slug})`)} created`); -} - -interface PlansListOptions { - app?: string; - instance?: string; - json?: boolean; -} - -export async function plansList(options: PlansListOptions): Promise { - const ctx = await resolveAppContext(options); - - const current = await withSpinner("Fetching billing config...", () => - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing"]), - "Failed to fetch config", - ), - ); - - const billing = current.billing as Record | undefined; - const plans = (billing?.plans as Record>) ?? {}; - - if (options.json) { - log.data(JSON.stringify(plans, null, 2)); - return; - } - - const entries = Object.entries(plans); - if (entries.length === 0) { - log.info("No plans configured. Use `clerk billing plans create` to add one."); - return; - } - - for (const [slug, plan] of entries) { - const amount = plan.amount as number; - 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; - const trial = plan.free_trial_enabled ? ` (${plan.free_trial_days}d trial)` : ""; - - log.info( - `${cyan(plan.name as string)} ${dim(`(${slug})`)} — ${price}/mo — ${payer}${trial}${!visible ? dim(" [hidden]") : ""}`, - ); - } -} - -interface PlansUpdateOptions { - app?: string; - instance?: string; - name?: string; - amount?: string; - currency?: string; - description?: string; - trialDays?: string; - hidden?: boolean; - visible?: boolean; - annualAmount?: string; - yes?: boolean; +function describeTargets(targets: Target[]): string { + const parts = targets.map((t) => (t === "org" ? "organizations" : "users")); + return parts.length === 2 ? `${parts[0]} and ${parts[1]}` : parts[0]!; } -export async function plansUpdate(slug: string, options: PlansUpdateOptions): Promise { +export async function billingEnable(options: BillingOptions): Promise { + const targets = parseForTargets(options.for); const ctx = await resolveAppContext(options); - const plan: Record = {}; - if (options.name) plan.name = options.name; - if (options.amount) plan.amount = parseInt(options.amount, 10); - 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.hidden) plan.publicly_visible = false; - if (options.visible) plan.publicly_visible = true; - if (options.trialDays) { - plan.free_trial_enabled = true; - plan.free_trial_days = parseInt(options.trialDays, 10); + const billing: Record = {}; + const payload: Record = { billing }; + if (targets.includes("org")) { + billing.organization_enabled = true; + // Cascade-enable orgs whenever billing is being turned on for orgs. This + // is idempotent — if orgs are already enabled the diff stays empty. + payload.organization_settings = { enabled: true }; } - - if (Object.keys(plan).length === 0) { - throwUsageError("No update options provided. Use --name, --amount, --hidden, etc."); + if (targets.includes("user")) { + billing.user_enabled = true; } - const config = { billing: { plans: { [slug]: plan } } }; - - const result = await withSpinner( - `Updating plan ${cyan(slug)} on ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - patchInstanceConfig(ctx.appId, ctx.instanceId, config), - "Failed to update plan", - ), - ); - - log.data(JSON.stringify(result, null, 2)); - log.success(`Plan ${cyan(slug)} updated`); -} - -interface PlansRemoveOptions { - app?: string; - instance?: string; - yes?: boolean; + await applyConfigPatch({ + ctx, + payload, + verb: `Enabling billing for ${describeTargets(targets)}`, + successMessage: `Billing enabled for ${describeTargets(targets)}`, + failureContext: "Failed to enable billing", + yes: options.yes, + dryRun: options.dryRun, + }); } -export async function plansRemove(slug: string, options: PlansRemoveOptions): Promise { +export async function billingDisable(options: BillingOptions): Promise { + const targets = parseForTargets(options.for); const ctx = await resolveAppContext(options); - // Fetch current config so we can PUT without the plan - const current = await withSpinner("Fetching current config...", () => - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing"]), - "Failed to fetch config", - ), - ); - - const billing = current.billing as Record | undefined; - const plans = { ...((billing?.plans as Record) ?? {}) }; - - if (!(slug in plans)) { - throwUsageError(`Plan "${slug}" not found.`); - } - - delete plans[slug]; - - const config = { billing: { plans } }; - - const result = await withSpinner( - `Removing plan ${cyan(slug)} on ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - patchInstanceConfig(ctx.appId, ctx.instanceId, config, { destructive: true }), - "Failed to remove plan", - ), - ); - - log.data(JSON.stringify(result, null, 2)); - log.success(`Plan ${cyan(slug)} removed`); + // Disabling billing never cascades to disabling organizations — leave + // `organization_settings` untouched per spec. + const billing: Record = {}; + if (targets.includes("org")) billing.organization_enabled = false; + if (targets.includes("user")) billing.user_enabled = false; + + await applyConfigPatch({ + ctx, + payload: { billing }, + verb: `Disabling billing for ${describeTargets(targets)}`, + successMessage: `Billing disabled for ${describeTargets(targets)}`, + failureContext: "Failed to disable billing", + yes: options.yes, + dryRun: options.dryRun, + }); } diff --git a/packages/cli-core/src/commands/completion/__complete.ts b/packages/cli-core/src/commands/completion/__complete.ts index 2283b996..0490eb26 100644 --- a/packages/cli-core/src/commands/completion/__complete.ts +++ b/packages/cli-core/src/commands/completion/__complete.ts @@ -52,6 +52,11 @@ const KNOWN_OPTION_VALUES: Record = { { name: "latest", description: "Latest stable release" }, { name: "canary", description: "Latest canary (pre-release) build" }, ], + "--for": [ + { name: "org", description: "Organizations only" }, + { name: "user", description: "Users only" }, + { name: "org,user", description: "Both organizations and users" }, + ], }; /** diff --git a/packages/cli-core/src/commands/config/apply-patch.ts b/packages/cli-core/src/commands/config/apply-patch.ts new file mode 100644 index 00000000..85b0ef8e --- /dev/null +++ b/packages/cli-core/src/commands/config/apply-patch.ts @@ -0,0 +1,76 @@ +import { fetchInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; +import { throwUserAbort, withApiContext } from "../../lib/errors.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { isHuman } from "../../mode.ts"; +import { log } from "../../lib/log.ts"; +import { hasConfigChanges, printDiff } from "./push.ts"; + +export interface ApplyPatchOptions { + ctx: { appId: string; instanceId: string; appLabel: string; instanceLabel: string }; + payload: Record; + /** Verb shown in dry-run/spinner headlines, e.g. "Enabling organizations". */ + verb: string; + /** Final success line in non-dry-run mode, e.g. "Organizations enabled". */ + successMessage: string; + /** Context attached to API errors when the PATCH fails. */ + failureContext: string; + /** Skip-confirmation flag from the calling command. */ + yes?: boolean; + /** Preview-only flag from the calling command. */ + dryRun?: boolean; + /** Optional human-mode warning printed just before the confirm prompt. */ + warning?: string; + /** + * Pre-fetched current config (e.g. when the caller already inspected it to + * decide on a warning). Skips the extra GET round-trip. + */ + currentConfig?: Record; +} + +/** + * Shared flow for the `enable`/`disable` shortcut commands: fetch current + * config, diff against the proposed patch, confirm in human mode, and apply. + * + * Mirrors the safety story from `clerk config patch` (see push.ts) so the + * shortcut commands don't regress on diff/dry-run/confirmation. + */ +export async function applyConfigPatch(opts: ApplyPatchOptions): Promise { + const { ctx, payload, verb, successMessage, failureContext, yes, dryRun, warning } = opts; + + const current = + opts.currentConfig ?? + (await withSpinner("Fetching current config...", () => + withApiContext(fetchInstanceConfig(ctx.appId, ctx.instanceId), "Failed to fetch config"), + )); + + if (!hasConfigChanges(current, payload, true)) { + log.info(dryRun ? "[dry-run] No changes detected" : "No changes detected"); + return; + } + + const headline = dryRun + ? `[dry-run] Proposing PATCH on ${ctx.appLabel} (${ctx.instanceLabel}):` + : `${verb} on ${ctx.appLabel} (${ctx.instanceLabel}):`; + log.info(`\n${headline}\n`); + printDiff(current, payload, true); + + if (!dryRun && isHuman() && !yes) { + if (warning) log.warn(warning); + const ok = await confirm({ message: "Proceed?" }); + if (!ok) throwUserAbort(); + } + + const spinnerMsg = dryRun + ? `[dry-run] Validating config on ${ctx.appLabel} (${ctx.instanceLabel})...` + : `${verb} on ${ctx.appLabel} (${ctx.instanceLabel})...`; + const result = await withSpinner(spinnerMsg, () => + withApiContext( + patchInstanceConfig(ctx.appId, ctx.instanceId, payload, { dryRun }), + dryRun ? "Dry-run failed" : failureContext, + ), + ); + + log.debug(`plapi: ${JSON.stringify(result)}`); + log.success(dryRun ? "[dry-run] Validation passed — no changes applied" : successMessage); +} diff --git a/packages/cli-core/src/commands/orgs/README.md b/packages/cli-core/src/commands/orgs/README.md index fb9b29b1..cbc4098b 100644 --- a/packages/cli-core/src/commands/orgs/README.md +++ b/packages/cli-core/src/commands/orgs/README.md @@ -1,27 +1,33 @@ -# clerk orgs +# clerk orgs (enable/disable) -Enable or disable Clerk Organizations on the linked instance. +Toggle Clerk Organizations on the linked instance. The handlers are wired to +top-level `clerk enable orgs` and `clerk disable orgs` commands; the source +lives here so future org-related commands (settings, CRUD) can co-locate. ## Usage ``` -clerk orgs enable [options] -clerk orgs disable [options] +clerk enable orgs [options] +clerk disable orgs [options] ``` ## Options ### `enable` -| Flag | Description | -| ------------------- | ----------------------------------------- | -| `--force-selection` | Force organization selection on login | -| `--auto-create` | Auto-create an organization for new users | -| `--max-members ` | Maximum members per organization | -| `--domains` | Enable verified domains | -| `--app ` | Target a specific application | -| `--instance ` | Target a specific instance (dev, prod) | -| `--yes` | Skip confirmation prompts | +| Flag | Description | +| ------------------- | ------------------------------------------ | +| `--force-selection` | Force organization selection on login | +| `--auto-create` | Auto-create an organization for new users | +| `--max-members ` | Maximum members per organization (integer) | +| `--domains` | Enable verified domains | +| `--app ` | Target a specific application | +| `--instance ` | Target a specific instance (dev, prod) | +| `--yes` | Skip the confirmation prompt | +| `--dry-run` | Preview the patch without applying it | + +The boolean flags above are one-way: they set the field to `true` only. To +clear a field, use `clerk config patch --json '{"organization_settings":{...}}'`. ### `disable` @@ -29,11 +35,18 @@ clerk orgs disable [options] | ----------------- | -------------------------------------- | | `--app ` | Target a specific application | | `--instance ` | Target a specific instance (dev, prod) | -| `--yes` | Skip confirmation prompts | +| `--yes` | Skip the confirmation prompt | +| `--dry-run` | Preview the patch without applying it | + +When `billing.organization_enabled` is currently true, `disable` warns and asks +for confirmation in human mode. In agent mode (no TTY), the command refuses +unless `--yes` is passed — this avoids stranding org billing in a stale state. +Disabling organizations never disables organization billing automatically; run +`clerk disable billing --for org` first if that's what you intend. ## Clerk API endpoints -| Method | Endpoint | Description | -| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------ | -| GET | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Fetch current config (used to check billing dependency on disable) | -| PATCH | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Patch `organization_settings` | +| Method | Endpoint | Description | +| ------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | +| GET | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Fetch current config for diff and the org-billing dependency check | +| PATCH | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Patch `organization_settings` (with `?dry_run=true` when `--dry-run` set) | diff --git a/packages/cli-core/src/commands/orgs/index.test.ts b/packages/cli-core/src/commands/orgs/index.test.ts index b64dcc71..69a71310 100644 --- a/packages/cli-core/src/commands/orgs/index.test.ts +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -18,7 +18,7 @@ mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); -describe("clerk orgs", () => { +describe("clerk enable/disable orgs", () => { const originalEnv = { ...process.env }; const originalFetch = globalThis.fetch; let tempDir: string; @@ -59,7 +59,7 @@ describe("clerk orgs", () => { }); } - // --- orgs enable --- + // --- enable --- test("enable sends PATCH with organization_settings.enabled = true", async () => { let capturedBody = ""; @@ -91,7 +91,7 @@ describe("clerk orgs", () => { expect(parsed.organization_settings.force_organization_selection).toBe(true); }); - test("enable passes --max-members flag", async () => { + test("enable passes --max-members flag as integer", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { if (init?.method === "PATCH") capturedBody = init.body as string; @@ -106,6 +106,37 @@ describe("clerk orgs", () => { expect(parsed.organization_settings.max_allowed_memberships).toBe(10); }); + test("enable rejects non-numeric --max-members before any API call", async () => { + let calls = 0; + stubFetch(async () => { + calls++; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await expect(captured.run(() => orgsEnable({ maxMembers: "abc" }))).rejects.toThrow( + "--max-members must be a positive integer", + ); + expect(calls).toBe(0); + }); + + test("enable rejects partial-numeric --max-members like '12abc'", async () => { + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await expect(captured.run(() => orgsEnable({ maxMembers: "12abc" }))).rejects.toThrow( + "--max-members must be a positive integer", + ); + }); + + test("enable rejects --max-members = 0", async () => { + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await expect(captured.run(() => orgsEnable({ maxMembers: "0" }))).rejects.toThrow( + "--max-members must be a positive integer", + ); + }); + test("enable passes --domains flag", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { @@ -139,6 +170,21 @@ describe("clerk orgs", () => { ).toBe(true); }); + test("enable --dry-run plumbs dry_run=true to the API and prints dry-run output", async () => { + let capturedUrl = ""; + stubFetch(async (input, init) => { + if (init?.method === "PATCH") capturedUrl = input.toString(); + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await captured.run(() => orgsEnable({ dryRun: true })); + + expect(capturedUrl).toContain("dry_run=true"); + expect(captured.err).toContain("[dry-run]"); + }); + test("enable shows success message", async () => { await setupProfile(); const { orgsEnable } = await import("./index.ts"); @@ -147,12 +193,30 @@ describe("clerk orgs", () => { expect(captured.err).toContain("Organizations enabled"); }); + test("enable reports no changes when already enabled", async () => { + let patchCalls = 0; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") patchCalls++; + // Current config already has orgs enabled with no extra flags. + return new Response(JSON.stringify({ organization_settings: { enabled: true } }), { + status: 200, + }); + }); + + await setupProfile(); + const { orgsEnable } = await import("./index.ts"); + await captured.run(() => orgsEnable({})); + + expect(patchCalls).toBe(0); + expect(captured.err).toContain("No changes detected"); + }); + test("enable errors when no profile is linked", async () => { const { orgsEnable } = await import("./index.ts"); await expect(captured.run(() => orgsEnable({}))).rejects.toThrow("No Clerk project linked"); }); - // --- orgs disable --- + // --- disable --- test("disable sends PATCH with organization_settings.enabled = false", async () => { let capturedBody = ""; @@ -171,24 +235,41 @@ describe("clerk orgs", () => { expect(parsed.organization_settings.enabled).toBe(false); }); - test("disable warns when org billing is enabled", async () => { + test("disable in agent mode refuses when org billing is enabled and no --yes is set", async () => { + let patchCalls = 0; stubFetch(async (_input, init) => { - if (!init?.method || init.method === "GET") { - return new Response(JSON.stringify({ billing: { organization_enabled: true } }), { - status: 200, - }); - } - return new Response(JSON.stringify({}), { status: 200 }); + if (init?.method === "PATCH") patchCalls++; + return new Response(JSON.stringify({ billing: { organization_enabled: true } }), { + status: 200, + }); }); await setupProfile(); const { orgsDisable } = await import("./index.ts"); - await captured.run(() => orgsDisable({})); + await expect(captured.run(() => orgsDisable({}))).rejects.toThrow( + "Organization billing is enabled", + ); + expect(patchCalls).toBe(0); + }); - expect(captured.err).toContain("Organization billing is enabled"); + test("disable in agent mode proceeds with --yes even when org billing is enabled", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + if (init?.method === "PATCH") capturedBody = init.body as string; + return new Response(JSON.stringify({ billing: { organization_enabled: true } }), { + status: 200, + }); + }); + + await setupProfile(); + const { orgsDisable } = await import("./index.ts"); + await captured.run(() => orgsDisable({ yes: true })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.organization_settings.enabled).toBe(false); }); - test("disable shows success message", async () => { + test("disable shows success message when billing is off", async () => { stubFetch(async () => { return new Response(JSON.stringify({ billing: { organization_enabled: false } }), { status: 200, diff --git a/packages/cli-core/src/commands/orgs/index.ts b/packages/cli-core/src/commands/orgs/index.ts index d8689487..936098d6 100644 --- a/packages/cli-core/src/commands/orgs/index.ts +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -1,8 +1,9 @@ import { resolveAppContext } from "../../lib/config.ts"; -import { fetchInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; -import { withApiContext } from "../../lib/errors.ts"; +import { fetchInstanceConfig } from "../../lib/plapi.ts"; +import { throwUsageError, withApiContext } from "../../lib/errors.ts"; import { withSpinner } from "../../lib/spinner.ts"; -import { log } from "../../lib/log.ts"; +import { isHuman } from "../../mode.ts"; +import { applyConfigPatch } from "../config/apply-patch.ts"; interface OrgsOptions { app?: string; @@ -12,65 +13,81 @@ interface OrgsOptions { maxMembers?: string; domains?: boolean; yes?: boolean; + dryRun?: boolean; +} + +function parsePositiveInt(value: string, flag: string): number { + // Reject anything that isn't a sequence of digits — `parseInt("12abc", 10)` + // would silently truncate and ship corrupt data to the API. + if (!/^\d+$/.test(value)) { + throwUsageError(`${flag} must be a positive integer (got "${value}").`); + } + const n = Number(value); + if (!Number.isSafeInteger(n) || n < 1) { + throwUsageError(`${flag} must be a positive integer (got "${value}").`); + } + return n; } export async function orgsEnable(options: OrgsOptions): Promise { const ctx = await resolveAppContext(options); - const patch: Record = { 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); + const orgSettings: Record = { enabled: true }; + if (options.forceSelection) orgSettings.force_organization_selection = true; + if (options.domains) orgSettings.domains_enabled = true; if (options.autoCreate) { - patch.organization_creation_defaults = { + orgSettings.organization_creation_defaults = { automatic_organization_creation: { enabled: true }, }; } + if (options.maxMembers !== undefined) { + orgSettings.max_allowed_memberships = parsePositiveInt(options.maxMembers, "--max-members"); + } - const config = { organization_settings: patch }; - - const result = await withSpinner( - `Enabling organizations on ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - patchInstanceConfig(ctx.appId, ctx.instanceId, config), - "Failed to enable organizations", - ), - ); - - log.data(JSON.stringify(result, null, 2)); - log.success("Organizations enabled"); + await applyConfigPatch({ + ctx, + payload: { organization_settings: orgSettings }, + verb: "Enabling organizations", + successMessage: "Organizations enabled", + failureContext: "Failed to enable organizations", + yes: options.yes, + dryRun: options.dryRun, + }); } export async function orgsDisable(options: OrgsOptions): Promise { const ctx = await resolveAppContext(options); - // Check if billing depends on orgs - const current = await withSpinner("Checking current config...", () => + const current = await withSpinner("Fetching current config...", () => withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing"]), + fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing", "organization_settings"]), "Failed to fetch config", ), ); const billing = current.billing as Record | undefined; - if (billing?.organization_enabled) { - log.warn( - "Organization billing is enabled. Disabling organizations will also disable org billing.", + const orgBillingOn = billing?.organization_enabled === true; + + // Wyatt's note: "warn-then-do" is worse than nothing in CI logs. In agent + // mode (no TTY), refuse and require an explicit override. + if (orgBillingOn && !isHuman() && !options.yes) { + throwUsageError( + "Organization billing is enabled. Disabling organizations would leave `billing.organization_enabled` stranded. " + + "Run `clerk disable billing --for org` first, or pass --yes to override.", ); } - const config = { organization_settings: { enabled: false } }; - - const result = await withSpinner( - `Disabling organizations on ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - patchInstanceConfig(ctx.appId, ctx.instanceId, config), - "Failed to disable organizations", - ), - ); - - log.data(JSON.stringify(result, null, 2)); - log.success("Organizations disabled"); + await applyConfigPatch({ + ctx, + payload: { organization_settings: { enabled: false } }, + verb: "Disabling organizations", + successMessage: "Organizations disabled", + failureContext: "Failed to disable organizations", + yes: options.yes, + dryRun: options.dryRun, + warning: orgBillingOn + ? "Organization billing is currently enabled. Disabling organizations will leave `billing.organization_enabled` stranded — consider running `clerk disable billing --for org` separately." + : undefined, + currentConfig: current, + }); } From 1cef254ecbe0b852a744325e11ae6194e0e06d1f Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 30 Apr 2026 13:49:12 -0400 Subject: [PATCH 6/8] feat(cli-core): offer clerk-billing agent skill install on clerk enable billing --- .changeset/billing-orgs-shortcut-commands.md | 2 +- README.md | 2 + packages/cli-core/src/cli-program.ts | 5 ++ .../cli-core/src/commands/billing/README.md | 12 ++++ .../src/commands/billing/index.test.ts | 71 +++++++++++++++++++ .../cli-core/src/commands/billing/index.ts | 59 +++++++++++---- .../src/commands/config/apply-patch.ts | 19 +---- packages/cli-core/src/commands/orgs/index.ts | 4 +- 8 files changed, 141 insertions(+), 33 deletions(-) diff --git a/.changeset/billing-orgs-shortcut-commands.md b/.changeset/billing-orgs-shortcut-commands.md index 6580eec4..6c1ad73f 100644 --- a/.changeset/billing-orgs-shortcut-commands.md +++ b/.changeset/billing-orgs-shortcut-commands.md @@ -5,6 +5,6 @@ Add `clerk enable` and `clerk disable` top-level commands for toggling features on the linked instance: - `clerk enable orgs` / `clerk disable orgs` — toggle organizations, with optional `--force-selection`, `--auto-create`, `--max-members `, and `--domains` configuration on enable. -- `clerk enable billing [--for org,user]` / `clerk disable billing [--for org,user]` — toggle billing for organizations and/or users. `--for` defaults to both targets when omitted; enabling for `org` also enables organizations. +- `clerk enable billing [--for org,user]` / `clerk disable billing [--for org,user]` — toggle billing for organizations and/or users. `--for` defaults to both targets when omitted; enabling for `org` also enables organizations. After a successful enable, offers to install the `clerk-billing` agent skill (suppress with `--no-skills`). All commands share the diff-and-confirm safety flow used by `clerk config patch`, including `--dry-run` and `--yes` flags. diff --git a/README.md b/README.md index be04ce16..059642e7 100644 --- a/README.md +++ b/README.md @@ -189,11 +189,13 @@ clerk enable billing --instance Instance to target (dev, prod, or instance ID) --yes Skip the confirmation prompt --dry-run Preview the patch without applying it + --no-skills Skip the post-enable `clerk-billing` agent skill install Examples: $ clerk enable billing Enable billing for organizations and users $ clerk enable billing --for org Enable billing for organizations only $ clerk enable billing --for user Enable billing for users only $ clerk enable billing --for org user Enable billing for both targets + $ clerk enable billing --no-skills Enable without installing the agent skill clerk disable billing --for Billing targets (org and/or user), separated by spaces or commas (e.g. org user). Defaults to both when omitted. diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index c6102b54..fd988eef 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -479,6 +479,7 @@ Give AI agents better Clerk context: install the Clerk skills .option("--instance ", "Instance to target (dev, prod, or instance ID)") .option("--yes", "Skip confirmation prompts") .option("--dry-run", "Show the patch that would be sent without applying it") + .option("--no-skills", "Skip the optional `clerk-billing` agent skill install") .setExamples([ { command: "clerk enable billing", @@ -496,6 +497,10 @@ Give AI agents better Clerk context: install the Clerk skills command: "clerk enable billing --for org user", description: "Enable billing for both targets", }, + { + command: "clerk enable billing --no-skills", + description: "Enable without installing the agent skill", + }, ]) .action(billingEnable); diff --git a/packages/cli-core/src/commands/billing/README.md b/packages/cli-core/src/commands/billing/README.md index 947b8a51..1d7f138c 100644 --- a/packages/cli-core/src/commands/billing/README.md +++ b/packages/cli-core/src/commands/billing/README.md @@ -35,6 +35,18 @@ clerk enable billing # defaults to both | `--instance ` | Target a specific instance (dev, prod) | | `--yes` | Skip the confirmation prompt | | `--dry-run` | Preview the patch without applying it | +| `--no-skills` | Skip the post-enable `clerk-billing` agent skill install (enable only) | + +## Agent skill + +After a successful `enable billing`, the command offers to install the upstream `clerk-billing` agent skill from [`clerk/skills`](https://github.com/clerk/skills). `clerk init` doesn't bundle this one as a default — billing is opt-in — so this is the natural moment to surface it. + +- **Human mode**: prompts `Install the` `clerk-billing` `agent skill?` defaulting to yes. Decline returns silently. +- **Agent mode (no TTY) or `--yes`**: installs non-interactively (`-y -g`). +- **`--no-skills`**: skips the install entirely. +- **`--dry-run`**: skips the install (no real side-effects in dry-run). + +The install runs via the user's package runner (`bunx`, `pnpm dlx`, `yarn dlx`, or `npx`), matching the `clerk init` flow. ## Cascade behavior diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts index 832612ab..e34ae562 100644 --- a/packages/cli-core/src/commands/billing/index.test.ts +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -18,6 +18,32 @@ mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); +// Stub the skill install primitives so post-enable skill installation is +// observable from tests without spawning a real `bunx skills add` subprocess. +// Tests reset these via `resetSkillStubs()` in beforeEach. +type SkillCall = { source: string; skillNames: readonly string[] }; +const skillCalls: SkillCall[] = []; +let resolveSkillsRunnerStub: () => Promise | unknown = () => ({ + id: "bunx", + display: "bunx", +}); +function resetSkillStubs() { + skillCalls.length = 0; + resolveSkillsRunnerStub = () => ({ id: "bunx", display: "bunx" }); +} +mock.module("../skill/install.ts", () => ({ + resolveSkillsRunner: async () => resolveSkillsRunnerStub(), + runSkillsAdd: async ( + _runner: unknown, + _cwd: string, + source: string, + skillNames: readonly string[], + ) => { + skillCalls.push({ source, skillNames }); + return true; + }, +})); + describe("clerk enable/disable billing", () => { const originalEnv = { ...process.env }; const originalFetch = globalThis.fetch; @@ -39,6 +65,7 @@ describe("clerk enable/disable billing", () => { stubFetch(async () => { return new Response(JSON.stringify({}), { status: 200 }); }); + resetSkillStubs(); }); afterEach(async () => { @@ -287,4 +314,48 @@ describe("clerk enable/disable billing", () => { expect(capturedUrl).toContain("dry_run=true"); expect(captured.err).toContain("[dry-run]"); }); + + // --- enable + clerk-billing skill install --- + + test("enable installs the clerk-billing agent skill in agent mode", async () => { + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org"] })); + + expect(skillCalls).toEqual([{ source: "clerk/skills", skillNames: ["clerk-billing"] }]); + }); + + test("enable --no-skills suppresses the skill install", async () => { + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org"], skills: false })); + + expect(skillCalls).toHaveLength(0); + }); + + test("enable --dry-run does not install the skill", async () => { + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org"], dryRun: true })); + + expect(skillCalls).toHaveLength(0); + }); + + test("enable skips skill install when no runner is available", async () => { + resolveSkillsRunnerStub = () => null; + + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org"] })); + + expect(skillCalls).toHaveLength(0); + }); + + test("disable does not trigger the skill install", async () => { + await setupProfile(); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ for: ["org"] })); + + expect(skillCalls).toHaveLength(0); + }); }); diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts index 5ab4e507..a6818bae 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -1,6 +1,11 @@ import { resolveAppContext } from "../../lib/config.ts"; import { throwUsageError } from "../../lib/errors.ts"; +import { isAgent, isHuman } from "../../mode.ts"; +import { log } from "../../lib/log.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { detectPackageManager } from "../../lib/package-manager.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; +import { resolveSkillsRunner, runSkillsAdd } from "../skill/install.ts"; interface BillingOptions { app?: string; @@ -8,19 +13,13 @@ interface BillingOptions { for?: string[]; yes?: boolean; dryRun?: boolean; + skills?: boolean; } type Target = "org" | "user"; -/** - * Parse the `--for` option, which accepts both variadic and comma-separated - * forms (mirroring `--keys` on `clerk config pull`): - * - * --for org user → ["org", "user"] - * --for org,user → ["org", "user"] - * --for org --for user → ["org", "user"] - * (omitted) → ["org", "user"] (default to both) - */ +// Accepts variadic (`--for org user`), CSV (`--for org,user`), or repeated +// (`--for org --for user`) — mirrors `--keys` on `clerk config pull`. function parseForTargets(values: string[] | undefined): Target[] { if (!values?.length) return ["org", "user"]; const seen = new Set(); @@ -53,8 +52,7 @@ export async function billingEnable(options: BillingOptions): Promise { const payload: Record = { billing }; if (targets.includes("org")) { billing.organization_enabled = true; - // Cascade-enable orgs whenever billing is being turned on for orgs. This - // is idempotent — if orgs are already enabled the diff stays empty. + // Org billing requires orgs enabled; cascade is idempotent. payload.organization_settings = { enabled: true }; } if (targets.includes("user")) { @@ -70,14 +68,49 @@ export async function billingEnable(options: BillingOptions): Promise { yes: options.yes, dryRun: options.dryRun, }); + + // `clerk init` doesn't bundle clerk-billing — it's opt-in. Surface it here. + if (!options.dryRun && options.skills !== false) { + await offerBillingSkillInstall(options); + } +} + +async function offerBillingSkillInstall(options: BillingOptions): Promise { + const skipPrompt = options.yes === true || isAgent(); + + if (isHuman() && !skipPrompt) { + const ok = await confirm({ + message: "Install the `clerk-billing` agent skill? (gives AI agents Clerk billing context)", + default: true, + }); + if (!ok) return; + } + + const interactive = isHuman() && !skipPrompt; + const cwd = process.cwd(); + const runner = await resolveSkillsRunner(await detectPackageManager(cwd), interactive); + if (!runner) return; + + const installed = await runSkillsAdd( + runner, + cwd, + "clerk/skills", + ["clerk-billing"], + interactive, + false, + "clerk-billing", + ); + if (installed) { + log.blank(); + log.success("`clerk-billing` agent skill installed."); + } } export async function billingDisable(options: BillingOptions): Promise { const targets = parseForTargets(options.for); const ctx = await resolveAppContext(options); - // Disabling billing never cascades to disabling organizations — leave - // `organization_settings` untouched per spec. + // No cascade: leave organization_settings untouched. const billing: Record = {}; if (targets.includes("org")) billing.organization_enabled = false; if (targets.includes("user")) billing.user_enabled = false; diff --git a/packages/cli-core/src/commands/config/apply-patch.ts b/packages/cli-core/src/commands/config/apply-patch.ts index 85b0ef8e..0cb8d5f1 100644 --- a/packages/cli-core/src/commands/config/apply-patch.ts +++ b/packages/cli-core/src/commands/config/apply-patch.ts @@ -9,32 +9,17 @@ import { hasConfigChanges, printDiff } from "./push.ts"; export interface ApplyPatchOptions { ctx: { appId: string; instanceId: string; appLabel: string; instanceLabel: string }; payload: Record; - /** Verb shown in dry-run/spinner headlines, e.g. "Enabling organizations". */ verb: string; - /** Final success line in non-dry-run mode, e.g. "Organizations enabled". */ successMessage: string; - /** Context attached to API errors when the PATCH fails. */ failureContext: string; - /** Skip-confirmation flag from the calling command. */ yes?: boolean; - /** Preview-only flag from the calling command. */ dryRun?: boolean; - /** Optional human-mode warning printed just before the confirm prompt. */ warning?: string; - /** - * Pre-fetched current config (e.g. when the caller already inspected it to - * decide on a warning). Skips the extra GET round-trip. - */ + /** Pre-fetched current config; skips the extra GET when caller already has it. */ currentConfig?: Record; } -/** - * Shared flow for the `enable`/`disable` shortcut commands: fetch current - * config, diff against the proposed patch, confirm in human mode, and apply. - * - * Mirrors the safety story from `clerk config patch` (see push.ts) so the - * shortcut commands don't regress on diff/dry-run/confirmation. - */ +/** Fetch + diff + confirm + PATCH, matching `clerk config patch` semantics. */ export async function applyConfigPatch(opts: ApplyPatchOptions): Promise { const { ctx, payload, verb, successMessage, failureContext, yes, dryRun, warning } = opts; diff --git a/packages/cli-core/src/commands/orgs/index.ts b/packages/cli-core/src/commands/orgs/index.ts index 936098d6..99ad3be2 100644 --- a/packages/cli-core/src/commands/orgs/index.ts +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -68,8 +68,8 @@ export async function orgsDisable(options: OrgsOptions): Promise { const billing = current.billing as Record | undefined; const orgBillingOn = billing?.organization_enabled === true; - // Wyatt's note: "warn-then-do" is worse than nothing in CI logs. In agent - // mode (no TTY), refuse and require an explicit override. + // Agent mode: refuse rather than warn-then-mutate (warn-then-mutate in CI + // logs reads as "the warning was heeded" when it wasn't). if (orgBillingOn && !isHuman() && !options.yes) { throwUsageError( "Organization billing is enabled. Disabling organizations would leave `billing.organization_enabled` stranded. " + From 9460de7006b58f444a38761a55b3fbca25edbe6e Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 30 Apr 2026 13:51:08 -0400 Subject: [PATCH 7/8] docs(changeset): tighten enable/disable changeset --- .changeset/billing-orgs-shortcut-commands.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.changeset/billing-orgs-shortcut-commands.md b/.changeset/billing-orgs-shortcut-commands.md index 6c1ad73f..b2cdcb49 100644 --- a/.changeset/billing-orgs-shortcut-commands.md +++ b/.changeset/billing-orgs-shortcut-commands.md @@ -2,9 +2,7 @@ "clerk": minor --- -Add `clerk enable` and `clerk disable` top-level commands for toggling features on the linked instance: +Add `clerk enable` and `clerk disable` top-level commands for toggling features on the linked instance. -- `clerk enable orgs` / `clerk disable orgs` — toggle organizations, with optional `--force-selection`, `--auto-create`, `--max-members `, and `--domains` configuration on enable. -- `clerk enable billing [--for org,user]` / `clerk disable billing [--for org,user]` — toggle billing for organizations and/or users. `--for` defaults to both targets when omitted; enabling for `org` also enables organizations. After a successful enable, offers to install the `clerk-billing` agent skill (suppress with `--no-skills`). - -All commands share the diff-and-confirm safety flow used by `clerk config patch`, including `--dry-run` and `--yes` flags. +- `clerk enable orgs` / `clerk disable orgs` — toggle organizations, with `--force-selection`, `--auto-create`, `--max-members `, and `--domains` on enable. +- `clerk enable billing [--for org,user]` / `clerk disable billing [--for org,user]` — toggle billing for organizations and/or users. `--for` defaults to both; enabling for `org` cascades to enabling organizations. Enable also offers to install the `clerk-billing` agent skill (suppress with `--no-skills`). From 6bf1545f418d0702606572cdcc4a5e142e8e3350 Mon Sep 17 00:00:00 2001 From: Nicolas Angelo Date: Thu, 30 Apr 2026 14:02:04 -0400 Subject: [PATCH 8/8] feat(cli-core): print next-steps hints after clerk enable orgs and enable billing --- packages/cli-core/src/commands/billing/index.ts | 3 +++ packages/cli-core/src/commands/orgs/index.ts | 3 +++ packages/cli-core/src/lib/next-steps.ts | 8 ++++++++ 3 files changed, 14 insertions(+) diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts index a6818bae..99b3700b 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -4,6 +4,7 @@ import { isAgent, isHuman } from "../../mode.ts"; import { log } from "../../lib/log.ts"; import { confirm } from "../../lib/prompts.ts"; import { detectPackageManager } from "../../lib/package-manager.ts"; +import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; import { resolveSkillsRunner, runSkillsAdd } from "../skill/install.ts"; @@ -73,6 +74,8 @@ export async function billingEnable(options: BillingOptions): Promise { if (!options.dryRun && options.skills !== false) { await offerBillingSkillInstall(options); } + + if (!options.dryRun) printNextSteps(NEXT_STEPS.ENABLE_BILLING); } async function offerBillingSkillInstall(options: BillingOptions): Promise { diff --git a/packages/cli-core/src/commands/orgs/index.ts b/packages/cli-core/src/commands/orgs/index.ts index 99ad3be2..b4717c00 100644 --- a/packages/cli-core/src/commands/orgs/index.ts +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -3,6 +3,7 @@ import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { throwUsageError, withApiContext } from "../../lib/errors.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { isHuman } from "../../mode.ts"; +import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; interface OrgsOptions { @@ -53,6 +54,8 @@ export async function orgsEnable(options: OrgsOptions): Promise { yes: options.yes, dryRun: options.dryRun, }); + + if (!options.dryRun) printNextSteps(NEXT_STEPS.ENABLE_ORGS); } export async function orgsDisable(options: OrgsOptions): Promise { diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index 7a2ad2d8..e6402016 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -33,6 +33,14 @@ export const NEXT_STEPS = { "Run `clerk auth login` again to retry auto-claim", "Run `clerk link` to connect your application manually", ], + ENABLE_ORGS: [ + "Run `clerk config schema --keys organization_settings` to see all available settings", + "Run `clerk config pull --keys organization_settings` to see current values", + ], + ENABLE_BILLING: [ + "Run `clerk config schema --keys billing` to see all available settings", + "Run `clerk config pull --keys billing` to see current values", + ], } as const; /**