Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -44,6 +44,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 @@ -901,6 +902,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 @@ -1006,7 +1052,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
218 changes: 92 additions & 126 deletions packages/cli-core/src/commands/deploy/README.md

Large diffs are not rendered by default.

81 changes: 81 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,81 @@
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 {
createProductionInstance,
getDeployStatus,
patchInstanceConfig,
validateCloning,
_resetDeployStatusMock,
} = (await import(deployApiModulePath)) as typeof import("./api.ts");

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();
});
});
155 changes: 155 additions & 0 deletions packages/cli-core/src/commands/deploy/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* 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 { sleep } from "../../lib/sleep.ts";
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";

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

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>>;
};

const MOCK_PRODUCTION_INSTANCE_ID = "MOCKED_NOT_REAL_FIXME";
const MOCK_DOMAIN_ID = "MOCKED_NOT_REAL_FIXME";
const MOCK_PUBLISHABLE_KEY = "MOCKED_NOT_REAL_FIXME";
const MOCK_SECRET_KEY = "MOCKED_NOT_REAL_FIXME";
const MOCK_LATENCY_MS = 2000;
const MOCK_INCOMPLETE_POLLS = 2;

async function simulateServerLatency(): Promise<void> {
await sleep(MOCK_LATENCY_MS);
}

function defaultCnameTargets(domain: string): CnameTarget[] {
return [
{ host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true },
{ host: `accounts.${domain}`, value: "accounts.clerk.services", required: true },
{
host: `clkmail.${domain}`,
value: `mail.${domain}.nam1.clerk.services`,
required: true,
},
];
}

const deployStatusPollCounts = new Map<string, number>();

export function _resetDeployStatusMock(): void {
deployStatusPollCounts.clear();
}

export const mockDeployApi: DeployApi = {
async createProductionInstance(_applicationId, params) {
await simulateServerLatency();
return {
instance_id: MOCK_PRODUCTION_INSTANCE_ID,
environment_type: "production",
active_domain: {
id: MOCK_DOMAIN_ID,
name: params.home_url,
},
secret_key: MOCK_SECRET_KEY,
publishable_key: MOCK_PUBLISHABLE_KEY,
cname_targets: defaultCnameTargets(params.home_url),
};
},

async validateCloning() {
await simulateServerLatency();
},

async getDeployStatus(applicationId, envOrInsId) {
await simulateServerLatency();
const key = `${applicationId}:${envOrInsId}`;
const count = (deployStatusPollCounts.get(key) ?? 0) + 1;
deployStatusPollCounts.set(key, count);
return {
status: count > MOCK_INCOMPLETE_POLLS ? "complete" : "incomplete",
};
},

async retryApplicationDomainSSL() {
await simulateServerLatency();
},

async retryApplicationDomainMail() {
await simulateServerLatency();
},

async patchInstanceConfig() {
await simulateServerLatency();
return {};
},
};

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