diff --git a/.changeset/clerk-branch-commands.md b/.changeset/clerk-branch-commands.md new file mode 100644 index 00000000..d1c81b01 --- /dev/null +++ b/.changeset/clerk-branch-commands.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk branch` commands (create/list/delete/diff) and `--branch ` targeting for `config` and `env` commands, plus `createBranch`/`deleteInstance` Platform API client methods. diff --git a/README.md b/README.md index e372cef3..214857d4 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Commands: users [options] Manage Clerk users env Manage environment variables config Manage instance configuration + branch Fork, list, diff, and delete instance branches 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 diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a8e7f113..aa17cf01 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -12,6 +12,7 @@ import { registerApps } from "./commands/apps/index.ts"; import { registerUsers } from "./commands/users/index.ts"; import { registerEnv } from "./commands/env/index.ts"; import { registerConfig } from "./commands/config/index.ts"; +import { registerBranch } from "./commands/branch/index.ts"; import { registerToggles } from "./commands/toggles/index.ts"; import { registerApi } from "./commands/api/index.ts"; import { registerDoctor } from "./commands/doctor/index.ts"; @@ -62,6 +63,7 @@ const registrants: CommandRegistrant[] = [ registerUsers, registerEnv, registerConfig, + registerBranch, registerToggles, registerApi, registerDoctor, diff --git a/packages/cli-core/src/commands/branch/README.md b/packages/cli-core/src/commands/branch/README.md new file mode 100644 index 00000000..a7afdb6b --- /dev/null +++ b/packages/cli-core/src/commands/branch/README.md @@ -0,0 +1,95 @@ +# clerk branch + +Manage Clerk instance branches. Branches are development instances forked from an existing instance, enabling isolated configuration experiments without affecting production or the primary development environment. + +## Subcommands + +### `clerk branch create --name [--from ] [--app ]` + +Forks an existing instance into a new named branch. By default clones from the `production` instance. + +Options: + +- `--name ` (required) — Name for the new branch +- `--from ` — Source instance to clone from (`production`, `development`, or a literal instance ID). Defaults to `production`. +- `--app ` — Target application ID (overrides linked project) + +Platform API: `POST /v1/platform/applications/{appId}/instances` + +Agent mode output: + +```json +{ + "status": "created", + "branch_name": "feature-auth", + "instance_id": "ins_abc123", + "parent_instance_id": "ins_prod456" +} +``` + +### `clerk branch list [--app ]` + +Lists all branches for the linked or specified application. Only instances with a `branch_name` are shown. + +Options: + +- `--app ` — Target application ID (overrides linked project) + +Platform API: `GET /v1/platform/applications/{appId}` + +Human output: one line per branch, tab-separated `branch_name\tinstance_id`. + +Agent mode output: + +```json +{ + "branches": [ + { + "branch_name": "feature-auth", + "instance_id": "ins_abc123", + "parent_instance_id": "ins_dev456" + } + ] +} +``` + +### `clerk branch delete [--app ]` + +Deletes a named branch instance. The branch is looked up by name from the application's instance list. + +Options: + +- `` (positional, required) — Name of the branch to delete +- `--app ` — Target application ID (overrides linked project) + +Platform API: + +1. `GET /v1/platform/applications/{appId}` — resolve branch name to instance ID +2. `DELETE /v1/platform/applications/{appId}/instances/{instanceId}` — delete the instance + +Agent mode output: + +```json +{ + "status": "deleted", + "branch_name": "feature-auth", + "instance_id": "ins_abc123" +} +``` + +### `clerk branch diff [--against ] [--app ]` + +Shows a configuration diff between a branch and another instance (defaults to `production`). + +Options: + +- `` (positional, required) — Name of the branch to diff +- `--against ` — Instance to compare against (`production`, `development`, or a literal instance ID). Defaults to `production`. +- `--app ` — Target application ID (overrides linked project) + +Platform API: + +1. `GET /v1/platform/applications/{appId}/instances/{branchInstanceId}/config` +2. `GET /v1/platform/applications/{appId}/instances/{parentInstanceId}/config` + +Output: a human-readable diff of changed leaf values, grouped by top-level config key. No agent-mode JSON — the diff is always rendered as text. diff --git a/packages/cli-core/src/commands/branch/create.ts b/packages/cli-core/src/commands/branch/create.ts new file mode 100644 index 00000000..cbb1c041 --- /dev/null +++ b/packages/cli-core/src/commands/branch/create.ts @@ -0,0 +1,39 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { createBranch } from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { log } from "../../lib/log.ts"; + +interface BranchCreateOptions { + app?: string; + name: string; + from?: string; +} + +export async function branchCreate(options: BranchCreateOptions): Promise { + const ctx = await resolveAppContext({ app: options.app, instance: options.from ?? "production" }); + const branch = await withSpinner(`Forking ${ctx.instanceLabel} → ${options.name}...`, () => + withApiContext( + createBranch(ctx.appId, { cloneInstanceId: ctx.instanceId, branchName: options.name }), + "Failed to create branch", + ), + ); + + if (isAgent()) { + log.data( + JSON.stringify( + { + status: "created", + branch_name: options.name, + instance_id: branch.id, + parent_instance_id: ctx.instanceId, + }, + null, + 2, + ), + ); + return; + } + log.success(`Forked \`${ctx.instanceLabel}\` → \`${options.name}\` (${branch.id})`); +} diff --git a/packages/cli-core/src/commands/branch/delete.ts b/packages/cli-core/src/commands/branch/delete.ts new file mode 100644 index 00000000..5342e2ab --- /dev/null +++ b/packages/cli-core/src/commands/branch/delete.ts @@ -0,0 +1,36 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { fetchApplication, deleteInstance } from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; +import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { log } from "../../lib/log.ts"; + +interface BranchDeleteOptions { + app?: string; + name: string; +} + +export async function branchDelete(options: BranchDeleteOptions): Promise { + const ctx = await resolveAppContext({ app: options.app }); + const app = await withApiContext(fetchApplication(ctx.appId), "Failed to resolve branch"); + const match = app.instances.find((i) => i.branch_name === options.name); + if (!match) { + throw new CliError(`No branch named "${options.name}".`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + await withSpinner(`Deleting ${options.name}...`, () => + withApiContext(deleteInstance(ctx.appId, match.instance_id), "Failed to delete branch"), + ); + if (isAgent()) { + log.data( + JSON.stringify( + { status: "deleted", branch_name: options.name, instance_id: match.instance_id }, + null, + 2, + ), + ); + return; + } + log.success(`Deleted branch \`${options.name}\``); +} diff --git a/packages/cli-core/src/commands/branch/diff.ts b/packages/cli-core/src/commands/branch/diff.ts new file mode 100644 index 00000000..ddc81fcb --- /dev/null +++ b/packages/cli-core/src/commands/branch/diff.ts @@ -0,0 +1,36 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { fetchInstanceConfig } from "../../lib/plapi.ts"; +import { printDiff } from "../config/push.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { withSpinner } from "../../lib/spinner.ts"; + +interface BranchDiffOptions { + app?: string; + name: string; + against?: string; +} + +export async function branchDiff(options: BranchDiffOptions): Promise { + const branchCtx = await resolveAppContext({ app: options.app, branch: options.name }); + const parentCtx = await resolveAppContext({ + app: options.app, + instance: options.against ?? "production", + }); + const [parentConfig, branchConfig] = await withSpinner( + `Diffing ${options.name} against ${parentCtx.instanceLabel}...`, + () => + Promise.all([ + withApiContext( + fetchInstanceConfig(parentCtx.appId, parentCtx.instanceId), + "Failed to fetch parent config", + ), + withApiContext( + fetchInstanceConfig(branchCtx.appId, branchCtx.instanceId), + "Failed to fetch branch config", + ), + ]), + ); + delete (parentConfig as Record).config_version; + delete (branchConfig as Record).config_version; + printDiff(parentConfig, branchConfig, false); +} diff --git a/packages/cli-core/src/commands/branch/index.ts b/packages/cli-core/src/commands/branch/index.ts new file mode 100644 index 00000000..fe4fb9f7 --- /dev/null +++ b/packages/cli-core/src/commands/branch/index.ts @@ -0,0 +1,52 @@ +import type { Program } from "../../cli-program.ts"; +import { branchCreate } from "./create.ts"; +import { branchList } from "./list.ts"; +import { branchDelete } from "./delete.ts"; +import { branchDiff } from "./diff.ts"; + +export function registerBranch(program: Program): void { + const branch = program + .command("branch") + .description("Fork, list, diff, and delete instance branches") + .setExamples([ + { + command: "clerk branch create --name agent/pr-42 --from production", + description: "Fork production into a branch", + }, + { command: "clerk branch list", description: "List branches" }, + { + command: "clerk branch diff agent/pr-42 --against prod", + description: "Diff a branch against production", + }, + { command: "clerk branch delete agent/pr-42", description: "Delete a branch" }, + ]); + + branch + .command("create") + .description("Fork an instance into a new branch (a development instance)") + .requiredOption("--name ", "Branch name (e.g. agent/pr-42)") + .option("--from ", "Parent instance to fork (dev, prod, or instance ID)", "production") + .option("--app ", "Application ID to target (works from any directory)") + .action(branchCreate); + + branch + .command("list") + .description("List branches for the application") + .option("--app ", "Application ID to target (works from any directory)") + .action(branchList); + + branch + .command("delete") + .description("Delete a branch") + .argument("", "Branch name") + .option("--app ", "Application ID to target (works from any directory)") + .action((name, opts) => branchDelete({ ...opts, name })); + + branch + .command("diff") + .description("Diff a branch's config against another instance") + .argument("", "Branch name") + .option("--against ", "Instance to diff against (default: production)", "production") + .option("--app ", "Application ID to target (works from any directory)") + .action((name, opts) => branchDiff({ ...opts, name })); +} diff --git a/packages/cli-core/src/commands/branch/list.test.ts b/packages/cli-core/src/commands/branch/list.test.ts new file mode 100644 index 00000000..140716db --- /dev/null +++ b/packages/cli-core/src/commands/branch/list.test.ts @@ -0,0 +1,163 @@ +import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { captureLog } from "../../test/lib/stubs.ts"; + +const mockFetchApplication = mock(); +mock.module("../../lib/plapi.ts", () => ({ + fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + PlapiError: class PlapiError extends Error {}, +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +mock.module("../../lib/config.ts", () => ({ + resolveAppContext: async () => ({ + appId: "app_test123", + appLabel: "Test App", + instanceId: "ins_dev", + instanceLabel: "development", + }), +})); + +const { branchList } = await import("./list.ts"); + +const mockAppWithBranches = { + application_id: "app_test123", + name: "Test App", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aaa", + }, + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_bbb", + }, + { + instance_id: "ins_branch1", + environment_type: "development", + publishable_key: "pk_test_ccc", + branch_name: "feature-auth", + parent_instance_id: "ins_dev", + }, + { + instance_id: "ins_branch2", + environment_type: "development", + publishable_key: "pk_test_ddd", + branch_name: "fix-email", + parent_instance_id: "ins_dev", + }, + ], +}; + +describe("branch list", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + let captured: ReturnType; + + beforeEach(() => { + mockIsAgent.mockReturnValue(false); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + captured = captureLog(); + }); + + afterEach(() => { + captured.teardown(); + mockFetchApplication.mockReset(); + mockIsAgent.mockReset(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function runList(options: Parameters[0] = {}) { + return captured.run(() => branchList(options)); + } + + describe("agent mode JSON output", () => { + test("outputs JSON with only branch instances", async () => { + mockIsAgent.mockReturnValue(true); + mockFetchApplication.mockResolvedValue(mockAppWithBranches); + + await runList(); + + const parsed = JSON.parse(captured.out); + expect(parsed.branches).toHaveLength(2); + expect(parsed.branches[0].branch_name).toBe("feature-auth"); + expect(parsed.branches[0].instance_id).toBe("ins_branch1"); + expect(parsed.branches[0].parent_instance_id).toBe("ins_dev"); + expect(parsed.branches[1].branch_name).toBe("fix-email"); + }); + + test("excludes instances without branch_name", async () => { + mockIsAgent.mockReturnValue(true); + mockFetchApplication.mockResolvedValue(mockAppWithBranches); + + await runList(); + + const parsed = JSON.parse(captured.out); + // Should only include the 2 branch instances, not dev or prod + for (const b of parsed.branches) { + expect(b.branch_name).toBeDefined(); + expect(b.instance_id).not.toBe("ins_dev"); + expect(b.instance_id).not.toBe("ins_prod"); + } + }); + + test("outputs empty branches array when no branches exist", async () => { + mockIsAgent.mockReturnValue(true); + mockFetchApplication.mockResolvedValue({ + ...mockAppWithBranches, + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aaa", + }, + ], + }); + + await runList(); + + const parsed = JSON.parse(captured.out); + expect(parsed.branches).toEqual([]); + }); + }); + + describe("human mode output", () => { + test("prints branch name and instance id to stdout", async () => { + mockFetchApplication.mockResolvedValue(mockAppWithBranches); + + await runList(); + + expect(captured.out).toContain("feature-auth"); + expect(captured.out).toContain("ins_branch1"); + expect(captured.out).toContain("fix-email"); + expect(captured.out).toContain("ins_branch2"); + }); + + test("shows info message when no branches exist", async () => { + mockFetchApplication.mockResolvedValue({ + ...mockAppWithBranches, + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_aaa", + }, + ], + }); + + await runList(); + + expect(captured.err).toContain("No branches."); + }); + }); +}); diff --git a/packages/cli-core/src/commands/branch/list.ts b/packages/cli-core/src/commands/branch/list.ts new file mode 100644 index 00000000..03454500 --- /dev/null +++ b/packages/cli-core/src/commands/branch/list.ts @@ -0,0 +1,31 @@ +import { resolveAppContext } from "../../lib/config.ts"; +import { fetchApplication } from "../../lib/plapi.ts"; +import { isAgent } from "../../mode.ts"; +import { withApiContext } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; + +interface BranchListOptions { + app?: string; +} + +export async function branchList(options: BranchListOptions): Promise { + const ctx = await resolveAppContext({ app: options.app }); + const app = await withApiContext(fetchApplication(ctx.appId), "Failed to list branches"); + const branches = app.instances + .filter((i) => i.branch_name) + .map((i) => ({ + branch_name: i.branch_name!, + instance_id: i.instance_id, + parent_instance_id: i.parent_instance_id, + })); + + if (isAgent()) { + log.data(JSON.stringify({ branches }, null, 2)); + return; + } + if (branches.length === 0) { + log.info("No branches."); + return; + } + for (const b of branches) log.data(`${b.branch_name}\t${b.instance_id}`); +} diff --git a/packages/cli-core/src/commands/config/index.ts b/packages/cli-core/src/commands/config/index.ts index 0c4b0aeb..1c778385 100644 --- a/packages/cli-core/src/commands/config/index.ts +++ b/packages/cli-core/src/commands/config/index.ts @@ -43,6 +43,7 @@ export function registerConfig(program: Program): void { .description("Pull instance configuration from Clerk") .option("--app ", "Application ID to target (works from any directory)") .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--branch ", "Target a branch by name (e.g. agent/pr-42)") .option("--output ", "Write config to a file instead of stdout") .option("--json", "Output JSON instead of the default YAML") .option( @@ -82,6 +83,7 @@ export function registerConfig(program: Program): void { .description("Partially update instance configuration (PATCH)") .option("--app ", "Application ID to target (works from any directory)") .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--branch ", "Target a branch by name (e.g. agent/pr-42)") .option("--file ", "Read config JSON from a file") .option("--json ", "Pass config JSON inline") .option("--dry-run", "Show what would be sent without making the API call") @@ -115,6 +117,7 @@ export function registerConfig(program: Program): void { .description("Replace entire instance configuration (PUT)") .option("--app ", "Application ID to target (works from any directory)") .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--branch ", "Target a branch by name (e.g. agent/pr-42)") .option("--file ", "Read config JSON from a file") .option("--json ", "Pass config JSON inline") .option("--dry-run", "Show what would be sent without making the API call") diff --git a/packages/cli-core/src/commands/config/pull.ts b/packages/cli-core/src/commands/config/pull.ts index ebd9f70e..9e9a56e7 100644 --- a/packages/cli-core/src/commands/config/pull.ts +++ b/packages/cli-core/src/commands/config/pull.ts @@ -8,6 +8,7 @@ import { stringify as stringifyYaml } from "yaml"; interface ConfigPullOptions { app?: string; instance?: string; + branch?: string; output?: string; keys?: string[]; json?: boolean; diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index 901dc2fe..8eea6df9 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -20,6 +20,7 @@ import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; interface ConfigPushOptions { app?: string; instance?: string; + branch?: string; file?: string; json?: string; dryRun?: boolean; diff --git a/packages/cli-core/src/commands/env/index.ts b/packages/cli-core/src/commands/env/index.ts index 384c46c8..0db367a0 100644 --- a/packages/cli-core/src/commands/env/index.ts +++ b/packages/cli-core/src/commands/env/index.ts @@ -17,6 +17,7 @@ export function registerEnv(program: Program): void { .description("Pull environment variables from Clerk into your local env file") .option("--app ", "Application ID to target (works from any directory)") .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option("--branch ", "Target a branch by name (e.g. agent/pr-42)") .option("--file ", "Target env file (default: auto-detect)") .setExamples([ { command: "clerk env pull", description: "Pull dev keys to .env.local" }, diff --git a/packages/cli-core/src/lib/config.test.ts b/packages/cli-core/src/lib/config.test.ts index 25049b14..3003c3a2 100644 --- a/packages/cli-core/src/lib/config.test.ts +++ b/packages/cli-core/src/lib/config.test.ts @@ -255,6 +255,60 @@ describe("config", () => { } }); + test("selects the canonical development instance by default when branch instances are listed first", () => { + const branchFirstApp = { + application_id: "app_123", + instances: [ + { + instance_id: "ins_branch", + environment_type: "development", + publishable_key: "pk_test_branch", + branch_name: "agent/pr-42", + }, + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_dev", + }, + ], + }; + + const result = resolveFetchedApplicationInstance("app_123", branchFirstApp); + + expect(result).toMatchObject({ + found: true, + instanceId: "ins_dev", + instanceLabel: "development", + }); + }); + + test("selects the canonical development instance for dev aliases when branch instances are listed first", () => { + const branchFirstApp = { + application_id: "app_123", + instances: [ + { + instance_id: "ins_branch", + environment_type: "development", + publishable_key: "pk_test_branch", + branch_name: "agent/pr-42", + }, + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_dev", + }, + ], + }; + + const result = resolveFetchedApplicationInstance("app_123", branchFirstApp, "dev"); + + expect(result).toMatchObject({ + found: true, + instanceId: "ins_dev", + instanceLabel: "development", + }); + }); + test("returns explicit missing state for unknown literal instance ids", () => { const result = resolveFetchedApplicationInstance("app_123", app, "ins_missing_123"); @@ -264,6 +318,39 @@ describe("config", () => { instanceLabel: "ins_missing_123", }); }); + + test("matches a branch by name", () => { + const branchApp = { + application_id: "app_1", + instances: [ + { instance_id: "ins_prod", environment_type: "production", publishable_key: "pk_live_x" }, + { + instance_id: "ins_branch", + environment_type: "development", + publishable_key: "pk_test_y", + branch_name: "agent/pr-42", + }, + ], + }; + + const r = resolveFetchedApplicationInstance("app_1", branchApp, undefined, "agent/pr-42"); + expect(r.found).toBe(true); + expect(r.instanceId).toBe("ins_branch"); + expect(r.instanceLabel).toBe("agent/pr-42"); + }); + + test("throws INSTANCE_NOT_FOUND when branch name does not match any instance", () => { + const branchApp = { + application_id: "app_1", + instances: [ + { instance_id: "ins_prod", environment_type: "production", publishable_key: "pk_live_x" }, + ], + }; + + expect(() => + resolveFetchedApplicationInstance("app_1", branchApp, undefined, "no-such-branch"), + ).toThrow("No branch named"); + }); }); describe("resolveAppContext (explicit app)", () => { diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 9dd85de6..0dc23f7f 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -232,6 +232,10 @@ const INSTANCE_ALIASES: Record = { production: "production", }; +function isPrimaryInstance(entry: ApplicationInstance): boolean { + return !entry.branch_name; +} + export function resolveInstanceId(profile: Profile, flag?: string): { id: string; label: string } { if (!flag) { return { id: profile.instances.development, label: "development" }; @@ -253,6 +257,7 @@ export function resolveInstanceId(profile: Profile, flag?: string): { id: string interface AppContextOptions { app?: string; instance?: string; + branch?: string; cwd?: string; } @@ -260,13 +265,31 @@ export function resolveFetchedApplicationInstance( appId: string, app: Application, instance?: string, + branch?: string, ): | { found: true; instance: ApplicationInstance; instanceId: string; instanceLabel: string } | { found: false; instanceId: string; instanceLabel: string } { + if (branch) { + const matched = app.instances.find((entry) => entry.branch_name === branch); + if (!matched) { + throw new CliError(`No branch named "${branch}" found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + instanceLabel: branch, + }; + } + if (instance) { const env = INSTANCE_ALIASES[instance]; if (env) { - const matched = app.instances.find((entry) => entry.environment_type === env); + const matched = app.instances.find( + (entry) => entry.environment_type === env && isPrimaryInstance(entry), + ); if (!matched) { throw new CliError(`No ${env} instance found for application ${appId}.`, { code: ERROR_CODE.INSTANCE_NOT_FOUND, @@ -297,7 +320,9 @@ export function resolveFetchedApplicationInstance( }; } - const development = app.instances.find((entry) => entry.environment_type === "development"); + const development = app.instances.find( + (entry) => entry.environment_type === "development" && isPrimaryInstance(entry), + ); if (!development) { throw new CliError(`No development instance found for application ${appId}.`, { code: ERROR_CODE.INSTANCE_NOT_FOUND, @@ -326,7 +351,12 @@ export async function resolveAppContext( const { fetchApplication } = await import("./plapi.ts"); const app = await fetchApplication(options.app); const appLabel = app.name || options.app; - const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); + const resolved = resolveFetchedApplicationInstance( + options.app, + app, + options.instance, + options.branch, + ); if (!resolved.found) { throw new CliError( `Instance ${resolved.instanceId} not found in application ${options.app}.`, @@ -353,6 +383,23 @@ export async function resolveAppContext( ); } + if (options.branch) { + const { fetchApplication } = await import("./plapi.ts"); + const app = await fetchApplication(resolved.profile.appId); + const r = resolveFetchedApplicationInstance( + resolved.profile.appId, + app, + undefined, + options.branch, + ); + return { + appId: resolved.profile.appId, + appLabel: resolved.profile.appName || resolved.profile.appId, + instanceId: r.instanceId, + instanceLabel: r.instanceLabel, + }; + } + const instance = resolveInstanceId(resolved.profile, options.instance); return { appId: resolved.profile.appId, diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index 954ac2ee..78df5369 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -366,9 +366,20 @@ describe("expandInputJson", () => { expect(result.error).toContain("must be a JSON object"); }); - test("auto-stdin handles camelCase keys", async () => { - const result = await expandViaStdin(["clerk", "config", "patch"], '{"dryRun":true}'); + test("explicit --input-json - handles camelCase keys for config payload commands", async () => { + const result = await expandViaStdin( + ["clerk", "config", "patch", "--input-json", "-"], + '{"dryRun":true}', + ); expect(result.result).toEqual(["clerk", "config", "patch", "--dry-run"]); }); + + test("auto-stdin leaves config patch YAML for the command payload reader", async () => { + const result = await expandViaStdin( + ["clerk", "config", "patch"], + "session:\n lifetime: 3600\n", + ); + expect(result.result).toEqual(["clerk", "config", "patch"]); + }); }); }); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts index bcc37281..54e67fc6 100644 --- a/packages/cli-core/src/lib/input-json.ts +++ b/packages/cli-core/src/lib/input-json.ts @@ -3,6 +3,10 @@ import { throwUsageError, ERROR_CODE } from "./errors.ts"; const INPUT_JSON_FLAG = "--input-json"; const FILE_PREFIX = "@"; const STDIN_MARKER = "-"; +const STDIN_PAYLOAD_COMMANDS = [ + ["config", "patch"], + ["config", "put"], +] as const; type JsonObject = Record; @@ -128,6 +132,16 @@ function hasStdinPipe(): boolean { return !process.stdin.isTTY; } +function commandPathAppears(argv: string[], commandPath: readonly string[]): boolean { + return argv.some((_, index) => + commandPath.every((segment, offset) => argv[index + offset] === segment), + ); +} + +function commandReadsPayloadFromStdin(argv: string[]): boolean { + return STDIN_PAYLOAD_COMMANDS.some((commandPath) => commandPathAppears(argv, commandPath)); +} + /** * Process an argv array: find `--input-json`, expand JSON to flags, return * a new argv with the expanded flags spliced in (so explicit CLI flags that @@ -151,6 +165,10 @@ export async function expandInputJson(argv: string[]): Promise { return argv; } + if (commandReadsPayloadFromStdin(argv)) { + return argv; + } + // No explicit --input-json flag — check for piped stdin if (hasStdinPipe()) { const jsonStr = await readOptionalStdin(); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 53f78ba2..5089e4e6 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -153,6 +153,8 @@ export interface ApplicationInstance { environment_type: string; secret_key?: string; publishable_key: string; + branch_name?: string; + parent_instance_id?: string; } export interface Application { @@ -331,3 +333,39 @@ export async function listApplications(): Promise { const response = await plapiFetch("GET", url); return response.json() as Promise; } + +export interface CreatedInstance { + object: string; + id: string; + environment_type: string; + branch_name?: string; + secret_key: string; + publishable_key: string; +} + +export async function createBranch( + applicationId: string, + params: { cloneInstanceId: string; branchName: string }, +): Promise { + const url = new URL(`/v1/platform/applications/${applicationId}/instances`, getPlapiBaseUrl()); + const response = await plapiFetch("POST", url, { + body: JSON.stringify({ + environment_type: "development", + clone_instance_id: params.cloneInstanceId, + branch_name: params.branchName, + }), + }); + return response.json() as Promise; +} + +export async function deleteInstance( + applicationId: string, + instanceId: string, +): Promise> { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${instanceId}`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("DELETE", url); + return response.json() as Promise>; +}