diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts
index 3dc13520..715f57b6 100644
--- a/packages/cli-core/src/cli-program.test.ts
+++ b/packages/cli-core/src/cli-program.test.ts
@@ -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" });
diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts
index a6d736fb..e200dda1 100644
--- a/packages/cli-core/src/cli-program.ts
+++ b/packages/cli-core/src/cli-program.ts
@@ -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";
@@ -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;
@@ -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);
}
diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md
index ecddd4aa..1c41bcce 100644
--- a/packages/cli-core/src/commands/deploy/README.md
+++ b/packages/cli-core/src/commands/deploy/README.md
@@ -1,17 +1,23 @@
# Deploy Command
-> **Fully mocked.** This command uses hardcoded test data and is not yet wired to real APIs. The interactive prompts are real, but all API calls (application lookup, instance creation, DNS, OAuth credential storage) are simulated.
+> **API-resolved state, mocked lifecycle.** Human mode resolves the linked application, production domains, deploy status, and instance config from the API layer on each run. Application/domain/config reads use live PLAPI helpers; production lifecycle calls (`validate_cloning`, `production_instance`, `deploy_status`, `ssl_retry`, `mail_retry`) plus production config PATCH still go through `commands/deploy/api.ts`, where they are mocked with the real Platform API request/response shapes.
Guides a user through deploying their Clerk application to production.
## Usage
```sh
-clerk deploy # Interactive wizard (human mode)
+clerk deploy # Interactive, idempotent wizard (human mode)
clerk deploy --debug # With debug output
clerk deploy --mode agent # Output agent prompt instead of interactive flow
```
+## Options
+
+| Flag | Purpose |
+| --------- | -------------------------------------------- |
+| `--debug` | Show detailed deploy and PLAPI debug output. |
+
## Agent Mode
> **TODO:** The `DEPLOY_PROMPT` string is hardcoded. It should probably fetch from the quickstart prompt in the Clerk docs instead.
@@ -19,7 +25,7 @@ clerk deploy --mode agent # Output agent prompt instead of interactive flow
When running in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), this command outputs a structured prompt describing the full deployment flow instead of running the interactive wizard. The prompt includes:
- Prerequisites and pre-flight checks
-- Domain selection options (custom vs. Clerk subdomain)
+- Production domain collection and DNS setup
- Production instance creation steps
- OAuth credential collection for social providers
- All relevant Platform API endpoints
@@ -30,6 +36,29 @@ Agent mode is detected via the mode system (`src/mode.ts`), which checks in prio
2. `CLERK_MODE` environment variable
3. TTY detection (`process.stdout.isTTY`)
+Agent mode does not call PLAPI. It prints `DEPLOY_PROMPT` and exits before the human-mode wizard starts. The prompt currently contains some stale endpoint guidance; see the TODO above `DEPLOY_PROMPT` in `index.ts` and `DEPLOY_MVP_UX_COPY_SPEC.md` §8.3.
+
+## PLAPI And Mocked Lifecycle
+
+Human mode reads deploy state through the API layer: application instances, production domains, development config, production config, and deploy status. It does not write deploy progress to the CLI config profile. The only config compatibility write is the ordinary linked-profile `instances.production` value.
+
+The production-instance lifecycle still calls the helpers in `commands/deploy/api.ts`. They use the exact request/response shapes published in the Platform API OpenAPI spec, but the bodies are produced locally so the wizard can simulate server-side deploy states while the production-instance backend remains mocked.
+
+| Step | Endpoint | Mocked state |
+| -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| Validate cloning | `POST /v1/platform/applications/{appID}/validate_cloning` | Resolves to 204; the helper exists so 402 `UnsupportedSubscriptionPlanFeatures` errors can later short-circuit before summary. |
+| Create production instance | `POST /v1/platform/applications/{appID}/production_instance` | Returns `instance_id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key`, and `cname_targets[]`. |
+| Poll deploy status | `GET /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Returns `incomplete` for the first two polls per `(appID, instanceID)` pair, then `complete`. CLI polls every 3s. |
+| Retry SSL provisioning | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | Resolves to 204; helper exposed for use when `deploy_status` stalls. |
+| Retry mail verification | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | Resolves to 204; helper exposed for use when `deploy_status` stalls. |
+| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Resolves to `{}` without hitting the network. |
+
+This keeps `clerk deploy` from drifting away from the server-side source of truth once these endpoints are backed by production data. Each run resolves the current production instance, domain, deploy status, and OAuth config from the API layer, then prints a checked-off plan before completing the next unfinished action. Re-running `clerk deploy` after production is fully configured shows every deploy action checked off and prints production next steps.
+
+Mocked lifecycle endpoints in `commands/deploy/api.ts` pause for ~2s before returning so spinners and the deploy-status poll feel like real network calls.
+
+If the user presses Ctrl-C after the production instance has been created, the wizard tells them to run `clerk deploy` again and exits with SIGINT code 130. The next run derives the current DNS or OAuth step from API state and resumes without starting another production instance.
+
## Sequence Diagram
```mermaid
@@ -37,146 +66,79 @@ sequenceDiagram
actor User
participant CLI as Clerk CLI
participant API as Clerk Platform API
- participant DNS as DNS Provider
participant Browser
Note over CLI: clerk deploy
- %% Auth & App Check
+ %% Auth & app context
Note over CLI: Auth token from local config
(stored during `clerk auth login`)
- CLI->>API: GET /v1/platform/applications/{appID}
- API-->>CLI: { application }
-
- %% Production Instance Check
- CLI->>API: GET /v1/platform/applications/{appID}/instances/production/config
- alt 200 — production instance exists
- API-->>CLI: { config }
- CLI->>User: Production instance already exists
- Note over CLI: Update flow — TBD
- else 404 — no production instance
- API-->>CLI: 404 Not Found
- end
- %% Read Dev Instance Config (features + social providers)
- CLI->>API: GET /v1/platform/applications/{appID}/instances/development/config
- API-->>CLI: { config_version, connection_oauth_google: {...}, ... }
+ %% Discover enabled OAuth providers in dev
+ CLI->>API: GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config?keys=connection_oauth_*
+ API-->>CLI: { connection_oauth_google: { enabled: true }, ... }
- %% Subscription Check
- CLI->>API: GET /v1/platform/applications/{appID}/subscription
- API-->>CLI: { id, stripe_subscription_id }
- CLI->>CLI: Compare dev features vs plan features
- alt Unsupported features found
+ %% Pre-flight subscription check
+ CLI->>API: POST /v1/platform/applications/{appID}/validate_cloning { clone_instance_id }
+ alt 402 Payment Required
+ API-->>CLI: UnsupportedSubscriptionPlanFeatures
CLI->>User: Upgrade plan to continue
+ else 204 No Content
+ API-->>CLI: ok
end
- %% Domain Selection
- CLI->>User: How would you like to set up your production domain?
- alt Custom domain
- User->>CLI: "Use my own domain"
- CLI->>User: Enter your domain:
- User->>CLI: example.com
- else Clerk subdomain
- User->>CLI: "Use a Clerk-provided subdomain"
- end
-
- %% Create Production Instance
- Note over CLI,API: No "add production instance" endpoint exists.
Current API only creates instances at app creation.
Needs a new endpoint or re-creation via
POST /v1/platform/applications
with environment_types: ["development","production"]
- CLI->>API: POST /v1/platform/applications (TBD — needs new endpoint?)
- API-->>CLI: { application, instances: [dev, prod] }
-
- %% Domain Setup
- opt Custom domain selected
- CLI->>API: POST /v1/platform/applications/{appID}/domains
- Note right of API: { name: "example.com",
is_satellite: false }
- API-->>CLI: { domain }
-
- CLI->>DNS: Lookup NS records for domain
- DNS-->>CLI: { provider, supportsDomainConnect }
-
- alt Supports Domain Connect
- CLI->>User: Open browser to configure DNS?
- User->>CLI: Yes
- CLI->>Browser: Open Domain Connect URL
- else No Domain Connect
- CLI->>User: Add these DNS records manually
- end
+ %% Plan summary + domain
+ CLI->>User: Plan summary
+ CLI->>User: Production domain (e.g. example.com)
+ User->>CLI: example.com
- CLI->>API: POST /v1/platform/applications/{appID}/domains/{domainID}/dns_check
- API-->>CLI: { status }
- end
+ %% Create production instance + domain in one round-trip
+ CLI->>API: POST /v1/platform/applications/{appID}/production_instance { home_url, clone_instance_id }
+ API-->>CLI: { instance_id, active_domain, publishable_key, secret_key, cname_targets }
- %% Social Provider Credential Collection
- Note over CLI: Dev config already fetched above —
check for enabled connection_oauth_* keys
+ CLI->>User: Add these CNAME records to your DNS provider
- loop Each enabled social provider (e.g. google)
- CLI->>User: Your app uses {Provider} OAuth. Have credentials?
+ %% Poll deploy status
+ loop every 3s until status == "complete"
+ CLI->>API: GET /v1/platform/applications/{appID}/instances/{instance_id}/deploy_status
+ API-->>CLI: { status: "incomplete" | "complete" }
+ end
- alt Walk me through it
- User->>CLI: "Walk me through setting it up"
- CLI->>User: Use these values:
JS origins: https://example.com
Redirect URI: https://accounts.example.com/v1/oauth_callback
- CLI->>Browser: Open Clerk docs for provider
- CLI->>User: Enter credentials below:
- else Already have credentials
- User->>CLI: "I already have my credentials"
+ opt Stalled provisioning
+ alt SSL stalled
+ CLI->>API: POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/ssl_retry
+ API-->>CLI: 204
+ else Mail stalled
+ CLI->>API: POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/mail_retry
+ API-->>CLI: 204
end
+ end
- CLI->>User: Client ID:
- User->>CLI: {client_id}
- CLI->>User: Client Secret:
- User->>CLI: {client_secret}
-
- CLI->>API: PATCH /v1/platform/applications/{appID}/instances/production/config
- Note right of API: { connection_oauth_google:
{ enabled: true,
client_id: "...",
client_secret: "..." } }
+ %% OAuth credential loop
+ loop Each enabled social provider
+ CLI->>User: Provider credentials
+ CLI->>API: PATCH /v1/platform/applications/{appID}/instances/{instance_id}/config { connection_oauth_{provider} }
API-->>CLI: { before, after, config_version }
end
- %% Done
CLI->>User: Production ready at https://{domain}
- CLI->>User: (Redeploy with updated secret keys if needed)
```
## API Endpoints
-All endpoints are on the **Platform API** (`/v1/platform/...`).
-
-| Step | Method | Endpoint | Notes |
-| -------------------- | ------- | ----------------------------------- | --------------------------------------------------------------------------------- |
-| Auth | — | Local config | Token stored from `clerk auth login` |
-| Get application | `GET` | `/v1/platform/applications/{appID}` | |
-| Check prod instance | `GET` | `.../instances/production/config` | 404 if none exists |
-| Read dev config | `GET` | `.../instances/development/config` | Returns all settings including `connection_oauth_*` keys |
-| Subscription check | `GET` | `.../subscription` | Returns `{ id, stripe_subscription_id }` only — feature comparison is client-side |
-| Create prod instance | `POST` | `/v1/platform/applications` | **Gap: no endpoint to add a production instance to an existing app** |
-| Add domain | `POST` | `.../domains` | Body: `{ name, is_satellite }` |
-| DNS check | `POST` | `.../domains/{domainID}/dns_check` | Triggers async DNS verification |
-| Write OAuth creds | `PATCH` | `.../instances/production/config` | Body: `{ connection_oauth_{provider}: { enabled, client_id, client_secret } }` |
-
-## API Gaps
-
-### Creating a production instance for an existing app
-
-The current Platform API only creates instances during application creation via `POST /v1/platform/applications` with the `environment_types` parameter:
-
-```json
-POST /v1/platform/applications
-{
- "name": "my-app",
- "environment_types": ["development", "production"],
- "domain": "example.com"
-}
-```
-
-There is **no endpoint** to add a production instance to an application that was originally created with only a development instance. This needs either:
-
-1. A new `POST /v1/platform/applications/{appID}/instances` endpoint
-2. Or a different approach (e.g., re-creating the application)
-
-### Subscription feature comparison
-
-`GET /v1/platform/applications/{appID}/subscription` returns only basic metadata (`id`, `stripe_subscription_id`), not feature lists. Feature detection is done server-side in `pkg/pricing/pricing.go` by inspecting instance config. The CLI would need either:
-
-1. A new endpoint that returns the feature comparison result
-2. Or access to plan feature lists to compare client-side
+All endpoints are on the **Platform API** (`/v1/platform/...`). The "Real" rows are live HTTP calls today; the "Mock" rows are wired through `commands/deploy/api.ts` with shapes that match the published OpenAPI spec exactly.
+
+| Step | Method | Endpoint | Status | Helper |
+| -------------------------- | ------- | ------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| Auth | n/a | Local config | Real | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. |
+| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Real | `fetchInstanceConfig` from `lib/plapi.ts`. Used to discover enabled `connection_oauth_*` providers in dev. |
+| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Mock | `patchInstanceConfig` in `commands/deploy/api.ts`. Writes production OAuth credentials once switched to live PLAPI. |
+| Read application | `GET` | `/v1/platform/applications/{appID}` | Real | `fetchApplication` from `lib/plapi.ts`. Resolves live development and production instance IDs. |
+| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | Real | `listApplicationDomains` from `lib/plapi.ts`. Recovers production domain name and CNAME targets on each run. |
+| Validate cloning | `POST` | `/v1/platform/applications/{appID}/validate_cloning` | Mock | `validateCloning` in `commands/deploy/api.ts`. Pre-flights subscription/feature support before plan summary. |
+| Create production instance | `POST` | `/v1/platform/applications/{appID}/production_instance` | Mock | `createProductionInstance` in `commands/deploy/api.ts`. Returns prod instance, primary domain, keys, and `cname_targets[]`. |
+| Poll deploy status | `GET` | `/v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Mock | `getDeployStatus` in `commands/deploy/api.ts`. CLI polls every 3 seconds while the production instance is provisioning DNS, SSL, and mail. |
+| Retry SSL provisioning | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | Mock | `retryApplicationDomainSSL` in `commands/deploy/api.ts`. Available for surfacing to the user when `deploy_status` stalls. |
+| Retry mail verification | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | Mock | `retryApplicationDomainMail` in `commands/deploy/api.ts`. Same as above, for SendGrid mail. Rejected on satellite domains. |
## OAuth Provider Config Format
@@ -196,13 +158,13 @@ PATCH /v1/platform/applications/{appID}/instances/production/config
### Provider-specific required fields
-| Provider | Required Fields |
-| --------- | ------------------------------------------------- |
-| Google | `client_id`, `client_secret` |
-| GitHub | `client_id`, `client_secret` |
-| Microsoft | `client_id`, `client_secret` |
-| Apple | `client_id`, `client_secret`, `key_id`, `team_id` |
-| Linear | `client_id`, `client_secret` |
+| Provider | Required Fields |
+| --------- | ---------------------------------------------------------------- |
+| Google | `client_id`, `client_secret` |
+| GitHub | `client_id`, `client_secret` |
+| Microsoft | `client_id`, `client_secret` |
+| Apple | `client_id`, `team_id`, `key_id`, `client_secret` (.p8 contents) |
+| Linear | `client_id`, `client_secret` |
Production instances return `422` if you try to enable a provider without credentials.
@@ -210,6 +172,10 @@ Production instances return `422` if you try to enable a provider without creden
Google enforces a pattern: `^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$`
+### Google OAuth JSON import
+
+For Google, the wizard offers `Load credentials from a Google Cloud Console JSON file`. It reads the `client_id` and `client_secret` from the top-level `web` object in the downloaded OAuth client JSON, or from `installed` for desktop-style client downloads. The file contents are used in memory and are not written to CLI config.
+
## Helpful values for OAuth walkthrough
When the user chooses the guided walkthrough, these values are derived from their domain:
diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts
new file mode 100644
index 00000000..8b4bec1b
--- /dev/null
+++ b/packages/cli-core/src/commands/deploy/api.test.ts
@@ -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();
+ });
+});
diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts
new file mode 100644
index 00000000..3de5ca2a
--- /dev/null
+++ b/packages/cli-core/src/commands/deploy/api.ts
@@ -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;
+ validateCloning: (applicationId: string, params: ValidateCloningParams) => Promise;
+ getDeployStatus: (applicationId: string, envOrInsId: string) => Promise;
+ retryApplicationDomainSSL: (applicationId: string, domainIdOrName: string) => Promise;
+ retryApplicationDomainMail: (applicationId: string, domainIdOrName: string) => Promise;
+ patchInstanceConfig: (
+ applicationId: string,
+ instanceId: string,
+ config: Record,
+ ) => Promise>;
+};
+
+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 {
+ 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();
+
+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,
+) => activeDeployApi.patchInstanceConfig(applicationId, instanceId, config);
diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts
new file mode 100644
index 00000000..2a66f1e9
--- /dev/null
+++ b/packages/cli-core/src/commands/deploy/copy.ts
@@ -0,0 +1,163 @@
+import { bold, cyan, dim, green, yellow } from "../../lib/color.ts";
+import type { CnameTarget } from "./api.ts";
+
+export type DeployPlanStep = {
+ label: string;
+ status: "done" | "pending";
+};
+
+export const INTRO_PREAMBLE = `This will prepare your linked Clerk app for production by cloning your
+development instance into a new production instance and walking you through
+the setup the dashboard would otherwise guide you through.
+
+Before you begin you will need:
+ - A domain you own (production cannot use a development subdomain).
+ - The ability to add DNS records on that domain.
+ - OAuth credentials for any social providers you have enabled in dev.
+
+${dim("Reference: https://clerk.com/docs/guides/development/deployment/production")}`;
+
+export function printPlan(appLabel: string, steps: readonly DeployPlanStep[]): string[] {
+ return [
+ `clerk deploy will prepare ${cyan(appLabel)} for production:`,
+ "",
+ ...steps.map((step) => ` ${planStatus(step.status)} ${step.label}`),
+ ];
+}
+
+function planStatus(status: DeployPlanStep["status"]): string {
+ if (status === "done") return green("[x]");
+ return yellow("[ ]");
+}
+
+export function dnsIntro(domain: string): string[] {
+ return [
+ `Configure DNS for ${cyan(domain)}`,
+ "",
+ "Clerk uses DNS records to provide session management and emails",
+ "verified from your domain.",
+ "",
+ `${yellow("NOTE")} It can take up to 48 hours for DNS records to fully propagate.`,
+ `${dim(cyan("TIP"))} If you can't add a CNAME for the Frontend API, you can use a proxy:`,
+ dim(" https://clerk.com/docs/guides/dashboard/dns-domains/proxy-fapi"),
+ dim("Reference: https://clerk.com/docs/guides/development/deployment/production#dns-records"),
+ ];
+}
+
+export function domainAssociationSummary(
+ domain: string,
+ targets: readonly CnameTarget[],
+): string[] {
+ return [
+ `Clerk will associate these subdomains with ${cyan(domain)}:`,
+ "",
+ ...targets.map((target) => ` ${cnameTargetLabel(target.host)} ${target.host}`),
+ "",
+ "This will create a Clerk production instance for your application.",
+ ];
+}
+
+export function dnsRecords(targets: readonly CnameTarget[]): string[] {
+ const lines = ["Add the following records at your DNS provider:"];
+ for (const target of targets) {
+ const label = cnameTargetLabel(target.host);
+ const optional = target.required ? "" : ` ${dim("(optional)")}`;
+ lines.push(
+ "",
+ ` ${label}${optional}`,
+ ` Type: CNAME`,
+ ` Host: ${target.host}`,
+ ` Value: ${target.value}`,
+ );
+ }
+ lines.push(
+ "",
+ `${yellow("NOTE")} If your DNS host proxies these records, set them to "DNS only" or verification will fail.`,
+ );
+ return lines;
+}
+
+function cnameTargetLabel(host: string): string {
+ const prefix = host.split(".", 1)[0];
+ switch (prefix) {
+ case "clerk":
+ return "Frontend API";
+ case "accounts":
+ return "Account portal";
+ case "clkmail":
+ case "clk._domainkey":
+ case "clk2._domainkey":
+ return "Email (Clerk handles SPF/DKIM automatically)";
+ default:
+ return "CNAME";
+ }
+}
+
+export function dnsDashboardHandoff(domain: string): string[] {
+ return [
+ `Check the Domains section in the Clerk Dashboard for ${domain} to monitor DNS propagation and SSL issuance.`,
+ "You can continue to the remaining setup now, or pause and run `clerk deploy` again later.",
+ ];
+}
+
+export function dnsVerified(domain: string): string[] {
+ return [`DNS verified for ${domain}.`];
+}
+
+export const OAUTH_SECTION_INTRO = `${bold("Configure OAuth credentials for production")}
+
+In development, Clerk provides shared OAuth credentials for most providers.
+In production, those are not secure. You need your own credentials for
+each enabled provider.
+
+${dim("Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/overview")}`;
+
+export function productionSummary(
+ domain: string,
+ completedOAuthProviderLabels: readonly string[],
+ domainStatus: "verified" | "pending" = "verified",
+): string[] {
+ return [
+ `Production ready at ${cyan(`https://${domain}`)}`,
+ "",
+ ` Domain ${domainStatus === "verified" ? "Verified" : "DNS pending"}`,
+ ` OAuth ${completedOAuthProviderLabels.length ? completedOAuthProviderLabels.join(", ") : "Not applicable"}`,
+ ];
+}
+
+export const NEXT_STEPS_BLOCK = `${bold("Next steps")}
+
+ 1. Pull production keys into your environment
+ clerk env pull --instance prod
+
+ This writes pk_live_... and sk_live_... to your .env. They replace your
+ pk_test_... and sk_test_... keys.
+
+ 2. Update env vars on your hosting provider
+ Vercel, AWS, GCP, Heroku, Render, etc. all expose env vars in their UI.
+ Add the same pk_live_/sk_live_ values there.
+
+ 3. Redeploy your app
+
+ 4. (If applicable) Update webhook URLs and signing secrets
+ ${dim("https://clerk.com/docs/guides/development/webhooks/syncing#configure-your-production-instance")}
+
+ 5. (If applicable) Update your Content Security Policy
+ ${dim("https://clerk.com/docs/guides/secure/best-practices/csp-headers")}
+
+${yellow("NOTE")} Production keys only work on your production domain. They will not work on localhost.
+ To run your dev environment, keep using your dev keys.
+
+${dim("Reference: https://clerk.com/docs/guides/development/deployment/production#api-keys-and-environment-variables")}`;
+
+export function pausedMessage(stepDescription: string): string {
+ return `Deploy paused at: ${stepDescription}
+
+${pausedOperationNotice()}`;
+}
+
+export function pausedOperationNotice(): string {
+ return `Deploy paused.
+
+Run \`clerk deploy\` again to continue from the current API state.`;
+}
diff --git a/packages/cli-core/src/commands/deploy/domain-connect.ts b/packages/cli-core/src/commands/deploy/domain-connect.ts
new file mode 100644
index 00000000..21be3795
--- /dev/null
+++ b/packages/cli-core/src/commands/deploy/domain-connect.ts
@@ -0,0 +1,12 @@
+/**
+ * Detect whether the registrar for `domain` supports Domain Connect and
+ * return the prefilled URL if so. Currently a placeholder that returns the
+ * Cloudflare template unconditionally; a real implementation would look up
+ * NS records and match the registrar against a provider table.
+ *
+ * FIXME(deploy): replace with NS-based registrar detection. Today every
+ * caller is told their registrar is Cloudflare regardless of reality.
+ */
+export function domainConnectUrl(domain: string): string | undefined {
+ return `https://domainconnect.cloudflare.com/v2/domainTemplates/providers/clerk.com/services/clerk-production/apply?domain=${domain}`;
+}
diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts
index 9805ccc2..2aa14454 100644
--- a/packages/cli-core/src/commands/deploy/index.test.ts
+++ b/packages/cli-core/src/commands/deploy/index.test.ts
@@ -1,5 +1,9 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join, relative } from "node:path";
+import { tmpdir } from "node:os";
import { captureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts";
+import { CliError, EXIT_CODE, UserAbortError } from "../../lib/errors.ts";
const mockIsAgent = mock();
let _modeOverride: string | undefined;
@@ -19,6 +23,16 @@ const mockSelect = mock();
const mockInput = mock();
const mockConfirm = mock();
const mockPassword = mock();
+const mockPatchInstanceConfig = mock();
+const mockFetchInstanceConfig = mock();
+const mockFetchApplication = mock();
+const mockListApplicationDomains = mock();
+const mockCreateProductionInstance = mock();
+const mockValidateCloning = mock();
+const mockGetDeployStatus = mock();
+const mockRetrySSL = mock();
+const mockRetryMail = mock();
+const mockDomainConnectUrl = mock();
mock.module("@inquirer/prompts", () => ({
...promptsStubs,
@@ -37,31 +51,296 @@ mock.module("../../lib/listage.ts", () => ({
select: (...args: unknown[]) => mockSelect(...args),
}));
+mock.module("../../lib/plapi.ts", () => ({
+ fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args),
+ fetchApplication: (...args: unknown[]) => mockFetchApplication(...args),
+ listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args),
+}));
+
+mock.module("./api.ts", () => ({
+ createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args),
+ validateCloning: (...args: unknown[]) => mockValidateCloning(...args),
+ getDeployStatus: (...args: unknown[]) => mockGetDeployStatus(...args),
+ retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args),
+ retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args),
+ patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args),
+}));
+
+mock.module("./domain-connect.ts", () => ({
+ domainConnectUrl: (...args: unknown[]) => mockDomainConnectUrl(...args),
+}));
+
+mock.module("../../lib/sleep.ts", () => ({
+ sleep: () => Promise.resolve(),
+}));
+
+const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts");
const { deploy } = await import("./index.ts");
+const { providerSetupIntro } = await import("./providers.ts");
+
+function stripAnsi(value: string): string {
+ return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
+}
+
+function promptExitError(): Error {
+ const error = new Error("User force closed the prompt with SIGINT");
+ error.name = "ExitPromptError";
+ return error;
+}
describe("deploy", () => {
let consoleSpy: ReturnType;
+ let stderrSpy: ReturnType | undefined;
let captured: ReturnType;
+ let tempDir: string;
beforeEach(() => {
captured = captureLog();
+ tempDir = "";
+ // Sensible defaults so most tests need only override what they exercise.
+ mockFetchInstanceConfig.mockResolvedValue({
+ connection_oauth_google: { enabled: true },
+ });
+ mockFetchApplication.mockResolvedValue({
+ application_id: "app_xyz789",
+ name: "my-saas-app",
+ instances: [
+ {
+ instance_id: "ins_dev_123",
+ environment_type: "development",
+ publishable_key: "pk_test_123",
+ },
+ ],
+ });
+ mockListApplicationDomains.mockResolvedValue({
+ data: [
+ {
+ object: "domain",
+ id: "dmn_prod_mock",
+ name: "example.com",
+ is_satellite: false,
+ is_provider_domain: false,
+ frontend_api_url: "https://clerk.example.com",
+ accounts_portal_url: "https://accounts.example.com",
+ development_origin: "",
+ cname_targets: [
+ {
+ host: "clerk.example.com",
+ value: "frontend-api.clerk.services",
+ required: true,
+ },
+ ],
+ created_at: "2026-05-06T00:00:00Z",
+ updated_at: "2026-05-06T00:00:00Z",
+ },
+ ],
+ total_count: 1,
+ });
+ mockValidateCloning.mockResolvedValue(undefined);
+ mockGetDeployStatus.mockResolvedValue({ status: "complete" });
+ mockCreateProductionInstance.mockImplementation(
+ (_appId: string, params: { home_url: string }) => ({
+ instance_id: "ins_prod_mock",
+ environment_type: "production" as const,
+ active_domain: { id: "dmn_prod_mock", name: params.home_url },
+ publishable_key: "pk_live_test",
+ secret_key: "sk_live_test",
+ cname_targets: [
+ {
+ host: `clerk.${params.home_url}`,
+ value: "frontend-api.clerk.services",
+ required: true,
+ },
+ {
+ host: `accounts.${params.home_url}`,
+ value: "accounts.clerk.services",
+ required: true,
+ },
+ {
+ host: `clkmail.${params.home_url}`,
+ value: `mail.${params.home_url}.nam1.clerk.services`,
+ required: true,
+ },
+ ],
+ }),
+ );
+ mockDomainConnectUrl.mockReturnValue(undefined);
});
- afterEach(() => {
+ afterEach(async () => {
captured.teardown();
+ _setConfigDir(undefined);
+ if (tempDir) {
+ await rm(tempDir, { recursive: true, force: true });
+ }
_modeOverride = undefined;
mockIsAgent.mockReset();
mockSelect.mockReset();
mockInput.mockReset();
mockConfirm.mockReset();
mockPassword.mockReset();
+ mockPatchInstanceConfig.mockReset();
+ mockFetchInstanceConfig.mockReset();
+ mockFetchApplication.mockReset();
+ mockListApplicationDomains.mockReset();
+ mockCreateProductionInstance.mockReset();
+ mockValidateCloning.mockReset();
+ mockGetDeployStatus.mockReset();
+ mockRetrySSL.mockReset();
+ mockRetryMail.mockReset();
+ mockDomainConnectUrl.mockReset();
consoleSpy?.mockRestore();
+ stderrSpy?.mockRestore();
});
function runDeploy(options: Parameters[0]) {
return captured.run(() => deploy(options));
}
+ async function expectTestApiFailure(promise: Promise, message: string): Promise {
+ let error: Error | undefined;
+ try {
+ await promise;
+ } catch (caught) {
+ error = caught as Error;
+ }
+
+ expect(error).toBeInstanceOf(Error);
+ expect(error).not.toBeInstanceOf(CliError);
+ expect(error?.message).toContain(message);
+ return error!;
+ }
+
+ async function linkedProject(profile: Record = {}) {
+ tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-"));
+ _setConfigDir(tempDir);
+ const nextProfile = {
+ workspaceId: "workspace_123",
+ appId: "app_xyz789",
+ appName: "my-saas-app",
+ instances: { development: "ins_dev_123" },
+ ...profile,
+ } as never;
+ await setProfile(process.cwd(), nextProfile);
+
+ const typedProfile = nextProfile as {
+ instances: { production?: string };
+ };
+ const productionInstanceId = typedProfile.instances.production;
+ if (productionInstanceId) {
+ mockLiveProduction({
+ instanceId: productionInstanceId,
+ domain: "example.com",
+ productionConfig: {
+ connection_oauth_google: {
+ enabled: true,
+ client_id: "google-client-id.apps.googleusercontent.com",
+ client_secret: "REDACTED",
+ },
+ },
+ });
+ }
+ }
+
+ function mockLiveProduction(
+ options: {
+ instanceId?: string;
+ domain?: string;
+ domainId?: string;
+ productionConfig?: Record;
+ developmentConfig?: Record;
+ } = {},
+ ) {
+ const instanceId = options.instanceId ?? "ins_prod_mock";
+ const domain = options.domain ?? "example.com";
+ const domainId = options.domainId ?? "dmn_prod_mock";
+ const developmentConfig = options.developmentConfig ?? {
+ connection_oauth_google: { enabled: true },
+ };
+ const productionConfig = options.productionConfig ?? {
+ connection_oauth_google: { enabled: false, client_id: "", client_secret: "" },
+ };
+
+ mockFetchApplication.mockResolvedValue({
+ application_id: "app_xyz789",
+ name: "my-saas-app",
+ instances: [
+ {
+ instance_id: "ins_dev_123",
+ environment_type: "development",
+ publishable_key: "pk_test_123",
+ },
+ {
+ instance_id: instanceId,
+ environment_type: "production",
+ publishable_key: "pk_live_123",
+ },
+ ],
+ });
+ mockListApplicationDomains.mockResolvedValue({
+ data: [
+ {
+ object: "domain",
+ id: domainId,
+ name: domain,
+ is_satellite: false,
+ is_provider_domain: false,
+ frontend_api_url: `https://clerk.${domain}`,
+ accounts_portal_url: `https://accounts.${domain}`,
+ development_origin: "",
+ cname_targets: [
+ { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true },
+ ],
+ created_at: "2026-05-06T00:00:00Z",
+ updated_at: "2026-05-06T00:00:00Z",
+ },
+ ],
+ total_count: 1,
+ });
+ mockFetchInstanceConfig.mockImplementation((_appId: string, instanceIdOrEnv: string) => {
+ if (instanceIdOrEnv === instanceId || instanceIdOrEnv === "production") {
+ return productionConfig;
+ }
+ return developmentConfig;
+ });
+ }
+
+ test("provider setup intro includes docs-backed copy for each OAuth provider", () => {
+ const intros = {
+ google: providerSetupIntro("google").map(stripAnsi),
+ github: providerSetupIntro("github").map(stripAnsi),
+ microsoft: providerSetupIntro("microsoft").map(stripAnsi),
+ apple: providerSetupIntro("apple").map(stripAnsi),
+ linear: providerSetupIntro("linear").map(stripAnsi),
+ };
+
+ expect(intros.google).toEqual([
+ "Configure Google OAuth for production",
+ "Production Google sign-in requires custom OAuth credentials from Google Cloud Console.",
+ "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/google",
+ ]);
+ expect(intros.github).toEqual([
+ "Configure GitHub OAuth for production",
+ "Production GitHub sign-in requires a GitHub OAuth app and custom credentials.",
+ "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/github",
+ ]);
+ expect(intros.microsoft).toEqual([
+ "Configure Microsoft OAuth for production",
+ "Production Microsoft sign-in requires a Microsoft Entra ID app and custom credentials.",
+ "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/microsoft",
+ ]);
+ expect(intros.apple).toEqual([
+ "Configure Apple OAuth for production",
+ "Production Apple sign-in requires an Apple Services ID, Team ID, Key ID, and private key file.",
+ "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/apple",
+ ]);
+ expect(intros.linear).toEqual([
+ "Configure Linear OAuth for production",
+ "Production Linear sign-in requires a Linear OAuth app and custom credentials.",
+ "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/linear",
+ ]);
+ });
+
describe("agent mode", () => {
test("outputs deploy prompt and returns", async () => {
mockIsAgent.mockReturnValue(true);
@@ -80,14 +359,14 @@ describe("deploy", () => {
const output = captured.out;
expect(output).toContain("Prerequisites");
- expect(output).toContain("Verify Subscription Compatibility");
- expect(output).toContain("Choose a Production Domain");
+ expect(output).toContain("Validate Cloning");
+ expect(output).toContain("Discover enabled OAuth providers");
expect(output).toContain("Create the Production Instance");
expect(output).toContain("Configure Social OAuth Providers");
expect(output).toContain("Finalize");
});
- test("prompt includes API reference", async () => {
+ test("prompt includes API reference for new deploy lifecycle endpoints", async () => {
mockIsAgent.mockReturnValue(true);
consoleSpy = spyOn(console, "log").mockImplementation(() => {});
@@ -95,8 +374,11 @@ describe("deploy", () => {
const output = captured.out;
expect(output).toContain("/v1/platform/applications");
- expect(output).toContain("instances/production/config");
- expect(output).toContain("instances/development/config");
+ expect(output).toContain("validate_cloning");
+ expect(output).toContain("production_instance");
+ expect(output).toContain("deploy_status");
+ expect(output).toContain("ssl_retry");
+ expect(output).toContain("mail_retry");
});
test("prompt includes OAuth redirect URI pattern", async () => {
@@ -125,13 +407,33 @@ describe("deploy", () => {
describe("human mode", () => {
function mockHumanFlow() {
mockIsAgent.mockReturnValue(false);
- // Domain selection → OAuth credential choice
- mockSelect.mockResolvedValueOnce("clerk-subdomain").mockResolvedValueOnce("have-credentials");
+ // Proceed → pause after DNS handoff.
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false);
+ mockInput.mockResolvedValueOnce("example.com");
+ }
+
+ async function runDnsHandoff() {
+ mockHumanFlow();
+ await runDeploy({});
+ mockLiveProduction();
+ captured = captureLog();
+ mockConfirm.mockReset();
+ mockSelect.mockReset();
+ mockInput.mockReset();
+ mockPassword.mockReset();
+ }
+
+ function mockOAuthCompletion() {
+ mockSelect.mockResolvedValueOnce("have-credentials");
mockInput.mockResolvedValueOnce("fake-client-id-12345");
mockPassword.mockResolvedValueOnce("fake-secret");
}
test("does not print deploy prompt", async () => {
+ await linkedProject();
mockHumanFlow();
consoleSpy = spyOn(console, "log").mockImplementation(() => {});
@@ -141,14 +443,981 @@ describe("deploy", () => {
expect(allOutput).not.toContain("deploying a Clerk application to production");
});
- test("shows mock banner", async () => {
+ test("calls validate_cloning preflight before plan summary", async () => {
+ await linkedProject();
mockHumanFlow();
- consoleSpy = spyOn(console, "log").mockImplementation(() => {});
await runDeploy({});
- const allOutput = captured.out;
- expect(allOutput).toContain("[mock]");
+ expect(mockValidateCloning).toHaveBeenCalledWith("app_xyz789", {
+ clone_instance_id: "ins_dev_123",
+ });
+ });
+
+ test("checks for an existing production instance before reading development config", async () => {
+ await linkedProject();
+ mockHumanFlow();
+ stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
+
+ await runDeploy({});
+ const err = stripAnsi(
+ stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""),
+ );
+
+ const productionCheckIndex = err.indexOf("Checking for production instance...");
+ const developmentConfigIndex = err.indexOf("Reading development configuration...");
+ expect(productionCheckIndex).toBeGreaterThan(-1);
+ expect(developmentConfigIndex).toBeGreaterThan(-1);
+ expect(productionCheckIndex).toBeLessThan(developmentConfigIndex);
+ });
+
+ test("discovers enabled OAuth providers by iterating the dev config response", async () => {
+ await linkedProject();
+ mockHumanFlow();
+ mockFetchInstanceConfig.mockResolvedValueOnce({
+ connection_oauth_google: { enabled: true },
+ connection_oauth_github: { enabled: true },
+ connection_oauth_microsoft: { enabled: false },
+ connection_oauth_unknown: { enabled: true },
+ unrelated_key: "ignored",
+ });
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(mockFetchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_dev_123");
+ expect(err).toContain("Configure Google OAuth credentials");
+ expect(err).toContain("Configure GitHub OAuth credentials");
+ expect(err).not.toContain("Configure Microsoft OAuth credentials");
+ expect(err).toContain("not yet supported by `clerk deploy`: unknown");
+ expect(err).toContain("Configure them from the Clerk Dashboard before going live");
+ });
+
+ test("DNS verification polls getDeployStatus until complete", async () => {
+ await linkedProject();
+ // Proceed → continue after DNS handoff → complete OAuth.
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true);
+ mockInput
+ .mockResolvedValueOnce("example.com")
+ .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockSelect.mockResolvedValueOnce("have-credentials");
+ mockPassword.mockResolvedValueOnce("google-secret");
+ mockGetDeployStatus
+ .mockResolvedValueOnce({ status: "incomplete" })
+ .mockResolvedValueOnce({ status: "complete" });
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock");
+ expect(mockGetDeployStatus.mock.calls.length).toBeGreaterThanOrEqual(2);
+ expect(err).toContain("DNS verified for example.com");
+ expect(err).toContain("Production ready at https://example.com");
+ });
+
+ test("uses existing wizard framing and concise plan confirmation", async () => {
+ await linkedProject();
+ mockHumanFlow();
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(mockConfirm).toHaveBeenCalledWith({ message: "Proceed?", default: true });
+ expect(err).toContain("clerk deploy will prepare my-saas-app for production");
+ expect(err).toContain("[ ] Create production instance");
+ expect(err).toContain("[ ] Configure DNS records");
+ expect(err).toContain("[ ] Configure Google OAuth credentials");
+ expect(err).toContain("Check the Domains section in the Clerk Dashboard");
+ });
+
+ test("asks directly for an owned production domain and accepts short domains", async () => {
+ await linkedProject();
+ mockHumanFlow();
+
+ await runDeploy({});
+
+ const firstInputArg = mockInput.mock.calls[0]?.[0] as {
+ message: string;
+ validate: (value: string) => true | string;
+ };
+ expect(firstInputArg.message).toContain("Production domain");
+ expect(firstInputArg.validate("x.io")).toBe(true);
+ expect(firstInputArg.validate("https://example.com")).toContain("without https://");
+ expect(firstInputArg.validate("example..com")).toContain("Enter a valid domain");
+ expect(firstInputArg.validate("example-.com")).toContain("Enter a valid domain");
+ expect(firstInputArg.validate("-example.com")).toContain("Enter a valid domain");
+ expect(firstInputArg.validate("demo.vercel.app")).toContain(
+ "Production needs a domain you own",
+ );
+ expect(firstInputArg.validate("demo.clerk.app")).toContain(
+ "Production needs a domain you own",
+ );
+ expect(mockSelect).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: "How would you like to set up your production domain?",
+ }),
+ );
+ });
+
+ test("Ctrl-C before changes are made reports cancelled instead of done", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm.mockRejectedValueOnce(promptExitError());
+ stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
+
+ await expect(runDeploy({})).rejects.toBeInstanceOf(UserAbortError);
+
+ const config = await readConfig();
+ expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined();
+ const terminalOutput = stderrSpy.mock.calls
+ .map((call: unknown[]) => String(call[0]))
+ .join("");
+ expect(terminalOutput).toContain("Cancelled");
+ expect(terminalOutput).not.toContain("Done");
+ });
+
+ test("Ctrl-C at domain collection reports cancelled instead of done", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm.mockResolvedValueOnce(true);
+ mockInput.mockRejectedValueOnce(promptExitError());
+ stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
+
+ await expect(runDeploy({})).rejects.toBeInstanceOf(UserAbortError);
+
+ const config = await readConfig();
+ expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined();
+ const terminalOutput = stderrSpy.mock.calls
+ .map((call: unknown[]) => String(call[0]))
+ .join("");
+ expect(terminalOutput).toContain("Cancelled");
+ expect(terminalOutput).not.toContain("Done");
+ });
+
+ test("prints production next steps after successful deploy", async () => {
+ await linkedProject();
+ await runDnsHandoff();
+ mockOAuthCompletion();
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain("Next steps");
+ expect(err).toContain("clerk env pull --instance prod");
+ expect(err).toContain("Update env vars on your hosting provider");
+ expect(err).toContain("Production keys only work on your production domain");
+ });
+
+ test("DNS setup prints dashboard handoff and asks before continuing", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false);
+ mockInput.mockResolvedValueOnce("example.com");
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+ expect(err).toContain("Clerk will associate these subdomains with example.com");
+ expect(err).toContain("clerk.example.com");
+ expect(err).toContain("accounts.example.com");
+ expect(err).toContain("clkmail.example.com");
+ expect(err).toContain("This will create a Clerk production instance");
+ expect(err).toContain("Add the following records at your DNS provider");
+ expect(err).toContain("Check the Domains section in the Clerk Dashboard");
+ expect(err).toContain("propagation and SSL issuance");
+ expect(err).toContain("run `clerk deploy` again later");
+ expect(mockConfirm).toHaveBeenCalledTimes(3);
+ expect(mockConfirm).toHaveBeenCalledWith({
+ message: "Create production instance?",
+ default: true,
+ });
+ expect(mockConfirm).toHaveBeenCalledWith({
+ message: "Continue to OAuth setup?",
+ default: true,
+ });
+ expect(mockConfirm).not.toHaveBeenCalledWith({
+ message: "Configure and verify DNS now?",
+ default: true,
+ });
+ expect(mockConfirm).not.toHaveBeenCalledWith({
+ message: "Have the DNS records been added?",
+ default: true,
+ });
+ });
+
+ test("declining production instance creation does not call the production instance API", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
+ mockInput.mockResolvedValueOnce("example.com");
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain("Clerk will associate these subdomains with example.com");
+ expect(err).toContain("No production instance was created.");
+ expect(mockCreateProductionInstance).not.toHaveBeenCalled();
+ expect(mockConfirm).toHaveBeenCalledWith({
+ message: "Create production instance?",
+ default: true,
+ });
+ });
+
+ test("Ctrl-C at the DNS handoff reports paused", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockRejectedValueOnce(promptExitError());
+ mockInput.mockResolvedValueOnce("example.com");
+ stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
+
+ let error: CliError | undefined;
+ try {
+ await runDeploy({});
+ } catch (caught) {
+ error = caught as CliError;
+ }
+ expect(error?.message).toContain("Deploy paused at: DNS verification");
+ expect(error?.message).toContain("Run `clerk deploy` again");
+ expect(error?.exitCode).toBe(EXIT_CODE.SIGINT);
+ const terminalOutput = stderrSpy.mock.calls
+ .map((call: unknown[]) => String(call[0]))
+ .join("");
+ expect(terminalOutput).toContain("Paused");
+ expect(terminalOutput).not.toContain("Done");
+ });
+
+ test("Google OAuth can load credentials from a downloaded JSON file", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ const googleJsonPath = join(tempDir, "client_secret_google.json");
+ await Bun.write(
+ googleJsonPath,
+ JSON.stringify({
+ web: {
+ client_id: "google-json-client.apps.googleusercontent.com",
+ client_secret: "fake-json-secret",
+ },
+ }),
+ );
+ await runDnsHandoff();
+ mockConfirm.mockResolvedValueOnce(true);
+ mockSelect.mockResolvedValueOnce("google-json");
+ mockInput.mockResolvedValueOnce(googleJsonPath);
+ await runDeploy({});
+ const oauthSelect = mockSelect.mock.calls.find((call) =>
+ String((call[0] as { message?: string }).message).includes("Google OAuth"),
+ )?.[0] as { choices: Array<{ name: string; value: string }> };
+
+ expect(oauthSelect.choices).toContainEqual({
+ name: "Load credentials from a Google Cloud Console JSON file",
+ value: "google-json",
+ });
+ expect(mockPassword).not.toHaveBeenCalled();
+ expect(captured.err).toContain("Saved Google OAuth credentials");
+ });
+
+ test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => {
+ await linkedProject({
+ instances: { development: "ins_dev_123", production: "ins_prod_apple" },
+ });
+ mockLiveProduction({
+ instanceId: "ins_prod_apple",
+ developmentConfig: {
+ connection_oauth_apple: { enabled: true },
+ },
+ productionConfig: {
+ connection_oauth_apple: {
+ enabled: true,
+ client_id: "",
+ team_id: "",
+ key_id: "",
+ client_secret: "",
+ },
+ },
+ });
+ mockIsAgent.mockReturnValue(false);
+
+ const invalidP8Path = join(tempDir, "not-a-key.p8");
+ const validP8Path = join(tempDir, "AuthKey.p8");
+ await Bun.write(invalidP8Path, "not a real key");
+ await Bun.write(
+ validP8Path,
+ "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n",
+ );
+
+ mockConfirm.mockResolvedValueOnce(true);
+ mockSelect.mockResolvedValueOnce("have-credentials");
+ mockInput
+ .mockResolvedValueOnce("apple-services-id")
+ .mockResolvedValueOnce("apple-team-id")
+ .mockResolvedValueOnce("apple-key-id")
+ .mockResolvedValueOnce(validP8Path);
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+
+ await runDeploy({});
+
+ const p8Input = mockInput.mock.calls.find((call) =>
+ String((call[0] as { message?: string }).message).includes("Apple Private Key"),
+ )?.[0] as { validate: (value: string) => Promise };
+ await expect(p8Input.validate("nope")).resolves.toContain("No file at nope.");
+ await expect(p8Input.validate(invalidP8Path)).resolves.toContain(
+ "missing the -----BEGIN PRIVATE KEY----- framing",
+ );
+ await expect(p8Input.validate(validP8Path)).resolves.toBe(true);
+ const relativeP8Path = relative(process.cwd(), validP8Path);
+ await expect(p8Input.validate(relativeP8Path)).resolves.toBe(true);
+ });
+
+ test("Google OAuth JSON file prompt validates path and shape before continuing", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ const invalidJsonPath = join(tempDir, "not-google.json");
+ const googleJsonPath = join(tempDir, "client_secret_google.json");
+ await Bun.write(invalidJsonPath, JSON.stringify({ nope: true }));
+ await Bun.write(
+ googleJsonPath,
+ JSON.stringify({
+ web: {
+ client_id: "google-json-client.apps.googleusercontent.com",
+ client_secret: "fake-json-secret",
+ },
+ }),
+ );
+ await runDnsHandoff();
+ mockConfirm.mockResolvedValueOnce(true);
+ mockSelect.mockResolvedValueOnce("google-json");
+ mockInput.mockResolvedValueOnce(googleJsonPath);
+ await runDeploy({});
+
+ const jsonInput = mockInput.mock.calls.find((call) =>
+ String((call[0] as { message?: string }).message).includes("Google OAuth JSON file path"),
+ )?.[0] as { validate: (value: string) => Promise };
+ await expect(jsonInput.validate("df")).resolves.toContain("No file at df.");
+ await expect(jsonInput.validate(invalidJsonPath)).resolves.toContain(
+ `That JSON file doesn't look like a Google OAuth client download`,
+ );
+ await expect(jsonInput.validate(googleJsonPath)).resolves.toBe(true);
+ const relativeJsonPath = relative(process.cwd(), googleJsonPath);
+ await expect(jsonInput.validate(relativeJsonPath)).resolves.toBe(true);
+ });
+
+ test("plain deploy is a no-op when the API reports deploy is already complete", async () => {
+ await linkedProject();
+ mockLiveProduction({
+ instanceId: "ins_prod_from_api",
+ productionConfig: {
+ connection_oauth_google: {
+ enabled: true,
+ client_id: "google-client-id.apps.googleusercontent.com",
+ client_secret: "REDACTED",
+ },
+ },
+ });
+ mockIsAgent.mockReturnValue(false);
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain("clerk deploy will prepare my-saas-app for production");
+ expect(err).toContain("[x] Create production instance");
+ expect(err).toContain("[x] Configure DNS records");
+ expect(err).toContain("[x] Configure Google OAuth credentials");
+ expect(err).toContain("No deploy actions remain.");
+ expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789");
+ expect(mockInput).not.toHaveBeenCalled();
+ expect(mockSelect).not.toHaveBeenCalled();
+ });
+
+ test("--test-force-production-instance makes app retrieval include mocked production", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockSelect.mockResolvedValueOnce("skip");
+ mockListApplicationDomains.mockRejectedValueOnce(
+ new Error("domains should be mocked when forcing production"),
+ );
+ mockFetchInstanceConfig.mockImplementation((_appId: string, instanceIdOrEnv: string) => {
+ if (instanceIdOrEnv === "ins_prod_mock") {
+ throw new Error("production config should be mocked when forcing production");
+ }
+ return { connection_oauth_google: { enabled: true } };
+ });
+
+ await runDeploy({ testForceProductionInstance: true });
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain("[x] Create production instance");
+ expect(err).toContain("Use production domain example.com");
+ expect(mockCreateProductionInstance).not.toHaveBeenCalled();
+ expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789");
+ expect(mockListApplicationDomains).not.toHaveBeenCalled();
+ expect(mockFetchInstanceConfig).not.toHaveBeenCalledWith("app_xyz789", "ins_prod_mock");
+ });
+
+ test("--test-fail-production-instance-check simulates production instance lookup failure", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+
+ await expectTestApiFailure(
+ runDeploy({ testFailProductionInstanceCheck: true }),
+ "Simulated deploy failure: production instance check.",
+ );
+
+ expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789");
+ expect(mockFetchInstanceConfig).not.toHaveBeenCalled();
+ });
+
+ test("--test-fail-production-instance-check prints Failed in interactive output", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
+ const originalCi = process.env.CI;
+ const originalIsTty = process.stderr.isTTY;
+ Object.defineProperty(process.stderr, "isTTY", { configurable: true, value: true });
+ delete process.env.CI;
+
+ try {
+ await expectTestApiFailure(
+ runDeploy({ testFailProductionInstanceCheck: true }),
+ "Simulated deploy failure: production instance check.",
+ );
+ } finally {
+ Object.defineProperty(process.stderr, "isTTY", {
+ configurable: true,
+ value: originalIsTty,
+ });
+ if (originalCi === undefined) {
+ delete process.env.CI;
+ } else {
+ process.env.CI = originalCi;
+ }
+ }
+
+ const terminalOutput = stripAnsi(
+ stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""),
+ );
+ expect(terminalOutput).toContain("Failed");
+ });
+
+ test("--test-fail-domain-lookup simulates production domain lookup failure", async () => {
+ await linkedProject();
+ mockLiveProduction({
+ instanceId: "ins_prod_from_api",
+ productionConfig: {},
+ });
+ mockIsAgent.mockReturnValue(false);
+
+ await expectTestApiFailure(
+ runDeploy({ testFailDomainLookup: true }),
+ "Simulated deploy failure: production domain lookup.",
+ );
+
+ expect(mockListApplicationDomains).toHaveBeenCalledWith("app_xyz789");
+ });
+
+ test("--test-fail-validate-cloning simulates cloning validation failure", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+
+ await expectTestApiFailure(
+ runDeploy({ testFailValidateCloning: true }),
+ "Simulated deploy failure: cloning validation.",
+ );
+
+ expect(mockValidateCloning).toHaveBeenCalledWith("app_xyz789", {
+ clone_instance_id: "ins_dev_123",
+ });
+ expect(mockCreateProductionInstance).not.toHaveBeenCalled();
+ });
+
+ test("--test-fail-create-production-instance simulates production creation failure", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
+ mockInput.mockResolvedValueOnce("example.com");
+
+ await expectTestApiFailure(
+ runDeploy({ testFailCreateProductionInstance: true }),
+ "Simulated deploy failure: production instance creation.",
+ );
+
+ expect(mockCreateProductionInstance).toHaveBeenCalledWith("app_xyz789", {
+ home_url: "example.com",
+ clone_instance_id: "ins_dev_123",
+ });
+ });
+
+ test("--test-fail-dns-verification simulates DNS verification failure", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true);
+ mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials");
+ mockInput
+ .mockResolvedValueOnce("example.com")
+ .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockPassword.mockResolvedValueOnce("google-secret");
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+
+ await expectTestApiFailure(
+ runDeploy({ testFailDnsVerification: true }),
+ "Simulated deploy failure: DNS verification.",
+ );
+
+ expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock");
+ expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
+ });
+
+ test("--test-fail-oauth-save simulates OAuth credential save failure", async () => {
+ await linkedProject({
+ instances: { development: "ins_dev_123", production: "ins_prod_123" },
+ });
+ mockLiveProduction({
+ instanceId: "ins_prod_123",
+ productionConfig: {},
+ });
+ mockIsAgent.mockReturnValue(false);
+ mockSelect.mockResolvedValueOnce("have-credentials");
+ mockConfirm.mockResolvedValueOnce(true);
+ mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockPassword.mockResolvedValueOnce("google-secret");
+
+ await expectTestApiFailure(
+ runDeploy({ testFailOAuthSave: true }),
+ "Simulated deploy failure: OAuth credential save.",
+ );
+
+ expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_123", {
+ connection_oauth_google: {
+ enabled: true,
+ client_id: "google-client-id.apps.googleusercontent.com",
+ client_secret: "google-secret",
+ },
+ });
+ });
+
+ test("plain deploy resumes DNS verification from live API state", async () => {
+ await linkedProject({
+ instances: { development: "ins_dev_123", production: "ins_prod_123" },
+ });
+ mockIsAgent.mockReturnValue(false);
+ mockLiveProduction({
+ instanceId: "ins_prod_123",
+ productionConfig: {},
+ });
+ mockGetDeployStatus
+ .mockResolvedValueOnce({ status: "incomplete" })
+ .mockResolvedValueOnce({ status: "complete" });
+ mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
+ mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials");
+ mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockPassword.mockResolvedValueOnce("google-secret");
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain("[x] Create production instance");
+ expect(err).toContain("[ ] Configure DNS records");
+ expect(err).toContain("[ ] Configure Google OAuth credentials");
+ expect(err).toContain("DNS verified for example.com");
+ expect(mockSelect).toHaveBeenCalledWith({
+ message: "DNS verification",
+ choices: [
+ { name: "Check DNS now", value: "check" },
+ { name: "Skip DNS verification for now", value: "skip" },
+ ],
+ });
+ const firstInput = mockInput.mock.calls[0]?.[0] as { message?: string } | undefined;
+ expect(String(firstInput?.message)).not.toContain("Production domain");
+ });
+
+ test("plain deploy can skip DNS verification and continue configuring production", async () => {
+ await linkedProject({
+ instances: { development: "ins_dev_123", production: "ins_prod_123" },
+ });
+ mockIsAgent.mockReturnValue(false);
+ mockLiveProduction({
+ instanceId: "ins_prod_123",
+ productionConfig: {},
+ });
+ mockGetDeployStatus.mockResolvedValue({ status: "incomplete" });
+ mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials");
+ mockConfirm.mockResolvedValueOnce(true);
+ mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockPassword.mockResolvedValueOnce("google-secret");
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain("Saved Google OAuth credentials");
+ expect(err).toContain("Domain DNS pending");
+ expect(err).not.toContain("Domain Verified");
+ expect(mockSelect).toHaveBeenCalledWith({
+ message: "DNS verification",
+ choices: [
+ { name: "Check DNS now", value: "check" },
+ { name: "Skip DNS verification for now", value: "skip" },
+ ],
+ });
+ expect(mockGetDeployStatus).toHaveBeenCalledTimes(1);
+ expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_123", {
+ connection_oauth_google: {
+ enabled: true,
+ client_id: "google-client-id.apps.googleusercontent.com",
+ client_secret: "google-secret",
+ },
+ });
+ });
+
+ test("DNS handoff reports plain deploy for later continuation", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false);
+ mockInput.mockResolvedValueOnce("example.com");
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+ expect(err).toContain("Check the Domains section in the Clerk Dashboard");
+ expect(err).toContain("run `clerk deploy` again later");
+ });
+
+ test("Ctrl-C during OAuth setup reports plain deploy continuation", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ await runDnsHandoff();
+ mockSelect.mockRejectedValueOnce(promptExitError());
+ stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
+
+ let error: CliError | undefined;
+ try {
+ await runDeploy({});
+ } catch (caught) {
+ error = caught as CliError;
+ }
+ expect(error?.message).toContain("Deploy paused at: Google OAuth credential setup");
+ expect(error?.message).toContain("Run `clerk deploy` again");
+ expect(error?.exitCode).toBe(EXIT_CODE.SIGINT);
+ const terminalOutput = stderrSpy.mock.calls
+ .map((call: unknown[]) => String(call[0]))
+ .join("");
+ expect(terminalOutput).toContain("Paused");
+ expect(terminalOutput).not.toContain("Done");
+ });
+
+ test("saves OAuth credentials to the production instance from live deploy state", async () => {
+ await linkedProject({
+ instances: { development: "ins_dev_123", production: "ins_prod_created_456" },
+ });
+ mockLiveProduction({
+ instanceId: "ins_prod_created_456",
+ productionConfig: {},
+ });
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
+ mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials");
+ mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockPassword.mockResolvedValueOnce("google-secret");
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+ mockGetDeployStatus.mockReset();
+ mockGetDeployStatus
+ .mockResolvedValueOnce({ status: "incomplete" })
+ .mockResolvedValueOnce({ status: "complete" });
+
+ await runDeploy({});
+
+ const err = stripAnsi(captured.err);
+ expect(captured.err).toContain("\x1b[1mConfigure OAuth credentials for production\x1b[0m");
+ expect(err).toContain("Configure Google OAuth for production");
+ expect(err).toContain(
+ "Production Google sign-in requires custom OAuth credentials from Google Cloud Console.",
+ );
+ expect(err).toContain(
+ "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/google",
+ );
+ expect(mockConfirm).not.toHaveBeenCalledWith({
+ message: "Set up Google OAuth now?",
+ default: true,
+ });
+ expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_created_456", {
+ connection_oauth_google: {
+ enabled: true,
+ client_id: "google-client-id.apps.googleusercontent.com",
+ client_secret: "google-secret",
+ },
+ });
+ });
+
+ test("plain deploy resolves complete live API state without prompting", async () => {
+ await linkedProject({
+ instances: { development: "ins_dev_123", production: "ins_prod_123" },
+ });
+ mockIsAgent.mockReturnValue(false);
+ mockLiveProduction({
+ instanceId: "ins_prod_123",
+ developmentConfig: {},
+ productionConfig: {},
+ });
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain("[x] Create production instance");
+ expect(err).toContain("[x] Configure DNS records");
+ expect(err).toContain("No deploy actions remain.");
+ expect(mockSelect).not.toHaveBeenCalled();
+ expect(mockInput).not.toHaveBeenCalled();
+ });
+
+ test("custom-domain DNS setup can pause and later resume", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(false);
+ mockInput.mockResolvedValueOnce("example.com");
+
+ await runDeploy({});
+ mockLiveProduction();
+ expect(stripAnsi(captured.err)).toContain("Check the Domains section in the Clerk Dashboard");
+
+ captured = captureLog();
+ mockConfirm.mockReset();
+ mockSelect.mockReset();
+ mockInput.mockReset();
+ mockPassword.mockReset();
+ mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
+ mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials");
+ mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockPassword.mockResolvedValueOnce("google-secret");
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+ mockGetDeployStatus.mockReset();
+ mockGetDeployStatus
+ .mockResolvedValueOnce({ status: "incomplete" })
+ .mockResolvedValueOnce({ status: "complete" });
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ const config = await readConfig();
+ expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_mock");
+ expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", {
+ connection_oauth_google: {
+ enabled: true,
+ client_id: "google-client-id.apps.googleusercontent.com",
+ client_secret: "google-secret",
+ },
+ });
+ expect(err).toContain("DNS verified for example.com");
+ expect(err).not.toContain("Issuing SSL certificates");
+ expect(err).not.toContain("SSL certificates are usually issued");
+ expect(err).not.toContain("SSL Issuing");
+ expect(err).toContain("Production ready at https://example.com");
+ });
+
+ test("OAuth setup can pause and resume at the pending provider", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ await runDnsHandoff();
+ mockSelect.mockResolvedValueOnce("skip");
+
+ await runDeploy({});
+ const pausedErr = stripAnsi(captured.err);
+ expect(pausedErr).toContain("Deploy paused");
+ expect(pausedErr).toContain("Run `clerk deploy` again");
+
+ captured = captureLog();
+ mockConfirm.mockReset();
+ mockSelect.mockReset();
+ mockInput.mockReset();
+ mockPassword.mockReset();
+ mockSelect.mockResolvedValueOnce("have-credentials");
+ mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockPassword.mockResolvedValueOnce("google-secret");
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ const config = await readConfig();
+ expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_mock");
+ expect(err).toContain("Saved Google OAuth credentials");
+ expect(err).toContain("Production ready at https://example.com");
+ });
+
+ test("Pausing OAuth mid-loop infers earlier completed providers from production config", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockFetchInstanceConfig.mockResolvedValue({
+ connection_oauth_google: { enabled: true },
+ connection_oauth_github: { enabled: true },
+ });
+ // Proceed → create prod → continue after DNS → enter google creds → skip github.
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true);
+ mockInput
+ .mockResolvedValueOnce("example.com")
+ .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("skip");
+ mockPassword.mockResolvedValueOnce("google-secret");
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+
+ await runDeploy({});
+ mockLiveProduction({
+ developmentConfig: {
+ connection_oauth_google: { enabled: true },
+ connection_oauth_github: { enabled: true },
+ },
+ productionConfig: {
+ connection_oauth_google: {
+ enabled: true,
+ client_id: "google-client-id.apps.googleusercontent.com",
+ client_secret: "REDACTED",
+ },
+ connection_oauth_github: { enabled: true, client_id: "", client_secret: "" },
+ },
+ });
+
+ // Resume and finish: should not re-prompt for google, should finalize.
+ captured = captureLog();
+ mockConfirm.mockReset();
+ mockSelect.mockReset();
+ mockInput.mockReset();
+ mockPassword.mockReset();
+ mockPatchInstanceConfig.mockReset();
+ mockSelect.mockResolvedValueOnce("have-credentials");
+ mockInput.mockResolvedValueOnce("github-client-id");
+ mockPassword.mockResolvedValueOnce("github-secret");
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+ expect(mockPatchInstanceConfig).toHaveBeenCalledTimes(1);
+ expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", {
+ connection_oauth_github: {
+ enabled: true,
+ client_id: "github-client-id",
+ client_secret: "github-secret",
+ },
+ });
+ expect(err).toContain("Production ready at https://example.com");
+ });
+
+ test("OAuth success output stays attached to the save step before spacing the next provider", async () => {
+ await linkedProject({
+ instances: { development: "ins_dev_123", production: "ins_prod_multi" },
+ });
+ mockLiveProduction({
+ instanceId: "ins_prod_multi",
+ developmentConfig: {
+ connection_oauth_apple: { enabled: true },
+ connection_oauth_github: { enabled: true },
+ },
+ productionConfig: {
+ connection_oauth_apple: {
+ enabled: true,
+ client_id: "",
+ team_id: "",
+ key_id: "",
+ client_secret: "",
+ },
+ connection_oauth_github: { enabled: true, client_id: "", client_secret: "" },
+ },
+ });
+ mockIsAgent.mockReturnValue(false);
+ const validP8Path = join(tempDir, "AuthKey.p8");
+ await Bun.write(
+ validP8Path,
+ "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n",
+ );
+ mockSelect
+ .mockResolvedValueOnce("have-credentials")
+ .mockResolvedValueOnce("have-credentials");
+ mockInput
+ .mockResolvedValueOnce("com.example.app")
+ .mockResolvedValueOnce("TEAMID1234")
+ .mockResolvedValueOnce("KEYID12345")
+ .mockResolvedValueOnce(validP8Path)
+ .mockResolvedValueOnce("github-client-id");
+ mockPassword.mockResolvedValueOnce("github-secret");
+ mockPatchInstanceConfig.mockResolvedValue({});
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain(
+ "Saved Apple OAuth credentials\n│\n│ Configure GitHub OAuth for production",
+ );
+ });
+
+ test("DNS verification timeout can skip and continue configuring production", async () => {
+ await linkedProject();
+ mockIsAgent.mockReturnValue(false);
+ mockConfirm
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce(true);
+ mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials");
+ mockInput
+ .mockResolvedValueOnce("example.com")
+ .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
+ mockPassword.mockResolvedValueOnce("google-secret");
+ mockPatchInstanceConfig.mockResolvedValueOnce({});
+ mockGetDeployStatus.mockResolvedValue({ status: "incomplete" });
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+ expect(err).toContain("DNS propagation can take time");
+ expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(2);
+ expect(err).toContain("Host: clerk.example.com");
+ expect(err).toContain("Value: frontend-api.clerk.services");
+ expect(err).toContain("Skipping DNS verification for now.");
+ expect(err).toContain("Saved Google OAuth credentials");
+ expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", {
+ connection_oauth_google: {
+ enabled: true,
+ client_id: "google-client-id.apps.googleusercontent.com",
+ client_secret: "google-secret",
+ },
+ });
+ });
+
+ test("warns about enabled OAuth providers not yet supported by clerk deploy", async () => {
+ await linkedProject();
+ mockHumanFlow();
+ mockFetchInstanceConfig.mockResolvedValueOnce({
+ connection_oauth_google: { enabled: true },
+ connection_oauth_discord: { enabled: true },
+ connection_oauth_facebook: { enabled: true },
+ });
+
+ await runDeploy({});
+ const err = stripAnsi(captured.err);
+
+ expect(err).toContain("Configure Google OAuth credentials");
+ expect(err).toContain("not yet supported by `clerk deploy`");
+ expect(err).toContain("discord");
+ expect(err).toContain("facebook");
+ expect(err).toContain("Configure them from the Clerk Dashboard before going live");
});
});
});
diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts
index 4f7b8023..fcaf3747 100644
--- a/packages/cli-core/src/commands/deploy/index.ts
+++ b/packages/cli-core/src/commands/deploy/index.ts
@@ -1,12 +1,71 @@
-import { input, password } from "@inquirer/prompts";
-import { select } from "../../lib/listage.ts";
-import { confirm } from "../../lib/prompts.ts";
import { isAgent } from "../../mode.ts";
-import { dim, bold, cyan, green, blue } from "../../lib/color.ts";
-import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts";
-import { openBrowser } from "../../lib/open.ts";
-import { log } from "../../lib/log.ts";
-
+import { NEXT_STEPS } from "../../lib/next-steps.ts";
+import { isInsideGutter, log } from "../../lib/log.ts";
+import { sleep } from "../../lib/sleep.ts";
+import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts";
+import {
+ PlapiError,
+ UserAbortError,
+ isPromptExitError,
+ throwUsageError,
+} from "../../lib/errors.ts";
+import { resolveProfile, setProfile } from "../../lib/config.ts";
+import {
+ type Application,
+ fetchApplication,
+ fetchInstanceConfig,
+ listApplicationDomains,
+ type ApplicationDomain,
+} from "../../lib/plapi.ts";
+import {
+ createProductionInstance as apiCreateProductionInstance,
+ getDeployStatus,
+ patchInstanceConfig,
+ validateCloning,
+ type CnameTarget,
+ type ProductionInstanceResponse,
+} from "./api.ts";
+import { domainConnectUrl } from "./domain-connect.ts";
+import {
+ INTRO_PREAMBLE,
+ NEXT_STEPS_BLOCK,
+ OAUTH_SECTION_INTRO,
+ type DeployPlanStep,
+ domainAssociationSummary,
+ dnsDashboardHandoff,
+ dnsIntro,
+ dnsRecords,
+ dnsVerified,
+ pausedOperationNotice,
+ printPlan,
+ productionSummary,
+} from "./copy.ts";
+import {
+ PROVIDER_LABELS,
+ PROVIDER_FIELDS,
+ providerLabel,
+ providerSetupIntro,
+ showOAuthWalkthrough,
+ type OAuthProvider,
+} from "./providers.ts";
+import {
+ chooseDnsVerificationAction,
+ chooseOAuthCredentialAction,
+ collectCustomDomain,
+ collectOAuthCredentials,
+ confirmContinueAfterDnsHandoff,
+ confirmCreateProductionInstance,
+ confirmProceed,
+} from "./prompts.ts";
+import {
+ DeployPausedError,
+ deployPausedError,
+ type DeployContext,
+ type DeployOperationState,
+} from "./state.ts";
+
+// TODO(deploy): rewrite to match the human flow described in
+// DEPLOY_MVP_UX_COPY_SPEC.md, or fetch from clerk.com/docs at runtime.
const DEPLOY_PROMPT = `You are deploying a Clerk application to production. Follow these steps:
## Prerequisites
@@ -16,51 +75,47 @@ Ensure the following before starting:
- A Clerk application is linked to the project (\`clerk link\` has been run)
- The project has a development instance with a working configuration
-## Step 1: Verify Subscription Compatibility
-
-Check that the development instance's features are covered by the application's subscription plan.
+## Step 1: Validate Cloning
-- Fetch the development config: \`GET /v1/platform/applications/{appID}/instances/development/config\`
-- Fetch the subscription: \`GET /v1/platform/applications/{appID}/subscription\`
-- If any development features are not covered by the plan, the user must upgrade before deploying.
+Confirm the development instance's features are covered by the application's subscription plan before starting any irreversible work.
-## Step 2: Choose a Production Domain
+- Call \`POST /v1/platform/applications/{appID}/validate_cloning\` with body \`{ "clone_instance_id": "" }\`.
+- 204 No Content means cloning is allowed. 402 Payment Required means the plan must be upgraded; surface the unsupported features to the user.
-Ask the user which domain setup they prefer:
+## Step 2: Discover enabled OAuth providers
-**Option A: Custom domain**
-- The user provides their own domain (e.g., example.com)
-- DNS must be configured to point to Clerk. Check if the DNS provider supports Domain Connect for automatic setup.
-- If Domain Connect is available, direct the user to the Domain Connect URL to authorize DNS changes.
-- If not, provide the DNS records the user must add manually.
-- Verify DNS propagation: \`POST /v1/platform/applications/{appID}/domains/{domainID}/dns_check\`
+Read the development instance config and pick out enabled social connections.
-**Option B: Clerk-provided subdomain**
-- A subdomain like \`{adjective}-{animal}-{number}.clerk.app\` is automatically assigned.
-- No DNS configuration is needed.
+- Call \`GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config\`.
+- For each key matching \`connection_oauth_*\` whose value has \`enabled: true\`, collect production credentials in step 4.
## Step 3: Create the Production Instance
-Create or configure the production instance for the application.
-- Add the domain: \`POST /v1/platform/applications/{appID}/domains\` with body \`{ "name": "", "is_satellite": false }\`
-- Note: There is currently no dedicated endpoint to add a production instance to an existing app. This may require \`POST /v1/platform/applications\` with \`environment_types: ["development", "production"]\`.
+Provision the production instance, primary domain, and keys in one round-trip.
-## Step 4: Configure Social OAuth Providers
+- Collect a production domain the user owns (\`example.com\`). Reject provider domains (\`*.vercel.app\`, \`*.clerk.app\`, etc.).
+- Call \`POST /v1/platform/applications/{appID}/production_instance\` with body \`{ "home_url": "", "clone_instance_id": "" }\`.
+- The 201 response includes \`instance_id\`, \`active_domain\`, \`publishable_key\`, \`secret_key\`, and \`cname_targets\`.
+- Show the user the \`cname_targets\` (\`{ host, value, required }\`) and offer Domain Connect handoff when the registrar supports it.
+- Poll \`GET /v1/platform/applications/{appID}/instances/{instance_id}/deploy_status\` every ~3 seconds until \`status === "complete"\`. The literal path segments \`development\` or \`production\` may be used in place of an instance ID.
+- When DNS or SSL stalls, expose the retry endpoints:
+ \`POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/ssl_retry\`
+ \`POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/mail_retry\`
-For each social provider enabled in the development instance (e.g., Google, GitHub, Apple), production OAuth credentials are required.
+## Step 4: Configure Social OAuth Providers
-Check the dev config for \`connection_oauth_*\` keys. For each enabled provider:
+For each enabled provider discovered in step 2, prompt for production credentials.
-1. Collect the required credentials from the user:
+1. Required fields per provider:
- Most providers: \`client_id\` and \`client_secret\`
- - Apple: also requires \`key_id\` and \`team_id\`
+ - Apple: also requires \`key_id\`, \`team_id\`, and the \`.p8\` private-key file
-2. When helping the user create OAuth credentials, provide these values:
+2. When walking the user through OAuth app creation, supply:
- Authorized JavaScript origins: \`https://{domain}\` and \`https://www.{domain}\`
- Authorized redirect URI: \`https://accounts.{domain}/v1/oauth_callback\`
-3. Write credentials to production config:
- \`PATCH /v1/platform/applications/{appID}/instances/production/config\`
+3. Persist each provider:
+ \`PATCH /v1/platform/applications/{appID}/instances/{instance_id}/config\`
Body: \`{ "connection_oauth_{provider}": { "enabled": true, "client_id": "...", "client_secret": "..." } }\`
Provider-specific documentation: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/{provider}
@@ -76,18 +131,31 @@ After all configuration is complete:
| Method | Endpoint | Purpose |
|--------|----------|---------|
-| GET | /v1/platform/applications/{appID} | Fetch application details |
-| GET | .../instances/development/config | Read dev instance config and enabled features |
-| GET | .../instances/production/config | Check if production instance exists (404 if not) |
-| GET | .../subscription | Check subscription plan |
-| POST | /v1/platform/applications | Create application with production instance |
-| POST | .../domains | Add a custom domain |
-| POST | .../domains/{domainID}/dns_check | Trigger DNS verification |
-| PATCH | .../instances/production/config | Write OAuth credentials to production |
+| POST | /v1/platform/applications/{appID}/validate_cloning | Pre-flight subscription/feature check |
+| POST | /v1/platform/applications/{appID}/production_instance | Create prod instance + primary domain (returns keys + cname_targets) |
+| GET | /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status | Poll DNS/SSL/Mail/Proxy progress |
+| POST | /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry | Re-trigger SSL provisioning |
+| POST | /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry | Re-trigger SendGrid mail verification |
+| GET | /v1/platform/applications/{appID}/instances/{instanceID}/config | Read dev or prod instance config |
+| PATCH | /v1/platform/applications/{appID}/instances/{instanceID}/config | Write OAuth credentials |
Refer to the Clerk Platform API docs for detailed request/response schemas.`;
-export async function deploy(options: { debug?: boolean }) {
+type DeployOptions = {
+ debug?: boolean;
+ testForceProductionInstance?: boolean;
+ testFailProductionInstanceCheck?: boolean;
+ testFailDomainLookup?: boolean;
+ testFailValidateCloning?: boolean;
+ testFailCreateProductionInstance?: boolean;
+ testFailDnsVerification?: boolean;
+ testFailOAuthSave?: boolean;
+};
+
+const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000;
+const DEPLOY_STATUS_MAX_POLLS = 100;
+
+export async function deploy(options: DeployOptions = {}) {
if (isAgent()) {
log.data(DEPLOY_PROMPT);
return;
@@ -97,161 +165,793 @@ export async function deploy(options: { debug?: boolean }) {
setLogLevel("debug");
}
- log.data("[mock] This command uses mocked data and is not yet wired up to real APIs.\n");
+ intro("clerk deploy");
+ try {
+ const ctx = await resolveDeployContext(options);
+ await runDeploy(ctx);
+ } catch (error) {
+ if (error instanceof DeployPausedError && isInsideGutter()) {
+ closeDeployGutter("Paused");
+ }
+ if (isPromptExitError(error) && isInsideGutter()) {
+ closeDeployGutter("Cancelled");
+ throw new UserAbortError();
+ }
+ throw error;
+ } finally {
+ // Successful and paused paths call outro themselves. This balances the
+ // intro gutter if an unexpected error escapes.
+ if (isInsideGutter()) {
+ closeDeployGutter("Failed");
+ }
+ }
+}
- log.debug("Checking for authenticated user and linked application...");
+function closeDeployGutter(messageOrSteps: string | readonly string[]): void {
+ outro(messageOrSteps);
+}
- // Mock state — will be replaced with real lookups
- const user = { id: "user_abc123", email: "kyle@clerk.dev" };
- const application = { id: "app_xyz789", name: "my-saas-app" };
+async function resolveDeployContext(options: DeployOptions): Promise {
+ const testFlags = resolveTestDeployFlags(options);
+ const resolved = await withSpinner("Resolving linked Clerk application...", () =>
+ resolveProfile(process.cwd()),
+ );
+ if (!resolved) {
+ return {
+ profileKey: process.cwd(),
+ profile: {
+ workspaceId: "",
+ appId: "",
+ instances: { development: "" },
+ },
+ appId: "",
+ appLabel: "",
+ developmentInstanceId: "",
+ ...testFlags,
+ };
+ }
- log.debug(`Found authenticated user: ${user.email} (${user.id})`);
- log.debug(`Found linked application: ${application.name} (${application.id})`);
+ return {
+ profileKey: resolved.path,
+ profile: resolved.profile,
+ ...testFlags,
+ ...(await withSpinner("Checking for production instance...", () =>
+ withTestFailureAfterApiCall(
+ resolveLiveApplicationContext(resolved.profile, {
+ forceMockProductionInstance: testFlags.testForceProductionInstance,
+ }),
+ testFlags.testFailProductionInstanceCheck,
+ "production instance check",
+ ),
+ )),
+ };
+}
- log.debug("Checking for production instance...");
- log.debug("No production instance found.");
+function resolveTestDeployFlags(
+ options: DeployOptions,
+): Pick<
+ DeployContext,
+ | "testForceProductionInstance"
+ | "testFailProductionInstanceCheck"
+ | "testFailDomainLookup"
+ | "testFailValidateCloning"
+ | "testFailCreateProductionInstance"
+ | "testFailDnsVerification"
+ | "testFailOAuthSave"
+> {
+ return {
+ testForceProductionInstance: options.testForceProductionInstance === true,
+ testFailProductionInstanceCheck: options.testFailProductionInstanceCheck === true,
+ testFailDomainLookup: options.testFailDomainLookup === true,
+ testFailValidateCloning: options.testFailValidateCloning === true,
+ testFailCreateProductionInstance: options.testFailCreateProductionInstance === true,
+ testFailDnsVerification: options.testFailDnsVerification === true,
+ testFailOAuthSave: options.testFailOAuthSave === true,
+ };
+}
- // Mock state — check subscription vs dev instance features
- log.debug("Checking development instance features against subscription...");
- const devFeatures = ["email_auth", "social_oauth"];
- const subscriptionFeatures = ["email_auth", "social_oauth"];
- const unsupported = devFeatures.filter((f) => !subscriptionFeatures.includes(f));
+function simulatedDeployApiFailure(step: string): PlapiError {
+ return new PlapiError(
+ 500,
+ JSON.stringify({ errors: [{ message: `Simulated deploy failure: ${step}.` }] }),
+ "clerk deploy test flag",
+ );
+}
- if (unsupported.length > 0) {
- log.debug(`Found features not covered by subscription: ${unsupported.join(", ")}`);
- log.debug("User must upgrade their plan before deploying.");
- return;
+async function withTestFailureAfterApiCall(
+ promise: Promise,
+ shouldFail: boolean | undefined,
+ step: string,
+): Promise {
+ const result = await promise;
+ if (shouldFail) {
+ throw simulatedDeployApiFailure(step);
}
+ return result;
+}
- log.debug("All development features are covered by subscription.");
+async function resolveLiveApplicationContext(
+ profile: DeployContext["profile"],
+ options: { forceMockProductionInstance?: boolean } = {},
+): Promise<{
+ appId: string;
+ appLabel: string;
+ developmentInstanceId: string;
+ productionInstanceId?: string;
+}> {
+ const fetchedApp = await fetchApplication(profile.appId);
+ const app = options.forceMockProductionInstance
+ ? withMockProductionInstance(fetchedApp)
+ : fetchedApp;
+ const development = app.instances.find((entry) => entry.environment_type === "development");
+ const production = app.instances.find((entry) => entry.environment_type === "production");
+ return {
+ appId: app.application_id,
+ appLabel: app.name || profile.appName || app.application_id,
+ developmentInstanceId: development?.instance_id ?? profile.instances.development,
+ productionInstanceId: production?.instance_id,
+ };
+}
- const domainChoice = await select({
- message: "How would you like to set up your production domain?",
- choices: [
+function withMockProductionInstance(app: Application): Application {
+ if (app.instances.some((entry) => entry.environment_type === "production")) {
+ return app;
+ }
+ return {
+ ...app,
+ instances: [
+ ...app.instances,
{
- name: "Use my own domain",
- value: "custom-domain",
+ instance_id: "ins_prod_mock",
+ environment_type: "production",
+ publishable_key: "pk_live_test",
},
+ ],
+ };
+}
+
+async function runDeploy(ctx: DeployContext): Promise {
+ if (!ctx.appId || !ctx.developmentInstanceId) {
+ log.blank();
+ log.warn(
+ "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.",
+ );
+ log.blank();
+ closeDeployGutter("Link required");
+ return;
+ }
+
+ if (ctx.productionInstanceId) {
+ await reconcileExistingDeploy(ctx);
+ return;
+ }
+
+ await startNewDeploy(ctx);
+}
+
+async function startNewDeploy(ctx: DeployContext): Promise {
+ const { known: oauthProviders, unknown: unknownOAuthProviders } =
+ await loadDevelopmentOAuthProviders(ctx);
+
+ await runValidateCloning(ctx);
+
+ log.blank();
+ log.info(INTRO_PREAMBLE);
+ log.blank();
+ for (const line of printPlan(ctx.appLabel, buildNewDeployPlan(oauthProviders))) {
+ log.info(line);
+ }
+ log.blank();
+
+ if (unknownOAuthProviders.length > 0) {
+ log.warn(
+ `These OAuth providers are enabled in development but not yet supported by \`clerk deploy\`: ${unknownOAuthProviders.join(", ")}.`,
+ );
+ log.warn(
+ "They will be cloned to production without working credentials. Configure them from the Clerk Dashboard before going live, or disable them in development first.",
+ );
+ log.blank();
+ }
+
+ const proceed = await confirmProceed();
+ if (!proceed) {
+ log.info("No changes were made.");
+ closeDeployGutter("Cancelled");
+ return;
+ }
+
+ bar();
+ const domain = await collectCustomDomain();
+ const plannedCnameTargets = plannedProductionCnameTargets(domain);
+ const shouldCreateProductionInstance = await confirmProductionInstanceCreation(
+ domain,
+ plannedCnameTargets,
+ );
+ if (!shouldCreateProductionInstance) return;
+
+ const production = await createProductionInstance(ctx, domain);
+ await persistProductionInstance(ctx, production.instance_id);
+ log.blank();
+
+ const productionDomain = production.active_domain.name;
+ let completedOAuthProviders: OAuthProvider[] = [];
+ const dnsStatus = await runDnsSetup(
+ ctx,
+ {
+ appId: ctx.appId,
+ developmentInstanceId: ctx.developmentInstanceId,
+ productionInstanceId: production.instance_id,
+ productionDomainId: production.active_domain.id,
+ domain: productionDomain,
+ pending: { type: "dns" },
+ oauthProviders,
+ completedOAuthProviders,
+ },
+ production.cname_targets,
+ );
+ if (!dnsStatus) return;
+
+ bar();
+ completedOAuthProviders = await runOAuthSetup(ctx, {
+ appId: ctx.appId,
+ developmentInstanceId: ctx.developmentInstanceId,
+ productionInstanceId: production.instance_id,
+ productionDomainId: production.active_domain.id,
+ domain: productionDomain,
+ pending: { type: "oauth", provider: oauthProviders[0] ?? "google" },
+ oauthProviders,
+ completedOAuthProviders,
+ });
+ if (completedOAuthProviders.length < oauthProviders.length) return;
+
+ await finishDeploy(ctx, productionDomain, completedOAuthProviders, dnsStatus);
+}
+
+async function reconcileExistingDeploy(ctx: DeployContext): Promise {
+ const snapshot = await resolveLiveDeploySnapshot(ctx);
+ if (!snapshot) {
+ log.blank();
+ log.info("A production instance exists, but Clerk did not return a production domain yet.");
+ log.info("Run `clerk deploy` again after the domain is available from the API.");
+ log.blank();
+ closeDeployGutter("No deploy actions available");
+ return;
+ }
+
+ log.blank();
+ for (const line of printPlan(ctx.appLabel, buildLiveDeployPlan(snapshot))) {
+ log.info(line);
+ }
+ log.blank();
+
+ if (!snapshot.pending) {
+ log.info("No deploy actions remain.");
+ await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, "verified");
+ return;
+ }
+
+ let dnsStatus: DnsVerificationResult = snapshot.dnsComplete ? "verified" : "pending";
+ if (snapshot.pending.type === "dns") {
+ const nextDnsStatus = await runExistingDomainDnsVerification(
+ ctx,
+ snapshotToOperationState(snapshot, { type: "dns" }),
+ );
+ if (!nextDnsStatus) return;
+ dnsStatus = nextDnsStatus;
+ }
+
+ if (
+ snapshot.pending.type === "oauth" ||
+ snapshot.oauthProviders.length > snapshot.completedOAuthProviders.length
+ ) {
+ bar();
+ const completed = await runOAuthSetup(
+ ctx,
+ snapshotToOperationState(snapshot, {
+ type: "oauth",
+ provider:
+ snapshot.oauthProviders.find(
+ (provider) => !snapshot.completedOAuthProviders.includes(provider),
+ ) ??
+ snapshot.oauthProviders[0] ??
+ "google",
+ }),
+ );
+ if (completed.length < snapshot.oauthProviders.length) return;
+ snapshot.completedOAuthProviders = completed;
+ }
+
+ await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, dnsStatus);
+}
+
+type LiveDeploySnapshot = Omit<
+ DeployOperationState,
+ "pending" | "oauthProviders" | "completedOAuthProviders"
+> & {
+ pending?: DeployOperationState["pending"];
+ oauthProviders: OAuthProvider[];
+ completedOAuthProviders: OAuthProvider[];
+ cnameTargets?: readonly CnameTarget[];
+ dnsComplete: boolean;
+};
+
+type DiscoveredOAuthProviders = {
+ known: OAuthProvider[];
+ unknown: string[];
+};
+
+type DnsVerificationResult = "verified" | "pending";
+
+async function loadDevelopmentOAuthProviders(
+ ctx: DeployContext,
+): Promise {
+ return withSpinner("Reading development configuration...", async () => {
+ const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId);
+ return discoverEnabledOAuthProviders(config);
+ });
+}
+
+async function resolveLiveDeploySnapshot(
+ ctx: DeployContext,
+): Promise {
+ const productionInstanceId = ctx.productionInstanceId;
+ if (!productionInstanceId) return undefined;
+
+ const domain = await loadProductionDomain(ctx);
+ if (!domain) return undefined;
+
+ const productionConfigPromise = ctx.testForceProductionInstance
+ ? Promise.resolve(mockProductionInstanceConfig())
+ : fetchInstanceConfig(ctx.appId, productionInstanceId);
+ const [{ known: oauthProviders }, productionConfig, deployStatus] = await Promise.all([
+ loadDevelopmentOAuthProviders(ctx),
+ productionConfigPromise,
+ getDeployStatus(ctx.appId, productionInstanceId),
+ ]);
+ const completedOAuthProviders = oauthProviders.filter((provider) =>
+ hasProductionOAuthCredentials(productionConfig, provider),
+ );
+ const pendingOAuthProvider = oauthProviders.find(
+ (provider) => !completedOAuthProviders.includes(provider),
+ );
+
+ const baseState = {
+ appId: ctx.appId,
+ developmentInstanceId: ctx.developmentInstanceId,
+ productionInstanceId,
+ productionDomainId: domain.id,
+ domain: domain.name,
+ oauthProviders,
+ completedOAuthProviders,
+ cnameTargets: domain.cname_targets ?? [],
+ };
+
+ const dnsComplete = deployStatus.status === "complete";
+ const pending = !dnsComplete
+ ? ({ type: "dns" } as const)
+ : pendingOAuthProvider
+ ? ({ type: "oauth", provider: pendingOAuthProvider } as const)
+ : undefined;
+
+ return { ...baseState, dnsComplete, pending };
+}
+
+async function loadProductionDomain(ctx: DeployContext): Promise {
+ if (ctx.testForceProductionInstance) {
+ return mockProductionDomain();
+ }
+ const domains = await listApplicationDomains(ctx.appId);
+ if (ctx.testFailDomainLookup) {
+ throw simulatedDeployApiFailure("production domain lookup");
+ }
+ return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0];
+}
+
+function mockProductionDomain(): ApplicationDomain {
+ return {
+ object: "domain",
+ id: "dmn_prod_mock",
+ name: "example.com",
+ is_satellite: false,
+ is_provider_domain: false,
+ frontend_api_url: "https://clerk.example.com",
+ accounts_portal_url: "https://accounts.example.com",
+ development_origin: "",
+ cname_targets: [
+ { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true },
+ { host: "accounts.example.com", value: "accounts.clerk.services", required: true },
{
- name: "Use a Clerk-provided subdomain",
- value: "clerk-subdomain",
+ host: "clkmail.example.com",
+ value: "mail.example.com.nam1.clerk.services",
+ required: true,
},
],
+ created_at: "2026-05-06T00:00:00Z",
+ updated_at: "2026-05-06T00:00:00Z",
+ };
+}
+
+function mockProductionInstanceConfig(): Record {
+ return {};
+}
+
+function hasProductionOAuthCredentials(
+ config: Record,
+ provider: OAuthProvider,
+): boolean {
+ const value = config[`${OAUTH_KEY_PREFIX}${provider}`];
+ if (!value || typeof value !== "object") return false;
+ const providerConfig = value as Record;
+ if (providerConfig.enabled !== true) return false;
+ return PROVIDER_FIELDS[provider].every((field) => {
+ const fieldValue = providerConfig[field.key];
+ return typeof fieldValue === "string" && fieldValue.length > 0;
});
+}
+
+const OAUTH_KEY_PREFIX = "connection_oauth_";
+
+function buildNewDeployPlan(oauthProviders: readonly OAuthProvider[]): DeployPlanStep[] {
+ return [
+ { label: "Create production instance", status: "pending" },
+ { label: "Choose a production domain you own", status: "pending" },
+ { label: "Configure DNS records", status: "pending" },
+ ...oauthProviders.map((provider) => ({
+ label: `Configure ${PROVIDER_LABELS[provider]} OAuth credentials`,
+ status: "pending" as const,
+ })),
+ ];
+}
+
+function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] {
+ return [
+ { label: "Create production instance", status: "done" },
+ { label: `Use production domain ${snapshot.domain}`, status: "done" },
+ { label: "Configure DNS records", status: snapshot.dnsComplete ? "done" : "pending" },
+ ...snapshot.oauthProviders.map((provider): DeployPlanStep => {
+ const status: DeployPlanStep["status"] = snapshot.completedOAuthProviders.includes(provider)
+ ? "done"
+ : "pending";
+ return {
+ label: `Configure ${PROVIDER_LABELS[provider]} OAuth credentials`,
+ status,
+ };
+ }),
+ ];
+}
+
+function snapshotToOperationState(
+ snapshot: LiveDeploySnapshot,
+ pending: DeployOperationState["pending"],
+): DeployOperationState {
+ return {
+ appId: snapshot.appId,
+ developmentInstanceId: snapshot.developmentInstanceId,
+ productionInstanceId: snapshot.productionInstanceId,
+ productionDomainId: snapshot.productionDomainId,
+ domain: snapshot.domain,
+ pending,
+ oauthProviders: snapshot.oauthProviders,
+ completedOAuthProviders: snapshot.completedOAuthProviders,
+ cnameTargets: snapshot.cnameTargets,
+ };
+}
- let domain: string;
-
- if (domainChoice === "custom-domain") {
- domain = await input({
- message: "Enter your domain:",
- });
- log.debug(`User provided custom domain: ${domain}`);
- } else {
- // Mock generated subdomain
- const generatedSubdomain = "sincere-chinchilla-87.clerk.app";
- domain = generatedSubdomain;
- log.debug(`Using Clerk-provided subdomain: ${domain}`);
+function discoverEnabledOAuthProviders(config: Record): DiscoveredOAuthProviders {
+ const known: OAuthProvider[] = [];
+ const unknown: string[] = [];
+ for (const [key, value] of Object.entries(config)) {
+ if (!key.startsWith(OAUTH_KEY_PREFIX)) continue;
+ if (!value || typeof value !== "object") continue;
+ if ((value as Record).enabled !== true) continue;
+ const provider = key.slice(OAUTH_KEY_PREFIX.length);
+ if (provider in PROVIDER_LABELS) {
+ known.push(provider as OAuthProvider);
+ } else {
+ unknown.push(provider);
+ }
}
+ return { known, unknown };
+}
- log.debug("Creating production instance...");
- log.debug(`Production instance created with domain: ${domain}`);
+async function runValidateCloning(ctx: DeployContext): Promise {
+ await withSpinner("Validating subscription compatibility...", async () => {
+ await withTestFailureAfterApiCall(
+ validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }),
+ ctx.testFailValidateCloning,
+ "cloning validation",
+ );
+ });
+}
- // DNS setup for custom domains
- if (domainChoice === "custom-domain") {
- log.debug(`Looking up DNS provider for ${domain}...`);
+async function createProductionInstance(
+ ctx: DeployContext,
+ domain: string,
+): Promise {
+ return withSpinner("Creating production instance...", async () => {
+ return withTestFailureAfterApiCall(
+ apiCreateProductionInstance(ctx.appId, {
+ home_url: domain,
+ clone_instance_id: ctx.developmentInstanceId,
+ }),
+ ctx.testFailCreateProductionInstance,
+ "production instance creation",
+ );
+ });
+}
- // Mock state — DNS lookup and Domain Connect check
- const dnsProvider = { name: "Cloudflare", supportsDomainConnect: true };
- log.debug(`DNS hosted by: ${dnsProvider.name}`);
- log.debug(`Checking Domain Connect support for ${dnsProvider.name}...`);
- log.debug(`${dnsProvider.name} supports Domain Connect.`);
+function plannedProductionCnameTargets(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 domainConnectUrl = `https://domainconnect.${dnsProvider.name.toLowerCase()}.com/v2/domainTemplates/providers/clerk.com/services/clerk-production/apply?domain=${domain}`;
- log.debug(`Composed Domain Connect URL: ${domainConnectUrl}`);
+async function confirmProductionInstanceCreation(
+ domain: string,
+ cnameTargets: readonly CnameTarget[],
+): Promise {
+ for (const line of domainAssociationSummary(domain, cnameTargets)) log.info(line);
+ log.blank();
+ const confirmed = await confirmCreateProductionInstance();
+ if (confirmed) {
+ log.blank();
+ return true;
+ }
- await confirm({
- message: `We can automatically configure DNS for ${domain} via ${dnsProvider.name}. Open browser to continue?`,
- default: true,
- });
+ log.blank();
+ log.info("No production instance was created.");
+ log.blank();
+ closeDeployGutter("Cancelled");
+ return false;
+}
- log.debug("Opening Domain Connect flow in browser...");
+async function runDnsSetup(
+ ctx: DeployContext,
+ state: DeployOperationState,
+ cnameTargets: readonly CnameTarget[],
+): Promise {
+ for (const line of dnsIntro(state.domain)) log.info(line);
+ log.blank();
+ for (const line of dnsRecords(cnameTargets)) log.info(line);
+ log.blank();
+
+ const connectUrl = domainConnectUrl(state.domain);
+ if (connectUrl) {
+ log.info(`Domain Connect: ${connectUrl}`);
+ log.blank();
}
- // Check dev instance settings that require production credentials
- log.debug("Checking development instance settings for production requirements...");
+ for (const line of dnsDashboardHandoff(state.domain)) log.info(line);
+ log.blank();
+ try {
+ const continueSetup = await confirmContinueAfterDnsHandoff();
+ if (!continueSetup) {
+ log.blank();
+ log.info(pausedOperationNotice());
+ log.blank();
+ closeDeployGutter("Paused");
+ return false;
+ }
+ return await runDnsVerification(ctx, { ...state, cnameTargets });
+ } catch (error) {
+ if (isPromptExitError(error)) {
+ throw deployPausedError(state, { interrupted: true });
+ }
+ throw error;
+ }
+}
- // Mock state — dev instance has Google OAuth enabled
- const devSettings = {
- socialProviders: ["google"],
- };
+async function runExistingDomainDnsVerification(
+ ctx: DeployContext,
+ state: DeployOperationState,
+): Promise {
+ try {
+ const action = await chooseDnsVerificationAction();
+ if (action === "skip") {
+ log.blank();
+ log.info("Skipping DNS verification for now.");
+ return "pending";
+ }
+ return await runDnsVerification(ctx, state);
+ } catch (error) {
+ if (isPromptExitError(error)) {
+ throw deployPausedError(state, { interrupted: true });
+ }
+ throw error;
+ }
+}
- if (devSettings.socialProviders.length > 0) {
- log.debug(
- `Found social providers requiring production credentials: ${devSettings.socialProviders.join(", ")}`,
+async function runDnsVerification(
+ ctx: DeployContext,
+ state: DeployOperationState,
+): Promise {
+ const productionInstanceId =
+ state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production;
+ if (!productionInstanceId) {
+ throwUsageError(
+ "Cannot verify DNS because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.",
);
+ }
- for (const provider of devSettings.socialProviders) {
- const displayName = provider.charAt(0).toUpperCase() + provider.slice(1);
- const docsUrl = `https://clerk.com/docs/guides/configure/auth-strategies/social-connections/${provider}#configure-for-your-production-instance`;
-
- const credentialChoice = await select({
- message: `Your app uses ${displayName} OAuth. Do you have your production credentials?`,
- choices: [
- {
- name: "Walk me through setting it up",
- value: "walkthrough",
- },
- {
- name: "I already have my credentials",
- value: "have-credentials",
- },
- ],
- });
-
- if (credentialChoice === "walkthrough") {
- log.data(
- `\n${bold(`When configuring your ${displayName} OAuth app, use these values:`)}\n`,
- );
- log.data(` ${dim("Authorized JavaScript origins:")}`);
- log.data(` ${cyan(`https://${domain}`)}`);
- log.data(` ${cyan(`https://www.${domain}`)}`);
- log.data(`\n ${dim("Authorized redirect URI:")}`);
- log.data(` ${cyan(`https://accounts.${domain}/v1/oauth_callback`)}`);
- log.data("");
-
- log.debug(`Opening ${displayName} OAuth setup guide in browser...`);
- const openResult = await openBrowser(docsUrl);
- if (!openResult.ok) {
- log.info(dim(`(Could not open browser automatically, visit ${docsUrl})`));
- }
-
- log.data("Once you've created your credentials, enter them below:\n");
+ const verified = await withSpinner(`Verifying DNS for ${state.domain}...`, async () => {
+ for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) {
+ const result = await getDeployStatus(ctx.appId, productionInstanceId);
+ if (ctx.testFailDnsVerification) {
+ throw simulatedDeployApiFailure("DNS verification");
}
+ if (result.status === "complete") return true;
+ await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS);
+ }
+ return false;
+ });
- const clientId = await input({
- message: `${displayName} OAuth Client ID:`,
- });
+ if (!verified) {
+ log.blank();
+ log.warn(
+ `DNS, SSL, or mail verification is still pending for ${state.domain}. ` +
+ "Run `clerk deploy` again once DNS has propagated, or check the dashboard for the failing component.",
+ );
+ log.info(
+ "DNS propagation can take time. Some providers may take several hours to serve the new records everywhere.",
+ );
+ if (state.cnameTargets && state.cnameTargets.length > 0) {
+ log.blank();
+ for (const line of dnsRecords(state.cnameTargets)) log.info(line);
+ }
+ log.blank();
+ const action = await chooseDnsVerificationAction();
+ if (action === "skip") {
+ log.blank();
+ log.info("Skipping DNS verification for now.");
+ return "pending";
+ }
+ return runDnsVerification(ctx, state);
+ }
- await password({
- message: `${displayName} OAuth Client Secret:`,
- });
+ log.blank();
+ for (const line of dnsVerified(state.domain)) log.success(line);
+ return "verified";
+}
+
+async function runOAuthSetup(
+ ctx: DeployContext,
+ state: DeployOperationState,
+): Promise {
+ const completed = new Set(state.completedOAuthProviders as OAuthProvider[]);
+ const startIndex =
+ state.pending.type === "oauth"
+ ? Math.max(0, state.oauthProviders.indexOf(state.pending.provider as OAuthProvider))
+ : 0;
+
+ if (state.oauthProviders.length > 0) {
+ log.info(OAUTH_SECTION_INTRO);
+ log.blank();
+ }
- log.debug(`Received ${displayName} credentials (client ID: ${clientId.slice(0, 8)}...)`);
+ const pendingProviders = state.oauthProviders.slice(startIndex) as OAuthProvider[];
+ for (const provider of pendingProviders) {
+ if (completed.has(provider)) continue;
+ try {
+ const productionInstanceId =
+ state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production;
+ if (!productionInstanceId) {
+ throwUsageError(
+ "Cannot save OAuth credentials because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.",
+ );
+ }
+
+ const saved = await collectAndSaveOAuthCredentials(
+ ctx,
+ provider,
+ state.domain,
+ productionInstanceId,
+ );
+ if (!saved) {
+ log.blank();
+ log.info(pausedOperationNotice());
+ log.blank();
+ closeDeployGutter("Paused");
+ return [...completed];
+ }
+ } catch (error) {
+ if (isPromptExitError(error)) {
+ const interruptedState = {
+ ...state,
+ pending: { type: "oauth" as const, provider },
+ completedOAuthProviders: [...completed],
+ };
+ throw deployPausedError(interruptedState, { interrupted: true });
+ }
+ throw error;
+ }
+ completed.add(provider);
+ if (pendingProviders.some((nextProvider) => !completed.has(nextProvider))) {
+ log.blank();
}
+ }
+
+ return [...completed];
+}
- log.debug("All social provider credentials collected.");
+async function collectAndSaveOAuthCredentials(
+ ctx: DeployContext,
+ provider: OAuthProvider,
+ domain: string,
+ productionInstanceId: string,
+): Promise {
+ const label = PROVIDER_LABELS[provider];
+ for (const line of providerSetupIntro(provider)) log.info(line);
+ log.blank();
+
+ const choice = await chooseOAuthCredentialAction(provider);
+
+ if (choice === "skip") {
+ return false;
}
- log.debug("Deploy complete.");
+ if (choice === "walkthrough") {
+ await showOAuthWalkthrough(provider, domain);
+ }
- log.data(
- `\n${bold(green(`Your production application is set up and ready at ${blue(`https://${domain}`)}`))}`,
- );
- log.data(
- dim(
- "If your application is not loading correctly, you may need to redeploy with your updated Clerk secret keys.",
- ),
+ const credentials = await collectOAuthCredentials(
+ provider,
+ choice === "google-json" ? "google-json" : "manual",
);
- printNextSteps(NEXT_STEPS.DEPLOY);
+ await withSpinner(`Saving ${label} OAuth credentials...`, async () => {
+ await withTestFailureAfterApiCall(
+ patchInstanceConfig(ctx.appId, productionInstanceId, {
+ [`connection_oauth_${provider}`]: {
+ enabled: true,
+ ...credentials,
+ },
+ }),
+ ctx.testFailOAuthSave,
+ "OAuth credential save",
+ );
+ });
+ log.success(`Saved ${label} OAuth credentials`);
+ return true;
+}
+
+async function persistProductionInstance(ctx: DeployContext, productionInstanceId: string) {
+ await setProfile(ctx.profileKey, {
+ ...ctx.profile,
+ instances: {
+ ...ctx.profile.instances,
+ production: productionInstanceId,
+ },
+ });
+ ctx.profile.instances.production = productionInstanceId;
+ ctx.productionInstanceId = productionInstanceId;
+}
+
+async function finishDeploy(
+ ctx: DeployContext,
+ domain: string,
+ completedOAuthProviders: readonly string[],
+ dnsStatus: DnsVerificationResult,
+): Promise {
+ log.blank();
+ for (const line of productionSummary(
+ domain,
+ completedOAuthProviders.map((provider) => providerLabel(provider)),
+ dnsStatus,
+ )) {
+ log.info(line);
+ }
+ log.blank();
+ printNextSteps();
+ log.blank();
+ closeDeployGutter(NEXT_STEPS.DEPLOY);
+}
+
+function printNextSteps(): void {
+ log.info(NEXT_STEPS_BLOCK);
}
diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts
new file mode 100644
index 00000000..fe38f7aa
--- /dev/null
+++ b/packages/cli-core/src/commands/deploy/prompts.ts
@@ -0,0 +1,236 @@
+import { input, password } from "@inquirer/prompts";
+import { homedir } from "node:os";
+import { join, resolve } from "node:path";
+import { select } from "../../lib/listage.ts";
+import { confirm } from "../../lib/prompts.ts";
+import {
+ PROVIDER_CREDENTIAL_LABELS,
+ PROVIDER_FIELDS,
+ PROVIDER_LABELS,
+ type OAuthProvider,
+} from "./providers.ts";
+
+type OAuthCredentialAction = "have-credentials" | "walkthrough" | "google-json" | "skip";
+type DnsVerificationAction = "check" | "skip";
+
+const PROVIDER_DOMAIN_SUFFIXES = [
+ ".clerk.app",
+ ".vercel.app",
+ ".netlify.app",
+ ".pages.dev",
+ ".fly.dev",
+ ".render.com",
+ ".herokuapp.com",
+];
+
+export async function confirmProceed(): Promise {
+ return confirm({ message: "Proceed?", default: true });
+}
+
+export async function collectCustomDomain(): Promise {
+ return input({
+ message: "Production domain (e.g. example.com)",
+ validate: (value) => validateDomain(value),
+ });
+}
+
+export function validateDomain(value: string): true | string {
+ const domain = value.trim();
+ if (!domain) return "Enter a domain.";
+ if (domain.startsWith("http://") || domain.startsWith("https://")) {
+ return "Enter a valid domain, such as example.com (without https://).";
+ }
+ if (!/^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(domain)) {
+ return "Enter a valid domain, such as example.com (without https://).";
+ }
+ if (PROVIDER_DOMAIN_SUFFIXES.some((suffix) => domain.toLowerCase().endsWith(suffix))) {
+ return `${domain} looks like a provider domain (e.g. *.vercel.app, *.clerk.app). Production needs a domain you own. See https://clerk.com/docs/guides/development/deployment/production`;
+ }
+ return true;
+}
+
+export async function confirmContinueAfterDnsHandoff(): Promise {
+ return confirm({
+ message: "Continue to OAuth setup?",
+ default: true,
+ });
+}
+
+export async function confirmCreateProductionInstance(): Promise {
+ return confirm({
+ message: "Create production instance?",
+ default: true,
+ });
+}
+
+export async function chooseDnsVerificationAction(): Promise {
+ return select({
+ message: "DNS verification",
+ choices: [
+ { name: "Check DNS now", value: "check" },
+ { name: "Skip DNS verification for now", value: "skip" },
+ ],
+ });
+}
+
+export async function chooseOAuthCredentialAction(
+ provider: OAuthProvider,
+): Promise {
+ const choices: Array<{ name: string; value: OAuthCredentialAction }> = [
+ { name: PROVIDER_CREDENTIAL_LABELS[provider], value: "have-credentials" },
+ { name: "Walk me through creating them", value: "walkthrough" },
+ ];
+ if (provider === "google") {
+ choices.push({
+ name: "Load credentials from a Google Cloud Console JSON file",
+ value: "google-json",
+ });
+ }
+ choices.push({
+ name: "Skip for now and run `clerk deploy` again later",
+ value: "skip",
+ });
+
+ return select({
+ message: `${PROVIDER_LABELS[provider]} OAuth`,
+ choices,
+ });
+}
+
+export async function chooseExistingProductionAction(): Promise<
+ "resume" | "next-steps" | "cancel"
+> {
+ return select({
+ message: "What would you like to do?",
+ choices: [
+ { name: "Resume the next incomplete step", value: "resume" },
+ { name: "Show next steps and exit", value: "next-steps" },
+ { name: "Cancel", value: "cancel" },
+ ],
+ });
+}
+
+export async function collectOAuthCredentials(
+ provider: OAuthProvider,
+ source: "manual" | "google-json" = "manual",
+): Promise> {
+ if (provider === "google" && source === "google-json") {
+ return collectGoogleJsonCredentials();
+ }
+
+ const label = PROVIDER_LABELS[provider];
+ const credentials: Record = {};
+ for (const field of PROVIDER_FIELDS[provider]) {
+ const message = `${label} OAuth ${field.label}`;
+ let value: string;
+ if (field.filePath) {
+ const path = await input({ message, validate: validateSecretFilePath(field.label) });
+ value = await readSecretFile(path);
+ } else if (field.secret) {
+ value = await password({ message, validate: required(field.label) });
+ } else {
+ value = await input({ message, validate: required(field.label) });
+ }
+ credentials[field.key] = value.trim();
+ }
+ return credentials;
+}
+
+function validateSecretFilePath(label: string) {
+ return async (path: string): Promise => {
+ if (!path.trim()) return `${label} is required`;
+ try {
+ await readSecretFile(path);
+ return true;
+ } catch (error) {
+ return error instanceof Error ? error.message : String(error);
+ }
+ };
+}
+
+async function collectGoogleJsonCredentials(): Promise> {
+ const path = await input({
+ message: "Google OAuth JSON file path",
+ validate: validateGoogleJsonFilePath,
+ });
+ return readGoogleJsonCredentials(path);
+}
+
+async function validateGoogleJsonFilePath(path: string): Promise {
+ if (!path.trim()) return "Google OAuth JSON file path is required";
+ try {
+ await readGoogleJsonCredentials(path);
+ return true;
+ } catch (error) {
+ return error instanceof Error ? error.message : String(error);
+ }
+}
+
+async function readGoogleJsonCredentials(path: string): Promise> {
+ const raw = await readTextFile(path);
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ throw new Error(
+ `That JSON file doesn't look like a Google OAuth client download. Expected a "web" or "installed" object.`,
+ );
+ }
+
+ const root = parsed && typeof parsed === "object" ? (parsed as Record) : {};
+ const client = (root.web ?? root.installed) as Record | undefined;
+ if (
+ !client ||
+ typeof client !== "object" ||
+ typeof client.client_id !== "string" ||
+ typeof client.client_secret !== "string"
+ ) {
+ throw new Error(
+ `That JSON file doesn't look like a Google OAuth client download. Expected a "web" or "installed" object.`,
+ );
+ }
+
+ return {
+ client_id: client.client_id,
+ client_secret: client.client_secret,
+ };
+}
+
+function required(label: string) {
+ return (value: string) => value.trim().length > 0 || `${label} is required`;
+}
+
+function expandPath(path: string): string {
+ let expanded = path;
+ if (path === "~") expanded = homedir();
+ else if (path.startsWith("~/")) expanded = join(homedir(), path.slice(2));
+ return resolve(expanded);
+}
+
+async function readSecretFile(path: string): Promise {
+ const contents = await readTextFile(path);
+ if (
+ !contents.includes("-----BEGIN PRIVATE KEY-----") ||
+ !contents.includes("-----END PRIVATE KEY-----")
+ ) {
+ throw new Error(
+ "That file is missing the -----BEGIN PRIVATE KEY----- framing. Make sure you selected the .p8 file Apple gave you.",
+ );
+ }
+ return contents;
+}
+
+async function readTextFile(path: string): Promise {
+ const expanded = expandPath(path.trim());
+ const file = Bun.file(expanded);
+ if (!(await file.exists())) {
+ throw new Error(`No file at ${path}.`);
+ }
+ try {
+ return await file.text();
+ } catch (error) {
+ throw new Error(
+ `Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}.`,
+ );
+ }
+}
diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts
new file mode 100644
index 00000000..dc4d3bc8
--- /dev/null
+++ b/packages/cli-core/src/commands/deploy/providers.ts
@@ -0,0 +1,125 @@
+import { bold, cyan, dim, yellow, red } from "../../lib/color.ts";
+import { log } from "../../lib/log.ts";
+import { openBrowser } from "../../lib/open.ts";
+
+export type OAuthProvider = "google" | "github" | "microsoft" | "apple" | "linear";
+
+export type OAuthField = {
+ key: string;
+ label: string;
+ secret?: boolean;
+ filePath?: boolean;
+};
+
+export const PROVIDER_LABELS: Record = {
+ google: "Google",
+ github: "GitHub",
+ microsoft: "Microsoft",
+ apple: "Apple",
+ linear: "Linear",
+};
+
+export const PROVIDER_FIELDS: Record = {
+ google: [
+ { key: "client_id", label: "Client ID" },
+ { key: "client_secret", label: "Client Secret", secret: true },
+ ],
+ github: [
+ { key: "client_id", label: "Client ID" },
+ { key: "client_secret", label: "Client Secret", secret: true },
+ ],
+ microsoft: [
+ { key: "client_id", label: "Application (Client) ID" },
+ { key: "client_secret", label: "Client Secret", secret: true },
+ ],
+ apple: [
+ { key: "client_id", label: "Apple Services ID" },
+ { key: "team_id", label: "Apple Team ID" },
+ { key: "key_id", label: "Apple Key ID" },
+ { key: "client_secret", label: "Apple Private Key - path to .p8 file", filePath: true },
+ ],
+ linear: [
+ { key: "client_id", label: "Client ID" },
+ { key: "client_secret", label: "Client Secret", secret: true },
+ ],
+};
+
+export const PROVIDER_CREDENTIAL_LABELS: Record = {
+ google: "I already have my Client ID and Client Secret",
+ github: "I already have my Client ID and Client Secret",
+ microsoft: "I already have my Application (Client) ID and Client Secret",
+ apple: "I already have my Services ID, Team ID, Key ID, and .p8 file",
+ linear: "I already have my Client ID and Client Secret",
+};
+
+export const PROVIDER_REDIRECT_LABELS: Record = {
+ google: "Authorized Redirect URI",
+ github: "Authorization Callback URL",
+ microsoft: "Redirect URI",
+ apple: "Return URL",
+ linear: "Callback URL",
+};
+
+export const PROVIDER_DOC_URLS: Record = {
+ google: "https://clerk.com/docs/guides/configure/auth-strategies/social-connections/google",
+ github: "https://clerk.com/docs/guides/configure/auth-strategies/social-connections/github",
+ microsoft: "https://clerk.com/docs/guides/configure/auth-strategies/social-connections/microsoft",
+ apple: "https://clerk.com/docs/guides/configure/auth-strategies/social-connections/apple",
+ linear: "https://clerk.com/docs/guides/configure/auth-strategies/social-connections/linear",
+};
+
+export const PROVIDER_SETUP_COPY: Record = {
+ google: "Production Google sign-in requires custom OAuth credentials from Google Cloud Console.",
+ github: "Production GitHub sign-in requires a GitHub OAuth app and custom credentials.",
+ microsoft:
+ "Production Microsoft sign-in requires a Microsoft Entra ID app and custom credentials.",
+ apple:
+ "Production Apple sign-in requires an Apple Services ID, Team ID, Key ID, and private key file.",
+ linear: "Production Linear sign-in requires a Linear OAuth app and custom credentials.",
+};
+
+export const PROVIDER_GOTCHAS: Record = {
+ google: `${yellow("IMPORTANT")} Set the OAuth consent screen's publishing status to "In production". Apps left in "Testing" are limited to 100 test users and may break for end users.`,
+ github: null,
+ microsoft: `${red("WARNING")} Microsoft client secrets expire (default 6 months, max 24). Set a calendar reminder to rotate before expiration or sign-in will break.`,
+ apple: `${yellow("IMPORTANT")} Apple OAuth needs four artifacts: Apple Services ID, Apple Team ID, Apple Key ID, and Apple Private Key (.p8 file). The .p8 file cannot be re-downloaded - save it before leaving Apple's developer portal.`,
+ linear: `${yellow("IMPORTANT")} You must be a workspace admin in Linear to create OAuth apps.`,
+};
+
+export function providerLabel(provider: string): string {
+ return PROVIDER_LABELS[provider as OAuthProvider] ?? provider;
+}
+
+export function providerSetupIntro(provider: OAuthProvider): string[] {
+ const label = PROVIDER_LABELS[provider];
+ return [
+ bold(`Configure ${label} OAuth for production`),
+ PROVIDER_SETUP_COPY[provider],
+ dim(`Reference: ${PROVIDER_DOC_URLS[provider]}`),
+ ];
+}
+
+export async function showOAuthWalkthrough(provider: OAuthProvider, domain: string): Promise {
+ const label = PROVIDER_LABELS[provider];
+ const docsUrl = PROVIDER_DOC_URLS[provider];
+
+ log.info(`\nConfigure your ${bold(label)} OAuth app with these values:\n`);
+ log.info(` ${dim("Authorized JavaScript origins")}`);
+ log.info(` ${cyan(`https://${domain}`)}`);
+ log.info(` ${cyan(`https://www.${domain}`)}`);
+ log.info(` ${dim(PROVIDER_REDIRECT_LABELS[provider])}`);
+ log.info(` ${cyan(`https://accounts.${domain}/v1/oauth_callback`)}`);
+ const gotcha = PROVIDER_GOTCHAS[provider];
+ if (gotcha) {
+ log.blank();
+ log.info(gotcha);
+ }
+ log.blank();
+ log.info(dim(`Provider guide: ${docsUrl}`));
+
+ const openResult = await openBrowser(docsUrl);
+ if (!openResult.ok) {
+ log.info(dim(`Open the setup guide: ${docsUrl}`));
+ }
+ log.blank();
+}
diff --git a/packages/cli-core/src/commands/deploy/state.ts b/packages/cli-core/src/commands/deploy/state.ts
new file mode 100644
index 00000000..5ea4ad06
--- /dev/null
+++ b/packages/cli-core/src/commands/deploy/state.ts
@@ -0,0 +1,51 @@
+import { CliError, EXIT_CODE } from "../../lib/errors.ts";
+import { pausedMessage } from "./copy.ts";
+import type { CnameTarget } from "./api.ts";
+import { providerLabel, type OAuthProvider } from "./providers.ts";
+import type { Profile } from "../../lib/config.ts";
+
+export type DeployOperationState = {
+ appId: string;
+ developmentInstanceId: string;
+ productionInstanceId?: string;
+ productionDomainId?: string;
+ domain: string;
+ pending: { type: "dns" } | { type: "oauth"; provider: string };
+ oauthProviders: string[];
+ completedOAuthProviders: string[];
+ cnameTargets?: readonly CnameTarget[];
+};
+
+export type DeployContext = {
+ profileKey: string;
+ profile: Profile;
+ appId: string;
+ appLabel: string;
+ developmentInstanceId: string;
+ productionInstanceId?: string;
+ testForceProductionInstance?: boolean;
+ testFailProductionInstanceCheck?: boolean;
+ testFailDomainLookup?: boolean;
+ testFailValidateCloning?: boolean;
+ testFailCreateProductionInstance?: boolean;
+ testFailDnsVerification?: boolean;
+ testFailOAuthSave?: boolean;
+};
+
+export function pausedStepDescription(state: DeployOperationState): string {
+ if (state.pending.type === "dns") {
+ return `DNS verification for ${state.domain}`;
+ }
+ return `${providerLabel(state.pending.provider as OAuthProvider)} OAuth credential setup`;
+}
+
+export class DeployPausedError extends CliError {}
+
+export function deployPausedError(
+ state: DeployOperationState,
+ options?: { interrupted?: boolean },
+): DeployPausedError {
+ return new DeployPausedError(pausedMessage(pausedStepDescription(state)), {
+ exitCode: options?.interrupted ? EXIT_CODE.SIGINT : EXIT_CODE.GENERAL,
+ });
+}
diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts
index ef577503..3d9edbcc 100644
--- a/packages/cli-core/src/lib/errors.ts
+++ b/packages/cli-core/src/lib/errors.ts
@@ -1,4 +1,5 @@
import { isAgent } from "../mode.ts";
+import { ExitPromptError } from "@inquirer/core";
/** Standard process exit codes used by the CLI. */
export const EXIT_CODE = {
@@ -151,6 +152,15 @@ export class UserAbortError extends Error {
}
}
+export function isPromptExitError(error: unknown): boolean {
+ return (
+ error instanceof ExitPromptError ||
+ (error instanceof Error &&
+ error.name === "ExitPromptError" &&
+ error.message.includes("User force closed the prompt"))
+ );
+}
+
/**
* Base class for HTTP API errors.
*
diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts
index 30d1e96a..e1ae2791 100644
--- a/packages/cli-core/src/lib/plapi.test.ts
+++ b/packages/cli-core/src/lib/plapi.test.ts
@@ -14,6 +14,12 @@ const {
patchInstanceConfig,
listApplications,
createApplication,
+ createProductionInstance,
+ validateCloning,
+ getDeployStatus,
+ retryApplicationDomainSSL,
+ retryApplicationDomainMail,
+ listApplicationDomains,
} = await import("./plapi.ts");
const { AuthError, PlapiError } = await import("./errors.ts");
@@ -380,4 +386,161 @@ describe("plapi", () => {
}
});
});
+
+ describe("createProductionInstance", () => {
+ test("sends POST to production_instance with clone params", async () => {
+ let capturedMethod = "";
+ let capturedUrl = "";
+ let capturedBody = "";
+ const responseBody = {
+ instance_id: "ins_prod_123",
+ environment_type: "production" as const,
+ active_domain: { id: "dmn_123", name: "example.com" },
+ publishable_key: "pk_live_123",
+ secret_key: "sk_live_123",
+ cname_targets: [
+ { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true },
+ ],
+ };
+ stubFetch(async (input, init) => {
+ capturedMethod = init?.method ?? "GET";
+ capturedUrl = input.toString();
+ capturedBody = init?.body as string;
+ return new Response(JSON.stringify(responseBody), { status: 201 });
+ });
+
+ const result = await createProductionInstance("app_abc", {
+ home_url: "example.com",
+ clone_instance_id: "ins_dev_123",
+ });
+
+ expect(capturedMethod).toBe("POST");
+ expect(capturedUrl).toBe(
+ "https://api.clerk.com/v1/platform/applications/app_abc/production_instance",
+ );
+ expect(JSON.parse(capturedBody)).toEqual({
+ home_url: "example.com",
+ clone_instance_id: "ins_dev_123",
+ });
+ expect(result).toEqual(responseBody);
+ });
+ });
+
+ describe("validateCloning", () => {
+ test("sends POST to validate_cloning and accepts empty success response", async () => {
+ let capturedMethod = "";
+ let capturedUrl = "";
+ let capturedBody = "";
+ stubFetch(async (input, init) => {
+ capturedMethod = init?.method ?? "GET";
+ capturedUrl = input.toString();
+ capturedBody = init?.body as string;
+ return new Response(null, { status: 204 });
+ });
+
+ await validateCloning("app_abc", { clone_instance_id: "ins_dev_123" });
+
+ expect(capturedMethod).toBe("POST");
+ expect(capturedUrl).toBe(
+ "https://api.clerk.com/v1/platform/applications/app_abc/validate_cloning",
+ );
+ expect(JSON.parse(capturedBody)).toEqual({ clone_instance_id: "ins_dev_123" });
+ });
+ });
+
+ describe("getDeployStatus", () => {
+ test("sends GET to deploy_status and returns parsed status", async () => {
+ let capturedMethod = "";
+ let capturedUrl = "";
+ stubFetch(async (input, init) => {
+ capturedMethod = init?.method ?? "GET";
+ capturedUrl = input.toString();
+ return new Response(JSON.stringify({ status: "complete" }), { status: 200 });
+ });
+
+ const result = await getDeployStatus("app_abc", "production");
+
+ expect(capturedMethod).toBe("GET");
+ expect(capturedUrl).toBe(
+ "https://api.clerk.com/v1/platform/applications/app_abc/instances/production/deploy_status",
+ );
+ expect(result).toEqual({ status: "complete" });
+ });
+ });
+
+ describe("retryApplicationDomainSSL", () => {
+ test("sends POST to ssl_retry and accepts empty success response", async () => {
+ let capturedMethod = "";
+ let capturedUrl = "";
+ stubFetch(async (input, init) => {
+ capturedMethod = init?.method ?? "GET";
+ capturedUrl = input.toString();
+ return new Response(null, { status: 204 });
+ });
+
+ await retryApplicationDomainSSL("app_abc", "dmn_123");
+
+ expect(capturedMethod).toBe("POST");
+ expect(capturedUrl).toBe(
+ "https://api.clerk.com/v1/platform/applications/app_abc/domains/dmn_123/ssl_retry",
+ );
+ });
+ });
+
+ describe("retryApplicationDomainMail", () => {
+ test("sends POST to mail_retry and accepts empty success response", async () => {
+ let capturedMethod = "";
+ let capturedUrl = "";
+ stubFetch(async (input, init) => {
+ capturedMethod = init?.method ?? "GET";
+ capturedUrl = input.toString();
+ return new Response(null, { status: 204 });
+ });
+
+ await retryApplicationDomainMail("app_abc", "example.com");
+
+ expect(capturedMethod).toBe("POST");
+ expect(capturedUrl).toBe(
+ "https://api.clerk.com/v1/platform/applications/app_abc/domains/example.com/mail_retry",
+ );
+ });
+ });
+
+ describe("listApplicationDomains", () => {
+ test("sends GET to application domains and returns parsed domains", async () => {
+ let capturedMethod = "";
+ let capturedUrl = "";
+ const responseBody = {
+ data: [
+ {
+ object: "domain" as const,
+ id: "dmn_123",
+ name: "example.com",
+ is_satellite: false,
+ is_provider_domain: false,
+ frontend_api_url: "https://clerk.example.com",
+ accounts_portal_url: "https://accounts.example.com",
+ development_origin: "",
+ cname_targets: [
+ { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true },
+ ],
+ created_at: "2026-05-06T00:00:00Z",
+ updated_at: "2026-05-06T00:00:00Z",
+ },
+ ],
+ total_count: 1,
+ };
+ stubFetch(async (input, init) => {
+ capturedMethod = init?.method ?? "GET";
+ capturedUrl = input.toString();
+ return new Response(JSON.stringify(responseBody), { status: 200 });
+ });
+
+ const result = await listApplicationDomains("app_abc");
+
+ expect(capturedMethod).toBe("GET");
+ expect(capturedUrl).toBe("https://api.clerk.com/v1/platform/applications/app_abc/domains");
+ expect(result).toEqual(responseBody);
+ });
+ });
});
diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts
index 3835570e..0f4a2208 100644
--- a/packages/cli-core/src/lib/plapi.ts
+++ b/packages/cli-core/src/lib/plapi.ts
@@ -141,6 +141,62 @@ export interface Application {
instances: ApplicationInstance[];
}
+export type DomainSummary = {
+ id: string;
+ name: string;
+};
+
+export type CnameTarget = {
+ host: string;
+ value: string;
+ required: boolean;
+};
+
+export type ApplicationDomain = {
+ object: "domain";
+ id: string;
+ name: string;
+ is_satellite: boolean;
+ is_provider_domain: boolean;
+ frontend_api_url: string;
+ accounts_portal_url?: string;
+ proxy_url?: string;
+ development_origin: string;
+ cname_targets?: CnameTarget[];
+ created_at: string;
+ updated_at: string;
+};
+
+export type ListApplicationDomainsResponse = {
+ data: ApplicationDomain[];
+ total_count: number;
+};
+
+export type ProductionInstanceResponse = {
+ instance_id: string;
+ environment_type: "production";
+ active_domain: DomainSummary;
+ secret_key?: string;
+ publishable_key: string;
+ cname_targets: CnameTarget[];
+};
+
+export type CreateProductionInstanceParams = {
+ home_url: string;
+ clone_instance_id?: string;
+ is_secondary?: boolean;
+};
+
+export type ValidateCloningParams = {
+ clone_instance_id: string;
+};
+
+export type DeployStatus = "complete" | "incomplete";
+
+export type DeployStatusResponse = {
+ status: DeployStatus;
+};
+
export async function fetchApplication(applicationId: string): Promise {
const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl());
url.searchParams.set("include_secret_keys", "true");
@@ -148,6 +204,71 @@ export async function fetchApplication(applicationId: string): Promise;
}
+export async function listApplicationDomains(
+ applicationId: string,
+): Promise {
+ const url = new URL(`/v1/platform/applications/${applicationId}/domains`, getPlapiBaseUrl());
+ const response = await plapiFetch("GET", url);
+ return response.json() as Promise;
+}
+
+export async function createProductionInstance(
+ applicationId: string,
+ params: CreateProductionInstanceParams,
+): Promise {
+ const url = new URL(
+ `/v1/platform/applications/${applicationId}/production_instance`,
+ getPlapiBaseUrl(),
+ );
+ const response = await plapiFetch("POST", url, { body: JSON.stringify(params) });
+ return response.json() as Promise;
+}
+
+export async function validateCloning(
+ applicationId: string,
+ params: ValidateCloningParams,
+): Promise {
+ const url = new URL(
+ `/v1/platform/applications/${applicationId}/validate_cloning`,
+ getPlapiBaseUrl(),
+ );
+ await plapiFetch("POST", url, { body: JSON.stringify(params) });
+}
+
+export async function getDeployStatus(
+ applicationId: string,
+ envOrInsId: string,
+): Promise {
+ const url = new URL(
+ `/v1/platform/applications/${applicationId}/instances/${envOrInsId}/deploy_status`,
+ getPlapiBaseUrl(),
+ );
+ const response = await plapiFetch("GET", url);
+ return response.json() as Promise;
+}
+
+export async function retryApplicationDomainSSL(
+ applicationId: string,
+ domainIdOrName: string,
+): Promise {
+ const url = new URL(
+ `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/ssl_retry`,
+ getPlapiBaseUrl(),
+ );
+ await plapiFetch("POST", url);
+}
+
+export async function retryApplicationDomainMail(
+ applicationId: string,
+ domainIdOrName: string,
+): Promise {
+ const url = new URL(
+ `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/mail_retry`,
+ getPlapiBaseUrl(),
+ );
+ await plapiFetch("POST", url);
+}
+
async function sendInstanceConfig(
method: "PUT" | "PATCH",
applicationId: string,
diff --git a/packages/cli-core/src/lib/sleep.ts b/packages/cli-core/src/lib/sleep.ts
new file mode 100644
index 00000000..ae67cc70
--- /dev/null
+++ b/packages/cli-core/src/lib/sleep.ts
@@ -0,0 +1,5 @@
+export function sleep(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}