diff --git a/.changeset/deploy-wizard.md b/.changeset/deploy-wizard.md new file mode 100644 index 00000000..46fa126d --- /dev/null +++ b/.changeset/deploy-wizard.md @@ -0,0 +1,11 @@ +--- +"clerk": minor +--- + +Add `clerk deploy`, an interactive wizard that promotes a Clerk application from development to production. + +- Walks through cloning the development instance, creating the production instance, and configuring CNAME records. +- Verifies mail, DNS, and SSL one component at a time so each step's status is visible while polling. +- Optionally exports the DNS records as a BIND zone file at `./clerk-.zone` for import into providers like Cloudflare, Route 53, and Google Cloud DNS. +- Resumes from the next pending step on subsequent runs, including reshowing the CNAME records when DNS is not yet verified. +- Collects production OAuth credentials for any social providers enabled in development. diff --git a/README.md b/README.md index 7e8be625..564efe72 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Commands: completion [shell] Generate shell autocompletion script skill Manage the bundled Clerk CLI agent skill update [options] Update the Clerk CLI to the latest version + deploy [options] Deploy a Clerk application to production help [command] Display help for command Give AI agents better Clerk context: install the Clerk skills diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index a0769a99..481fdd46 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -45,6 +45,14 @@ 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"]); +}); + 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 982697f4..46b8eabf 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"; @@ -921,6 +922,12 @@ Tutorial — enable completions for your shell: ]) .action(update); + program + .command("deploy") + .description("Deploy a Clerk application to production") + .option("--debug", "Show detailed deployment debug output") + .action(deploy); + registerExtras(program); return program; @@ -1008,7 +1015,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..8fb504c3 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,28 +1,30 @@ # 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. +> **Live PLAPI lifecycle.** Human mode resolves the linked application, production domains, deploy status, and instance config from the Platform API on each run. The production-instance lifecycle calls (`validate_cloning`, `production_instance`, `deploy_status`, `dns_check`, `ssl_retry`, `mail_retry`) call the helpers in `lib/plapi.ts` directly. PLAPI error codes are translated to typed `CliError`s by `commands/deploy/errors.ts`. Guides a user through deploying their Clerk application to production. +When the CLI reaches the DNS configuration step, it displays the required CNAME records and a Domain Connect URL, then prompts the user to export those records as a BIND zone file (`./clerk-.zone`). The prompt defaults to "no" and the file is overwritten silently on subsequent runs. This applies to both new deploys and resumed deploys, so users who return to finish an interrupted deploy see the same records preamble that the initial run shows. + +Once DNS records are added and the user proceeds to verification, the CLI runs a chain of three per-component spinners covering mail, DNS, and SSL in sequence. Each spinner resolves independently and emits a confirmation line as its component flips true, so the user sees incremental progress instead of a single opaque wait. All three spinners share the same five-minute wall-clock cap. + ## 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 +clerk deploy --mode agent # Exit with human-mode-required guidance ``` -## Agent Mode +## Options -> **TODO:** The `DEPLOY_PROMPT` string is hardcoded. It should probably fetch from the quickstart prompt in the Clerk docs instead. +| Flag | Purpose | +| --------- | -------------------------------------------- | +| `--debug` | Show detailed deploy and PLAPI debug output. | -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: +## Agent Mode -- Prerequisites and pre-flight checks -- Domain selection options (custom vs. Clerk subdomain) -- Production instance creation steps -- OAuth credential collection for social providers -- All relevant Platform API endpoints +When running in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), this command exits with a usage error explaining that human mode is required. Production deploy configuration depends on interactive prompts for domain, DNS, and OAuth credential collection, so agents should hand off to a human-run terminal session. Agent mode is detected via the mode system (`src/mode.ts`), which checks in priority order: @@ -30,6 +32,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 and exits before the human-mode wizard starts. + +## PLAPI Lifecycle + +Human mode reads and writes deploy state through the Platform API on every run. The CLI does not persist deploy progress locally — the only profile write is the ordinary `instances.production` value once the production instance has been created. + +| Step | Endpoint | Behavior | +| -------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Validate cloning | `POST /v1/platform/applications/{appID}/validate_cloning` | 204 on success. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. | +| Create production instance | `POST /v1/platform/applications/{appID}/production_instance` | Returns `instance_id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and `cname_targets[]`. | +| | | 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | +| Trigger DNS check | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | Fired best-effort once per "Check DNS now" selection to actively kick the check job. Idempotent (no-op if a check is in flight). | +| Poll deploy status | `GET /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Returns `status` plus the three component booleans `dns_ok`, `ssl_ok`, `mail_ok`. The CLI drives three sequential per-component spinners (mail, DNS, SSL) that all share the same five-minute wall-clock cap; each spinner emits a success line as its boolean flips true. A `proxy_ok` defensive check guards the DNS spinner. Polls every 3s. | +| Retry SSL provisioning | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | 204 on success. 409 `ssl_retry_throttled` carries `meta.retry_after_seconds` (12-min per-domain throttle). | +| Retry mail verification | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | 204 on success. 409 `mail_retry_inflight` → poll `deploy_status`. 403 `operation_not_allowed_on_satellite_domain` for satellites. | +| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Returns the updated config snapshot. Used to persist production `connection_oauth_*` credentials. | + +After displaying the DNS records block, when CNAME records are present the CLI prompts "Export DNS records as a BIND zone file? (y/N)". On yes, it writes `./clerk-.zone` in the current working directory. The file is a standard BIND fragment containing `$ORIGIN`, `$TTL 300`, and one fully-qualified CNAME per record. The default is "no" and the file is overwritten silently on subsequent runs. Most major DNS providers (Cloudflare, Route 53, Google Cloud DNS, and others) support importing BIND zone fragments. The same prompt appears on both new deploys and resumed deploys. + +PLAPI errors are translated to typed `CliError`s by `commands/deploy/errors.ts`. The CLI does not auto-retry SSL or mail provisioning — when `deploy_status` polling times out with `ssl_ok` or `mail_ok` still false, the CLI surfaces the component status and instructs the user to rerun `clerk deploy` once DNS propagates. + +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 +62,86 @@ 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 + %% Plan summary + domain + CLI->>User: Plan summary + CLI->>User: Production domain (e.g. example.com) + User->>CLI: example.com - %% 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 + %% 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 } - CLI->>API: POST /v1/platform/applications/{appID}/domains/{domainID}/dns_check - API-->>CLI: { status } - end + CLI->>User: Add these CNAME records to your DNS provider - %% Social Provider Credential Collection - Note over CLI: Dev config already fetched above —
check for enabled connection_oauth_* keys + %% Trigger an active DNS check when the user opts to verify now + opt User selects "Check DNS now" + CLI->>API: POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/dns_check + API-->>CLI: 204 + end - 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, dns_ok, ssl_ok, mail_ok } + 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/...`) and are live HTTP calls. The deploy command calls the helpers in `lib/plapi.ts` directly. + +| Step | Method | Endpoint | Helper | +| -------------------------- | ------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Auth | n/a | Local config | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | +| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `fetchInstanceConfig` from `lib/plapi.ts`. Discovers enabled `connection_oauth_*` providers. | +| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `patchInstanceConfig`. Writes production OAuth credentials. | +| Read application | `GET` | `/v1/platform/applications/{appID}` | `fetchApplication`. Resolves development and production instance IDs. | +| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | `listApplicationDomains`. Recovers production domain name and CNAME targets on each run. | +| Validate cloning | `POST` | `/v1/platform/applications/{appID}/validate_cloning` | `validateCloning`. Pre-flights subscription/feature support. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/production_instance` | `createProductionInstance`. Returns prod instance, primary domain, keys, and `cname_targets[]`. | +| Trigger DNS check | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | `triggerDomainDnsCheck`. Fired best-effort when the user picks "Check DNS now" to actively kick the check job. | +| Poll deploy status | `GET` | `/v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | `getDeployStatus`. Drives the mail, DNS, and SSL sequential spinners in `pollDeployStatus`; each spinner resolves independently within the shared five-minute cap. | +| Retry SSL provisioning | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | `retryApplicationDomainSSL`. Exposed on the API surface; not invoked from the deploy flow yet (handled by re-running). | +| Retry mail verification | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | `retryApplicationDomainMail`. Same — rejected on satellite domains with 403 `operation_not_allowed_on_satellite_domain`. | ## OAuth Provider Config Format @@ -196,13 +161,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 +175,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/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts new file mode 100644 index 00000000..13064336 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -0,0 +1,77 @@ +import { test, expect, describe } from "bun:test"; +import { bindZoneFile, deployComponentLabels } from "./copy.ts"; +import type { CnameTarget } from "../../lib/plapi.ts"; + +describe("bindZoneFile", () => { + const fixedDate = new Date("2026-05-20T18:30:00.000Z"); + + test("renders a BIND fragment with $ORIGIN, $TTL, and one CNAME per target", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "accounts.example.com", value: "accounts.clerk.services", required: true }, + { + host: "clkmail.example.com", + value: "mail.example.com.nam1.clerk.services", + required: true, + }, + ]; + const output = bindZoneFile("example.com", targets, fixedDate); + + expect(output).toContain("; Generated by `clerk deploy` on 2026-05-20T18:30:00.000Z"); + expect(output).toContain( + "; Import into your existing zone for example.com to add Clerk's required DNS records.", + ); + expect(output).toContain("$ORIGIN example.com."); + expect(output).toContain("$TTL 300"); + expect(output).toContain("clerk.example.com.\tIN\tCNAME\tfrontend-api.clerk.services."); + expect(output).toContain("accounts.example.com.\tIN\tCNAME\taccounts.clerk.services."); + expect(output).toContain( + "clkmail.example.com.\tIN\tCNAME\tmail.example.com.nam1.clerk.services.", + ); + expect(output.endsWith("\n")).toBe(true); + }); + + test("includes optional records (required: false) without filtering", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: false }, + ]; + const output = bindZoneFile("example.com", targets, fixedDate); + + expect(output).toContain("clerk.example.com.\tIN\tCNAME\tfrontend-api.clerk.services."); + expect(output).toContain("clk2._domainkey.example.com.\tIN\tCNAME\tdkim2.clerk.services."); + }); + + test("does not double-append the trailing dot when the value already ends with one", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services.", required: true }, + ]; + const output = bindZoneFile("example.com", targets, fixedDate); + + expect(output).toContain("clerk.example.com.\tIN\tCNAME\tfrontend-api.clerk.services."); + expect(output).not.toContain("frontend-api.clerk.services.."); + }); +}); + +describe("deployComponentLabels", () => { + test("returns mail in-progress and done labels", () => { + expect(deployComponentLabels("mail", "example.com")).toEqual({ + progress: "Verifying mail sender for example.com...", + done: "Mail sender verified", + }); + }); + + test("returns dns labels matching the existing dnsVerified string", () => { + expect(deployComponentLabels("dns", "example.com")).toEqual({ + progress: "Verifying DNS records for example.com...", + done: "DNS verified for example.com.", + }); + }); + + test("returns ssl labels", () => { + expect(deployComponentLabels("ssl", "example.com")).toEqual({ + progress: "Issuing SSL certificate for example.com...", + done: "SSL certificate issued for example.com", + }); + }); +}); 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..a859c416 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -0,0 +1,250 @@ +import { bold, cyan, dim, green, yellow } from "../../lib/color.ts"; +import type { CnameTarget } from "../../lib/plapi.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): string[] { + const hosts = [`clerk.${domain}`, `accounts.${domain}`, `clkmail.${domain}`]; + return [ + `Clerk will associate these subdomains with ${cyan(domain)}:`, + "", + ...hosts.map((host) => ` ${cnameTargetLabel(host)} ${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 verify DNS now, or skip and continue. DNS propagation can take time.", + ]; +} + +export function dnsVerified(domain: string): string[] { + return [`DNS verified for ${domain}.`]; +} + +export type DeployComponentStatus = { + dns: boolean; + ssl: boolean; + mail: boolean; +}; + +export type DeployComponent = "mail" | "dns" | "ssl"; + +export const DEPLOY_COMPONENT_ORDER = [ + "mail", + "dns", + "ssl", +] as const satisfies readonly DeployComponent[]; + +export function deployComponentLabels( + component: DeployComponent, + domain: string, +): { progress: string; done: string } { + switch (component) { + case "mail": + return { + progress: `Verifying mail sender for ${domain}...`, + done: "Mail sender verified", + }; + case "dns": + return { + progress: `Verifying DNS records for ${domain}...`, + done: `DNS verified for ${domain}.`, + }; + case "ssl": + return { + progress: `Issuing SSL certificate for ${domain}...`, + done: `SSL certificate issued for ${domain}`, + }; + } +} + +/** + * Status line for the three independent components Clerk verifies after + * `production_instance` is created: DNS propagation, SSL issuance via Let's + * Encrypt, and SendGrid mail sender verification. Each flips true on its own + * schedule — see the deploy endpoints handbook for timing details. + */ +export function deployComponentStatus(status: DeployComponentStatus): string { + const mark = (ok: boolean) => (ok ? green("✓") : yellow("pending")); + return `DNS: ${mark(status.dns)} SSL: ${mark(status.ssl)} Mail: ${mark(status.mail)}`; +} + +/** + * Footer printed when `deploy_status` polling times out before all three + * booleans flip true. The user keeps the deploy state; rerunning + * `clerk deploy` resumes from whichever component is still pending. + */ +export function deployStatusPendingFooter(domain: string, status: DeployComponentStatus): string[] { + const pending: string[] = []; + if (!status.dns) pending.push("DNS"); + if (!status.ssl) pending.push("SSL"); + if (!status.mail) pending.push("mail"); + + const lead = + pending.length === 0 + ? `Production setup for ${domain} is still finalizing.` + : `${pending.join(", ")} still pending for ${domain}.`; + + return [ + lead, + "DNS propagation can take several hours depending on your provider.", + "Run `clerk deploy` again to resume — the production instance is already created.", + ]; +} + +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.`; +} + +function ensureTrailingDot(value: string): string { + return value.endsWith(".") ? value : `${value}.`; +} + +export function bindZoneFile(domain: string, targets: readonly CnameTarget[], now: Date): string { + const lines = [ + `; Generated by \`clerk deploy\` on ${now.toISOString()}`, + `; Import into your existing zone for ${domain} to add Clerk's required DNS records.`, + `$ORIGIN ${ensureTrailingDot(domain)}`, + `$TTL 300`, + ``, + ]; + for (const target of targets) { + lines.push(`${ensureTrailingDot(target.host)}\tIN\tCNAME\t${ensureTrailingDot(target.value)}`); + } + return `${lines.join("\n")}\n`; +} 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/errors.ts b/packages/cli-core/src/commands/deploy/errors.ts new file mode 100644 index 00000000..873b5b4a --- /dev/null +++ b/packages/cli-core/src/commands/deploy/errors.ts @@ -0,0 +1,169 @@ +/** + * PLAPI deploy error mapping. + * + * The handbook (`2026-05-15 clerk-plapi-deploy-endpoints-handbook.md`) maps + * HTTP status + `code` to a deterministic CLI action. This module is the + * single place that translation lives — callers wrap PLAPI calls with + * `mapDeployError(promise, { onProductionInstanceExists })` and either get + * the resolved value or a typed `CliError` they can branch on. + */ + +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; + +type ProductionInstanceExistsRecovery = () => Promise; + +export type MapDeployErrorOptions = { + /** + * When PLAPI returns 409 `production_instance_exists` for a creation call, + * the wizard should re-derive state via `fetchApplication` rather than + * surfacing the error. Pass a recovery callback here to opt into that path. + */ + onProductionInstanceExists?: ProductionInstanceExistsRecovery; +}; + +export async function mapDeployError( + promise: Promise, + options: MapDeployErrorOptions = {}, +): Promise { + try { + return await promise; + } catch (error) { + if (!(error instanceof PlapiError)) throw error; + const recovered = await maybeRecover(error, options); + if (recovered.recovered) return recovered.value; + throw translatePlapiError(error); + } +} + +type RecoveryOutcome = { recovered: true; value: T } | { recovered: false }; + +async function maybeRecover( + error: PlapiError, + options: MapDeployErrorOptions, +): Promise> { + if (error.status === 409 && error.code === "production_instance_exists") { + if (options.onProductionInstanceExists) { + return { recovered: true, value: await options.onProductionInstanceExists() }; + } + } + return { recovered: false }; +} + +function translatePlapiError(error: PlapiError): CliError | PlapiError { + const { status, code } = error; + + if (status === 402 && code === "unsupported_subscription_plan_features") { + return new CliError(planInsufficientMessage(error), { + code: ERROR_CODE.PLAN_INSUFFICIENT, + docsUrl: "https://clerk.com/pricing", + }); + } + + if (status === 400 && code === "provider_domain_operation_not_allowed_for_api") { + return new CliError( + "The home URL points to a provider domain (e.g. *.vercel.app, *.replit.app). " + + "Production instances require a domain you own — use a custom domain instead.", + { code: ERROR_CODE.PROVIDER_DOMAIN_NOT_ALLOWED }, + ); + } + + if (status === 400 && code === "home_url_taken") { + return new CliError( + "Another instance is already using that home URL. Pick a different domain and run `clerk deploy` again.", + { code: ERROR_CODE.HOME_URL_TAKEN }, + ); + } + + if (status === 409 && code === "production_instance_exists") { + // Reached only when the caller did NOT pass onProductionInstanceExists. + // Surface a typed error so any future call site can branch on it. + return new CliError( + "This application already has a production instance. Run `clerk deploy` to resume the existing flow.", + { code: ERROR_CODE.PRODUCTION_INSTANCE_EXISTS }, + ); + } + + if (status === 409 && code === "ssl_retry_throttled") { + const retryAfter = readRetryAfterSeconds(error.meta); + const wait = retryAfter ? ` Retry available in ${formatSeconds(retryAfter)}.` : ""; + return new CliError(`SSL provisioning was already requested recently.${wait}`, { + code: ERROR_CODE.SSL_RETRY_THROTTLED, + }); + } + + if (status === 409 && code === "mail_retry_inflight") { + return new CliError( + "Mail verification is already in progress for this domain. " + + "Run `clerk deploy` again once the verification job completes.", + { code: ERROR_CODE.MAIL_RETRY_INFLIGHT }, + ); + } + + if (status === 403 && code === "operation_not_allowed_on_satellite_domain") { + return new CliError( + "Mail settings are inherited from the primary domain on satellite domains and cannot be retried here.", + { code: ERROR_CODE.MAIL_RETRY_SATELLITE }, + ); + } + + if (status === 404 && code === "resource_not_found") { + return new CliError( + "Clerk couldn't find this application (it may have been deleted or your workspace no longer has access). " + + "Run `clerk link` to re-link this directory.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + if (status === 422 && code === "form_param_format_invalid") { + const paramName = readParamName(error.meta); + const baseMessage = error.message || "A request parameter was invalid."; + const message = paramName ? `${baseMessage} (parameter: ${paramName})` : baseMessage; + return new CliError(message, { code: ERROR_CODE.FORM_PARAM_INVALID }); + } + + // Pass everything else through unchanged — the global handler prints the + // PLAPI message with a "Platform API request failed" prefix. + return error; +} + +function planInsufficientMessage(error: PlapiError): string { + const features = readFeatures(error.meta); + if (features.length === 0) { + return ( + "Your subscription plan doesn't cover all the features enabled in your development instance. " + + "Upgrade your plan from the Clerk Dashboard before deploying." + ); + } + return ( + "Your subscription plan doesn't cover these features enabled in development:\n" + + features.map((f) => ` • ${f}`).join("\n") + + "\n\nUpgrade your plan from the Clerk Dashboard or disable these features in development before deploying." + ); +} + +function readFeatures(meta: Record | null): string[] { + if (!meta) return []; + const features = meta.features; + if (!Array.isArray(features)) return []; + return features.filter((f): f is string => typeof f === "string"); +} + +function readParamName(meta: Record | null): string | undefined { + if (!meta) return undefined; + const value = meta.param_name; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readRetryAfterSeconds(meta: Record | null): number | undefined { + if (!meta) return undefined; + const value = meta.retry_after_seconds; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; + return Math.ceil(value); +} + +function formatSeconds(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`; +} diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 9805ccc2..d1eda7fa 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,17 @@ 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 mockTriggerDomainDnsCheck = mock(); +const mockRetrySSL = mock(); +const mockRetryMail = mock(); +const mockDomainConnectUrl = mock(); mock.module("@inquirer/prompts", () => ({ ...promptsStubs, @@ -37,83 +52,309 @@ 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), + createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), + validateCloning: (...args: unknown[]) => mockValidateCloning(...args), + getDeployStatus: (...args: unknown[]) => mockGetDeployStatus(...args), + patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), + triggerDomainDnsCheck: (...args: unknown[]) => mockTriggerDomainDnsCheck(...args), + retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args), + retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...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", + dns_ok: true, + ssl_ok: true, + mail_ok: true, + }); + mockCreateProductionInstance.mockImplementation( + (_appId: string, params: { home_url: string }) => { + const hostname = params.home_url.replace(/^https?:\/\//, ""); + return { + instance_id: "ins_prod_mock", + environment_type: "production" as const, + active_domain: { id: "dmn_prod_mock", name: hostname }, + publishable_key: "pk_live_test", + secret_key: "sk_live_test", + cname_targets: [ + { + host: `clerk.${hostname}`, + value: "frontend-api.clerk.services", + required: true, + }, + { + host: `accounts.${hostname}`, + value: "accounts.clerk.services", + required: true, + }, + { + host: `clkmail.${hostname}`, + value: `mail.${hostname}.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(); + mockTriggerDomainDnsCheck.mockReset(); + mockRetrySSL.mockReset(); + mockRetryMail.mockReset(); + mockDomainConnectUrl.mockReset(); consoleSpy?.mockRestore(); + stderrSpy?.mockRestore(); }); function runDeploy(options: Parameters[0]) { return captured.run(() => deploy(options)); } - describe("agent mode", () => { - test("outputs deploy prompt and returns", async () => { - mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - - await runDeploy({}); - - expect(captured.out).toContain("deploying a Clerk application to production"); - }); + 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); - test("prompt includes all deployment steps", async () => { - mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + 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", + }, + }, + }); + } + } - await runDeploy({}); + function mockLiveProduction( + options: { + instanceId?: string; + domain?: string; + domainId?: string; + productionConfig?: Record; + developmentConfig?: Record; + cnameTargets?: readonly { host: string; value: string; required: boolean }[]; + } = {}, + ) { + 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: "" }, + }; + const cnameTargets = options.cnameTargets ?? [ + { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, + ]; - const output = captured.out; - expect(output).toContain("Prerequisites"); - expect(output).toContain("Verify Subscription Compatibility"); - expect(output).toContain("Choose a Production Domain"); - expect(output).toContain("Create the Production Instance"); - expect(output).toContain("Configure Social OAuth Providers"); - expect(output).toContain("Finalize"); + 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: cnameTargets, + 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("prompt includes API reference", async () => { - mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - - await runDeploy({}); + 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), + }; - const output = captured.out; - expect(output).toContain("/v1/platform/applications"); - expect(output).toContain("instances/production/config"); - expect(output).toContain("instances/development/config"); - }); + 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", + ]); + }); - test("prompt includes OAuth redirect URI pattern", async () => { + describe("agent mode", () => { + test("exits with human mode guidance", async () => { mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await runDeploy({}); + await expect(runDeploy({})).rejects.toMatchObject({ + code: "usage_error", + exitCode: EXIT_CODE.USAGE, + message: + "clerk deploy requires human mode because production configuration uses interactive prompts. Run `clerk deploy --mode human` from an interactive terminal.", + }); - const output = captured.out; - expect(output).toContain("accounts.{domain}/v1/oauth_callback"); + expect(captured.out).toBe(""); }); test("does not trigger interactive prompts", async () => { mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await runDeploy({ debug: true }); + await expect(runDeploy({ debug: true })).rejects.toBeInstanceOf(CliError); expect(mockSelect).not.toHaveBeenCalled(); expect(mockInput).not.toHaveBeenCalled(); @@ -125,13 +366,31 @@ describe("deploy", () => { describe("human mode", () => { function mockHumanFlow() { mockIsAgent.mockReturnValue(false); - // Domain selection → OAuth credential choice - mockSelect.mockResolvedValueOnce("clerk-subdomain").mockResolvedValueOnce("have-credentials"); + // Proceed → create instance → skip DNS verification → pause at OAuth. + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + 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 +400,1083 @@ 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 → create instance → check DNS now → complete OAuth. + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockGetDeployStatus + .mockResolvedValueOnce({ + status: "incomplete", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }) + .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + 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("DNS verification emits per-component spinner labels in mail/dns/ssl order", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true) // Create production instance? + .mockResolvedValueOnce(false); // Export BIND zone file? (wired in Task 5; harmless when not yet consumed) + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockGetDeployStatus + .mockResolvedValueOnce({ + status: "incomplete", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }) + .mockResolvedValueOnce({ + status: "incomplete", + dns_ok: false, + ssl_ok: false, + mail_ok: true, + }) + .mockResolvedValueOnce({ + status: "incomplete", + dns_ok: true, + ssl_ok: false, + mail_ok: true, + }) + .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + const mailIdx = err.indexOf("Mail sender verified"); + const dnsIdx = err.indexOf("DNS verified for example.com"); + const sslIdx = err.indexOf("SSL certificate issued for example.com"); + expect(mailIdx).toBeGreaterThan(-1); + expect(dnsIdx).toBeGreaterThan(-1); + expect(sslIdx).toBeGreaterThan(-1); + expect(mailIdx).toBeLessThan(dnsIdx); + expect(dnsIdx).toBeLessThan(sslIdx); + }); + + test("DNS verification fails closed when status stays incomplete despite all exposed booleans true (proxy_ok case)", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + // Every poll returns dns/ssl/mail all true but status incomplete (proxy_ok = false on server). + mockGetDeployStatus.mockResolvedValue({ + status: "incomplete", + dns_ok: true, + ssl_ok: true, + mail_ok: true, + }); + mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: skip (wired in Task 5) + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("Production setup for example.com is still finalizing."); + expect(err).not.toContain("Production ready at"); + }); + + 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 about verifying DNS", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true) // Create production instance? + .mockResolvedValueOnce(false); // Export DNS records as a BIND zone file? + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + 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("DNS propagation can take time"); + expect(err).toContain("Skipping DNS verification for now."); + expect(mockConfirm).toHaveBeenCalledTimes(3); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Create production instance?", + default: true, + }); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Export DNS records as a BIND zone file?", + default: false, + }); + expect(mockConfirm).not.toHaveBeenCalledWith({ + message: "Continue to OAuth setup?", + default: true, + }); + expect(mockSelect).toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], + }); + }); + + 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); + mockSelect.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("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", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }) + .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + 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" }, + ], + }); + expect(mockTriggerDomainDnsCheck).toHaveBeenCalledWith("app_xyz789", "dmn_prod_mock"); + const firstInput = mockInput.mock.calls[0]?.[0] as { message?: string } | undefined; + expect(String(firstInput?.message)).not.toContain("Production domain"); + }); + + test("resume DNS verification prints CNAME records before the verification prompt", 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", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }); + mockConfirm.mockResolvedValueOnce(false); // BIND export prompt placeholder (wired in Task 5) + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + const recordsIdx = err.indexOf("Add the following records at your DNS provider"); + const promptIdx = err.indexOf("DNS verification"); + expect(recordsIdx).toBeGreaterThan(-1); + expect(promptIdx).toBeGreaterThan(-1); + expect(recordsIdx).toBeLessThan(promptIdx); + }); + + test("BIND export prompt writes the zone file when the user accepts", 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", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }); + mockConfirm.mockResolvedValueOnce(true); // BIND export prompt: yes + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + const writeSpy = spyOn(Bun, "write").mockResolvedValue(0); + try { + await runDeploy({}); + const err = stripAnsi(captured.err); + + const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone")); + expect(zoneCall).toBeDefined(); + const pathArg = zoneCall![0]; + const contentArg = zoneCall![1]; + expect(String(pathArg)).toMatch(/clerk-example\.com\.zone$/); + expect(String(contentArg)).toContain("$ORIGIN example.com."); + expect(String(contentArg)).toContain("$TTL 300"); + expect(String(contentArg)).toContain("IN\tCNAME"); + expect(err).toContain("Wrote "); + expect(err).toContain("clerk-example.com.zone"); + } finally { + writeSpy.mockRestore(); + } + }); + + test("BIND export prompt writes no file when the user declines", 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", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }); + mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: no + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + const writeSpy = spyOn(Bun, "write").mockResolvedValue(0); + try { + await runDeploy({}); + const err = stripAnsi(captured.err); + + const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone")); + expect(zoneCall).toBeUndefined(); + expect(err).not.toContain("Wrote "); + } finally { + writeSpy.mockRestore(); + } + }); + + test("BIND export prompt is skipped when cnameTargets is empty", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: {}, + cnameTargets: [], // override: domain has no CNAME targets + }); + mockGetDeployStatus.mockResolvedValue({ + status: "incomplete", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + const writeSpy = spyOn(Bun, "write").mockResolvedValue(0); + try { + await runDeploy({}); + + // confirm() was never called for the BIND prompt in this run. + const bindPromptCalls = mockConfirm.mock.calls.filter((call) => { + const arg = call[0] as { message?: string } | undefined; + return typeof arg?.message === "string" && arg.message.includes("BIND zone file"); + }); + expect(bindPromptCalls.length).toBe(0); + const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone")); + expect(zoneCall).toBeUndefined(); + } finally { + writeSpy.mockRestore(); + } + }); + + test("DNS verification timeout names the specific pending components from deploy_status", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetDeployStatus.mockResolvedValue({ + status: "incomplete", + dns_ok: true, + ssl_ok: false, + mail_ok: false, + }); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("SSL, mail still pending for example.com"); + expect(err).not.toContain("DNS, SSL, mail still pending"); + }); + + 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", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }); + 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 points users to the Clerk Dashboard for propagation status", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + 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("DNS propagation can take time"); + expect(err).toContain("Skipping DNS verification for now."); + }); + + 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", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }) + .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + + 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 skip verification and later resume", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeploy({}); + mockLiveProduction(); + expect(stripAnsi(captured.err)).toContain("Check the Domains section in the Clerk Dashboard"); + expect(stripAnsi(captured.err)).toContain("Skipping DNS verification for now."); + + 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", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }) + .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + + 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 → check DNS → enter google creds → skip github. + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect + .mockResolvedValueOnce("check") + .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); + mockSelect + .mockResolvedValueOnce("check") + .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", + dns_ok: false, + ssl_ok: false, + mail_ok: false, + }); + + await runDeploy({}); + const err = stripAnsi(captured.err); + expect(err).toContain("DNS propagation can take several hours"); + expect(err).toContain("DNS, SSL, mail still pending for example.com"); + expect(err).toContain("DNS: pending"); + 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..beab32df 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,257 +1,845 @@ -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 { isInsideGutter, log } from "../../lib/log.ts"; +import { sleep } from "../../lib/sleep.ts"; +import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; +import { UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; +import { resolveProfile, setProfile } from "../../lib/config.ts"; +import { + createProductionInstance as apiCreateProductionInstance, + fetchApplication, + fetchInstanceConfig, + getDeployStatus, + listApplicationDomains, + patchInstanceConfig, + triggerDomainDnsCheck, + validateCloning, + type ApplicationDomain, + type CnameTarget, + type DeployStatusResponse, + type ProductionInstanceResponse, +} from "../../lib/plapi.ts"; +import { domainConnectUrl } from "./domain-connect.ts"; +import { + INTRO_PREAMBLE, + NEXT_STEPS_BLOCK, + OAUTH_SECTION_INTRO, + type DeployComponentStatus, + type DeployPlanStep, + DEPLOY_COMPONENT_ORDER, + deployComponentLabels, + deployComponentStatus, + deployStatusPendingFooter, + domainAssociationSummary, + bindZoneFile, + dnsDashboardHandoff, + dnsIntro, + dnsRecords, + pausedOperationNotice, + printPlan, + productionSummary, +} from "./copy.ts"; +import { mapDeployError } from "./errors.ts"; +import { + PROVIDER_LABELS, + PROVIDER_FIELDS, + providerLabel, + providerSetupIntro, + showOAuthWalkthrough, + type OAuthProvider, +} from "./providers.ts"; +import { + chooseDnsVerificationAction, + chooseOAuthCredentialAction, + collectCustomDomain, + collectOAuthCredentials, + confirmCreateProductionInstance, + confirmExportBindZone, + confirmProceed, +} from "./prompts.ts"; +import { + DeployPausedError, + deployPausedError, + type DeployContext, + type DeployOperationState, +} from "./state.ts"; + +type DeployOptions = { + debug?: boolean; +}; + +const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000; +const DEPLOY_STATUS_MAX_POLLS = 100; + +export async function deploy(options: DeployOptions = {}) { + if (isAgent()) { + throwUsageError( + "clerk deploy requires human mode because production configuration uses interactive prompts. Run `clerk deploy --mode human` from an interactive terminal.", + ); + } + if (options.debug) { + const { setLogLevel } = await import("../../lib/log.ts"); + setLogLevel("debug"); + } -const DEPLOY_PROMPT = `You are deploying a Clerk application to production. Follow these steps: + intro("clerk deploy"); + try { + const ctx = await resolveDeployContext(); + await runDeploy(ctx); + } catch (error) { + if (error instanceof DeployPausedError && isInsideGutter()) { + outro("Paused"); + } + if (isPromptExitError(error) && isInsideGutter()) { + outro("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()) { + outro("Failed"); + } + } +} -## Prerequisites +async function resolveDeployContext(): Promise { + 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: "", + }; + } -Ensure the following before starting: -- The user is authenticated (\`clerk auth login\` has been run) -- A Clerk application is linked to the project (\`clerk link\` has been run) -- The project has a development instance with a working configuration + return { + profileKey: resolved.path, + profile: resolved.profile, + ...(await withSpinner("Checking for production instance...", () => + resolveLiveApplicationContext(resolved.profile), + )), + }; +} -## Step 1: Verify Subscription Compatibility +async function resolveLiveApplicationContext(profile: DeployContext["profile"]): Promise<{ + appId: string; + appLabel: string; + developmentInstanceId: string; + productionInstanceId?: string; +}> { + const app = await fetchApplication(profile.appId); + 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, + }; +} -Check that the development instance's features are covered by the application's subscription plan. +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`.", + ); + outro("Link required"); + return; + } -- 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. + if (ctx.productionInstanceId) { + await reconcileExistingDeploy(ctx); + return; + } -## Step 2: Choose a Production Domain + await startNewDeploy(ctx); +} -Ask the user which domain setup they prefer: +async function startNewDeploy(ctx: DeployContext): Promise { + const { known: oauthProviders, unknown: unknownOAuthProviders } = + await loadDevelopmentOAuthProviders(ctx); -**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\` + await runValidateCloning(ctx); -**Option B: Clerk-provided subdomain** -- A subdomain like \`{adjective}-{animal}-{number}.clerk.app\` is automatically assigned. -- No DNS configuration is needed. + log.blank(); + log.info(INTRO_PREAMBLE); + log.blank(); + for (const line of printPlan(ctx.appLabel, buildNewDeployPlan(oauthProviders))) { + log.info(line); + } + log.blank(); -## Step 3: Create the Production Instance + 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(); + } -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"]\`. + const proceed = await confirmProceed(); + if (!proceed) { + log.info("No changes were made."); + outro("Cancelled"); + return; + } -## Step 4: Configure Social OAuth Providers + bar(); + const domain = await collectCustomDomain(); + const shouldCreateProductionInstance = await confirmProductionInstanceCreation(domain); + if (!shouldCreateProductionInstance) return; -For each social provider enabled in the development instance (e.g., Google, GitHub, Apple), production OAuth credentials are required. + const productionOrExists = await createProductionInstance(ctx, domain); + if (productionOrExists === "exists") { + log.blank(); + log.info( + "A production instance already exists for this application. Resuming the existing deploy.", + ); + log.blank(); + const refreshed = await withSpinner("Refreshing application state...", () => + resolveLiveApplicationContext(ctx.profile), + ); + ctx.productionInstanceId = refreshed.productionInstanceId; + await reconcileExistingDeploy(ctx); + return; + } + const production = productionOrExists; + 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; -Check the dev config for \`connection_oauth_*\` keys. For each enabled provider: + await finishDeploy(ctx, productionDomain, completedOAuthProviders, dnsStatus); +} -1. Collect the required credentials from the user: - - Most providers: \`client_id\` and \`client_secret\` - - Apple: also requires \`key_id\` and \`team_id\` +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."); + outro("No deploy actions available"); + return; + } -2. When helping the user create OAuth credentials, provide these values: - - Authorized JavaScript origins: \`https://{domain}\` and \`https://www.{domain}\` - - Authorized redirect URI: \`https://accounts.{domain}/v1/oauth_callback\` + log.blank(); + for (const line of printPlan(ctx.appLabel, buildLiveDeployPlan(snapshot))) { + log.info(line); + } + log.blank(); -3. Write credentials to production config: - \`PATCH /v1/platform/applications/{appID}/instances/production/config\` - Body: \`{ "connection_oauth_{provider}": { "enabled": true, "client_id": "...", "client_secret": "..." } }\` + if (!snapshot.pending) { + log.info("No deploy actions remain."); + await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, "verified"); + return; + } -Provider-specific documentation: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/{provider} + 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; + } -## Step 5: Finalize + 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; + } -After all configuration is complete: -- Inform the user their production application is ready at \`https://{domain}\` -- Remind them to redeploy their application with the updated Clerk production secret keys -- They can pull production keys with: \`clerk env pull --instance prod\` + await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, dnsStatus); +} -## API Reference +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); + }); +} -| 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 | +async function resolveLiveDeploySnapshot( + ctx: DeployContext, +): Promise { + const productionInstanceId = ctx.productionInstanceId; + if (!productionInstanceId) return undefined; -Refer to the Clerk Platform API docs for detailed request/response schemas.`; + const domain = await loadProductionDomain(ctx); + if (!domain) return undefined; -export async function deploy(options: { debug?: boolean }) { - if (isAgent()) { - log.data(DEPLOY_PROMPT); - return; - } - if (options.debug) { - const { setLogLevel } = await import("../../lib/log.ts"); - setLogLevel("debug"); + const { known: oauthProviders } = await loadDevelopmentOAuthProviders(ctx); + const { productionConfig, deployStatus } = await loadProductionState(ctx, 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 loadInitialDeployStatus( + appId: string, + productionInstanceId: string, +): Promise { + try { + return await getDeployStatus(appId, productionInstanceId); + } catch (error) { + log.debug( + `deploy: snapshot deploy-status read failed, treating DNS as pending: ${error instanceof Error ? error.message : String(error)}`, + ); + return { status: "incomplete", dns_ok: false, ssl_ok: false, mail_ok: false }; } +} - log.data("[mock] This command uses mocked data and is not yet wired up to real APIs.\n"); +async function loadProductionState( + ctx: DeployContext, + productionInstanceId: string, +): Promise<{ + productionConfig: Record; + deployStatus: DeployStatusResponse; +}> { + return withSpinner("Reading production configuration...", async () => { + const [productionConfig, deployStatus] = await Promise.all([ + fetchInstanceConfig(ctx.appId, productionInstanceId), + loadInitialDeployStatus(ctx.appId, productionInstanceId), + ]); + return { productionConfig, deployStatus }; + }); +} - log.debug("Checking for authenticated user and linked application..."); +async function loadProductionDomain(ctx: DeployContext): Promise { + const domains = await listApplicationDomains(ctx.appId); + return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; +} - // 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" }; +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; + }); +} - log.debug(`Found authenticated user: ${user.email} (${user.id})`); - log.debug(`Found linked application: ${application.name} (${application.id})`); +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, + })), + ]; +} - log.debug("Checking for production instance..."); - log.debug("No production instance found."); +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, + }; + }), + ]; +} - // 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 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, + }; +} - if (unsupported.length > 0) { - log.debug(`Found features not covered by subscription: ${unsupported.join(", ")}`); - log.debug("User must upgrade their plan before deploying."); - return; +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("All development features are covered by subscription."); +async function runValidateCloning(ctx: DeployContext): Promise { + await withSpinner("Validating subscription compatibility...", async () => { + await mapDeployError( + validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }), + ); + }); +} - const domainChoice = await select({ - message: "How would you like to set up your production domain?", - choices: [ - { - name: "Use my own domain", - value: "custom-domain", - }, - { - name: "Use a Clerk-provided subdomain", - value: "clerk-subdomain", - }, - ], +async function createProductionInstance( + ctx: DeployContext, + domain: string, +): Promise { + return withSpinner("Creating production instance...", async () => { + return mapDeployError( + apiCreateProductionInstance(ctx.appId, { + home_url: `https://${domain}`, + clone_instance_id: ctx.developmentInstanceId, + }), + { onProductionInstanceExists: async () => "exists" }, + ); }); +} - let domain: string; +async function confirmProductionInstanceCreation(domain: string): Promise { + for (const line of domainAssociationSummary(domain)) log.info(line); + log.blank(); + const confirmed = await confirmCreateProductionInstance(); + if (confirmed) { + log.blank(); + return true; + } - 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}`); + log.blank(); + log.info("No production instance was created."); + outro("Cancelled"); + return false; +} + +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(); } - log.debug("Creating production instance..."); - log.debug(`Production instance created with domain: ${domain}`); + for (const line of dnsDashboardHandoff(state.domain)) log.info(line); + log.blank(); + await offerBindZoneExport(state.domain, cnameTargets); + log.blank(); + try { + const action = await chooseDnsVerificationAction(); + if (action === "skip") { + log.blank(); + log.info("Skipping DNS verification for now."); + return "pending"; + } + return await runDnsVerification(ctx, { ...state, cnameTargets }); + } catch (error) { + if (isPromptExitError(error)) { + throw deployPausedError(state, { interrupted: true }); + } + throw error; + } +} - // DNS setup for custom domains - if (domainChoice === "custom-domain") { - log.debug(`Looking up DNS provider for ${domain}...`); +async function runExistingDomainDnsVerification( + ctx: DeployContext, + state: DeployOperationState, +): Promise { + for (const line of dnsIntro(state.domain)) log.info(line); + log.blank(); + if (state.cnameTargets && state.cnameTargets.length > 0) { + for (const line of dnsRecords(state.cnameTargets)) log.info(line); + log.blank(); + } + const connectUrl = domainConnectUrl(state.domain); + if (connectUrl) { + log.info(`Domain Connect: ${connectUrl}`); + log.blank(); + } + for (const line of dnsDashboardHandoff(state.domain)) log.info(line); + log.blank(); + await offerBindZoneExport(state.domain, state.cnameTargets); + log.blank(); + + 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; + } +} + +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.", + ); + } - // 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.`); + await requestDomainDnsCheck(ctx.appId, state.productionDomainId ?? state.domain); - 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}`); + const outcome = await pollDeployStatus(ctx.appId, productionInstanceId, state.domain); - await confirm({ - message: `We can automatically configure DNS for ${domain} via ${dnsProvider.name}. Open browser to continue?`, - default: true, - }); + if (outcome.verified) { + log.blank(); + log.info(deployComponentStatus(outcome.status)); + return "verified"; + } + + log.blank(); + log.info(deployComponentStatus(outcome.status)); + log.blank(); + for (const line of deployStatusPendingFooter(state.domain, outcome.status)) { + log.warn(line); + } - log.debug("Opening Domain Connect flow in browser..."); + // When all DNS components are verified but the server has not yet marked the + // deployment complete (proxy_ok or another server-side gate is still pending), + // do not offer a retry — the user cannot influence the remaining wait. Fail + // closed so the caller exits without reaching finishDeploy. + if (outcome.status.dns && outcome.status.ssl && outcome.status.mail) { + return false; } - // Check dev instance settings that require production credentials - log.debug("Checking development instance settings for production requirements..."); + 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); +} - // Mock state — dev instance has Google OAuth enabled - const devSettings = { - socialProviders: ["google"], +type DeployStatusOutcome = + | { verified: true; status: DeployComponentStatus } + | { verified: false; status: DeployComponentStatus }; + +async function pollDeployStatus( + appId: string, + productionInstanceId: string, + domain: string, +): Promise { + let response = await mapDeployError(getDeployStatus(appId, productionInstanceId)); + let status: DeployComponentStatus = { + dns: response.dns_ok, + ssl: response.ssl_ok, + mail: response.mail_ok, }; + let pollsRemaining = DEPLOY_STATUS_MAX_POLLS - 1; + + for (const component of DEPLOY_COMPONENT_ORDER) { + const labels = deployComponentLabels(component, domain); + const flipped = await withSpinner(labels.progress, async () => { + if (status[component]) return true; + while (pollsRemaining > 0) { + await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS); + pollsRemaining--; + response = await mapDeployError(getDeployStatus(appId, productionInstanceId)); + status = { + dns: response.dns_ok, + ssl: response.ssl_ok, + mail: response.mail_ok, + }; + if (status[component]) return true; + } + return false; + }); + if (!flipped) return { verified: false, status }; + log.success(labels.done); + } + + if (response.status !== "complete") { + return { verified: false, status }; + } + return { verified: true, status }; +} + +async function offerBindZoneExport( + domain: string, + cnameTargets: readonly CnameTarget[] | undefined, +): Promise { + if (!cnameTargets || cnameTargets.length === 0) return; + const accepted = await confirmExportBindZone(); + if (!accepted) return; + const contents = bindZoneFile(domain, cnameTargets, new Date()); + const filePath = `${process.cwd()}/clerk-${domain}.zone`; + await Bun.write(filePath, contents); + log.success(`Wrote ${filePath}`); +} - if (devSettings.socialProviders.length > 0) { +async function requestDomainDnsCheck(appId: string, domainIdOrName: string): Promise { + try { + await triggerDomainDnsCheck(appId, domainIdOrName); + } catch (error) { log.debug( - `Found social providers requiring production credentials: ${devSettings.socialProviders.join(", ")}`, + `deploy: dns_check trigger failed, falling back to passive polling: ${error instanceof Error ? error.message : String(error)}`, ); + } +} - 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`, +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(); + } + + 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.", ); - 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 clientId = await input({ - message: `${displayName} OAuth Client ID:`, - }); + const saved = await collectAndSaveOAuthCredentials( + ctx, + provider, + state.domain, + productionInstanceId, + ); + if (!saved) { + log.blank(); + log.info(pausedOperationNotice()); + outro("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(); + } + } - await password({ - message: `${displayName} OAuth Client Secret:`, - }); + return [...completed]; +} - log.debug(`Received ${displayName} credentials (client ID: ${clientId.slice(0, 8)}...)`); - } +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(); - log.debug("All social provider credentials collected."); + 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 patchInstanceConfig(ctx.appId, productionInstanceId, { + [`connection_oauth_${provider}`]: { + enabled: true, + ...credentials, + }, + }); + }); + 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(); + log.info(NEXT_STEPS_BLOCK); + outro("Success"); } 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..254de6d7 --- /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 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 confirmExportBindZone(): Promise { + return confirm({ + message: "Export DNS records as a BIND zone file?", + default: false, + }); +} + +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..e1fe5c5d --- /dev/null +++ b/packages/cli-core/src/commands/deploy/state.ts @@ -0,0 +1,44 @@ +import { CliError, EXIT_CODE } from "../../lib/errors.ts"; +import { pausedMessage } from "./copy.ts"; +import type { CnameTarget } from "../../lib/plapi.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; +}; + +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 929417a0..b754b8dd 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 = { @@ -45,6 +46,22 @@ export const ERROR_CODE = { DOCTOR_FAILED: "doctor_failed", /** Frontend API request failed. */ FAPI_ERROR: "fapi_error", + /** Subscription plan does not cover the dev instance's enabled features. */ + PLAN_INSUFFICIENT: "plan_insufficient", + /** Application already has a production instance; flow should re-derive state. */ + PRODUCTION_INSTANCE_EXISTS: "production_instance_exists", + /** `home_url` is a provider domain (e.g. *.vercel.app) and not allowed. */ + PROVIDER_DOMAIN_NOT_ALLOWED: "provider_domain_not_allowed", + /** `home_url` is already claimed by another instance. */ + HOME_URL_TAKEN: "home_url_taken", + /** SSL retry refused: under the 12-minute per-domain throttle. */ + SSL_RETRY_THROTTLED: "ssl_retry_throttled", + /** Mail retry refused: a SendGrid verification job is already running. */ + MAIL_RETRY_INFLIGHT: "mail_retry_inflight", + /** Mail retry refused: domain is a satellite — mail inherits from primary. */ + MAIL_RETRY_SATELLITE: "mail_retry_satellite", + /** PLAPI rejected a request parameter as malformed. */ + FORM_PARAM_INVALID: "form_param_invalid", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; @@ -212,6 +229,15 @@ function parseApiBody(status: number, body: string): ParsedApiBody { }; } +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..1c07f004 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,169 @@ 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", dns_ok: true, ssl_ok: true, mail_ok: true }), + { 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", + dns_ok: true, + ssl_ok: true, + mail_ok: true, + }); + }); + }); + + 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 5a9ca3ac..5cf077f7 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -140,6 +140,65 @@ 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; + dns_ok: boolean; + ssl_ok: boolean; + mail_ok: boolean; +}; + export async function fetchApplication(applicationId: string): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); url.searchParams.set("include_secret_keys", "true"); @@ -147,6 +206,82 @@ 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 triggerDomainDnsCheck( + applicationId: string, + domainIdOrName: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/dns_check`, + getPlapiBaseUrl(), + ); + await plapiFetch("POST", url); +} + +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); + }); +} diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index d3bebfdc..c87d042e 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -24,8 +24,17 @@ export function intro(title?: string) { pushPrefix(); } -/** Print outro bracket: └ message — restores normal log output. - * Pass a string[] to render as next steps after the bracket. */ +/** + * Print outro bracket: + * + * ``` + * │ + * └ $message + * ``` + * + * Then restores normal log output. Pass a string[] to render as next steps + * after the bracket. + **/ export function outro(messageOrSteps?: string | readonly string[]) { if (!isHuman()) return; popPrefix();