Skip to content
Draft
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Commands:
completion [shell] Generate shell autocompletion script
skill Manage the bundled Clerk CLI agent skill
update [options] Update the Clerk CLI to the latest version
deploy [options] Deploy a Clerk application to production
help [command] Display help for command

Give AI agents better Clerk context: install the Clerk skills
Expand Down
13 changes: 13 additions & 0 deletions packages/cli-core/src/cli-program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ test("users list exposes common filters and pagination options", () => {
);
});

test("deploy exposes the expected options", () => {
const program = createProgram();
const deploy = program.commands.find((command) => command.name() === "deploy")!;
const optionNames = deploy.options.map((option) => option.long);

expect(optionNames).toEqual([
"--debug",
"--test-force-production-instance",
"--test-fail-production-instance-check",
"--test-fail-domain-lookup",
]);
});

describe("parseIntegerOption (via users list --limit / --offset)", () => {
function parseUsersList(args: readonly string[]) {
return createProgram().parseAsync(["users", "list", ...args], { from: "user" });
Expand Down
29 changes: 27 additions & 2 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@ import {
PlapiError,
FapiError,
EXIT_CODE,
isPromptExitError,
throwUsageError,
} from "./lib/errors.ts";
import { clerkHelpConfig } from "./lib/help.ts";
import { ExitPromptError } from "@inquirer/core";
import { isAgent } from "./mode.ts";
import { log } from "./lib/log.ts";
import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts";
import { update } from "./commands/update/index.ts";
import { deploy } from "./commands/deploy/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";
Expand Down Expand Up @@ -917,6 +918,30 @@ Tutorial — enable completions for your shell:
])
.action(update);

program
.command("deploy")
.description("Deploy a Clerk application to production")
.option("--debug", "Show detailed deployment debug output")
.addOption(
createOption(
"--test-force-production-instance",
"Force deploy to use a mocked production instance",
),
)
.addOption(
createOption(
"--test-fail-production-instance-check",
"Simulate a deploy failure while checking for a production instance",
),
)
.addOption(
createOption(
"--test-fail-domain-lookup",
"Simulate a deploy failure while loading the production domain",
),
)
.action(deploy);

registerExtras(program);

return program;
Expand Down Expand Up @@ -1004,7 +1029,7 @@ export async function runProgram(
} catch (error) {
const verbose = program.opts().verbose ?? false;

if (error instanceof UserAbortError || error instanceof ExitPromptError) {
if (error instanceof UserAbortError || isPromptExitError(error)) {
process.exit(EXIT_CODE.SUCCESS);
}

Expand Down
223 changes: 89 additions & 134 deletions packages/cli-core/src/commands/deploy/README.md

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions packages/cli-core/src/commands/deploy/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { test, expect, describe, beforeEach, mock } from "bun:test";

const mockPlapiCreateProductionInstance = mock();
const mockPlapiValidateCloning = mock();
const mockPlapiGetDeployStatus = mock();
const mockPlapiPatchInstanceConfig = mock();
const mockPlapiRetryApplicationDomainSSL = mock();
const mockPlapiRetryApplicationDomainMail = mock();

mock.module("../../lib/plapi.ts", () => ({
createProductionInstance: (...args: unknown[]) => mockPlapiCreateProductionInstance(...args),
validateCloning: (...args: unknown[]) => mockPlapiValidateCloning(...args),
getDeployStatus: (...args: unknown[]) => mockPlapiGetDeployStatus(...args),
patchInstanceConfig: (...args: unknown[]) => mockPlapiPatchInstanceConfig(...args),
retryApplicationDomainSSL: (...args: unknown[]) => mockPlapiRetryApplicationDomainSSL(...args),
retryApplicationDomainMail: (...args: unknown[]) => mockPlapiRetryApplicationDomainMail(...args),
}));

const deployApiModulePath = "./api.ts?adapter-test";
const apiModule = (await import(deployApiModulePath)) as typeof import("./api.ts");
const {
createProductionInstance,
getDeployStatus,
patchInstanceConfig,
retryApplicationDomainMail,
retryApplicationDomainSSL,
validateCloning,
} = apiModule;

describe("deploy api adapter (live routing)", () => {
beforeEach(() => {
mockPlapiCreateProductionInstance.mockReset();
mockPlapiValidateCloning.mockReset();
mockPlapiGetDeployStatus.mockReset();
mockPlapiPatchInstanceConfig.mockReset();
mockPlapiRetryApplicationDomainSSL.mockReset();
mockPlapiRetryApplicationDomainMail.mockReset();
});

test("createProductionInstance delegates to lib/plapi.ts", async () => {
mockPlapiCreateProductionInstance.mockResolvedValue({
instance_id: "ins_prod_live",
environment_type: "production",
active_domain: { id: "dmn_live", name: "example.com" },
publishable_key: "pk_live_test",
cname_targets: [],
});

const result = await createProductionInstance("app_123", {
home_url: "example.com",
clone_instance_id: "ins_dev_123",
});

expect(mockPlapiCreateProductionInstance).toHaveBeenCalledWith("app_123", {
home_url: "example.com",
clone_instance_id: "ins_dev_123",
});
expect(result.instance_id).toBe("ins_prod_live");
});

test("validateCloning delegates to lib/plapi.ts", async () => {
mockPlapiValidateCloning.mockResolvedValue(undefined);
await validateCloning("app_123", { clone_instance_id: "ins_dev_123" });
expect(mockPlapiValidateCloning).toHaveBeenCalledWith("app_123", {
clone_instance_id: "ins_dev_123",
});
});

test("getDeployStatus delegates to lib/plapi.ts and surfaces booleans", async () => {
mockPlapiGetDeployStatus.mockResolvedValue({
status: "incomplete",
dns_ok: true,
ssl_ok: false,
mail_ok: false,
});
const result = await getDeployStatus("app_123", "production");
expect(mockPlapiGetDeployStatus).toHaveBeenCalledWith("app_123", "production");
expect(result).toEqual({
status: "incomplete",
dns_ok: true,
ssl_ok: false,
mail_ok: false,
});
});

test("patchInstanceConfig delegates to lib/plapi.ts", async () => {
mockPlapiPatchInstanceConfig.mockResolvedValue({ ok: true });
const result = await patchInstanceConfig("app_123", "ins_prod_live", {
connection_oauth_google: { enabled: true },
});
expect(mockPlapiPatchInstanceConfig).toHaveBeenCalledWith("app_123", "ins_prod_live", {
connection_oauth_google: { enabled: true },
});
expect(result).toEqual({ ok: true });
});

test("retryApplicationDomainSSL delegates to lib/plapi.ts", async () => {
mockPlapiRetryApplicationDomainSSL.mockResolvedValue(undefined);
await retryApplicationDomainSSL("app_123", "example.com");
expect(mockPlapiRetryApplicationDomainSSL).toHaveBeenCalledWith("app_123", "example.com");
});

test("retryApplicationDomainMail delegates to lib/plapi.ts", async () => {
mockPlapiRetryApplicationDomainMail.mockResolvedValue(undefined);
await retryApplicationDomainMail("app_123", "example.com");
expect(mockPlapiRetryApplicationDomainMail).toHaveBeenCalledWith("app_123", "example.com");
});
});
39 changes: 39 additions & 0 deletions packages/cli-core/src/commands/deploy/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Deploy command API surface.
*
* Re-exports the PLAPI endpoints the deploy lifecycle calls so the test suite
* can substitute the whole adapter via `mock.module("./api.ts", ...)` without
* mocking each plapi call site individually.
*/

export {
createProductionInstance,
getDeployStatus,
patchInstanceConfig,
retryApplicationDomainMail,
retryApplicationDomainSSL,
validateCloning,
} from "../../lib/plapi.ts";

export type {
CnameTarget,
CreateProductionInstanceParams,
DeployStatusResponse,
ProductionInstanceResponse,
ValidateCloningParams,
} from "../../lib/plapi.ts";

export type DeployApiMockOptions = {
failValidateCloning?: boolean;
failCreateProductionInstance?: boolean;
failDnsVerification?: boolean;
failOAuthSave?: boolean;
};

/**
* No-op in production. Tests replace this via `mock.module("./api.ts", ...)`
* to intercept the call and inject lifecycle failures into the mocked
* `createProductionInstance` / `validateCloning` / `getDeployStatus` /
* `patchInstanceConfig` exports above.
*/
export function configureMockDeployApi(_options: DeployApiMockOptions = {}): void {}
Loading