diff --git a/.changeset/billing-orgs-shortcut-commands.md b/.changeset/billing-orgs-shortcut-commands.md new file mode 100644 index 00000000..b2cdcb49 --- /dev/null +++ b/.changeset/billing-orgs-shortcut-commands.md @@ -0,0 +1,8 @@ +--- +"clerk": minor +--- + +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 `--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`). diff --git a/README.md b/README.md index 21560a6f..14a286de 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Commands: users [options] Manage Clerk users env Manage environment variables config Manage instance configuration + enable Enable Clerk features on the linked instance + disable Disable Clerk features on the linked instance api [options] [endpoint] [filter] Make authenticated requests to the Clerk API doctor [options] Check your project's Clerk integration health completion [shell] Generate shell autocompletion script diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 9ada9d8f..a6d736fb 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -46,6 +46,8 @@ 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 } from "./commands/billing/index.ts"; import { registerExtras } from "@clerk/cli-extras"; const USER_LIST_ORDER_BY_FIELDS = [ @@ -608,6 +610,152 @@ Give AI agents better Clerk context: install the Clerk skills ]) .action(configPut); + // --- clerk enable / disable --- + const enable = program + .command("enable") + .description("Enable Clerk features on the linked instance") + .setExamples([ + { 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 enable billing", + description: "Enable billing for organizations and users", + }, + ]); + + 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)") + .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") + .option("--dry-run", "Show the patch that would be sent without applying it") + .setExamples([ + { command: "clerk enable orgs", description: "Enable organizations" }, + { + command: "clerk enable orgs --force-selection", + description: "Enable and force org selection", + }, + { + 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); + + 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") + .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", + 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 enable billing --no-skills", + description: "Enable without installing the agent skill", + }, + ]) + .action(billingEnable); + + const disable = program + .command("disable") + .description("Disable Clerk features on the linked instance") + .setExamples([ + { command: "clerk disable orgs", description: "Disable organizations" }, + { + command: "clerk disable billing --for org", + description: "Disable billing for organizations only (leaves organizations enabled)", + }, + { + command: "clerk disable billing", + description: "Disable billing for organizations and 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 disable orgs --dry-run", + description: "Preview without applying", + }, + ]) + .action(orgsDisable); + + 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 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(billingDisable); + 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..1d7f138c --- /dev/null +++ b/packages/cli-core/src/commands/billing/README.md @@ -0,0 +1,66 @@ +# clerk billing (enable/disable) + +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 enable billing [--for ] [options] +clerk disable billing [--for ] [options] +``` + +`` 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: + +```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 +``` + +## Options + +| 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 | +| `--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 + +- `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 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 new file mode 100644 index 00000000..3ecf8ff3 --- /dev/null +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -0,0 +1,381 @@ +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(), +})); + +// 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; + 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 }); + }); + resetSkillStubs(); + }); + + 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" }, + }); + } + + // --- enable --- + + 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; + 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); + expect(parsed.billing.user_enabled).toBeUndefined(); + expect(parsed.organization_settings.enabled).toBe(true); + }); + + 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; + 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); + expect(parsed.billing.organization_enabled).toBeUndefined(); + expect(parsed.organization_settings).toBeUndefined(); + }); + + 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; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org,user"] })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBe(true); + expect(parsed.organization_settings.enabled).toBe(true); + }); + + 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; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + 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.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBe(true); + expect(parsed.organization_settings.enabled).toBe(true); + }); + + 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; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({})); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBe(true); + expect(parsed.organization_settings.enabled).toBe(true); + }); + + test("enable rejects invalid --for token", async () => { + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await expect(captured.run(() => billingEnable({ for: ["foo"] }))).rejects.toThrow( + 'Invalid --for value: "foo"', + ); + }); + + test("enable rejects empty --for value", async () => { + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await expect(captured.run(() => billingEnable({ for: [","] }))).rejects.toThrow( + "--for must include at least one of", + ); + }); + + test("enable trims whitespace and dedupes --for tokens", 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 , org , user "] })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.organization_enabled).toBe(true); + expect(parsed.billing.user_enabled).toBe(true); + }); + + 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"); + }); + + 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 { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({ for: ["org"], dryRun: true })); + + expect(capturedUrl).toContain("dry_run=true"); + expect(captured.err).toContain("[dry-run]"); + }); + + // --- disable --- + + 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; + 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); + expect(parsed.billing.user_enabled).toBeUndefined(); + expect(parsed.organization_settings).toBeUndefined(); + }); + + 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; + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await setupProfile(); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ for: ["user"] })); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.user_enabled).toBe(false); + expect(parsed.billing.organization_enabled).toBeUndefined(); + expect(parsed.organization_settings).toBeUndefined(); + }); + + test("disable with no --for defaults to both targets and never cascades to orgs", 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, user_enabled: true }, + organization_settings: { enabled: true }, + }), + { status: 200 }, + ); + }); + + await setupProfile(); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({})); + + const parsed = JSON.parse(capturedBody); + expect(parsed.billing.organization_enabled).toBe(false); + expect(parsed.billing.user_enabled).toBe(false); + expect(parsed.organization_settings).toBeUndefined(); + }); + + test("disable shows success message", async () => { + await setupProfile(); + const { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ for: ["org"] })); + + expect(captured.err).toContain("Billing disabled for organizations"); + }); + + 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 { billingDisable } = await import("./index.ts"); + await captured.run(() => billingDisable({ dryRun: true })); + + 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); + }); + + test("enable on an already-configured instance skips skill install and next-steps", async () => { + stubFetch(async () => { + return new Response( + JSON.stringify({ + billing: { organization_enabled: true, user_enabled: true }, + organization_settings: { enabled: true }, + }), + { status: 200 }, + ); + }); + + await setupProfile(); + const { billingEnable } = await import("./index.ts"); + await captured.run(() => billingEnable({})); + + expect(skillCalls).toHaveLength(0); + expect(captured.err).toContain("No changes detected"); + expect(captured.err).not.toContain("clerk config schema --keys billing"); + }); +}); 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..1f1ec7a5 --- /dev/null +++ b/packages/cli-core/src/commands/billing/index.ts @@ -0,0 +1,129 @@ +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 { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { applyConfigPatch } from "../config/apply-patch.ts"; +import { resolveSkillsRunner, runSkillsAdd } from "../skill/install.ts"; + +interface BillingOptions { + app?: string; + instance?: string; + for?: string[]; + yes?: boolean; + dryRun?: boolean; + skills?: boolean; +} + +type Target = "org" | "user"; + +// 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(); + 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 (seen.size === 0) { + throwUsageError('--for must include at least one of: "org", "user".'); + } + return [...seen]; +} + +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 billingEnable(options: BillingOptions): Promise { + const targets = parseForTargets(options.for); + const ctx = await resolveAppContext(options); + + const billing: Record = {}; + const payload: Record = { billing }; + if (targets.includes("org")) { + billing.organization_enabled = true; + // Org billing requires orgs enabled; cascade is idempotent. + payload.organization_settings = { enabled: true }; + } + if (targets.includes("user")) { + billing.user_enabled = true; + } + + const applied = 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, + }); + + if (!applied || options.dryRun) return; + + // `clerk init` doesn't bundle clerk-billing — it's opt-in. Surface it here. + if (options.skills !== false) await offerBillingSkillInstall(options); + printNextSteps(NEXT_STEPS.ENABLE_BILLING); +} + +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); + + // 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; + + 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..b3368fee --- /dev/null +++ b/packages/cli-core/src/commands/config/apply-patch.ts @@ -0,0 +1,65 @@ +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: string; + successMessage: string; + failureContext: string; + yes?: boolean; + dryRun?: boolean; + warning?: string; + /** Pre-fetched current config; skips the extra GET when caller already has it. */ + currentConfig?: Record; +} + +/** 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; + + 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 false; + } + + 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); + + // Warning prints whenever it's set, even when --yes or agent mode skips the + // prompt — the warning is an audit signal, not a confirmation cue. + if (warning) log.warn(warning); + + if (!dryRun && isHuman() && !yes) { + 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); + return true; +} 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..cbc4098b --- /dev/null +++ b/packages/cli-core/src/commands/orgs/README.md @@ -0,0 +1,52 @@ +# clerk orgs (enable/disable) + +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 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 (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` + +| Flag | Description | +| ----------------- | -------------------------------------- | +| `--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 | + +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 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 new file mode 100644 index 00000000..84a90250 --- /dev/null +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -0,0 +1,303 @@ +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 enable/disable 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" }, + }); + } + + // --- 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 as integer", 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 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) => { + 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 --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"); + await captured.run(() => orgsEnable({})); + + 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"); + }); + + // --- 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 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 === "PATCH") patchCalls++; + return new Response(JSON.stringify({ billing: { organization_enabled: true } }), { + status: 200, + }); + }); + + await setupProfile(); + const { orgsDisable } = await import("./index.ts"); + await expect(captured.run(() => orgsDisable({}))).rejects.toThrow( + "Organization billing is enabled", + ); + expect(patchCalls).toBe(0); + }); + + test("disable with --yes still prints the stranded-billing warning before patching", 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 })); + + expect(captured.err).toContain("Organization billing is currently enabled"); + const parsed = JSON.parse(capturedBody); + expect(parsed.organization_settings.enabled).toBe(false); + }); + + 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 when billing is off", 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..89627c92 --- /dev/null +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -0,0 +1,96 @@ +import { resolveAppContext } from "../../lib/config.ts"; +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 { + app?: string; + instance?: string; + forceSelection?: boolean; + autoCreate?: boolean; + 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 orgSettings: Record = { enabled: true }; + if (options.forceSelection) orgSettings.force_organization_selection = true; + if (options.domains) orgSettings.domains_enabled = true; + if (options.autoCreate) { + orgSettings.organization_creation_defaults = { + automatic_organization_creation: { enabled: true }, + }; + } + if (options.maxMembers !== undefined) { + orgSettings.max_allowed_memberships = parsePositiveInt(options.maxMembers, "--max-members"); + } + + const applied = await applyConfigPatch({ + ctx, + payload: { organization_settings: orgSettings }, + verb: "Enabling organizations", + successMessage: "Organizations enabled", + failureContext: "Failed to enable organizations", + yes: options.yes, + dryRun: options.dryRun, + }); + + if (applied && !options.dryRun) printNextSteps(NEXT_STEPS.ENABLE_ORGS); +} + +export async function orgsDisable(options: OrgsOptions): Promise { + const ctx = await resolveAppContext(options); + + const current = await withSpinner("Fetching current config...", () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing", "organization_settings"]), + "Failed to fetch config", + ), + ); + + const billing = current.billing as Record | undefined; + const orgBillingOn = billing?.organization_enabled === true; + + // 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. " + + "Run `clerk disable billing --for org` first, or pass --yes to override.", + ); + } + + 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, + }); +} 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; /**