Skip to content
17 changes: 17 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,23 @@ 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",
"--test-fail-validate-cloning",
"--test-fail-create-production-instance",
"--test-fail-dns-verification",
"--test-fail-oauth-save",
]);
});

describe("parseIntegerOption (via users list --limit / --offset)", () => {
function parseUsersList(args: readonly string[]) {
return createProgram().parseAsync(["users", "list", ...args], { from: "user" });
Expand Down
50 changes: 48 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,51 @@ Tutorial — enable completions for your shell:
])
.action(update);

program
.command("deploy", { hidden: true })
.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",
),
)
.addOption(
createOption(
"--test-fail-validate-cloning",
"Simulate a deploy failure while validating cloning",
),
)
.addOption(
createOption(
"--test-fail-create-production-instance",
"Simulate a deploy failure while creating the production instance",
),
)
.addOption(
createOption("--test-fail-dns-verification", "Simulate a deploy failure while verifying DNS"),
)
.addOption(
createOption(
"--test-fail-oauth-save",
"Simulate a deploy failure while saving OAuth credentials",
),
)
.action(deploy);

registerExtras(program);

return program;
Expand Down Expand Up @@ -1004,7 +1050,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
226 changes: 92 additions & 134 deletions packages/cli-core/src/commands/deploy/README.md

Large diffs are not rendered by default.

120 changes: 120 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,120 @@
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();
const mockSleep = 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),
}));

mock.module("../../lib/sleep.ts", () => ({
sleep: (...args: unknown[]) => mockSleep(...args),
}));

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

describe("deploy api adapter", () => {
beforeEach(() => {
mockPlapiCreateProductionInstance.mockImplementation(() => {
throw new Error("live createProductionInstance should not be called");
});
mockPlapiValidateCloning.mockImplementation(() => {
throw new Error("live validateCloning should not be called");
});
mockPlapiGetDeployStatus.mockImplementation(() => {
throw new Error("live getDeployStatus should not be called");
});
mockPlapiPatchInstanceConfig.mockImplementation(() => {
throw new Error("live patchInstanceConfig should not be called");
});
mockPlapiRetryApplicationDomainSSL.mockImplementation(() => {
throw new Error("live retryApplicationDomainSSL should not be called");
});
mockPlapiRetryApplicationDomainMail.mockImplementation(() => {
throw new Error("live retryApplicationDomainMail should not be called");
});
mockSleep.mockResolvedValue(undefined);
_resetDeployStatusMock();
});

test("uses mocked deploy lifecycle operations by default", async () => {
const production = await createProductionInstance("app_123", {
home_url: "example.com",
clone_instance_id: "ins_dev_123",
});
await validateCloning("app_123", { clone_instance_id: "ins_dev_123" });
await patchInstanceConfig("app_123", production.instance_id, {
connection_oauth_google: { enabled: true },
});

expect(production.instance_id).toBe("MOCKED_NOT_REAL_FIXME");
expect(production.active_domain.name).toBe("example.com");
expect(production.cname_targets).toHaveLength(3);
expect(mockPlapiCreateProductionInstance).not.toHaveBeenCalled();
expect(mockPlapiValidateCloning).not.toHaveBeenCalled();
expect(mockPlapiPatchInstanceConfig).not.toHaveBeenCalled();
});

test("mock deploy status represents incomplete then complete server state", async () => {
expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "incomplete" });
expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "incomplete" });
expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "complete" });
expect(mockPlapiGetDeployStatus).not.toHaveBeenCalled();
});

test("mock deploy api can fail lifecycle operations with PLAPI-shaped errors", async () => {
configureMockDeployApi({
failValidateCloning: true,
failCreateProductionInstance: true,
failDnsVerification: true,
failOAuthSave: true,
});

await expect(validateCloning("app_123", { clone_instance_id: "ins_dev_123" })).rejects.toThrow(
"Simulated deploy failure: cloning validation.",
);
await expect(
createProductionInstance("app_123", {
home_url: "example.com",
clone_instance_id: "ins_dev_123",
}),
).rejects.toThrow("Simulated deploy failure: production instance creation.");
await expect(getDeployStatus("app_123", "ins_prod_123")).rejects.toThrow(
"Simulated deploy failure: DNS verification.",
);
await expect(
patchInstanceConfig("app_123", "ins_prod_123", {
connection_oauth_google: { enabled: true },
}),
).rejects.toThrow("Simulated deploy failure: OAuth credential save.");

expect(mockPlapiValidateCloning).not.toHaveBeenCalled();
expect(mockPlapiCreateProductionInstance).not.toHaveBeenCalled();
expect(mockPlapiGetDeployStatus).not.toHaveBeenCalled();
expect(mockPlapiPatchInstanceConfig).not.toHaveBeenCalled();
});

test("reset mock deploy api clears lifecycle failure flags", async () => {
configureMockDeployApi({ failValidateCloning: true });
_resetDeployStatusMock();

await expect(
validateCloning("app_123", { clone_instance_id: "ins_dev_123" }),
).resolves.toBeUndefined();
});
});
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/deploy/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Deploy command API adapter.
*
* Live endpoint wrappers live in `lib/plapi.ts`, but the deploy lifecycle
* remains mocked while the production-instance backend settles. Keep this
* adapter as the switch point: the command resolves deploy progress through
* API-shaped calls, while these lifecycle operations simulate backend states
* locally.
*/

import {
createProductionInstance as liveCreateProductionInstance,
getDeployStatus as liveGetDeployStatus,
patchInstanceConfig as livePatchInstanceConfig,
retryApplicationDomainMail as liveRetryApplicationDomainMail,
retryApplicationDomainSSL as liveRetryApplicationDomainSSL,
validateCloning as liveValidateCloning,
type CnameTarget,
type CreateProductionInstanceParams,
type DeployStatusResponse,
type ProductionInstanceResponse,
type ValidateCloningParams,
} from "../../lib/plapi.ts";
import { mockDeployApi } from "./mock.ts";

export { configureMockDeployApi } from "./mock.ts";

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

export type DeployApi = {
createProductionInstance: (
applicationId: string,
params: CreateProductionInstanceParams,
) => Promise<ProductionInstanceResponse>;
validateCloning: (applicationId: string, params: ValidateCloningParams) => Promise<void>;
getDeployStatus: (applicationId: string, envOrInsId: string) => Promise<DeployStatusResponse>;
retryApplicationDomainSSL: (applicationId: string, domainIdOrName: string) => Promise<void>;
retryApplicationDomainMail: (applicationId: string, domainIdOrName: string) => Promise<void>;
patchInstanceConfig: (
applicationId: string,
instanceId: string,
config: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
};

export const liveDeployApi: DeployApi = {
createProductionInstance: liveCreateProductionInstance,
validateCloning: liveValidateCloning,
getDeployStatus: liveGetDeployStatus,
retryApplicationDomainSSL: liveRetryApplicationDomainSSL,
retryApplicationDomainMail: liveRetryApplicationDomainMail,
patchInstanceConfig: livePatchInstanceConfig,
};

const activeDeployApi: DeployApi = mockDeployApi;

export const createProductionInstance = (
applicationId: string,
params: CreateProductionInstanceParams,
) => activeDeployApi.createProductionInstance(applicationId, params);

export const validateCloning = (applicationId: string, params: ValidateCloningParams) =>
activeDeployApi.validateCloning(applicationId, params);

export const getDeployStatus = (applicationId: string, envOrInsId: string) =>
activeDeployApi.getDeployStatus(applicationId, envOrInsId);

export const retryApplicationDomainSSL = (applicationId: string, domainIdOrName: string) =>
activeDeployApi.retryApplicationDomainSSL(applicationId, domainIdOrName);

export const retryApplicationDomainMail = (applicationId: string, domainIdOrName: string) =>
activeDeployApi.retryApplicationDomainMail(applicationId, domainIdOrName);

export const patchInstanceConfig = (
applicationId: string,
instanceId: string,
config: Record<string, unknown>,
) => activeDeployApi.patchInstanceConfig(applicationId, instanceId, config);
Loading
Loading