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. 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) 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.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/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.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"); + }); +}); 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"); +}