From c4e099b7b8f4c292ec9929e33687c5df55b33797 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 5 May 2026 18:14:49 -0600 Subject: [PATCH 01/20] feat(deploy): implement resumable deploy wizard --- packages/cli-core/src/cli-program.ts | 13 +- .../cli-core/src/commands/deploy/README.md | 214 ++--- packages/cli-core/src/commands/deploy/api.ts | 251 ++++++ packages/cli-core/src/commands/deploy/copy.ts | 154 ++++ .../src/commands/deploy/index.test.ts | 813 +++++++++++++++++- .../cli-core/src/commands/deploy/index.ts | 721 ++++++++++++---- .../cli-core/src/commands/deploy/prompts.ts | 225 +++++ .../cli-core/src/commands/deploy/providers.ts | 98 +++ .../cli-core/src/commands/deploy/state.ts | 48 ++ packages/cli-core/src/lib/config.ts | 14 +- packages/cli-core/src/lib/errors.ts | 10 + packages/cli-core/src/lib/log.test.ts | 20 + packages/cli-core/src/lib/log.ts | 42 +- packages/cli-core/src/lib/sleep.ts | 5 + packages/cli-core/src/lib/spinner.test.ts | 32 + packages/cli-core/src/lib/spinner.ts | 26 +- 16 files changed, 2365 insertions(+), 321 deletions(-) create mode 100644 packages/cli-core/src/commands/deploy/api.ts create mode 100644 packages/cli-core/src/commands/deploy/copy.ts create mode 100644 packages/cli-core/src/commands/deploy/prompts.ts create mode 100644 packages/cli-core/src/commands/deploy/providers.ts create mode 100644 packages/cli-core/src/commands/deploy/state.ts create mode 100644 packages/cli-core/src/lib/sleep.ts create mode 100644 packages/cli-core/src/lib/spinner.test.ts diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 982697f4..23a92757 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,14 @@ Tutorial — enable completions for your shell: ]) .action(update); + program + .command("deploy", { hidden: true }) + .description("Deploy a Clerk application to production") + .option("--debug", "Show detailed deployment debug output") + .option("--continue", "Resume a paused deploy operation") + .option("--abort", "Abort and clear a paused deploy operation") + .action(deploy); + registerExtras(program); return program; @@ -1008,7 +1017,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..4da7e646 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,6 +1,6 @@ # 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. +> **Mostly mocked.** Deploy lifecycle endpoints (`validate_cloning`, `production_instance`, `deploy_status`, `ssl_retry`, `mail_retry`) plus the production-instance config PATCH are mocked locally with the exact request/response shapes from the real Platform API, so swapping each to a live call is a one-import change in `commands/deploy/api.ts`. Production-targeted writes have to stay mocked while the production instance itself (`ins_prod_mock`) is a fake. The only real PLAPI call today is `fetchInstanceConfig` against the development instance for OAuth provider discovery. Guides a user through deploying their Clerk application to production. @@ -9,9 +9,19 @@ Guides a user through deploying their Clerk application to production. ```sh clerk deploy # Interactive wizard (human mode) clerk deploy --debug # With debug output +clerk deploy --continue # Resume a paused deploy operation +clerk deploy --abort # Clear a paused deploy operation after confirmation clerk deploy --mode agent # Output agent prompt instead of interactive flow ``` +## Options + +| Flag | Purpose | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--debug` | Show detailed mocked Platform API debug output. | +| `--continue` | Resume the DNS or OAuth step saved in local CLI config. Reports "no paused operation" when none exists; reports a project mismatch when the bookmark belongs to another project. | +| `--abort` | Confirm, then clear the saved paused deploy operation. Reports "no paused operation" when none exists; leaves server-side changes as-is. | + ## Agent Mode > **TODO:** The `DEPLOY_PROMPT` string is hardcoded. It should probably fetch from the quickstart prompt in the Clerk docs instead. @@ -19,7 +29,7 @@ clerk deploy --mode agent # Output agent prompt instead of interactive flow When running in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), this command outputs a structured prompt describing the full deployment flow instead of running the interactive wizard. The prompt includes: - Prerequisites and pre-flight checks -- Domain selection options (custom vs. Clerk subdomain) +- Production domain collection and DNS setup - Production instance creation steps - OAuth credential collection for social providers - All relevant Platform API endpoints @@ -30,6 +40,27 @@ Agent mode is detected via the mode system (`src/mode.ts`), which checks in prio 2. `CLERK_MODE` environment variable 3. TTY detection (`process.stdout.isTTY`) +Agent mode does not call PLAPI. It prints `DEPLOY_PROMPT` and exits before the human-mode mocked wizard starts. The prompt currently contains some stale endpoint guidance; see the TODO above `DEPLOY_PROMPT` in `index.ts` and `DEPLOY_MVP_UX_COPY_SPEC.md` §8.3. + +## Mocked PLAPI Calls + +Human mode calls the helpers in `commands/deploy/api.ts`. They use the exact request/response shapes published in the Platform API OpenAPI spec, but the bodies are produced locally rather than sent over the network. Real implementations should replace each helper one at a time without touching the call sites. + +| Step | Endpoint | Mocked behavior | +| -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Validate cloning | `POST /v1/platform/applications/{appID}/validate_cloning` | Resolves to 204; the helper exists so 402 `UnsupportedSubscriptionPlanFeatures` errors short-circuit before plan confirmation. | +| Create production instance | `POST /v1/platform/applications/{appID}/production_instance` | Returns `instance_id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key`, and `cname_targets[]`. | +| Poll deploy status | `GET /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Returns `incomplete` for the first two polls per `(appID, instanceID)` pair, then `complete`. CLI polls every 3s. | +| Retry SSL provisioning | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | Resolves to 204; helper exposed for use when `deploy_status` stalls. | +| Retry mail verification | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | Resolves to 204; helper exposed for use when `deploy_status` stalls. | +| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Resolves to `{}` without hitting the network. Mocked alongside the others while the production instance itself is a fake. | + +Local paused deploy state is written to the CLI config profile, not PLAPI. `--abort` only clears that local bookmark and does not undo anything already saved to a Clerk production instance. The production `home_url` collected during the wizard lives only on the deploy bookmark (`profile.deploy.domain`); it isn't mirrored onto `profile.instances`, so the bookmark is the single source of truth while the wizard is in flight. Re-running plain `clerk deploy` after the bookmark has been cleared and `instances.production` is set errors with guidance to run `clerk env pull --instance prod` instead. + +Mocked endpoints in `commands/deploy/api.ts` pause for ~2s before returning so spinners and the deploy-status poll feel like real network calls. Real implementations remove the artificial delay. + +If the user presses Ctrl-C after the production instance has been created, the wizard saves the current DNS or OAuth step as a paused operation, prints the `clerk deploy --continue` recovery command, and exits with SIGINT code 130. Running plain `clerk deploy` while that bookmark exists exits with an error instead of starting another deploy. + ## Sequence Diagram ```mermaid @@ -37,146 +68,77 @@ sequenceDiagram actor User participant CLI as Clerk CLI participant API as Clerk Platform API - participant DNS as DNS Provider participant Browser Note over CLI: clerk deploy - %% Auth & App Check + %% Auth & app context Note over CLI: Auth token from local config
(stored during `clerk auth login`) - CLI->>API: GET /v1/platform/applications/{appID} - API-->>CLI: { application } - - %% Production Instance Check - CLI->>API: GET /v1/platform/applications/{appID}/instances/production/config - alt 200 — production instance exists - API-->>CLI: { config } - CLI->>User: Production instance already exists - Note over CLI: Update flow — TBD - else 404 — no production instance - API-->>CLI: 404 Not Found - end - %% Read Dev Instance Config (features + social providers) - CLI->>API: GET /v1/platform/applications/{appID}/instances/development/config - API-->>CLI: { config_version, connection_oauth_google: {...}, ... } + %% Discover enabled OAuth providers in dev + CLI->>API: GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config?keys=connection_oauth_* + API-->>CLI: { connection_oauth_google: { enabled: true }, ... } - %% Subscription Check - CLI->>API: GET /v1/platform/applications/{appID}/subscription - API-->>CLI: { id, stripe_subscription_id } - CLI->>CLI: Compare dev features vs plan features - alt Unsupported features found + %% Pre-flight subscription check + CLI->>API: POST /v1/platform/applications/{appID}/validate_cloning { clone_instance_id } + alt 402 Payment Required + API-->>CLI: UnsupportedSubscriptionPlanFeatures CLI->>User: Upgrade plan to continue + else 204 No Content + API-->>CLI: ok end - %% Domain Selection - CLI->>User: How would you like to set up your production domain? - alt Custom domain - User->>CLI: "Use my own domain" - CLI->>User: Enter your domain: - User->>CLI: example.com - else Clerk subdomain - User->>CLI: "Use a Clerk-provided subdomain" - end - - %% Create Production Instance - Note over CLI,API: No "add production instance" endpoint exists.
Current API only creates instances at app creation.
Needs a new endpoint or re-creation via
POST /v1/platform/applications
with environment_types: ["development","production"] - CLI->>API: POST /v1/platform/applications (TBD — needs new endpoint?) - API-->>CLI: { application, instances: [dev, prod] } - - %% Domain Setup - opt Custom domain selected - CLI->>API: POST /v1/platform/applications/{appID}/domains - Note right of API: { name: "example.com",
is_satellite: false } - API-->>CLI: { domain } - - CLI->>DNS: Lookup NS records for domain - DNS-->>CLI: { provider, supportsDomainConnect } - - alt Supports Domain Connect - CLI->>User: Open browser to configure DNS? - User->>CLI: Yes - CLI->>Browser: Open Domain Connect URL - else No Domain Connect - CLI->>User: Add these DNS records manually - end + %% Plan summary + domain + CLI->>User: Plan summary + CLI->>User: Production domain (e.g. example.com) + User->>CLI: example.com - CLI->>API: POST /v1/platform/applications/{appID}/domains/{domainID}/dns_check - API-->>CLI: { status } - end + %% Create production instance + domain in one round-trip + CLI->>API: POST /v1/platform/applications/{appID}/production_instance { home_url, clone_instance_id } + API-->>CLI: { instance_id, active_domain, publishable_key, secret_key, cname_targets } - %% Social Provider Credential Collection - Note over CLI: Dev config already fetched above —
check for enabled connection_oauth_* keys + CLI->>User: Add these CNAME records to your DNS provider - loop Each enabled social provider (e.g. google) - CLI->>User: Your app uses {Provider} OAuth. Have credentials? + %% Poll deploy status + loop every 3s until status == "complete" + CLI->>API: GET /v1/platform/applications/{appID}/instances/{instance_id}/deploy_status + API-->>CLI: { status: "incomplete" | "complete" } + end - alt Walk me through it - User->>CLI: "Walk me through setting it up" - CLI->>User: Use these values:
JS origins: https://example.com
Redirect URI: https://accounts.example.com/v1/oauth_callback - CLI->>Browser: Open Clerk docs for provider - CLI->>User: Enter credentials below: - else Already have credentials - User->>CLI: "I already have my credentials" + opt Stalled provisioning + alt SSL stalled + CLI->>API: POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/ssl_retry + API-->>CLI: 204 + else Mail stalled + CLI->>API: POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/mail_retry + API-->>CLI: 204 end + end - CLI->>User: Client ID: - User->>CLI: {client_id} - CLI->>User: Client Secret: - User->>CLI: {client_secret} - - CLI->>API: PATCH /v1/platform/applications/{appID}/instances/production/config - Note right of API: { connection_oauth_google:
{ enabled: true,
client_id: "...",
client_secret: "..." } } + %% OAuth credential loop + loop Each enabled social provider + CLI->>User: Provider credentials + CLI->>API: PATCH /v1/platform/applications/{appID}/instances/{instance_id}/config { connection_oauth_{provider} } API-->>CLI: { before, after, config_version } end - %% Done CLI->>User: Production ready at https://{domain} - CLI->>User: (Redeploy with updated secret keys if needed) ``` ## API Endpoints -All endpoints are on the **Platform API** (`/v1/platform/...`). - -| Step | Method | Endpoint | Notes | -| -------------------- | ------- | ----------------------------------- | --------------------------------------------------------------------------------- | -| Auth | — | Local config | Token stored from `clerk auth login` | -| Get application | `GET` | `/v1/platform/applications/{appID}` | | -| Check prod instance | `GET` | `.../instances/production/config` | 404 if none exists | -| Read dev config | `GET` | `.../instances/development/config` | Returns all settings including `connection_oauth_*` keys | -| Subscription check | `GET` | `.../subscription` | Returns `{ id, stripe_subscription_id }` only — feature comparison is client-side | -| Create prod instance | `POST` | `/v1/platform/applications` | **Gap: no endpoint to add a production instance to an existing app** | -| Add domain | `POST` | `.../domains` | Body: `{ name, is_satellite }` | -| DNS check | `POST` | `.../domains/{domainID}/dns_check` | Triggers async DNS verification | -| Write OAuth creds | `PATCH` | `.../instances/production/config` | Body: `{ connection_oauth_{provider}: { enabled, client_id, client_secret } }` | - -## API Gaps - -### Creating a production instance for an existing app - -The current Platform API only creates instances during application creation via `POST /v1/platform/applications` with the `environment_types` parameter: - -```json -POST /v1/platform/applications -{ - "name": "my-app", - "environment_types": ["development", "production"], - "domain": "example.com" -} -``` - -There is **no endpoint** to add a production instance to an application that was originally created with only a development instance. This needs either: - -1. A new `POST /v1/platform/applications/{appID}/instances` endpoint -2. Or a different approach (e.g., re-creating the application) +All endpoints are on the **Platform API** (`/v1/platform/...`). The "Real" rows are live HTTP calls today; the "Mock" rows are wired through `commands/deploy/api.ts` with shapes that match the published OpenAPI spec exactly. -### 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 +| Step | Method | Endpoint | Status | Helper | +| -------------------------- | ------- | ------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Auth | n/a | Local config | Real | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | +| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Real | `fetchInstanceConfig` from `lib/plapi.ts`. Used to discover enabled `connection_oauth_*` providers in dev. | +| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Real | `patchInstanceConfig` from `lib/plapi.ts`. Writes production OAuth credentials. | +| Validate cloning | `POST` | `/v1/platform/applications/{appID}/validate_cloning` | Mock | `validateCloning` in `commands/deploy/api.ts`. Pre-flights subscription/feature support before plan summary. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/production_instance` | Mock | `createProductionInstance` in `commands/deploy/api.ts`. Returns prod instance, primary domain, keys, and `cname_targets[]`. | +| Poll deploy status | `GET` | `/v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Mock | `getDeployStatus` in `commands/deploy/api.ts`. CLI polls every 3 seconds while the production instance is provisioning DNS, SSL, and mail. | +| Retry SSL provisioning | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | Mock | `retryApplicationDomainSSL` in `commands/deploy/api.ts`. Available for surfacing to the user when `deploy_status` stalls. | +| Retry mail verification | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | Mock | `retryApplicationDomainMail` in `commands/deploy/api.ts`. Same as above, for SendGrid mail. Rejected on satellite domains. | ## OAuth Provider Config Format @@ -196,13 +158,13 @@ PATCH /v1/platform/applications/{appID}/instances/production/config ### Provider-specific required fields -| Provider | Required Fields | -| --------- | ------------------------------------------------- | -| Google | `client_id`, `client_secret` | -| GitHub | `client_id`, `client_secret` | -| Microsoft | `client_id`, `client_secret` | -| Apple | `client_id`, `client_secret`, `key_id`, `team_id` | -| Linear | `client_id`, `client_secret` | +| Provider | Required Fields | +| --------- | ---------------------------------------------------------------- | +| Google | `client_id`, `client_secret` | +| GitHub | `client_id`, `client_secret` | +| Microsoft | `client_id`, `client_secret` | +| Apple | `client_id`, `team_id`, `key_id`, `client_secret` (.p8 contents) | +| Linear | `client_id`, `client_secret` | Production instances return `422` if you try to enable a provider without credentials. @@ -210,6 +172,10 @@ Production instances return `422` if you try to enable a provider without creden Google enforces a pattern: `^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$` +### Google OAuth JSON import + +For Google, the wizard offers `Load credentials from a Google Cloud Console JSON file`. It reads the `client_id` and `client_secret` from the top-level `web` object in the downloaded OAuth client JSON, or from `installed` for desktop-style client downloads. The file contents are used in memory and are not written to CLI config. + ## Helpful values for OAuth walkthrough When the user chooses the guided walkthrough, these values are derived from their domain: diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts new file mode 100644 index 00000000..fc87b980 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/api.ts @@ -0,0 +1,251 @@ +/** + * FIXME(deploy): the entire module is a stand-in. Every export below is a + * mock that must be replaced with the live Platform API call before + * shipping the deploy command. Grep `FIXME(deploy)` to find each spot. + * + * Mock implementations of the deploy lifecycle Platform API endpoints. + * + * Type signatures and field names mirror the published Platform API + * OpenAPI spec exactly. Implementations are mocked so the CLI deploy + * wizard runs end-to-end without a backend. Swapping these to live calls + * is intentionally a one-function-at-a-time change with no shape + * rewrites. + * + * Endpoint paths: + * POST /v1/platform/applications/{applicationID}/production_instance + * POST /v1/platform/applications/{applicationID}/validate_cloning + * GET /v1/platform/applications/{applicationID}/instances/{envOrInsID}/deploy_status + * POST /v1/platform/applications/{applicationID}/domains/{domainIDOrName}/ssl_retry + * POST /v1/platform/applications/{applicationID}/domains/{domainIDOrName}/mail_retry + * PATCH /v1/platform/applications/{applicationID}/instances/{instanceID}/config + */ + +import { log } from "../../lib/log.ts"; +import { sleep } from "../../lib/sleep.ts"; + +export type DomainSummary = { + id: string; + name: string; +}; + +export type CnameTarget = { + host: string; + value: string; + required: boolean; +}; + +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; +}; + +// FIXME(deploy): hardcoded mock identifiers and keys. Drop alongside the mock helpers below. +const MOCK_PRODUCTION_INSTANCE_ID = "MOCKED_NOT_REAL_FIXME"; +const MOCK_DOMAIN_ID = "MOCKED_NOT_REAL_FIXME"; +const MOCK_PUBLISHABLE_KEY = "MOCKED_NOT_REAL_FIXME"; +const MOCK_SECRET_KEY = "MOCKED_NOT_REAL_FIXME"; + +/** + * FIXME(deploy): artificial server-side latency every mocked endpoint + * pays before returning. Exists so the wizard's spinners and DNS-status + * polling feel like real network calls instead of instant resolution. + * Remove the helper and every `await simulateServerLatency()` call site + * once these endpoints hit the real network. + */ +const MOCK_LATENCY_MS = 2000; + +async function simulateServerLatency(): Promise { + // FIXME(deploy): artificial delay. Remove when the surrounding mock is replaced with a real PLAPI call. + await sleep(MOCK_LATENCY_MS); +} + +/** + * Mock for `POST /v1/platform/applications/{applicationID}/production_instance`. + * + * The real endpoint creates a prod instance + primary domain, optionally + * cloning from a dev instance, and returns keys + DNS targets in one + * round-trip. + */ +export async function createProductionInstance( + applicationId: string, + params: CreateProductionInstanceParams, +): Promise { + // FIXME(deploy): mock. Replace with a live POST to PLAPI and remove the hardcoded response. + log.debug( + `plapi-mock: POST /v1/platform/applications/${applicationId}/production_instance ` + + `home_url=${params.home_url} clone_instance_id=${params.clone_instance_id ?? ""}`, + ); + await simulateServerLatency(); + return { + instance_id: MOCK_PRODUCTION_INSTANCE_ID, + environment_type: "production", + active_domain: { + id: MOCK_DOMAIN_ID, + name: params.home_url, + }, + secret_key: MOCK_SECRET_KEY, + publishable_key: MOCK_PUBLISHABLE_KEY, + cname_targets: defaultCnameTargets(params.home_url), + }; +} + +/** + * Mock for `POST /v1/platform/applications/{applicationID}/validate_cloning`. + * + * The real endpoint validates that the dev instance's features are + * covered by the application's subscription plan. Returns 204 on success + * or 402 with UnsupportedSubscriptionPlanFeatures. + */ +export async function validateCloning( + applicationId: string, + params: ValidateCloningParams, +): Promise { + // FIXME(deploy): mock. Replace with a live POST to PLAPI; bubble 402 UnsupportedSubscriptionPlanFeatures. + log.debug( + `plapi-mock: POST /v1/platform/applications/${applicationId}/validate_cloning ` + + `clone_instance_id=${params.clone_instance_id}`, + ); + await simulateServerLatency(); +} + +/** + * Mock for `GET /v1/platform/applications/{applicationID}/instances/{envOrInsID}/deploy_status`. + * + * The real endpoint reports whether DNS, SSL, Mail, and Proxy checks have + * all passed for the instance's primary domain. `envOrInsID` accepts the + * literal "production" or "development" shortcut in addition to instance + * IDs. + * + * The mock keeps a per-process counter keyed by instance so callers + * polling on a 3s interval observe a realistic incomplete → complete + * progression without any extra wiring. + */ +// FIXME(deploy): per-process counter that drives the fake incomplete→complete progression. Drop with the helper below. +const deployStatusPollCounts = new Map(); +const MOCK_INCOMPLETE_POLLS = 2; + +export async function getDeployStatus( + applicationId: string, + envOrInsId: string, +): Promise { + // FIXME(deploy): mock. Replace with a live GET to PLAPI. The real endpoint already returns the same shape. + log.debug( + `plapi-mock: GET /v1/platform/applications/${applicationId}/instances/${envOrInsId}/deploy_status`, + ); + await simulateServerLatency(); + const key = `${applicationId}:${envOrInsId}`; + const count = (deployStatusPollCounts.get(key) ?? 0) + 1; + deployStatusPollCounts.set(key, count); + return { + status: count > MOCK_INCOMPLETE_POLLS ? "complete" : "incomplete", + }; +} + +/** Test-only: reset the mock deploy-status progression counters. */ +export function _resetDeployStatusMock(): void { + deployStatusPollCounts.clear(); +} + +/** + * Mock for `POST /v1/platform/applications/{applicationID}/domains/{domainIDOrName}/ssl_retry`. + * + * The real endpoint re-provisions the SSL certificate for a production + * domain. Returns 204 on success, 400 InstanceNotLive if SSL setup hasn't + * begun. + */ +export async function retryApplicationDomainSSL( + applicationId: string, + domainIdOrName: string, +): Promise { + // FIXME(deploy): mock. Replace with a live POST to PLAPI. + log.debug( + `plapi-mock: POST /v1/platform/applications/${applicationId}/domains/${domainIdOrName}/ssl_retry`, + ); + await simulateServerLatency(); +} + +/** + * Mock for `POST /v1/platform/applications/{applicationID}/domains/{domainIDOrName}/mail_retry`. + * + * The real endpoint re-schedules SendGrid mail verification. Rejected on + * satellite domains (they inherit mail from the primary). + */ +export async function retryApplicationDomainMail( + applicationId: string, + domainIdOrName: string, +): Promise { + // FIXME(deploy): mock. Replace with a live POST to PLAPI; bubble OperationNotAllowedOnSatelliteDomain. + log.debug( + `plapi-mock: POST /v1/platform/applications/${applicationId}/domains/${domainIdOrName}/mail_retry`, + ); + await simulateServerLatency(); +} + +/** + * Mock for `PATCH /v1/platform/applications/{applicationID}/instances/{instanceID}/config` + * scoped to the deploy command's production instance writes. + * + * The endpoint itself is real and exposed via `lib/plapi.ts` for other + * commands, but the deploy wizard targets a mocked production instance, so a + * live PATCH would 404. This mock keeps the call shape identical so swapping + * back to live is a one-import change. + */ +export async function patchInstanceConfig( + applicationId: string, + instanceId: string, + config: Record, +): Promise> { + // FIXME(deploy): mock. Swap back to `lib/plapi.ts` `patchInstanceConfig` once the production instance is real. + log.debug( + `plapi-mock: PATCH /v1/platform/applications/${applicationId}/instances/${instanceId}/config ` + + `keys=${Object.keys(config).join(",")}`, + ); + await simulateServerLatency(); + return {}; +} + +// FIXME(deploy): hardcoded CNAME values that the real `production_instance` create response will populate. +function defaultCnameTargets(domain: string): CnameTarget[] { + return [ + { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, + { host: `accounts.${domain}`, value: "accounts.clerk.services", required: true }, + { + host: `clkmail.${domain}`, + value: `mail.${domain}.nam1.clerk.services`, + required: true, + }, + ]; +} + +/** + * 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/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts new file mode 100644 index 00000000..1294161f --- /dev/null +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -0,0 +1,154 @@ +import { cyan, dim, green, red, yellow } from "../../lib/color.ts"; +import type { CnameTarget } from "./api.ts"; + +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, oauthProviderLabels: readonly string[]): string[] { + return [ + `clerk deploy will prepare ${cyan(appLabel)} for production:`, + "", + ` ${green("CREATE")} Create production instance`, + ` ${green("DOMAIN")} Choose a production domain you own`, + ` ${green("DNS")} Configure DNS records`, + ...oauthProviderLabels.map( + (label) => ` ${yellow("OAUTH")} Configure ${label} OAuth credentials`, + ), + ]; +} + +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 dnsRecords(targets: readonly CnameTarget[]): string[] { + const lines = ["Add the following records at your DNS provider:"]; + for (const target of targets) { + const label = cnameTargetLabel(target.host); + const optional = target.required ? "" : ` ${dim("(optional)")}`; + lines.push( + "", + ` ${label}${optional}`, + ` Type: CNAME`, + ` Host: ${target.host}`, + ` Value: ${target.value}`, + ); + } + lines.push( + "", + `${yellow("NOTE")} If your DNS host proxies these records, set them to "DNS only" or verification will fail.`, + ); + return lines; +} + +function cnameTargetLabel(host: string): string { + const prefix = host.split(".", 1)[0]; + switch (prefix) { + case "clerk": + return "Frontend API"; + case "accounts": + return "Account portal"; + case "clkmail": + case "clk._domainkey": + case "clk2._domainkey": + return "Email (Clerk handles SPF/DKIM automatically)"; + default: + return "CNAME"; + } +} + +export function dnsDashboardHandoff(domain: string): string[] { + return [ + `Check the Domains section in the Clerk Dashboard for ${domain} to monitor DNS propagation and SSL issuance.`, + "You can continue to the remaining setup now, or pause and run `clerk deploy --continue` later.", + ]; +} + +export function dnsVerified(domain: string): string[] { + return [`DNS verified for ${domain}.`]; +} + +export const OAUTH_SECTION_INTRO = `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[], +): string[] { + return [ + `Production ready at ${cyan(`https://${domain}`)}`, + "", + " Domain Verified", + ` OAuth ${completedOAuthProviderLabels.length ? completedOAuthProviderLabels.join(", ") : "Not applicable"}`, + ]; +} + +export const NEXT_STEPS_BLOCK = `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 activeDeployInProgressMessage(stepDescription: string): string { + return `There is an active deploy in progress at: ${stepDescription} + +Use \`clerk deploy --continue\` to resume it, or \`clerk deploy --abort\` to clear it.`; +} + +export function pausedOperationNotice(): string { + return `Deploy paused. + +Use \`clerk deploy --continue\` to resume it, or \`clerk deploy --abort\` to clear it.`; +} + +export const INVALID_CONTINUE_MESSAGE = `${red("The paused deploy operation no longer matches this linked project.")} +Run \`clerk deploy\` from the project that started the paused operation, or run +\`clerk link\` if you intend to deploy this one.`; diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 9805ccc2..a41c194a 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 { EXIT_CODE, UserAbortError, type CliError } from "../../lib/errors.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -19,6 +23,14 @@ const mockSelect = mock(); const mockInput = mock(); const mockConfirm = mock(); const mockPassword = mock(); +const mockPatchInstanceConfig = mock(); +const mockFetchInstanceConfig = mock(); +const mockCreateProductionInstance = mock(); +const mockValidateCloning = mock(); +const mockGetDeployStatus = mock(); +const mockRetrySSL = mock(); +const mockRetryMail = mock(); +const mockDomainConnectUrl = mock(); mock.module("@inquirer/prompts", () => ({ ...promptsStubs, @@ -37,31 +49,117 @@ mock.module("../../lib/listage.ts", () => ({ select: (...args: unknown[]) => mockSelect(...args), })); +mock.module("../../lib/plapi.ts", () => ({ + fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), +})); + +mock.module("./api.ts", () => ({ + createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), + validateCloning: (...args: unknown[]) => mockValidateCloning(...args), + getDeployStatus: (...args: unknown[]) => mockGetDeployStatus(...args), + retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args), + retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), + domainConnectUrl: (...args: unknown[]) => mockDomainConnectUrl(...args), + patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), +})); + +const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts"); const { deploy } = await import("./index.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 }, + }); + mockValidateCloning.mockResolvedValue(undefined); + mockGetDeployStatus.mockResolvedValue({ status: "complete" }); + mockCreateProductionInstance.mockImplementation( + (_appId: string, params: { home_url: string }) => ({ + instance_id: "ins_prod_mock", + environment_type: "production" as const, + active_domain: { id: "dmn_prod_mock", name: params.home_url }, + publishable_key: "pk_live_test", + secret_key: "sk_live_test", + cname_targets: [ + { + host: `clerk.${params.home_url}`, + value: "frontend-api.clerk.services", + required: true, + }, + { + host: `accounts.${params.home_url}`, + value: "accounts.clerk.services", + required: true, + }, + { + host: `clkmail.${params.home_url}`, + value: `mail.${params.home_url}.nam1.clerk.services`, + required: true, + }, + ], + }), + ); + mockDomainConnectUrl.mockReturnValue(undefined); }); - afterEach(() => { + afterEach(async () => { captured.teardown(); + _setConfigDir(undefined); + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } _modeOverride = undefined; mockIsAgent.mockReset(); mockSelect.mockReset(); mockInput.mockReset(); mockConfirm.mockReset(); mockPassword.mockReset(); + mockPatchInstanceConfig.mockReset(); + mockFetchInstanceConfig.mockReset(); + mockCreateProductionInstance.mockReset(); + mockValidateCloning.mockReset(); + mockGetDeployStatus.mockReset(); + mockRetrySSL.mockReset(); + mockRetryMail.mockReset(); + mockDomainConnectUrl.mockReset(); consoleSpy?.mockRestore(); + stderrSpy?.mockRestore(); }); function runDeploy(options: Parameters[0]) { return captured.run(() => deploy(options)); } + async function linkedProject(profile: Record = {}) { + tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-")); + _setConfigDir(tempDir); + await setProfile(process.cwd(), { + workspaceId: "workspace_123", + appId: "app_xyz789", + appName: "my-saas-app", + instances: { development: "ins_dev_123" }, + ...profile, + } as never); + } + describe("agent mode", () => { test("outputs deploy prompt and returns", async () => { mockIsAgent.mockReturnValue(true); @@ -80,14 +178,14 @@ describe("deploy", () => { const output = captured.out; expect(output).toContain("Prerequisites"); - expect(output).toContain("Verify Subscription Compatibility"); - expect(output).toContain("Choose a Production Domain"); + expect(output).toContain("Validate Cloning"); + expect(output).toContain("Discover enabled OAuth providers"); expect(output).toContain("Create the Production Instance"); expect(output).toContain("Configure Social OAuth Providers"); expect(output).toContain("Finalize"); }); - test("prompt includes API reference", async () => { + test("prompt includes API reference for new deploy lifecycle endpoints", async () => { mockIsAgent.mockReturnValue(true); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); @@ -95,8 +193,11 @@ describe("deploy", () => { const output = captured.out; expect(output).toContain("/v1/platform/applications"); - expect(output).toContain("instances/production/config"); - expect(output).toContain("instances/development/config"); + expect(output).toContain("validate_cloning"); + expect(output).toContain("production_instance"); + expect(output).toContain("deploy_status"); + expect(output).toContain("ssl_retry"); + expect(output).toContain("mail_retry"); }); test("prompt includes OAuth redirect URI pattern", async () => { @@ -125,13 +226,30 @@ describe("deploy", () => { describe("human mode", () => { function mockHumanFlow() { mockIsAgent.mockReturnValue(false); - // Domain selection → OAuth credential choice - mockSelect.mockResolvedValueOnce("clerk-subdomain").mockResolvedValueOnce("have-credentials"); + // Proceed → pause after DNS handoff. + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockInput.mockResolvedValueOnce("example.com"); + } + + async function runDnsHandoff() { + mockHumanFlow(); + await runDeploy({}); + captured = captureLog(); + mockConfirm.mockReset(); + mockSelect.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + } + + function mockOAuthCompletion() { + mockConfirm.mockResolvedValueOnce(true); + 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 +259,685 @@ 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("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).not.toContain("unknown"); + }); + + test("DNS verification polls getDeployStatus until complete", async () => { + await linkedProject(); + // Proceed → continue after DNS handoff → complete OAuth. + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockGetDeployStatus + .mockResolvedValueOnce({ status: "incomplete" }) + .mockResolvedValueOnce({ status: "complete" }); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock"); + expect(mockGetDeployStatus.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(err).toContain("DNS verified for example.com"); + expect(err).toContain("Production ready at https://example.com"); + }); + + test("uses existing wizard framing and concise plan confirmation", async () => { + await linkedProject(); + mockHumanFlow(); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(mockConfirm).toHaveBeenCalledWith({ message: "Proceed?", default: true }); + expect(err).toContain("clerk deploy will prepare my-saas-app for production"); + expect(err).toContain("Create production instance"); + expect(err).toContain("Configure 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("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()]?.deploy).toBeUndefined(); + 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).toContain("\x1b[31m└"); + 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()]?.deploy).toBeUndefined(); + 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).toContain("\x1b[31m└"); + expect(terminalOutput).not.toContain("Done"); + }); + + test("prints production next steps after successful deploy", async () => { + await linkedProject(); + await runDnsHandoff(); + mockOAuthCompletion(); + + await runDeploy({ continue: true }); + const err = stripAnsi(captured.err); + + expect(err).toContain("Next steps"); + expect(err).toContain("clerk env pull --instance prod"); + expect(err).toContain("Update env vars on your hosting provider"); + expect(err).toContain("Production keys only work on your production domain"); + }); + + test("DNS setup prints dashboard handoff and asks before continuing", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + const config = await readConfig(); + + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + pending: { type: "dns" }, + domain: "example.com", + }); + 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("clerk deploy --continue"); + expect(err).toContain("clerk deploy --abort"); + expect(mockConfirm).toHaveBeenCalledTimes(2); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Continue to OAuth setup?", + default: true, + }); + expect(mockConfirm).not.toHaveBeenCalledWith({ + message: "Configure and verify DNS now?", + default: true, + }); + expect(mockConfirm).not.toHaveBeenCalledWith({ + message: "Have the DNS records been added?", + default: true, + }); + }); + + test("Ctrl-C at the DNS handoff saves state and reports paused", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockRejectedValueOnce(promptExitError()); + mockInput.mockResolvedValueOnce("example.com"); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + + let error: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + error = caught as CliError; + } + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_mock", + domain: "example.com", + pending: { type: "dns" }, + }); + expect(error?.message).toContain("Deploy paused at: DNS verification"); + expect(error?.message).toContain("clerk deploy --continue"); + expect(error?.message).toContain("clerk deploy --abort"); + 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).toContain("\x1b[33m└"); + 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({ continue: true }); + 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" }, + deploy: { + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_apple", + domain: "example.com", + pending: { type: "oauth", provider: "apple" }, + oauthProviders: ["apple"], + completedOAuthProviders: [], + }, + }); + 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({ continue: true }); + + 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({ continue: true }); + + 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 errors when a production instance is already linked", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + + let error: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + error = caught as CliError; + } + + expect(error?.message).toContain("This app already has a production instance configured"); + expect(error?.message).toContain("clerk env pull --instance prod"); + expect(error?.message).toContain("clerk deploy --continue"); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + test("plain deploy errors while a deploy operation is paused", async () => { + await linkedProject({ + deploy: { + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_123", + domain: "example.com", + pending: { type: "dns" }, + oauthProviders: ["google"], + completedOAuthProviders: [], + }, + }); + mockIsAgent.mockReturnValue(false); + + let error: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + error = caught as CliError; + } + + expect(error?.message).toContain("There is an active deploy in progress"); + expect(error?.message).toContain("Use `clerk deploy --continue`"); + expect(error?.message).toContain("DNS verification"); + expect(error?.exitCode).toBe(EXIT_CODE.GENERAL); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + }); + + test("DNS handoff saves DNS state and reports --continue", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_mock", + domain: "example.com", + pending: { type: "dns" }, + }); + expect(err).toContain("Check the Domains section in the Clerk Dashboard"); + expect(err).toContain("clerk deploy --continue"); + }); + + test("Ctrl-C during OAuth setup saves provider state and reports --continue", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + await runDnsHandoff(); + mockConfirm.mockRejectedValueOnce(promptExitError()); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + + let error: CliError | undefined; + try { + await runDeploy({ continue: true }); + } catch (caught) { + error = caught as CliError; + } + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_mock", + domain: "example.com", + pending: { type: "oauth", provider: "google" }, + }); + expect(error?.message).toContain("Deploy paused at: Google OAuth credential setup"); + expect(error?.message).toContain("clerk deploy --continue"); + expect(error?.message).toContain("clerk deploy --abort"); + 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).toContain("\x1b[33m└"); + expect(terminalOutput).not.toContain("Done"); + }); + + test("saves OAuth credentials to the production instance from deploy state", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_created_456" }, + deploy: { + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_created_456", + domain: "example.com", + pending: { type: "oauth", provider: "google" }, + oauthProviders: ["google"], + completedOAuthProviders: [], + }, + }); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({ continue: 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("--continue reports when there is no paused deploy operation", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + + await runDeploy({ continue: true }); + + expect(captured.err).toContain("There is no paused deploy operation"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + }); + + test("--abort reports when there is no paused deploy operation", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + + await runDeploy({ abort: true }); + + expect(captured.err).toContain("There is no paused deploy operation"); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + }); + + test("--abort asks for confirmation and clears paused deploy state", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + deploy: { + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_123", + domain: "example.com", + pending: { type: "dns" }, + oauthProviders: ["google"], + completedOAuthProviders: [], + }, + }); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true); + + await runDeploy({ abort: true }); + + const config = await readConfig(); + const err = stripAnsi(captured.err); + expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); + expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_123"); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Abort the paused deploy operation?", + default: false, + }); + expect(err).toContain("Cleared the paused deploy bookmark"); + expect(err).toContain("does not undo any changes already saved"); + expect(err).not.toContain("rerun `clerk deploy`"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + }); + + test("--abort keeps paused deploy state when confirmation is declined", async () => { + await linkedProject({ + deploy: { + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_123", + domain: "example.com", + pending: { type: "dns" }, + oauthProviders: ["google"], + completedOAuthProviders: [], + }, + }); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(false); + + await runDeploy({ abort: true }); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + appId: "app_xyz789", + domain: "example.com", + pending: { type: "dns" }, + }); + expect(captured.err).toContain("Paused deploy abort cancelled"); + expect(captured.err).toContain("clerk deploy --continue"); + expect(captured.err).toContain("clerk deploy --abort"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + }); + + test("rejects --continue and --abort together", async () => { + await linkedProject({ + deploy: { + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_123", + domain: "example.com", + pending: { type: "dns" }, + oauthProviders: ["google"], + completedOAuthProviders: [], + }, + }); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({ continue: true, abort: true })).rejects.toThrow( + "Cannot use --continue and --abort together", + ); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + }); + + test("--continue reports invalid paused state with recovery guidance", async () => { + await linkedProject({ + deploy: { + appId: "other_app", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_123", + domain: "example.com", + pending: { type: "dns" }, + oauthProviders: ["google"], + completedOAuthProviders: [], + }, + }); + mockIsAgent.mockReturnValue(false); + + await runDeploy({ continue: true }); + const err = stripAnsi(captured.err); + + expect(err).toContain("The paused deploy operation no longer matches this linked project"); + expect(err).toContain( + "Run `clerk deploy` from the project that started the paused operation", + ); + }); + + test("custom-domain DNS setup can pause and later resume", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeploy({}); + + let config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + appId: "app_xyz789", + developmentInstanceId: "ins_dev_123", + productionInstanceId: "ins_prod_mock", + domain: "example.com", + pending: { type: "dns" }, + }); + expect(stripAnsi(captured.err)).toContain("Check the Domains section in the Clerk Dashboard"); + + captured = captureLog(); + mockConfirm.mockReset(); + mockSelect.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({ continue: true }); + const err = stripAnsi(captured.err); + + config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); + 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(); + mockConfirm.mockResolvedValueOnce(false); + + await runDeploy({ continue: true }); + + let config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + pending: { type: "oauth", provider: "google" }, + domain: "example.com", + }); + expect(captured.err).toContain("Deploy paused"); + expect(captured.err).toContain("clerk deploy --continue"); + expect(captured.err).toContain("clerk deploy --abort"); + + captured = captureLog(); + mockConfirm.mockReset(); + mockSelect.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + + await runDeploy({ continue: true }); + const err = stripAnsi(captured.err); + + config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); + 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"); }); }); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 4f7b8023..7e05298e 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,12 +1,59 @@ -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 { dim } from "../../lib/color.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { isInsideGutter, log, setPrefixTone, type PrefixTone } from "../../lib/log.ts"; +import { sleep } from "../../lib/sleep.ts"; +import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; +import { CliError, UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; +import { resolveProfile, setProfile, type DeployOperationState } from "../../lib/config.ts"; +import { fetchInstanceConfig } from "../../lib/plapi.ts"; +import { + createProductionInstance as apiCreateProductionInstance, + domainConnectUrl, + getDeployStatus, + patchInstanceConfig, + validateCloning, + type CnameTarget, + type ProductionInstanceResponse, +} from "./api.ts"; +import { + INTRO_PREAMBLE, + INVALID_CONTINUE_MESSAGE, + NEXT_STEPS_BLOCK, + OAUTH_SECTION_INTRO, + dnsDashboardHandoff, + dnsIntro, + dnsRecords, + dnsVerified, + pausedOperationNotice, + printPlan, + productionSummary, +} from "./copy.ts"; +import { + PROVIDER_LABELS, + providerLabel, + showOAuthWalkthrough, + type OAuthProvider, +} from "./providers.ts"; +import { + chooseOAuthCredentialAction, + collectCustomDomain, + collectOAuthCredentials, + confirmContinueAfterDnsHandoff, + confirmOAuthSetupNow, + confirmProceed, +} from "./prompts.ts"; +import { + DeployPausedError, + activeDeployInProgressError, + deployPausedError, + isDeployStateValid, + type DeployContext, +} from "./state.ts"; + +// TODO(deploy): rewrite to match the human flow described in +// DEPLOY_MVP_UX_COPY_SPEC.md, or fetch from clerk.com/docs at runtime. const DEPLOY_PROMPT = `You are deploying a Clerk application to production. Follow these steps: ## Prerequisites @@ -16,51 +63,47 @@ Ensure the following before starting: - A Clerk application is linked to the project (\`clerk link\` has been run) - The project has a development instance with a working configuration -## Step 1: Verify Subscription Compatibility - -Check that the development instance's features are covered by the application's subscription plan. +## Step 1: Validate Cloning -- Fetch the development config: \`GET /v1/platform/applications/{appID}/instances/development/config\` -- Fetch the subscription: \`GET /v1/platform/applications/{appID}/subscription\` -- If any development features are not covered by the plan, the user must upgrade before deploying. +Confirm the development instance's features are covered by the application's subscription plan before starting any irreversible work. -## Step 2: Choose a Production Domain +- Call \`POST /v1/platform/applications/{appID}/validate_cloning\` with body \`{ "clone_instance_id": "" }\`. +- 204 No Content means cloning is allowed. 402 Payment Required means the plan must be upgraded; surface the unsupported features to the user. -Ask the user which domain setup they prefer: +## Step 2: Discover enabled OAuth providers -**Option A: Custom domain** -- The user provides their own domain (e.g., example.com) -- DNS must be configured to point to Clerk. Check if the DNS provider supports Domain Connect for automatic setup. -- If Domain Connect is available, direct the user to the Domain Connect URL to authorize DNS changes. -- If not, provide the DNS records the user must add manually. -- Verify DNS propagation: \`POST /v1/platform/applications/{appID}/domains/{domainID}/dns_check\` +Read the development instance config and pick out enabled social connections. -**Option B: Clerk-provided subdomain** -- A subdomain like \`{adjective}-{animal}-{number}.clerk.app\` is automatically assigned. -- No DNS configuration is needed. +- Call \`GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config\`. +- For each key matching \`connection_oauth_*\` whose value has \`enabled: true\`, collect production credentials in step 4. ## Step 3: Create the Production Instance -Create or configure the production instance for the application. -- Add the domain: \`POST /v1/platform/applications/{appID}/domains\` with body \`{ "name": "", "is_satellite": false }\` -- Note: There is currently no dedicated endpoint to add a production instance to an existing app. This may require \`POST /v1/platform/applications\` with \`environment_types: ["development", "production"]\`. +Provision the production instance, primary domain, and keys in one round-trip. -## Step 4: Configure Social OAuth Providers +- Collect a production domain the user owns (\`example.com\`). Reject provider domains (\`*.vercel.app\`, \`*.clerk.app\`, etc.). +- Call \`POST /v1/platform/applications/{appID}/production_instance\` with body \`{ "home_url": "", "clone_instance_id": "" }\`. +- The 201 response includes \`instance_id\`, \`active_domain\`, \`publishable_key\`, \`secret_key\`, and \`cname_targets\`. +- Show the user the \`cname_targets\` (\`{ host, value, required }\`) and offer Domain Connect handoff when the registrar supports it. +- Poll \`GET /v1/platform/applications/{appID}/instances/{instance_id}/deploy_status\` every ~3 seconds until \`status === "complete"\`. The literal path segments \`development\` or \`production\` may be used in place of an instance ID. +- When DNS or SSL stalls, expose the retry endpoints: + \`POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/ssl_retry\` + \`POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/mail_retry\` -For each social provider enabled in the development instance (e.g., Google, GitHub, Apple), production OAuth credentials are required. +## Step 4: Configure Social OAuth Providers -Check the dev config for \`connection_oauth_*\` keys. For each enabled provider: +For each enabled provider discovered in step 2, prompt for production credentials. -1. Collect the required credentials from the user: +1. Required fields per provider: - Most providers: \`client_id\` and \`client_secret\` - - Apple: also requires \`key_id\` and \`team_id\` + - Apple: also requires \`key_id\`, \`team_id\`, and the \`.p8\` private-key file -2. When helping the user create OAuth credentials, provide these values: +2. When walking the user through OAuth app creation, supply: - Authorized JavaScript origins: \`https://{domain}\` and \`https://www.{domain}\` - Authorized redirect URI: \`https://accounts.{domain}/v1/oauth_callback\` -3. Write credentials to production config: - \`PATCH /v1/platform/applications/{appID}/instances/production/config\` +3. Persist each provider: + \`PATCH /v1/platform/applications/{appID}/instances/{instance_id}/config\` Body: \`{ "connection_oauth_{provider}": { "enabled": true, "client_id": "...", "client_secret": "..." } }\` Provider-specific documentation: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/{provider} @@ -76,18 +119,30 @@ After all configuration is complete: | Method | Endpoint | Purpose | |--------|----------|---------| -| GET | /v1/platform/applications/{appID} | Fetch application details | -| GET | .../instances/development/config | Read dev instance config and enabled features | -| GET | .../instances/production/config | Check if production instance exists (404 if not) | -| GET | .../subscription | Check subscription plan | -| POST | /v1/platform/applications | Create application with production instance | -| POST | .../domains | Add a custom domain | -| POST | .../domains/{domainID}/dns_check | Trigger DNS verification | -| PATCH | .../instances/production/config | Write OAuth credentials to production | +| POST | /v1/platform/applications/{appID}/validate_cloning | Pre-flight subscription/feature check | +| POST | /v1/platform/applications/{appID}/production_instance | Create prod instance + primary domain (returns keys + cname_targets) | +| GET | /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status | Poll DNS/SSL/Mail/Proxy progress | +| POST | /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry | Re-trigger SSL provisioning | +| POST | /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry | Re-trigger SendGrid mail verification | +| GET | /v1/platform/applications/{appID}/instances/{instanceID}/config | Read dev or prod instance config | +| PATCH | /v1/platform/applications/{appID}/instances/{instanceID}/config | Write OAuth credentials | Refer to the Clerk Platform API docs for detailed request/response schemas.`; -export async function deploy(options: { debug?: boolean }) { +type DeployOptions = { + debug?: boolean; + continue?: boolean; + abort?: boolean; +}; + +const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000; +const DEPLOY_STATUS_MAX_POLLS = 100; + +export async function deploy(options: DeployOptions = {}) { + if (options.continue && options.abort) { + throwUsageError("Cannot use --continue and --abort together."); + } + if (isAgent()) { log.data(DEPLOY_PROMPT); return; @@ -97,161 +152,495 @@ export async function deploy(options: { debug?: boolean }) { setLogLevel("debug"); } - log.data("[mock] This command uses mocked data and is not yet wired up to real APIs.\n"); + intro("clerk deploy", { tone: "active" }); + try { + const ctx = await resolveDeployContext(); + + if (options.continue) { + await continueDeploy(ctx); + return; + } + + if (options.abort) { + await abortDeploy(ctx); + return; + } - log.debug("Checking for authenticated user and linked application..."); + if (ctx.profile.deploy) { + throw activeDeployInProgressError(ctx.profile.deploy); + } - // 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" }; + await startDeploy(ctx); + } catch (error) { + if (error instanceof DeployPausedError && isInsideGutter()) { + closeDeployGutter("error", "Paused"); + } + if (isPromptExitError(error) && isInsideGutter()) { + closeDeployGutter("cancel", "Cancelled"); + throw new UserAbortError(); + } + throw error; + } finally { + // Successful and paused paths call outro themselves. This balances the + // intro gutter if an unexpected error escapes. + if (isInsideGutter()) { + closeDeployGutter("error", "Failed"); + } + } +} - log.debug(`Found authenticated user: ${user.email} (${user.id})`); - log.debug(`Found linked application: ${application.name} (${application.id})`); +function closeDeployGutter(tone: PrefixTone, messageOrSteps: string | readonly string[]): void { + setPrefixTone(tone); + outro(messageOrSteps); +} - log.debug("Checking for production instance..."); - log.debug("No production instance found."); +async function resolveDeployContext(): Promise { + return withSpinner("Resolving linked Clerk application...", async () => { + const resolved = await resolveProfile(process.cwd()); + if (!resolved) { + return { + profileKey: process.cwd(), + profile: { + workspaceId: "", + appId: "", + instances: { development: "" }, + }, + appId: "", + appLabel: "", + developmentInstanceId: "", + }; + } - // 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)); + return { + profileKey: resolved.path, + profile: resolved.profile, + appId: resolved.profile.appId, + appLabel: resolved.profile.appName || resolved.profile.appId, + developmentInstanceId: resolved.profile.instances.development, + }; + }); +} - if (unsupported.length > 0) { - log.debug(`Found features not covered by subscription: ${unsupported.join(", ")}`); - log.debug("User must upgrade their plan before deploying."); +async function startDeploy(ctx: DeployContext): Promise { + if (!ctx.appId || !ctx.developmentInstanceId) { + log.blank(); + log.warn( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", + ); + log.blank(); + closeDeployGutter("error", "Link required"); return; } - log.debug("All development features are covered by subscription."); + if (ctx.profile.instances.production) { + throw new CliError( + "This app already has a production instance configured. " + + "Run `clerk env pull --instance prod` to pull production keys, or finish any pending steps with `clerk deploy --continue`.", + ); + } - 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", - }, - ], + const oauthProviders = await loadDevelopmentOAuthProviders(ctx); + + await runValidateCloning(ctx); + + log.blank(); + log.info(INTRO_PREAMBLE); + log.blank(); + for (const line of printPlan( + ctx.appLabel, + oauthProviders.map((provider) => PROVIDER_LABELS[provider]), + )) { + log.info(line); + } + log.blank(); + + const proceed = await confirmProceed(); + if (!proceed) { + log.info("No changes were made."); + closeDeployGutter("cancel", "Cancelled"); + return; + } + + bar(); + const domain = await collectCustomDomain(); + const production = await createProductionInstance(ctx, domain); + await persistProductionInstance(ctx, production.instance_id); + log.blank(); + + const productionDomain = production.active_domain.name; + let completedOAuthProviders: OAuthProvider[] = []; + const dnsDone = 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 (!dnsDone) 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; - let domain: string; + await finishDeploy(ctx, productionDomain, completedOAuthProviders); +} - 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}`); +async function continueDeploy(ctx: DeployContext): Promise { + const state = ctx.profile.deploy; + if (!state) { + log.blank(); + log.info("There is no paused deploy operation to continue."); + log.info("Run `clerk deploy` to start one."); + log.blank(); + closeDeployGutter("neutral", "Nothing to continue"); + return; } - log.debug("Creating production instance..."); - log.debug(`Production instance created with domain: ${domain}`); + if (!isDeployStateValid(ctx, state)) { + log.blank(); + log.warn(INVALID_CONTINUE_MESSAGE); + log.blank(); + closeDeployGutter("error", "Cannot continue"); + return; + } - // DNS setup for custom domains - if (domainChoice === "custom-domain") { - log.debug(`Looking up DNS provider for ${domain}...`); + if (state.pending.type === "dns") { + const dnsDone = await runDnsVerification(ctx, state); + if (!dnsDone) return; + } - // 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.`); + if ( + state.pending.type === "oauth" || + state.oauthProviders.length > state.completedOAuthProviders.length + ) { + bar(); + const completed = await runOAuthSetup(ctx, state); + if (completed.length < state.oauthProviders.length) return; + } - 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}`); + await finishDeploy(ctx, state.domain, state.oauthProviders); +} - await confirm({ - message: `We can automatically configure DNS for ${domain} via ${dnsProvider.name}. Open browser to continue?`, - default: true, - }); +async function abortDeploy(ctx: DeployContext): Promise { + const state = ctx.profile.deploy; + if (!state) { + log.blank(); + log.info("There is no paused deploy operation to abort."); + log.blank(); + closeDeployGutter("neutral", "Nothing to abort"); + return; + } - log.debug("Opening Domain Connect flow in browser..."); + const confirmed = await confirm({ + message: "Abort the paused deploy operation?", + default: false, + }); + if (!confirmed) { + log.blank(); + log.info("Paused deploy abort cancelled."); + log.blank(); + log.info(pausedOperationNotice()); + log.blank(); + closeDeployGutter("error", "Paused"); + return; } - // Check dev instance settings that require production credentials - log.debug("Checking development instance settings for production requirements..."); + await clearDeployState(ctx); + log.blank(); + log.info("Cleared the paused deploy bookmark."); + log.blank(); + log.info( + dim("Note: this does not undo any changes already saved to your Clerk production instance."), + ); + log.info(dim("Use the dashboard to inspect or undo server-side changes.")); + log.blank(); + closeDeployGutter("cancel", "Aborted"); +} - // Mock state — dev instance has Google OAuth enabled - const devSettings = { - socialProviders: ["google"], - }; +async function loadDevelopmentOAuthProviders(ctx: DeployContext): Promise { + return withSpinner("Reading development configuration...", async () => { + const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); + return discoverEnabledOAuthProviders(config); + }); +} + +const OAUTH_KEY_PREFIX = "connection_oauth_"; - if (devSettings.socialProviders.length > 0) { - log.debug( - `Found social providers requiring production credentials: ${devSettings.socialProviders.join(", ")}`, +function discoverEnabledOAuthProviders(config: Record): OAuthProvider[] { + const enabled: OAuthProvider[] = []; + 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) enabled.push(provider as OAuthProvider); + } + return enabled; +} + +async function runValidateCloning(ctx: DeployContext): Promise { + await withSpinner("Validating subscription compatibility...", async () => { + await validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }); + }); +} + +async function createProductionInstance( + ctx: DeployContext, + domain: string, +): Promise { + return withSpinner("Creating production instance...", async () => + apiCreateProductionInstance(ctx.appId, { + home_url: domain, + clone_instance_id: ctx.developmentInstanceId, + }), + ); +} + +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(); + } + + await saveDeployState(ctx, state); + for (const line of dnsDashboardHandoff(state.domain)) log.info(line); + log.blank(); + try { + const continueSetup = await confirmContinueAfterDnsHandoff(); + if (!continueSetup) { + log.blank(); + log.info(pausedOperationNotice()); + log.blank(); + closeDeployGutter("error", "Paused"); + return false; + } + 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.profile.instances.production; + if (!productionInstanceId) { + throwUsageError( + "Cannot verify DNS without a production instance. Run `clerk deploy --abort` and start again.", ); + } - for (const provider of devSettings.socialProviders) { - const displayName = provider.charAt(0).toUpperCase() + provider.slice(1); - const docsUrl = `https://clerk.com/docs/guides/configure/auth-strategies/social-connections/${provider}#configure-for-your-production-instance`; - - const credentialChoice = await select({ - message: `Your app uses ${displayName} OAuth. Do you have your production credentials?`, - choices: [ - { - name: "Walk me through setting it up", - value: "walkthrough", - }, - { - name: "I already have my credentials", - value: "have-credentials", - }, - ], - }); - - if (credentialChoice === "walkthrough") { - log.data( - `\n${bold(`When configuring your ${displayName} OAuth app, use these values:`)}\n`, - ); - log.data(` ${dim("Authorized JavaScript origins:")}`); - log.data(` ${cyan(`https://${domain}`)}`); - log.data(` ${cyan(`https://www.${domain}`)}`); - log.data(`\n ${dim("Authorized redirect URI:")}`); - log.data(` ${cyan(`https://accounts.${domain}/v1/oauth_callback`)}`); - log.data(""); - - log.debug(`Opening ${displayName} OAuth setup guide in browser...`); - const openResult = await openBrowser(docsUrl); - if (!openResult.ok) { - log.info(dim(`(Could not open browser automatically, visit ${docsUrl})`)); - } - - log.data("Once you've created your credentials, enter them below:\n"); - } + const verified = await withSpinner(`Verifying DNS for ${state.domain}...`, async () => { + for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) { + const result = await getDeployStatus(ctx.appId, productionInstanceId); + if (result.status === "complete") return true; + await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS); + } + return false; + }); - const clientId = await input({ - message: `${displayName} OAuth Client ID:`, - }); + if (!verified) { + log.blank(); + log.warn( + `DNS, SSL, or mail verification is still pending for ${state.domain}. ` + + "Run `clerk deploy --continue` once DNS has propagated, or check the dashboard for the failing component.", + ); + log.blank(); + setPrefixTone("error"); + return false; + } + + log.blank(); + for (const line of dnsVerified(state.domain)) log.success(line); + return true; +} - await password({ - message: `${displayName} OAuth Client Secret:`, - }); +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(); + } + + for (const provider of state.oauthProviders.slice(startIndex) as OAuthProvider[]) { + if (completed.has(provider)) continue; + try { + const setupNow = await confirmOAuthSetupNow(provider); + if (!setupNow) { + await saveDeployState(ctx, { ...state, pending: { type: "oauth", provider } }); + log.blank(); + log.info(pausedOperationNotice()); + log.blank(); + closeDeployGutter("error", "Paused"); + return [...completed]; + } - log.debug(`Received ${displayName} credentials (client ID: ${clientId.slice(0, 8)}...)`); + const productionInstanceId = state.productionInstanceId ?? ctx.profile.instances.production; + if (!productionInstanceId) { + throwUsageError( + "Cannot save OAuth credentials without a production instance. Run `clerk deploy --abort` and start again.", + ); + } + + const saved = await collectAndSaveOAuthCredentials( + ctx, + provider, + state.domain, + productionInstanceId, + ); + if (!saved) { + await saveDeployState(ctx, { ...state, pending: { type: "oauth", provider } }); + log.blank(); + log.info(pausedOperationNotice()); + log.blank(); + closeDeployGutter("error", "Paused"); + return [...completed]; + } + } catch (error) { + if (isPromptExitError(error)) { + const interruptedState = { + ...state, + pending: { type: "oauth" as const, provider }, + completedOAuthProviders: [...completed], + }; + await saveDeployState(ctx, interruptedState); + throw deployPausedError(interruptedState, { interrupted: true }); + } + throw error; } + completed.add(provider); + await saveDeployState(ctx, { + ...state, + pending: { type: "oauth", provider }, + completedOAuthProviders: [...completed], + }); + } - log.debug("All social provider credentials collected."); + return [...completed]; +} + +async function collectAndSaveOAuthCredentials( + ctx: DeployContext, + provider: OAuthProvider, + domain: string, + productionInstanceId: string, +): Promise { + const label = PROVIDER_LABELS[provider]; + 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.blank(); + 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; +} + +async function saveDeployState(ctx: DeployContext, state: DeployOperationState): Promise { + const nextProfile = { + ...ctx.profile, + deploy: state, + instances: { + ...ctx.profile.instances, + ...(state.productionInstanceId ? { production: state.productionInstanceId } : {}), + }, + }; + await setProfile(ctx.profileKey, nextProfile); + ctx.profile = nextProfile; +} + +async function clearDeployState(ctx: DeployContext): Promise { + const { deploy: _deploy, ...profile } = ctx.profile; + await setProfile(ctx.profileKey, profile); + ctx.profile = profile; +} + +async function finishDeploy( + ctx: DeployContext, + domain: string, + completedOAuthProviders: readonly string[], +): Promise { + await clearDeployState(ctx); + log.blank(); + for (const line of productionSummary( + domain, + completedOAuthProviders.map((provider) => providerLabel(provider)), + )) { + log.info(line); + } + log.blank(); + printNextSteps(); + log.blank(); + closeDeployGutter("success", NEXT_STEPS.DEPLOY); +} + +function printNextSteps(): void { + log.info(NEXT_STEPS_BLOCK); } diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts new file mode 100644 index 00000000..b109549c --- /dev/null +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -0,0 +1,225 @@ +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"; + +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-z]{2,}$/i.test(domain)) { + return "Enter a valid domain, such as example.com (without https://)."; + } + if (PROVIDER_DOMAIN_SUFFIXES.some((suffix) => domain.toLowerCase().endsWith(suffix))) { + return `${domain} looks like a provider domain (e.g. *.vercel.app, *.clerk.app). Production needs a domain you own. See https://clerk.com/docs/guides/development/deployment/production`; + } + return true; +} + +export async function confirmContinueAfterDnsHandoff(): Promise { + return confirm({ + message: "Continue to OAuth setup?", + default: true, + }); +} + +export async function confirmOAuthSetupNow(provider: OAuthProvider): Promise { + return confirm({ + message: `Set up ${PROVIDER_LABELS[provider]} OAuth now?`, + default: true, + }); +} + +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 resume later (`clerk deploy --continue`)", + 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..f158d593 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -0,0 +1,98 @@ +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_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 async function showOAuthWalkthrough(provider: OAuthProvider, domain: string): Promise { + const label = PROVIDER_LABELS[provider]; + const docsUrl = `https://clerk.com/docs/guides/configure/auth-strategies/social-connections/${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..2289e762 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/state.ts @@ -0,0 +1,48 @@ +import { cyan } from "../../lib/color.ts"; +import { CliError, EXIT_CODE } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { activeDeployInProgressMessage, pausedMessage } from "./copy.ts"; +import { providerLabel, type OAuthProvider } from "./providers.ts"; +import type { DeployOperationState, Profile } from "../../lib/config.ts"; + +export type DeployContext = { + profileKey: string; + profile: Profile; + appId: string; + appLabel: string; + developmentInstanceId: string; +}; + +export function isDeployStateValid(ctx: DeployContext, state: DeployOperationState): boolean { + return state.appId === ctx.appId && state.developmentInstanceId === ctx.developmentInstanceId; +} + +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 function printPausedMessage(state: DeployOperationState): void { + log.info(`Deploy is paused for ${cyan(state.domain)}.`); + log.blank(); + log.info(pausedMessage(pausedStepDescription(state))); +} + +export class DeployPausedError extends CliError {} + +export function activeDeployInProgressError(state: DeployOperationState): DeployPausedError { + return new DeployPausedError(activeDeployInProgressMessage(pausedStepDescription(state)), { + exitCode: EXIT_CODE.GENERAL, + }); +} + +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/config.ts b/packages/cli-core/src/lib/config.ts index 9dd85de6..c3726159 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -39,6 +39,18 @@ interface Profile { development: string; production?: string; }; + deploy?: DeployOperationState; +} + +interface DeployOperationState { + appId: string; + developmentInstanceId: string; + productionInstanceId?: string; + productionDomainId?: string; + domain: string; + pending: { type: "dns" } | { type: "oauth"; provider: string }; + oauthProviders: string[]; + completedOAuthProviders: string[]; } export function profileLabel(profile: Profile): string { @@ -362,4 +374,4 @@ export async function resolveAppContext( }; } -export type { Auth, Profile, ClerkConfig, AppContextOptions }; +export type { Auth, Profile, ClerkConfig, AppContextOptions, DeployOperationState }; diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 929417a0..4917be42 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 = { @@ -212,6 +213,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/log.test.ts b/packages/cli-core/src/lib/log.test.ts index ddaafbeb..ef06ff08 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -7,6 +7,7 @@ import { getLogLevel, pushPrefix, popPrefix, + setPrefixTone, type LogLevel, } from "./log.ts"; @@ -255,6 +256,25 @@ describe("blank", () => { expect(cap.stderr.length).toBe(1); expect(cap.stderr[0]).toContain("│"); }); + + test("colors pipe prefix from the active gutter tone", () => { + const cap = createCapture(); + + withCapturedLogs(cap, () => { + pushPrefix("active"); + log.info("working"); + setPrefixTone("error"); + log.info("needs attention"); + setPrefixTone("cancel"); + log.info("cancelled"); + popPrefix(); + }); + + expect(cap.stderr).toHaveLength(3); + expect(cap.stderr[0]).toContain("\x1b[36m│"); + expect(cap.stderr[1]).toContain("\x1b[33m│"); + expect(cap.stderr[2]).toContain("\x1b[31m│"); + }); }); describe("raw", () => { diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index 3530f132..4073636c 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { dim, green, red, yellow } from "./color.ts"; +import { cyan, dim, green, red, yellow } from "./color.ts"; // ── Log level ──────────────────────────────────────────────────────────── @@ -30,24 +30,50 @@ function isLevelEnabled(level: LogLevel): boolean { // ── Pipe prefix state (for intro/outro flow) ────────────────────────────── const S_BAR = "│"; -let prefixDepth = 0; +export type PrefixTone = "neutral" | "active" | "error" | "cancel" | "success"; -export function pushPrefix() { - prefixDepth++; +const prefixTones: PrefixTone[] = []; + +export function pushPrefix(tone: PrefixTone = "neutral") { + prefixTones.push(tone); } export function popPrefix() { - prefixDepth = Math.max(0, prefixDepth - 1); + prefixTones.pop(); +} + +export function setPrefixTone(tone: PrefixTone) { + if (prefixTones.length === 0) return; + prefixTones[prefixTones.length - 1] = tone; +} + +export function getPrefixTone(): PrefixTone { + return prefixTones[prefixTones.length - 1] ?? "neutral"; +} + +export function formatPrefixSymbol(symbol: string, tone: PrefixTone = getPrefixTone()): string { + switch (tone) { + case "active": + return cyan(symbol); + case "error": + return yellow(symbol); + case "cancel": + return red(symbol); + case "success": + return green(symbol); + case "neutral": + return dim(symbol); + } } /** True while an intro/outro block is active and stderr output is gutter-prefixed. */ export function isInsideGutter(): boolean { - return prefixDepth > 0; + return prefixTones.length > 0; } function applyPrefix(msg: string): string { - if (prefixDepth === 0) return msg; - const bar = dim(S_BAR); + if (prefixTones.length === 0) return msg; + const bar = formatPrefixSymbol(S_BAR); if (!msg) return bar; return msg .split("\n") 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.test.ts b/packages/cli-core/src/lib/spinner.test.ts new file mode 100644 index 00000000..b8560bf1 --- /dev/null +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { setPrefixTone } from "./log.ts"; +import { intro, outro } from "./spinner.ts"; + +describe("gutter tone rendering", () => { + let stderrSpy: ReturnType | undefined; + const originalMode = process.env.CLERK_MODE; + + afterEach(() => { + stderrSpy?.mockRestore(); + stderrSpy = undefined; + if (originalMode === undefined) { + delete process.env.CLERK_MODE; + } else { + process.env.CLERK_MODE = originalMode; + } + }); + + test("uses active and error tones for intro and outro rails", () => { + process.env.CLERK_MODE = "human"; + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + + intro("clerk deploy", { tone: "active" }); + setPrefixTone("error"); + outro("Paused"); + + const output = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""); + expect(output).toContain("\x1b[36m┌"); + expect(output).toContain("\x1b[33m│"); + expect(output).toContain("\x1b[33m└"); + }); +}); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index d3bebfdc..165556bf 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -1,6 +1,13 @@ import { isHuman } from "../mode.ts"; import { dim, cyan, green, red } from "./color.ts"; -import { pushPrefix, popPrefix } from "./log.ts"; +import { + formatPrefixSymbol, + getPrefixTone, + popPrefix, + pushPrefix, + setPrefixTone, + type PrefixTone, +} from "./log.ts"; const FRAMES = ["◒", "◐", "◓", "◑"]; const INTERVAL = 80; @@ -17,36 +24,38 @@ const isInteractive = () => stream.isTTY && !process.env.CI; // --- Public API --- /** Print intro bracket: ┌ title — prefixes log output with │ until outro(). */ -export function intro(title?: string) { +export function intro(title?: string, options: { tone?: PrefixTone } = {}) { if (!isHuman()) return; - const line = title ? `${dim(S_BAR_START)} ${title}` : dim(S_BAR_START); + const tone = options.tone ?? "neutral"; + const line = title ? `${formatPrefixSymbol(S_BAR_START, tone)} ${title}` : dim(S_BAR_START); stream.write(`${line}\n`); - pushPrefix(); + pushPrefix(tone); } /** Print outro bracket: └ message — 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; + const tone = getPrefixTone(); popPrefix(); - stream.write(`${dim(S_BAR)}\n`); + stream.write(`${formatPrefixSymbol(S_BAR, tone)}\n`); if (Array.isArray(messageOrSteps)) { - stream.write(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); + stream.write(`${formatPrefixSymbol(S_BAR_END, tone)} ${dim("Next steps")}\n`); for (const step of messageOrSteps) { stream.write(` ${cyan("\u2192")} ${step}\n`); } stream.write("\n"); } else { const label = messageOrSteps ?? "Done"; - stream.write(`${dim(S_BAR_END)} ${label}\n\n`); + stream.write(`${formatPrefixSymbol(S_BAR_END, tone)} ${label}\n\n`); } } /** Print a bar separator: │ */ export function bar() { if (!isHuman()) return; - stream.write(`${dim(S_BAR)}\n`); + stream.write(`${formatPrefixSymbol(S_BAR)}\n`); } function createSpinner() { @@ -105,6 +114,7 @@ export async function withSpinner( s.stop(doneMessage ?? message.replace(/\.{3}$/, "")); return result; } catch (error) { + setPrefixTone("error"); s.error("Failed"); throw error; } From 5d1feb1ecf6b53ca6e8cee0f8449749d812f52aa Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 08:13:00 -0600 Subject: [PATCH 02/20] fix(deploy): address review feedback on resumable wizard - Preserve completed providers when pausing OAuth setup mid-loop, so `clerk deploy --continue` can finish multi-provider stacks. - Surface a warning for OAuth providers enabled in dev that the wizard does not yet support, instead of silently skipping them. - Close the gutter as Paused (not Failed) when DNS verification times out, since the state is recoverable via --continue. - Tighten the production-domain regex to reject malformed inputs like example..com or example-.com before they reach the API. --- .../src/commands/deploy/index.test.ts | 109 +++++++++++++++++- .../cli-core/src/commands/deploy/index.ts | 49 ++++++-- .../cli-core/src/commands/deploy/prompts.ts | 2 +- 3 files changed, 149 insertions(+), 11 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index a41c194a..cfa7be76 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -63,6 +63,10 @@ mock.module("./api.ts", () => ({ patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), })); +mock.module("../../lib/sleep.ts", () => ({ + sleep: () => Promise.resolve(), +})); + const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts"); const { deploy } = await import("./index.ts"); @@ -288,7 +292,8 @@ describe("deploy", () => { expect(err).toContain("Configure Google OAuth credentials"); expect(err).toContain("Configure GitHub OAuth credentials"); expect(err).not.toContain("Configure Microsoft OAuth credentials"); - expect(err).not.toContain("unknown"); + 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 () => { @@ -345,6 +350,9 @@ describe("deploy", () => { 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", ); @@ -939,5 +947,104 @@ describe("deploy", () => { expect(err).toContain("Saved Google OAuth credentials"); expect(err).toContain("Production ready at https://example.com"); }); + + test("Pausing OAuth mid-loop preserves earlier completed providers in saved state", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockFetchInstanceConfig.mockResolvedValue({ + connection_oauth_google: { enabled: true }, + connection_oauth_github: { enabled: true }, + }); + // Proceed → continue after DNS → setup google now → enter google creds → say no on github. + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + + let config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + pending: { type: "oauth", provider: "github" }, + completedOAuthProviders: ["google"], + oauthProviders: ["google", "github"], + }); + + // Resume and finish: should not re-prompt for google, should finalize. + captured = captureLog(); + mockConfirm.mockReset(); + mockSelect.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + mockPatchInstanceConfig.mockReset(); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("github-client-id"); + mockPassword.mockResolvedValueOnce("github-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({ continue: true }); + const err = stripAnsi(captured.err); + + config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); + 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("DNS verification timeout outros as paused, not failed", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("example.com"); + mockGetDeployStatus.mockResolvedValue({ status: "incomplete" }); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + + await runDeploy({}); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ + pending: { type: "dns" }, + domain: "example.com", + }); + const terminalOutput = stderrSpy.mock.calls + .map((call: unknown[]) => String(call[0])) + .join(""); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).not.toContain("Failed"); + }); + + 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 7e05298e..3c5919f1 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -239,7 +239,8 @@ async function startDeploy(ctx: DeployContext): Promise { ); } - const oauthProviders = await loadDevelopmentOAuthProviders(ctx); + const { known: oauthProviders, unknown: unknownOAuthProviders } = + await loadDevelopmentOAuthProviders(ctx); await runValidateCloning(ctx); @@ -254,6 +255,16 @@ async function startDeploy(ctx: DeployContext): Promise { } log.blank(); + if (unknownOAuthProviders.length > 0) { + log.warn( + `These OAuth providers are enabled in development but not yet supported by \`clerk deploy\`: ${unknownOAuthProviders.join(", ")}.`, + ); + log.warn( + "They will be cloned to production without working credentials. Configure them from the Clerk Dashboard before going live, or disable them in development first.", + ); + log.blank(); + } + const proceed = await confirmProceed(); if (!proceed) { log.info("No changes were made."); @@ -373,7 +384,14 @@ async function abortDeploy(ctx: DeployContext): Promise { closeDeployGutter("cancel", "Aborted"); } -async function loadDevelopmentOAuthProviders(ctx: DeployContext): Promise { +type DiscoveredOAuthProviders = { + known: OAuthProvider[]; + unknown: string[]; +}; + +async function loadDevelopmentOAuthProviders( + ctx: DeployContext, +): Promise { return withSpinner("Reading development configuration...", async () => { const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); return discoverEnabledOAuthProviders(config); @@ -382,16 +400,21 @@ async function loadDevelopmentOAuthProviders(ctx: DeployContext): Promise): OAuthProvider[] { - const enabled: OAuthProvider[] = []; +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) enabled.push(provider as OAuthProvider); + if (provider in PROVIDER_LABELS) { + known.push(provider as OAuthProvider); + } else { + unknown.push(provider); + } } - return enabled; + return { known, unknown }; } async function runValidateCloning(ctx: DeployContext): Promise { @@ -476,7 +499,7 @@ async function runDnsVerification( "Run `clerk deploy --continue` once DNS has propagated, or check the dashboard for the failing component.", ); log.blank(); - setPrefixTone("error"); + closeDeployGutter("error", "Paused"); return false; } @@ -505,7 +528,11 @@ async function runOAuthSetup( try { const setupNow = await confirmOAuthSetupNow(provider); if (!setupNow) { - await saveDeployState(ctx, { ...state, pending: { type: "oauth", provider } }); + await saveDeployState(ctx, { + ...state, + pending: { type: "oauth", provider }, + completedOAuthProviders: [...completed], + }); log.blank(); log.info(pausedOperationNotice()); log.blank(); @@ -527,7 +554,11 @@ async function runOAuthSetup( productionInstanceId, ); if (!saved) { - await saveDeployState(ctx, { ...state, pending: { type: "oauth", provider } }); + await saveDeployState(ctx, { + ...state, + pending: { type: "oauth", provider }, + completedOAuthProviders: [...completed], + }); log.blank(); log.info(pausedOperationNotice()); log.blank(); diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index b109549c..75ac580f 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -39,7 +39,7 @@ export function validateDomain(value: string): true | string { 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-z]{2,}$/i.test(domain)) { + 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))) { From 1e951cf1c4b62fddf7abf00593f0c278a6773f1e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 09:34:43 -0600 Subject: [PATCH 03/20] refactor(deploy): isolate lifecycle api calls Move deploy lifecycle endpoint wrappers into the shared PLAPI client while routing the deploy wizard through a command-local adapter that defaults to mocked operations until the backend endpoints are ready. --- .../cli-core/src/commands/deploy/api.test.ts | 70 ++++ packages/cli-core/src/commands/deploy/api.ts | 342 +++++++----------- .../src/commands/deploy/domain-connect.ts | 12 + .../src/commands/deploy/index.test.ts | 5 +- .../cli-core/src/commands/deploy/index.ts | 2 +- packages/cli-core/src/lib/plapi.test.ts | 124 +++++++ packages/cli-core/src/lib/plapi.ts | 93 +++++ 7 files changed, 427 insertions(+), 221 deletions(-) create mode 100644 packages/cli-core/src/commands/deploy/api.test.ts create mode 100644 packages/cli-core/src/commands/deploy/domain-connect.ts diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts new file mode 100644 index 00000000..131e20e1 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/api.test.ts @@ -0,0 +1,70 @@ +import { test, expect, describe, beforeEach, mock } from "bun:test"; + +const mockPlapiCreateProductionInstance = mock(); +const mockPlapiValidateCloning = mock(); +const mockPlapiGetDeployStatus = mock(); +const mockPlapiPatchInstanceConfig = mock(); +const mockSleep = mock(); + +mock.module("../../lib/plapi.ts", () => ({ + createProductionInstance: (...args: unknown[]) => mockPlapiCreateProductionInstance(...args), + validateCloning: (...args: unknown[]) => mockPlapiValidateCloning(...args), + getDeployStatus: (...args: unknown[]) => mockPlapiGetDeployStatus(...args), + patchInstanceConfig: (...args: unknown[]) => mockPlapiPatchInstanceConfig(...args), +})); + +mock.module("../../lib/sleep.ts", () => ({ + sleep: (...args: unknown[]) => mockSleep(...args), +})); + +const deployApiModulePath = "./api.ts?adapter-test"; +const { + createProductionInstance, + getDeployStatus, + patchInstanceConfig, + validateCloning, + _resetDeployStatusMock, +} = (await import(deployApiModulePath)) as typeof import("./api.ts"); + +describe("deploy api adapter", () => { + beforeEach(() => { + mockPlapiCreateProductionInstance.mockImplementation(() => { + throw new Error("live createProductionInstance should not be called"); + }); + mockPlapiValidateCloning.mockImplementation(() => { + throw new Error("live validateCloning should not be called"); + }); + mockPlapiGetDeployStatus.mockImplementation(() => { + throw new Error("live getDeployStatus should not be called"); + }); + mockPlapiPatchInstanceConfig.mockImplementation(() => { + throw new Error("live patchInstanceConfig should not be called"); + }); + mockSleep.mockResolvedValue(undefined); + _resetDeployStatusMock(); + }); + + test("uses mocked deploy lifecycle operations by default", async () => { + const production = await createProductionInstance("app_123", { + home_url: "example.com", + clone_instance_id: "ins_dev_123", + }); + await validateCloning("app_123", { clone_instance_id: "ins_dev_123" }); + await patchInstanceConfig("app_123", production.instance_id, { + connection_oauth_google: { enabled: true }, + }); + + expect(production.active_domain.name).toBe("example.com"); + expect(production.cname_targets).toHaveLength(3); + expect(mockPlapiCreateProductionInstance).not.toHaveBeenCalled(); + expect(mockPlapiValidateCloning).not.toHaveBeenCalled(); + expect(mockPlapiPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("mock deploy status progresses without calling live PLAPI", async () => { + expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "incomplete" }); + expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "incomplete" }); + expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "complete" }); + expect(mockPlapiGetDeployStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts index fc87b980..39284068 100644 --- a/packages/cli-core/src/commands/deploy/api.ts +++ b/packages/cli-core/src/commands/deploy/api.ts @@ -1,251 +1,155 @@ /** - * FIXME(deploy): the entire module is a stand-in. Every export below is a - * mock that must be replaced with the live Platform API call before - * shipping the deploy command. Grep `FIXME(deploy)` to find each spot. + * Deploy command API adapter. * - * Mock implementations of the deploy lifecycle Platform API endpoints. - * - * Type signatures and field names mirror the published Platform API - * OpenAPI spec exactly. Implementations are mocked so the CLI deploy - * wizard runs end-to-end without a backend. Swapping these to live calls - * is intentionally a one-function-at-a-time change with no shape - * rewrites. - * - * Endpoint paths: - * POST /v1/platform/applications/{applicationID}/production_instance - * POST /v1/platform/applications/{applicationID}/validate_cloning - * GET /v1/platform/applications/{applicationID}/instances/{envOrInsID}/deploy_status - * POST /v1/platform/applications/{applicationID}/domains/{domainIDOrName}/ssl_retry - * POST /v1/platform/applications/{applicationID}/domains/{domainIDOrName}/mail_retry - * PATCH /v1/platform/applications/{applicationID}/instances/{instanceID}/config + * The live endpoint wrappers live in `lib/plapi.ts`, but the deploy command + * still runs against mocks until the production-instance backend is ready. + * Keep this adapter as the single switch point so the command cannot + * accidentally call unfinished live deploy lifecycle endpoints. */ -import { log } from "../../lib/log.ts"; import { sleep } from "../../lib/sleep.ts"; - -export type DomainSummary = { - id: string; - name: string; -}; - -export type CnameTarget = { - host: string; - value: string; - required: boolean; -}; - -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; +import { + createProductionInstance as liveCreateProductionInstance, + getDeployStatus as liveGetDeployStatus, + patchInstanceConfig as livePatchInstanceConfig, + retryApplicationDomainMail as liveRetryApplicationDomainMail, + retryApplicationDomainSSL as liveRetryApplicationDomainSSL, + validateCloning as liveValidateCloning, + type CnameTarget, + type CreateProductionInstanceParams, + type DeployStatusResponse, + type ProductionInstanceResponse, + type ValidateCloningParams, +} from "../../lib/plapi.ts"; + +export type { + CnameTarget, + CreateProductionInstanceParams, + DeployStatusResponse, + ProductionInstanceResponse, + ValidateCloningParams, +} from "../../lib/plapi.ts"; + +type DeployApi = { + createProductionInstance: ( + applicationId: string, + params: CreateProductionInstanceParams, + ) => Promise; + validateCloning: (applicationId: string, params: ValidateCloningParams) => Promise; + getDeployStatus: (applicationId: string, envOrInsId: string) => Promise; + retryApplicationDomainSSL: (applicationId: string, domainIdOrName: string) => Promise; + retryApplicationDomainMail: (applicationId: string, domainIdOrName: string) => Promise; + patchInstanceConfig: ( + applicationId: string, + instanceId: string, + config: Record, + ) => Promise>; }; -export type DeployStatus = "complete" | "incomplete"; - -export type DeployStatusResponse = { - status: DeployStatus; -}; - -// FIXME(deploy): hardcoded mock identifiers and keys. Drop alongside the mock helpers below. const MOCK_PRODUCTION_INSTANCE_ID = "MOCKED_NOT_REAL_FIXME"; const MOCK_DOMAIN_ID = "MOCKED_NOT_REAL_FIXME"; const MOCK_PUBLISHABLE_KEY = "MOCKED_NOT_REAL_FIXME"; const MOCK_SECRET_KEY = "MOCKED_NOT_REAL_FIXME"; - -/** - * FIXME(deploy): artificial server-side latency every mocked endpoint - * pays before returning. Exists so the wizard's spinners and DNS-status - * polling feel like real network calls instead of instant resolution. - * Remove the helper and every `await simulateServerLatency()` call site - * once these endpoints hit the real network. - */ const MOCK_LATENCY_MS = 2000; +const MOCK_INCOMPLETE_POLLS = 2; async function simulateServerLatency(): Promise { - // FIXME(deploy): artificial delay. Remove when the surrounding mock is replaced with a real PLAPI call. await sleep(MOCK_LATENCY_MS); } -/** - * Mock for `POST /v1/platform/applications/{applicationID}/production_instance`. - * - * The real endpoint creates a prod instance + primary domain, optionally - * cloning from a dev instance, and returns keys + DNS targets in one - * round-trip. - */ -export async function createProductionInstance( - applicationId: string, - params: CreateProductionInstanceParams, -): Promise { - // FIXME(deploy): mock. Replace with a live POST to PLAPI and remove the hardcoded response. - log.debug( - `plapi-mock: POST /v1/platform/applications/${applicationId}/production_instance ` + - `home_url=${params.home_url} clone_instance_id=${params.clone_instance_id ?? ""}`, - ); - await simulateServerLatency(); - return { - instance_id: MOCK_PRODUCTION_INSTANCE_ID, - environment_type: "production", - active_domain: { - id: MOCK_DOMAIN_ID, - name: params.home_url, +function defaultCnameTargets(domain: string): CnameTarget[] { + return [ + { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, + { host: `accounts.${domain}`, value: "accounts.clerk.services", required: true }, + { + host: `clkmail.${domain}`, + value: `mail.${domain}.nam1.clerk.services`, + required: true, }, - secret_key: MOCK_SECRET_KEY, - publishable_key: MOCK_PUBLISHABLE_KEY, - cname_targets: defaultCnameTargets(params.home_url), - }; -} - -/** - * Mock for `POST /v1/platform/applications/{applicationID}/validate_cloning`. - * - * The real endpoint validates that the dev instance's features are - * covered by the application's subscription plan. Returns 204 on success - * or 402 with UnsupportedSubscriptionPlanFeatures. - */ -export async function validateCloning( - applicationId: string, - params: ValidateCloningParams, -): Promise { - // FIXME(deploy): mock. Replace with a live POST to PLAPI; bubble 402 UnsupportedSubscriptionPlanFeatures. - log.debug( - `plapi-mock: POST /v1/platform/applications/${applicationId}/validate_cloning ` + - `clone_instance_id=${params.clone_instance_id}`, - ); - await simulateServerLatency(); + ]; } -/** - * Mock for `GET /v1/platform/applications/{applicationID}/instances/{envOrInsID}/deploy_status`. - * - * The real endpoint reports whether DNS, SSL, Mail, and Proxy checks have - * all passed for the instance's primary domain. `envOrInsID` accepts the - * literal "production" or "development" shortcut in addition to instance - * IDs. - * - * The mock keeps a per-process counter keyed by instance so callers - * polling on a 3s interval observe a realistic incomplete → complete - * progression without any extra wiring. - */ -// FIXME(deploy): per-process counter that drives the fake incomplete→complete progression. Drop with the helper below. const deployStatusPollCounts = new Map(); -const MOCK_INCOMPLETE_POLLS = 2; -export async function getDeployStatus( - applicationId: string, - envOrInsId: string, -): Promise { - // FIXME(deploy): mock. Replace with a live GET to PLAPI. The real endpoint already returns the same shape. - log.debug( - `plapi-mock: GET /v1/platform/applications/${applicationId}/instances/${envOrInsId}/deploy_status`, - ); - await simulateServerLatency(); - const key = `${applicationId}:${envOrInsId}`; - const count = (deployStatusPollCounts.get(key) ?? 0) + 1; - deployStatusPollCounts.set(key, count); - return { - status: count > MOCK_INCOMPLETE_POLLS ? "complete" : "incomplete", - }; -} - -/** Test-only: reset the mock deploy-status progression counters. */ export function _resetDeployStatusMock(): void { deployStatusPollCounts.clear(); } -/** - * Mock for `POST /v1/platform/applications/{applicationID}/domains/{domainIDOrName}/ssl_retry`. - * - * The real endpoint re-provisions the SSL certificate for a production - * domain. Returns 204 on success, 400 InstanceNotLive if SSL setup hasn't - * begun. - */ -export async function retryApplicationDomainSSL( - applicationId: string, - domainIdOrName: string, -): Promise { - // FIXME(deploy): mock. Replace with a live POST to PLAPI. - log.debug( - `plapi-mock: POST /v1/platform/applications/${applicationId}/domains/${domainIdOrName}/ssl_retry`, - ); - await simulateServerLatency(); -} +export const mockDeployApi: DeployApi = { + async createProductionInstance(_applicationId, params) { + await simulateServerLatency(); + return { + instance_id: MOCK_PRODUCTION_INSTANCE_ID, + environment_type: "production", + active_domain: { + id: MOCK_DOMAIN_ID, + name: params.home_url, + }, + secret_key: MOCK_SECRET_KEY, + publishable_key: MOCK_PUBLISHABLE_KEY, + cname_targets: defaultCnameTargets(params.home_url), + }; + }, + + async validateCloning() { + await simulateServerLatency(); + }, + + async getDeployStatus(applicationId, envOrInsId) { + await simulateServerLatency(); + const key = `${applicationId}:${envOrInsId}`; + const count = (deployStatusPollCounts.get(key) ?? 0) + 1; + deployStatusPollCounts.set(key, count); + return { + status: count > MOCK_INCOMPLETE_POLLS ? "complete" : "incomplete", + }; + }, + + async retryApplicationDomainSSL() { + await simulateServerLatency(); + }, + + async retryApplicationDomainMail() { + await simulateServerLatency(); + }, + + async patchInstanceConfig() { + await simulateServerLatency(); + return {}; + }, +}; -/** - * Mock for `POST /v1/platform/applications/{applicationID}/domains/{domainIDOrName}/mail_retry`. - * - * The real endpoint re-schedules SendGrid mail verification. Rejected on - * satellite domains (they inherit mail from the primary). - */ -export async function retryApplicationDomainMail( +export const liveDeployApi: DeployApi = { + createProductionInstance: liveCreateProductionInstance, + validateCloning: liveValidateCloning, + getDeployStatus: liveGetDeployStatus, + retryApplicationDomainSSL: liveRetryApplicationDomainSSL, + retryApplicationDomainMail: liveRetryApplicationDomainMail, + patchInstanceConfig: livePatchInstanceConfig, +}; + +// FIXME(deploy): switch this to `liveDeployApi` once the backend endpoints are ready. +const activeDeployApi: DeployApi = mockDeployApi; + +export const createProductionInstance = ( applicationId: string, - domainIdOrName: string, -): Promise { - // FIXME(deploy): mock. Replace with a live POST to PLAPI; bubble OperationNotAllowedOnSatelliteDomain. - log.debug( - `plapi-mock: POST /v1/platform/applications/${applicationId}/domains/${domainIdOrName}/mail_retry`, - ); - await simulateServerLatency(); -} + params: CreateProductionInstanceParams, +) => activeDeployApi.createProductionInstance(applicationId, params); -/** - * Mock for `PATCH /v1/platform/applications/{applicationID}/instances/{instanceID}/config` - * scoped to the deploy command's production instance writes. - * - * The endpoint itself is real and exposed via `lib/plapi.ts` for other - * commands, but the deploy wizard targets a mocked production instance, so a - * live PATCH would 404. This mock keeps the call shape identical so swapping - * back to live is a one-import change. - */ -export async function patchInstanceConfig( +export const validateCloning = (applicationId: string, params: ValidateCloningParams) => + activeDeployApi.validateCloning(applicationId, params); + +export const getDeployStatus = (applicationId: string, envOrInsId: string) => + activeDeployApi.getDeployStatus(applicationId, envOrInsId); + +export const retryApplicationDomainSSL = (applicationId: string, domainIdOrName: string) => + activeDeployApi.retryApplicationDomainSSL(applicationId, domainIdOrName); + +export const retryApplicationDomainMail = (applicationId: string, domainIdOrName: string) => + activeDeployApi.retryApplicationDomainMail(applicationId, domainIdOrName); + +export const patchInstanceConfig = ( applicationId: string, instanceId: string, config: Record, -): Promise> { - // FIXME(deploy): mock. Swap back to `lib/plapi.ts` `patchInstanceConfig` once the production instance is real. - log.debug( - `plapi-mock: PATCH /v1/platform/applications/${applicationId}/instances/${instanceId}/config ` + - `keys=${Object.keys(config).join(",")}`, - ); - await simulateServerLatency(); - return {}; -} - -// FIXME(deploy): hardcoded CNAME values that the real `production_instance` create response will populate. -function defaultCnameTargets(domain: string): CnameTarget[] { - return [ - { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, - { host: `accounts.${domain}`, value: "accounts.clerk.services", required: true }, - { - host: `clkmail.${domain}`, - value: `mail.${domain}.nam1.clerk.services`, - required: true, - }, - ]; -} - -/** - * 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}`; -} +) => activeDeployApi.patchInstanceConfig(applicationId, instanceId, config); diff --git a/packages/cli-core/src/commands/deploy/domain-connect.ts b/packages/cli-core/src/commands/deploy/domain-connect.ts new file mode 100644 index 00000000..21be3795 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/domain-connect.ts @@ -0,0 +1,12 @@ +/** + * Detect whether the registrar for `domain` supports Domain Connect and + * return the prefilled URL if so. Currently a placeholder that returns the + * Cloudflare template unconditionally; a real implementation would look up + * NS records and match the registrar against a provider table. + * + * FIXME(deploy): replace with NS-based registrar detection. Today every + * caller is told their registrar is Cloudflare regardless of reality. + */ +export function domainConnectUrl(domain: string): string | undefined { + return `https://domainconnect.cloudflare.com/v2/domainTemplates/providers/clerk.com/services/clerk-production/apply?domain=${domain}`; +} diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index cfa7be76..dfa23ab2 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -59,10 +59,13 @@ mock.module("./api.ts", () => ({ getDeployStatus: (...args: unknown[]) => mockGetDeployStatus(...args), retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args), retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), - domainConnectUrl: (...args: unknown[]) => mockDomainConnectUrl(...args), patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), })); +mock.module("./domain-connect.ts", () => ({ + domainConnectUrl: (...args: unknown[]) => mockDomainConnectUrl(...args), +})); + mock.module("../../lib/sleep.ts", () => ({ sleep: () => Promise.resolve(), })); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 3c5919f1..d6b8219f 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -10,13 +10,13 @@ import { resolveProfile, setProfile, type DeployOperationState } from "../../lib import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { createProductionInstance as apiCreateProductionInstance, - domainConnectUrl, getDeployStatus, patchInstanceConfig, validateCloning, type CnameTarget, type ProductionInstanceResponse, } from "./api.ts"; +import { domainConnectUrl } from "./domain-connect.ts"; import { INTRO_PREAMBLE, INVALID_CONTINUE_MESSAGE, diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 30d1e96a..34e60ec4 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -14,6 +14,11 @@ const { patchInstanceConfig, listApplications, createApplication, + createProductionInstance, + validateCloning, + getDeployStatus, + retryApplicationDomainSSL, + retryApplicationDomainMail, } = await import("./plapi.ts"); const { AuthError, PlapiError } = await import("./errors.ts"); @@ -380,4 +385,123 @@ describe("plapi", () => { } }); }); + + describe("createProductionInstance", () => { + test("sends POST to production_instance with clone params", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + let capturedBody = ""; + const responseBody = { + instance_id: "ins_prod_123", + environment_type: "production" as const, + active_domain: { id: "dmn_123", name: "example.com" }, + publishable_key: "pk_live_123", + secret_key: "sk_live_123", + cname_targets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + ], + }; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + capturedBody = init?.body as string; + return new Response(JSON.stringify(responseBody), { status: 201 }); + }); + + const result = await createProductionInstance("app_abc", { + home_url: "example.com", + clone_instance_id: "ins_dev_123", + }); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/production_instance", + ); + expect(JSON.parse(capturedBody)).toEqual({ + home_url: "example.com", + clone_instance_id: "ins_dev_123", + }); + expect(result).toEqual(responseBody); + }); + }); + + describe("validateCloning", () => { + test("sends POST to validate_cloning and accepts empty success response", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + let capturedBody = ""; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + capturedBody = init?.body as string; + return new Response(null, { status: 204 }); + }); + + await validateCloning("app_abc", { clone_instance_id: "ins_dev_123" }); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/validate_cloning", + ); + expect(JSON.parse(capturedBody)).toEqual({ clone_instance_id: "ins_dev_123" }); + }); + }); + + describe("getDeployStatus", () => { + test("sends GET to deploy_status and returns parsed status", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + return new Response(JSON.stringify({ status: "complete" }), { status: 200 }); + }); + + const result = await getDeployStatus("app_abc", "production"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/production/deploy_status", + ); + expect(result).toEqual({ status: "complete" }); + }); + }); + + describe("retryApplicationDomainSSL", () => { + test("sends POST to ssl_retry and accepts empty success response", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + return new Response(null, { status: 204 }); + }); + + await retryApplicationDomainSSL("app_abc", "dmn_123"); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/domains/dmn_123/ssl_retry", + ); + }); + }); + + describe("retryApplicationDomainMail", () => { + test("sends POST to mail_retry and accepts empty success response", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + return new Response(null, { status: 204 }); + }); + + await retryApplicationDomainMail("app_abc", "example.com"); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/domains/example.com/mail_retry", + ); + }); + }); }); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 5a9ca3ac..ab28ed0b 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -140,6 +140,42 @@ export interface Application { instances: ApplicationInstance[]; } +export type DomainSummary = { + id: string; + name: string; +}; + +export type CnameTarget = { + host: string; + value: string; + required: boolean; +}; + +export type ProductionInstanceResponse = { + instance_id: string; + environment_type: "production"; + active_domain: DomainSummary; + secret_key?: string; + publishable_key: string; + cname_targets: CnameTarget[]; +}; + +export type CreateProductionInstanceParams = { + home_url: string; + clone_instance_id?: string; + is_secondary?: boolean; +}; + +export type ValidateCloningParams = { + clone_instance_id: string; +}; + +export type DeployStatus = "complete" | "incomplete"; + +export type DeployStatusResponse = { + status: DeployStatus; +}; + export async function fetchApplication(applicationId: string): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); url.searchParams.set("include_secret_keys", "true"); @@ -147,6 +183,63 @@ export async function fetchApplication(applicationId: string): Promise; } +export async function createProductionInstance( + applicationId: string, + params: CreateProductionInstanceParams, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/production_instance`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("POST", url, { body: JSON.stringify(params) }); + return response.json() as Promise; +} + +export async function validateCloning( + applicationId: string, + params: ValidateCloningParams, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/validate_cloning`, + getPlapiBaseUrl(), + ); + await plapiFetch("POST", url, { body: JSON.stringify(params) }); +} + +export async function getDeployStatus( + applicationId: string, + envOrInsId: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInsId}/deploy_status`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function retryApplicationDomainSSL( + applicationId: string, + domainIdOrName: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/ssl_retry`, + getPlapiBaseUrl(), + ); + await plapiFetch("POST", url); +} + +export async function retryApplicationDomainMail( + applicationId: string, + domainIdOrName: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/mail_retry`, + getPlapiBaseUrl(), + ); + await plapiFetch("POST", url); +} + async function sendInstanceConfig( method: "PUT" | "PATCH", applicationId: string, From 21ba74c7c80cf87c9566df844f3848fbb4bec159 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 13:21:25 -0600 Subject: [PATCH 04/20] feat(deploy): resolve production state from API --- packages/cli-core/src/cli-program.test.ts | 17 + packages/cli-core/src/cli-program.ts | 44 +- .../cli-core/src/commands/deploy/README.md | 38 +- .../cli-core/src/commands/deploy/api.test.ts | 13 +- packages/cli-core/src/commands/deploy/api.ts | 10 +- packages/cli-core/src/commands/deploy/copy.ts | 55 +- .../src/commands/deploy/index.test.ts | 902 ++++++++++++------ .../cli-core/src/commands/deploy/index.ts | 622 ++++++++---- .../cli-core/src/commands/deploy/prompts.ts | 17 +- .../cli-core/src/commands/deploy/providers.ts | 29 +- .../cli-core/src/commands/deploy/state.ts | 43 +- packages/cli-core/src/lib/config.ts | 14 +- packages/cli-core/src/lib/plapi.test.ts | 39 + packages/cli-core/src/lib/plapi.ts | 28 + 14 files changed, 1311 insertions(+), 560 deletions(-) diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index a0769a99..67ecb7e9 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -45,6 +45,23 @@ test("users list exposes common filters and pagination options", () => { ); }); +test("deploy exposes the expected options", () => { + const program = createProgram(); + const deploy = program.commands.find((command) => command.name() === "deploy")!; + const optionNames = deploy.options.map((option) => option.long); + + expect(optionNames).toEqual([ + "--debug", + "--test-force-production-instance", + "--test-fail-production-instance-check", + "--test-fail-domain-lookup", + "--test-fail-validate-cloning", + "--test-fail-create-production-instance", + "--test-fail-dns-verification", + "--test-fail-oauth-save", + ]); +}); + describe("parseIntegerOption (via users list --limit / --offset)", () => { function parseUsersList(args: readonly string[]) { return createProgram().parseAsync(["users", "list", ...args], { from: "user" }); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 23a92757..c86d1b9b 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -926,8 +926,48 @@ Tutorial — enable completions for your shell: .command("deploy", { hidden: true }) .description("Deploy a Clerk application to production") .option("--debug", "Show detailed deployment debug output") - .option("--continue", "Resume a paused deploy operation") - .option("--abort", "Abort and clear a paused deploy operation") + .addOption( + createOption( + "--test-force-production-instance", + "Force deploy to use a mocked production instance", + ).hideHelp(), + ) + .addOption( + createOption( + "--test-fail-production-instance-check", + "Simulate a deploy failure while checking for a production instance", + ).hideHelp(), + ) + .addOption( + createOption( + "--test-fail-domain-lookup", + "Simulate a deploy failure while loading the production domain", + ).hideHelp(), + ) + .addOption( + createOption( + "--test-fail-validate-cloning", + "Simulate a deploy failure while validating cloning", + ).hideHelp(), + ) + .addOption( + createOption( + "--test-fail-create-production-instance", + "Simulate a deploy failure while creating the production instance", + ).hideHelp(), + ) + .addOption( + createOption( + "--test-fail-dns-verification", + "Simulate a deploy failure while verifying DNS", + ).hideHelp(), + ) + .addOption( + createOption( + "--test-fail-oauth-save", + "Simulate a deploy failure while saving OAuth credentials", + ).hideHelp(), + ) .action(deploy); registerExtras(program); diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 4da7e646..1c41bcce 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,26 +1,22 @@ # Deploy Command -> **Mostly mocked.** Deploy lifecycle endpoints (`validate_cloning`, `production_instance`, `deploy_status`, `ssl_retry`, `mail_retry`) plus the production-instance config PATCH are mocked locally with the exact request/response shapes from the real Platform API, so swapping each to a live call is a one-import change in `commands/deploy/api.ts`. Production-targeted writes have to stay mocked while the production instance itself (`ins_prod_mock`) is a fake. The only real PLAPI call today is `fetchInstanceConfig` against the development instance for OAuth provider discovery. +> **API-resolved state, mocked lifecycle.** Human mode resolves the linked application, production domains, deploy status, and instance config from the API layer on each run. Application/domain/config reads use live PLAPI helpers; production lifecycle calls (`validate_cloning`, `production_instance`, `deploy_status`, `ssl_retry`, `mail_retry`) plus production config PATCH still go through `commands/deploy/api.ts`, where they are mocked with the real Platform API request/response shapes. Guides a user through deploying their Clerk application to production. ## Usage ```sh -clerk deploy # Interactive wizard (human mode) +clerk deploy # Interactive, idempotent wizard (human mode) clerk deploy --debug # With debug output -clerk deploy --continue # Resume a paused deploy operation -clerk deploy --abort # Clear a paused deploy operation after confirmation clerk deploy --mode agent # Output agent prompt instead of interactive flow ``` ## Options -| Flag | Purpose | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--debug` | Show detailed mocked Platform API debug output. | -| `--continue` | Resume the DNS or OAuth step saved in local CLI config. Reports "no paused operation" when none exists; reports a project mismatch when the bookmark belongs to another project. | -| `--abort` | Confirm, then clear the saved paused deploy operation. Reports "no paused operation" when none exists; leaves server-side changes as-is. | +| Flag | Purpose | +| --------- | -------------------------------------------- | +| `--debug` | Show detailed deploy and PLAPI debug output. | ## Agent Mode @@ -40,26 +36,28 @@ Agent mode is detected via the mode system (`src/mode.ts`), which checks in prio 2. `CLERK_MODE` environment variable 3. TTY detection (`process.stdout.isTTY`) -Agent mode does not call PLAPI. It prints `DEPLOY_PROMPT` and exits before the human-mode mocked wizard starts. The prompt currently contains some stale endpoint guidance; see the TODO above `DEPLOY_PROMPT` in `index.ts` and `DEPLOY_MVP_UX_COPY_SPEC.md` §8.3. +Agent mode does not call PLAPI. It prints `DEPLOY_PROMPT` and exits before the human-mode wizard starts. The prompt currently contains some stale endpoint guidance; see the TODO above `DEPLOY_PROMPT` in `index.ts` and `DEPLOY_MVP_UX_COPY_SPEC.md` §8.3. -## Mocked PLAPI Calls +## PLAPI And Mocked Lifecycle -Human mode calls the helpers in `commands/deploy/api.ts`. They use the exact request/response shapes published in the Platform API OpenAPI spec, but the bodies are produced locally rather than sent over the network. Real implementations should replace each helper one at a time without touching the call sites. +Human mode reads deploy state through the API layer: application instances, production domains, development config, production config, and deploy status. It does not write deploy progress to the CLI config profile. The only config compatibility write is the ordinary linked-profile `instances.production` value. -| Step | Endpoint | Mocked behavior | +The production-instance lifecycle still calls the helpers in `commands/deploy/api.ts`. They use the exact request/response shapes published in the Platform API OpenAPI spec, but the bodies are produced locally so the wizard can simulate server-side deploy states while the production-instance backend remains mocked. + +| Step | Endpoint | Mocked state | | -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| Validate cloning | `POST /v1/platform/applications/{appID}/validate_cloning` | Resolves to 204; the helper exists so 402 `UnsupportedSubscriptionPlanFeatures` errors short-circuit before plan confirmation. | +| Validate cloning | `POST /v1/platform/applications/{appID}/validate_cloning` | Resolves to 204; the helper exists so 402 `UnsupportedSubscriptionPlanFeatures` errors can later short-circuit before summary. | | Create production instance | `POST /v1/platform/applications/{appID}/production_instance` | Returns `instance_id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key`, and `cname_targets[]`. | | Poll deploy status | `GET /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Returns `incomplete` for the first two polls per `(appID, instanceID)` pair, then `complete`. CLI polls every 3s. | | Retry SSL provisioning | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | Resolves to 204; helper exposed for use when `deploy_status` stalls. | | Retry mail verification | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | Resolves to 204; helper exposed for use when `deploy_status` stalls. | -| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Resolves to `{}` without hitting the network. Mocked alongside the others while the production instance itself is a fake. | +| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Resolves to `{}` without hitting the network. | -Local paused deploy state is written to the CLI config profile, not PLAPI. `--abort` only clears that local bookmark and does not undo anything already saved to a Clerk production instance. The production `home_url` collected during the wizard lives only on the deploy bookmark (`profile.deploy.domain`); it isn't mirrored onto `profile.instances`, so the bookmark is the single source of truth while the wizard is in flight. Re-running plain `clerk deploy` after the bookmark has been cleared and `instances.production` is set errors with guidance to run `clerk env pull --instance prod` instead. +This keeps `clerk deploy` from drifting away from the server-side source of truth once these endpoints are backed by production data. Each run resolves the current production instance, domain, deploy status, and OAuth config from the API layer, then prints a checked-off plan before completing the next unfinished action. Re-running `clerk deploy` after production is fully configured shows every deploy action checked off and prints production next steps. -Mocked endpoints in `commands/deploy/api.ts` pause for ~2s before returning so spinners and the deploy-status poll feel like real network calls. Real implementations remove the artificial delay. +Mocked lifecycle endpoints in `commands/deploy/api.ts` pause for ~2s before returning so spinners and the deploy-status poll feel like real network calls. -If the user presses Ctrl-C after the production instance has been created, the wizard saves the current DNS or OAuth step as a paused operation, prints the `clerk deploy --continue` recovery command, and exits with SIGINT code 130. Running plain `clerk deploy` while that bookmark exists exits with an error instead of starting another deploy. +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 @@ -133,7 +131,9 @@ All endpoints are on the **Platform API** (`/v1/platform/...`). The "Real" rows | -------------------------- | ------- | ------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | | Auth | n/a | Local config | Real | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | | Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Real | `fetchInstanceConfig` from `lib/plapi.ts`. Used to discover enabled `connection_oauth_*` providers in dev. | -| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Real | `patchInstanceConfig` from `lib/plapi.ts`. Writes production OAuth credentials. | +| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Mock | `patchInstanceConfig` in `commands/deploy/api.ts`. Writes production OAuth credentials once switched to live PLAPI. | +| Read application | `GET` | `/v1/platform/applications/{appID}` | Real | `fetchApplication` from `lib/plapi.ts`. Resolves live development and production instance IDs. | +| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | Real | `listApplicationDomains` from `lib/plapi.ts`. Recovers production domain name and CNAME targets on each run. | | Validate cloning | `POST` | `/v1/platform/applications/{appID}/validate_cloning` | Mock | `validateCloning` in `commands/deploy/api.ts`. Pre-flights subscription/feature support before plan summary. | | Create production instance | `POST` | `/v1/platform/applications/{appID}/production_instance` | Mock | `createProductionInstance` in `commands/deploy/api.ts`. Returns prod instance, primary domain, keys, and `cname_targets[]`. | | Poll deploy status | `GET` | `/v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Mock | `getDeployStatus` in `commands/deploy/api.ts`. CLI polls every 3 seconds while the production instance is provisioning DNS, SSL, and mail. | diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts index 131e20e1..8b4bec1b 100644 --- a/packages/cli-core/src/commands/deploy/api.test.ts +++ b/packages/cli-core/src/commands/deploy/api.test.ts @@ -4,6 +4,8 @@ const mockPlapiCreateProductionInstance = mock(); const mockPlapiValidateCloning = mock(); const mockPlapiGetDeployStatus = mock(); const mockPlapiPatchInstanceConfig = mock(); +const mockPlapiRetryApplicationDomainSSL = mock(); +const mockPlapiRetryApplicationDomainMail = mock(); const mockSleep = mock(); mock.module("../../lib/plapi.ts", () => ({ @@ -11,6 +13,8 @@ mock.module("../../lib/plapi.ts", () => ({ validateCloning: (...args: unknown[]) => mockPlapiValidateCloning(...args), getDeployStatus: (...args: unknown[]) => mockPlapiGetDeployStatus(...args), patchInstanceConfig: (...args: unknown[]) => mockPlapiPatchInstanceConfig(...args), + retryApplicationDomainSSL: (...args: unknown[]) => mockPlapiRetryApplicationDomainSSL(...args), + retryApplicationDomainMail: (...args: unknown[]) => mockPlapiRetryApplicationDomainMail(...args), })); mock.module("../../lib/sleep.ts", () => ({ @@ -40,6 +44,12 @@ describe("deploy api adapter", () => { mockPlapiPatchInstanceConfig.mockImplementation(() => { throw new Error("live patchInstanceConfig should not be called"); }); + mockPlapiRetryApplicationDomainSSL.mockImplementation(() => { + throw new Error("live retryApplicationDomainSSL should not be called"); + }); + mockPlapiRetryApplicationDomainMail.mockImplementation(() => { + throw new Error("live retryApplicationDomainMail should not be called"); + }); mockSleep.mockResolvedValue(undefined); _resetDeployStatusMock(); }); @@ -54,6 +64,7 @@ describe("deploy api adapter", () => { connection_oauth_google: { enabled: true }, }); + expect(production.instance_id).toBe("MOCKED_NOT_REAL_FIXME"); expect(production.active_domain.name).toBe("example.com"); expect(production.cname_targets).toHaveLength(3); expect(mockPlapiCreateProductionInstance).not.toHaveBeenCalled(); @@ -61,7 +72,7 @@ describe("deploy api adapter", () => { expect(mockPlapiPatchInstanceConfig).not.toHaveBeenCalled(); }); - test("mock deploy status progresses without calling live PLAPI", async () => { + test("mock deploy status represents incomplete then complete server state", async () => { expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "incomplete" }); expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "incomplete" }); expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "complete" }); diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts index 39284068..3de5ca2a 100644 --- a/packages/cli-core/src/commands/deploy/api.ts +++ b/packages/cli-core/src/commands/deploy/api.ts @@ -1,10 +1,11 @@ /** * Deploy command API adapter. * - * The live endpoint wrappers live in `lib/plapi.ts`, but the deploy command - * still runs against mocks until the production-instance backend is ready. - * Keep this adapter as the single switch point so the command cannot - * accidentally call unfinished live deploy lifecycle endpoints. + * Live endpoint wrappers live in `lib/plapi.ts`, but the deploy lifecycle + * remains mocked while the production-instance backend settles. Keep this + * adapter as the switch point: the command resolves deploy progress through + * API-shaped calls, while these lifecycle operations simulate backend states + * locally. */ import { sleep } from "../../lib/sleep.ts"; @@ -128,7 +129,6 @@ export const liveDeployApi: DeployApi = { patchInstanceConfig: livePatchInstanceConfig, }; -// FIXME(deploy): switch this to `liveDeployApi` once the backend endpoints are ready. const activeDeployApi: DeployApi = mockDeployApi; export const createProductionInstance = ( diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 1294161f..2a66f1e9 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -1,6 +1,11 @@ -import { cyan, dim, green, red, yellow } from "../../lib/color.ts"; +import { bold, cyan, dim, green, yellow } from "../../lib/color.ts"; import type { CnameTarget } from "./api.ts"; +export type DeployPlanStep = { + label: string; + status: "done" | "pending"; +}; + export const INTRO_PREAMBLE = `This will prepare your linked Clerk app for production by cloning your development instance into a new production instance and walking you through the setup the dashboard would otherwise guide you through. @@ -12,19 +17,19 @@ Before you begin you will need: ${dim("Reference: https://clerk.com/docs/guides/development/deployment/production")}`; -export function printPlan(appLabel: string, oauthProviderLabels: readonly string[]): string[] { +export function printPlan(appLabel: string, steps: readonly DeployPlanStep[]): string[] { return [ `clerk deploy will prepare ${cyan(appLabel)} for production:`, "", - ` ${green("CREATE")} Create production instance`, - ` ${green("DOMAIN")} Choose a production domain you own`, - ` ${green("DNS")} Configure DNS records`, - ...oauthProviderLabels.map( - (label) => ` ${yellow("OAUTH")} Configure ${label} OAuth credentials`, - ), + ...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)}`, @@ -39,6 +44,19 @@ export function dnsIntro(domain: string): string[] { ]; } +export function domainAssociationSummary( + domain: string, + targets: readonly CnameTarget[], +): string[] { + return [ + `Clerk will associate these subdomains with ${cyan(domain)}:`, + "", + ...targets.map((target) => ` ${cnameTargetLabel(target.host)} ${target.host}`), + "", + "This will create a Clerk production instance for your application.", + ]; +} + export function dnsRecords(targets: readonly CnameTarget[]): string[] { const lines = ["Add the following records at your DNS provider:"]; for (const target of targets) { @@ -78,7 +96,7 @@ function cnameTargetLabel(host: string): string { export function dnsDashboardHandoff(domain: string): string[] { return [ `Check the Domains section in the Clerk Dashboard for ${domain} to monitor DNS propagation and SSL issuance.`, - "You can continue to the remaining setup now, or pause and run `clerk deploy --continue` later.", + "You can continue to the remaining setup now, or pause and run `clerk deploy` again later.", ]; } @@ -86,7 +104,7 @@ export function dnsVerified(domain: string): string[] { return [`DNS verified for ${domain}.`]; } -export const OAUTH_SECTION_INTRO = `Configure OAuth credentials for production +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 @@ -97,16 +115,17 @@ ${dim("Reference: https://clerk.com/docs/guides/configure/auth-strategies/social export function productionSummary( domain: string, completedOAuthProviderLabels: readonly string[], + domainStatus: "verified" | "pending" = "verified", ): string[] { return [ `Production ready at ${cyan(`https://${domain}`)}`, "", - " Domain Verified", + ` Domain ${domainStatus === "verified" ? "Verified" : "DNS pending"}`, ` OAuth ${completedOAuthProviderLabels.length ? completedOAuthProviderLabels.join(", ") : "Not applicable"}`, ]; } -export const NEXT_STEPS_BLOCK = `Next steps +export const NEXT_STEPS_BLOCK = `${bold("Next steps")} 1. Pull production keys into your environment clerk env pull --instance prod @@ -137,18 +156,8 @@ export function pausedMessage(stepDescription: string): string { ${pausedOperationNotice()}`; } -export function activeDeployInProgressMessage(stepDescription: string): string { - return `There is an active deploy in progress at: ${stepDescription} - -Use \`clerk deploy --continue\` to resume it, or \`clerk deploy --abort\` to clear it.`; -} - export function pausedOperationNotice(): string { return `Deploy paused. -Use \`clerk deploy --continue\` to resume it, or \`clerk deploy --abort\` to clear it.`; +Run \`clerk deploy\` again to continue from the current API state.`; } - -export const INVALID_CONTINUE_MESSAGE = `${red("The paused deploy operation no longer matches this linked project.")} -Run \`clerk deploy\` from the project that started the paused operation, or run -\`clerk link\` if you intend to deploy this one.`; diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index dfa23ab2..e3058d2a 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -25,6 +25,8 @@ 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(); @@ -51,6 +53,8 @@ mock.module("../../lib/listage.ts", () => ({ mock.module("../../lib/plapi.ts", () => ({ fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), + fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), })); mock.module("./api.ts", () => ({ @@ -72,6 +76,7 @@ mock.module("../../lib/sleep.ts", () => ({ 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"), ""); @@ -96,6 +101,41 @@ describe("deploy", () => { mockFetchInstanceConfig.mockResolvedValue({ connection_oauth_google: { enabled: true }, }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_xyz789", + name: "my-saas-app", + instances: [ + { + instance_id: "ins_dev_123", + environment_type: "development", + publishable_key: "pk_test_123", + }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_prod_mock", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [ + { + host: "clerk.example.com", + value: "frontend-api.clerk.services", + required: true, + }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }, + ], + total_count: 1, + }); mockValidateCloning.mockResolvedValue(undefined); mockGetDeployStatus.mockResolvedValue({ status: "complete" }); mockCreateProductionInstance.mockImplementation( @@ -141,6 +181,8 @@ describe("deploy", () => { mockPassword.mockReset(); mockPatchInstanceConfig.mockReset(); mockFetchInstanceConfig.mockReset(); + mockFetchApplication.mockReset(); + mockListApplicationDomains.mockReset(); mockCreateProductionInstance.mockReset(); mockValidateCloning.mockReset(); mockGetDeployStatus.mockReset(); @@ -158,15 +200,133 @@ describe("deploy", () => { async function linkedProject(profile: Record = {}) { tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-")); _setConfigDir(tempDir); - await setProfile(process.cwd(), { + const nextProfile = { workspaceId: "workspace_123", appId: "app_xyz789", appName: "my-saas-app", instances: { development: "ins_dev_123" }, ...profile, - } as never); + } as never; + await setProfile(process.cwd(), nextProfile); + + const typedProfile = nextProfile as { + instances: { production?: string }; + }; + const productionInstanceId = typedProfile.instances.production; + if (productionInstanceId) { + mockLiveProduction({ + instanceId: productionInstanceId, + domain: "example.com", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); + } + } + + function mockLiveProduction( + options: { + instanceId?: string; + domain?: string; + domainId?: string; + productionConfig?: Record; + developmentConfig?: Record; + } = {}, + ) { + const instanceId = options.instanceId ?? "ins_prod_mock"; + const domain = options.domain ?? "example.com"; + const domainId = options.domainId ?? "dmn_prod_mock"; + const developmentConfig = options.developmentConfig ?? { + connection_oauth_google: { enabled: true }, + }; + const productionConfig = options.productionConfig ?? { + connection_oauth_google: { enabled: false, client_id: "", client_secret: "" }, + }; + + mockFetchApplication.mockResolvedValue({ + application_id: "app_xyz789", + name: "my-saas-app", + instances: [ + { + instance_id: "ins_dev_123", + environment_type: "development", + publishable_key: "pk_test_123", + }, + { + instance_id: instanceId, + environment_type: "production", + publishable_key: "pk_live_123", + }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: domainId, + name: domain, + is_satellite: false, + is_provider_domain: false, + frontend_api_url: `https://clerk.${domain}`, + accounts_portal_url: `https://accounts.${domain}`, + development_origin: "", + cname_targets: [ + { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }, + ], + total_count: 1, + }); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceIdOrEnv: string) => { + if (instanceIdOrEnv === instanceId || instanceIdOrEnv === "production") { + return productionConfig; + } + return developmentConfig; + }); } + test("provider setup intro includes docs-backed copy for each OAuth provider", () => { + const intros = { + google: providerSetupIntro("google").map(stripAnsi), + github: providerSetupIntro("github").map(stripAnsi), + microsoft: providerSetupIntro("microsoft").map(stripAnsi), + apple: providerSetupIntro("apple").map(stripAnsi), + linear: providerSetupIntro("linear").map(stripAnsi), + }; + + expect(intros.google).toEqual([ + "Configure Google OAuth for production", + "Production Google sign-in requires custom OAuth credentials from Google Cloud Console.", + "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/google", + ]); + expect(intros.github).toEqual([ + "Configure GitHub OAuth for production", + "Production GitHub sign-in requires a GitHub OAuth app and custom credentials.", + "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/github", + ]); + expect(intros.microsoft).toEqual([ + "Configure Microsoft OAuth for production", + "Production Microsoft sign-in requires a Microsoft Entra ID app and custom credentials.", + "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/microsoft", + ]); + expect(intros.apple).toEqual([ + "Configure Apple OAuth for production", + "Production Apple sign-in requires an Apple Services ID, Team ID, Key ID, and private key file.", + "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/apple", + ]); + expect(intros.linear).toEqual([ + "Configure Linear OAuth for production", + "Production Linear sign-in requires a Linear OAuth app and custom credentials.", + "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/linear", + ]); + }); + describe("agent mode", () => { test("outputs deploy prompt and returns", async () => { mockIsAgent.mockReturnValue(true); @@ -234,13 +394,17 @@ describe("deploy", () => { function mockHumanFlow() { mockIsAgent.mockReturnValue(false); // Proceed → pause after DNS handoff. - mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); mockInput.mockResolvedValueOnce("example.com"); } async function runDnsHandoff() { mockHumanFlow(); await runDeploy({}); + mockLiveProduction(); captured = captureLog(); mockConfirm.mockReset(); mockSelect.mockReset(); @@ -249,7 +413,6 @@ describe("deploy", () => { } function mockOAuthCompletion() { - mockConfirm.mockResolvedValueOnce(true); mockSelect.mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("fake-client-id-12345"); mockPassword.mockResolvedValueOnce("fake-secret"); @@ -277,6 +440,23 @@ describe("deploy", () => { }); }); + 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(); @@ -304,6 +484,7 @@ describe("deploy", () => { // Proceed → continue after DNS handoff → complete OAuth. mockIsAgent.mockReturnValue(false); mockConfirm + .mockResolvedValueOnce(true) .mockResolvedValueOnce(true) .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); @@ -335,8 +516,9 @@ describe("deploy", () => { 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 Google OAuth credentials"); + 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"); }); @@ -378,7 +560,6 @@ describe("deploy", () => { await expect(runDeploy({})).rejects.toBeInstanceOf(UserAbortError); const config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined(); const terminalOutput = stderrSpy.mock.calls .map((call: unknown[]) => String(call[0])) @@ -398,7 +579,6 @@ describe("deploy", () => { await expect(runDeploy({})).rejects.toBeInstanceOf(UserAbortError); const config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined(); const terminalOutput = stderrSpy.mock.calls .map((call: unknown[]) => String(call[0])) @@ -413,7 +593,7 @@ describe("deploy", () => { await runDnsHandoff(); mockOAuthCompletion(); - await runDeploy({ continue: true }); + await runDeploy({}); const err = stripAnsi(captured.err); expect(err).toContain("Next steps"); @@ -425,23 +605,28 @@ describe("deploy", () => { test("DNS setup prints dashboard handoff and asks before continuing", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); mockInput.mockResolvedValueOnce("example.com"); await runDeploy({}); const err = stripAnsi(captured.err); - const config = await readConfig(); - - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - pending: { type: "dns" }, - domain: "example.com", - }); + 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("clerk deploy --continue"); - expect(err).toContain("clerk deploy --abort"); - expect(mockConfirm).toHaveBeenCalledTimes(2); + expect(err).toContain("run `clerk deploy` again later"); + expect(mockConfirm).toHaveBeenCalledTimes(3); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Create production instance?", + default: true, + }); expect(mockConfirm).toHaveBeenCalledWith({ message: "Continue to OAuth setup?", default: true, @@ -456,10 +641,31 @@ describe("deploy", () => { }); }); - test("Ctrl-C at the DNS handoff saves state and reports paused", async () => { + 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).mockRejectedValueOnce(promptExitError()); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(promptExitError()); mockInput.mockResolvedValueOnce("example.com"); stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); @@ -469,18 +675,8 @@ describe("deploy", () => { } catch (caught) { error = caught as CliError; } - - const config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_mock", - domain: "example.com", - pending: { type: "dns" }, - }); expect(error?.message).toContain("Deploy paused at: DNS verification"); - expect(error?.message).toContain("clerk deploy --continue"); - expect(error?.message).toContain("clerk deploy --abort"); + 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])) @@ -507,7 +703,7 @@ describe("deploy", () => { mockConfirm.mockResolvedValueOnce(true); mockSelect.mockResolvedValueOnce("google-json"); mockInput.mockResolvedValueOnce(googleJsonPath); - await runDeploy({ continue: true }); + 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 }> }; @@ -523,14 +719,20 @@ describe("deploy", () => { test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_apple" }, - deploy: { - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_apple", - domain: "example.com", - pending: { type: "oauth", provider: "apple" }, - oauthProviders: ["apple"], - completedOAuthProviders: [], + }); + 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); @@ -552,7 +754,7 @@ describe("deploy", () => { .mockResolvedValueOnce(validP8Path); mockPatchInstanceConfig.mockResolvedValueOnce({}); - await runDeploy({ continue: true }); + await runDeploy({}); const p8Input = mockInput.mock.calls.find((call) => String((call[0] as { message?: string }).message).includes("Apple Private Key"), @@ -585,7 +787,7 @@ describe("deploy", () => { mockConfirm.mockResolvedValueOnce(true); mockSelect.mockResolvedValueOnce("google-json"); mockInput.mockResolvedValueOnce(googleJsonPath); - await runDeploy({ continue: true }); + await runDeploy({}); const jsonInput = mockInput.mock.calls.find((call) => String((call[0] as { message?: string }).message).includes("Google OAuth JSON file path"), @@ -599,133 +801,136 @@ describe("deploy", () => { await expect(jsonInput.validate(relativeJsonPath)).resolves.toBe(true); }); - test("plain deploy errors when a production instance is already linked", async () => { - await linkedProject({ - instances: { development: "ins_dev_123", production: "ins_prod_123" }, + 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); - let error: CliError | undefined; - try { - await runDeploy({}); - } catch (caught) { - error = caught as CliError; - } + await runDeploy({}); + const err = stripAnsi(captured.err); - expect(error?.message).toContain("This app already has a production instance configured"); - expect(error?.message).toContain("clerk env pull --instance prod"); - expect(error?.message).toContain("clerk deploy --continue"); + 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 errors while a deploy operation is paused", async () => { - await linkedProject({ - deploy: { - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_123", - domain: "example.com", - pending: { type: "dns" }, - oauthProviders: ["google"], - completedOAuthProviders: [], - }, - }); + test("--test-force-production-instance makes app retrieval include mocked production", async () => { + await linkedProject(); mockIsAgent.mockReturnValue(false); + mockSelect.mockResolvedValueOnce("skip"); + mockListApplicationDomains.mockRejectedValueOnce( + new Error("domains should be mocked when forcing production"), + ); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceIdOrEnv: string) => { + if (instanceIdOrEnv === "ins_prod_mock") { + throw new Error("production config should be mocked when forcing production"); + } + return { connection_oauth_google: { enabled: true } }; + }); - let error: CliError | undefined; - try { - await runDeploy({}); - } catch (caught) { - error = caught as CliError; - } + await runDeploy({ testForceProductionInstance: true }); + const err = stripAnsi(captured.err); - expect(error?.message).toContain("There is an active deploy in progress"); - expect(error?.message).toContain("Use `clerk deploy --continue`"); - expect(error?.message).toContain("DNS verification"); - expect(error?.exitCode).toBe(EXIT_CODE.GENERAL); - expect(mockSelect).not.toHaveBeenCalled(); - expect(mockInput).not.toHaveBeenCalled(); + expect(err).toContain("[x] Create production instance"); + expect(err).toContain("Use production domain example.com"); + expect(mockCreateProductionInstance).not.toHaveBeenCalled(); + expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789"); + expect(mockListApplicationDomains).not.toHaveBeenCalled(); + expect(mockFetchInstanceConfig).not.toHaveBeenCalledWith("app_xyz789", "ins_prod_mock"); }); - test("DNS handoff saves DNS state and reports --continue", async () => { + test("--test-fail-production-instance-check simulates production instance lookup failure", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); - mockInput.mockResolvedValueOnce("example.com"); - await runDeploy({}); - const err = stripAnsi(captured.err); + await expect(runDeploy({ testFailProductionInstanceCheck: true })).rejects.toThrow( + "Simulated deploy failure: production instance check.", + ); - const config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_mock", - domain: "example.com", - pending: { type: "dns" }, + expect(mockFetchApplication).not.toHaveBeenCalled(); + expect(mockFetchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("--test-fail-domain-lookup simulates production domain lookup failure", async () => { + await linkedProject(); + mockLiveProduction({ + instanceId: "ins_prod_from_api", + productionConfig: {}, }); - expect(err).toContain("Check the Domains section in the Clerk Dashboard"); - expect(err).toContain("clerk deploy --continue"); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({ testFailDomainLookup: true })).rejects.toThrow( + "Simulated deploy failure: production domain lookup.", + ); + + expect(mockListApplicationDomains).not.toHaveBeenCalled(); }); - test("Ctrl-C during OAuth setup saves provider state and reports --continue", async () => { + test("--test-fail-validate-cloning simulates cloning validation failure", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - await runDnsHandoff(); - mockConfirm.mockRejectedValueOnce(promptExitError()); - stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); - let error: CliError | undefined; - try { - await runDeploy({ continue: true }); - } catch (caught) { - error = caught as CliError; - } + await expect(runDeploy({ testFailValidateCloning: true })).rejects.toThrow( + "Simulated deploy failure: cloning validation.", + ); - const config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_mock", - domain: "example.com", - pending: { type: "oauth", provider: "google" }, - }); - expect(error?.message).toContain("Deploy paused at: Google OAuth credential setup"); - expect(error?.message).toContain("clerk deploy --continue"); - expect(error?.message).toContain("clerk deploy --abort"); - 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).toContain("\x1b[33m└"); - expect(terminalOutput).not.toContain("Done"); + expect(mockValidateCloning).not.toHaveBeenCalled(); + expect(mockCreateProductionInstance).not.toHaveBeenCalled(); }); - test("saves OAuth credentials to the production instance from deploy state", async () => { - await linkedProject({ - instances: { development: "ins_dev_123", production: "ins_prod_created_456" }, - deploy: { - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_created_456", - domain: "example.com", - pending: { type: "oauth", provider: "google" }, - oauthProviders: ["google"], - completedOAuthProviders: [], - }, - }); + test("--test-fail-create-production-instance simulates production creation failure", async () => { + await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("have-credentials"); - mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("example.com"); + + await expect(runDeploy({ testFailCreateProductionInstance: true })).rejects.toThrow( + "Simulated deploy failure: production instance creation.", + ); + + expect(mockCreateProductionInstance).not.toHaveBeenCalled(); + }); + + test("--test-fail-dns-verification simulates DNS verification failure", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - await runDeploy({ continue: true }); + await runDeploy({ testFailDnsVerification: true }); + const err = stripAnsi(captured.err); - expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_created_456", { + expect(mockGetDeployStatus).not.toHaveBeenCalled(); + expect(err).toContain("DNS propagation can take time"); + expect(err).toContain("Add the following records at your DNS provider:"); + 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", @@ -734,153 +939,215 @@ describe("deploy", () => { }); }); - test("--continue reports when there is no paused deploy operation", async () => { - await linkedProject(); + test("--test-fail-oauth-save simulates OAuth credential save failure", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: {}, + }); mockIsAgent.mockReturnValue(false); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockConfirm.mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); - await runDeploy({ continue: true }); + await expect(runDeploy({ testFailOAuthSave: true })).rejects.toThrow( + "Simulated deploy failure: OAuth credential save.", + ); - expect(captured.err).toContain("There is no paused deploy operation"); - expect(mockSelect).not.toHaveBeenCalled(); - expect(mockInput).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); }); - test("--abort reports when there is no paused deploy operation", async () => { - await linkedProject(); + test("plain deploy resumes DNS verification from live API state", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: {}, + }); + mockGetDeployStatus + .mockResolvedValueOnce({ status: "incomplete" }) + .mockResolvedValueOnce({ status: "complete" }); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); - await runDeploy({ abort: true }); + await runDeploy({}); + const err = stripAnsi(captured.err); - expect(captured.err).toContain("There is no paused deploy operation"); - expect(mockConfirm).not.toHaveBeenCalled(); - expect(mockSelect).not.toHaveBeenCalled(); - expect(mockInput).not.toHaveBeenCalled(); + expect(err).toContain("[x] Create production instance"); + expect(err).toContain("[ ] Configure DNS records"); + expect(err).toContain("[ ] Configure Google OAuth credentials"); + expect(err).toContain("DNS verified for example.com"); + expect(mockSelect).toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], + }); + const firstInput = mockInput.mock.calls[0]?.[0] as { message?: string } | undefined; + expect(String(firstInput?.message)).not.toContain("Production domain"); }); - test("--abort asks for confirmation and clears paused deploy state", async () => { + test("plain deploy can skip DNS verification and continue configuring production", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, - deploy: { - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_123", - domain: "example.com", - pending: { type: "dns" }, - oauthProviders: ["google"], - completedOAuthProviders: [], - }, }); mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: {}, + }); + mockGetDeployStatus.mockResolvedValue({ status: "incomplete" }); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); mockConfirm.mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); - await runDeploy({ abort: true }); - - const config = await readConfig(); + await runDeploy({}); const err = stripAnsi(captured.err); - expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); - expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_123"); - expect(mockConfirm).toHaveBeenCalledWith({ - message: "Abort the paused deploy operation?", - default: false, - }); - expect(err).toContain("Cleared the paused deploy bookmark"); - expect(err).toContain("does not undo any changes already saved"); - expect(err).not.toContain("rerun `clerk deploy`"); - expect(mockSelect).not.toHaveBeenCalled(); - expect(mockInput).not.toHaveBeenCalled(); - }); - test("--abort keeps paused deploy state when confirmation is declined", async () => { - await linkedProject({ - deploy: { - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_123", - domain: "example.com", - pending: { type: "dns" }, - oauthProviders: ["google"], - completedOAuthProviders: [], + expect(err).toContain("Saved Google OAuth credentials"); + expect(err).toContain("Domain DNS pending"); + expect(err).not.toContain("Domain Verified"); + expect(mockSelect).toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], + }); + expect(mockGetDeployStatus).toHaveBeenCalledTimes(1); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_123", { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "google-secret", }, }); + }); + + test("DNS handoff reports plain deploy for later continuation", async () => { + await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + mockInput.mockResolvedValueOnce("example.com"); - await runDeploy({ abort: true }); + await runDeploy({}); + const err = stripAnsi(captured.err); + expect(err).toContain("Check the Domains section in the Clerk Dashboard"); + expect(err).toContain("run `clerk deploy` again later"); + }); - const config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - appId: "app_xyz789", - domain: "example.com", - pending: { type: "dns" }, - }); - expect(captured.err).toContain("Paused deploy abort cancelled"); - expect(captured.err).toContain("clerk deploy --continue"); - expect(captured.err).toContain("clerk deploy --abort"); - expect(mockSelect).not.toHaveBeenCalled(); - expect(mockInput).not.toHaveBeenCalled(); + 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).toContain("\x1b[33m└"); + expect(terminalOutput).not.toContain("Done"); }); - test("rejects --continue and --abort together", async () => { + test("saves OAuth credentials to the production instance from live deploy state", async () => { await linkedProject({ - deploy: { - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_123", - domain: "example.com", - pending: { type: "dns" }, - oauthProviders: ["google"], - completedOAuthProviders: [], - }, + instances: { development: "ins_dev_123", production: "ins_prod_created_456" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_created_456", + productionConfig: {}, }); mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetDeployStatus.mockReset(); + mockGetDeployStatus + .mockResolvedValueOnce({ status: "incomplete" }) + .mockResolvedValueOnce({ status: "complete" }); + + await runDeploy({}); - await expect(runDeploy({ continue: true, abort: true })).rejects.toThrow( - "Cannot use --continue and --abort together", + 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(mockConfirm).not.toHaveBeenCalled(); - expect(mockSelect).not.toHaveBeenCalled(); - expect(mockInput).not.toHaveBeenCalled(); + 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("--continue reports invalid paused state with recovery guidance", async () => { + test("plain deploy resolves complete live API state without prompting", async () => { await linkedProject({ - deploy: { - appId: "other_app", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_123", - domain: "example.com", - pending: { type: "dns" }, - oauthProviders: ["google"], - completedOAuthProviders: [], - }, + instances: { development: "ins_dev_123", production: "ins_prod_123" }, }); mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); - await runDeploy({ continue: true }); + await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("The paused deploy operation no longer matches this linked project"); - expect(err).toContain( - "Run `clerk deploy` from the project that started the paused operation", - ); + expect(err).toContain("[x] Create production instance"); + expect(err).toContain("[x] Configure DNS records"); + expect(err).toContain("No deploy actions remain."); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); }); test("custom-domain DNS setup can pause and later resume", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); mockInput.mockResolvedValueOnce("example.com"); await runDeploy({}); - - let config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - appId: "app_xyz789", - developmentInstanceId: "ins_dev_123", - productionInstanceId: "ins_prod_mock", - domain: "example.com", - pending: { type: "dns" }, - }); + mockLiveProduction(); expect(stripAnsi(captured.err)).toContain("Check the Domains section in the Clerk Dashboard"); captured = captureLog(); @@ -888,17 +1155,20 @@ describe("deploy", () => { mockSelect.mockReset(); mockInput.mockReset(); mockPassword.mockReset(); - mockConfirm.mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("have-credentials"); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetDeployStatus.mockReset(); + mockGetDeployStatus + .mockResolvedValueOnce({ status: "incomplete" }) + .mockResolvedValueOnce({ status: "complete" }); - await runDeploy({ continue: true }); + await runDeploy({}); const err = stripAnsi(captured.err); - config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); + 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: { @@ -918,66 +1188,64 @@ describe("deploy", () => { await linkedProject(); mockIsAgent.mockReturnValue(false); await runDnsHandoff(); - mockConfirm.mockResolvedValueOnce(false); + mockSelect.mockResolvedValueOnce("skip"); - await runDeploy({ continue: true }); - - let config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - pending: { type: "oauth", provider: "google" }, - domain: "example.com", - }); - expect(captured.err).toContain("Deploy paused"); - expect(captured.err).toContain("clerk deploy --continue"); - expect(captured.err).toContain("clerk deploy --abort"); + 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(); - mockConfirm.mockResolvedValueOnce(true); mockSelect.mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); - await runDeploy({ continue: true }); + await runDeploy({}); const err = stripAnsi(captured.err); - config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); + 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 preserves earlier completed providers in saved state", async () => { + 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 → continue after DNS → setup google now → enter google creds → say no on github. + // Proceed → create prod → continue after DNS → enter google creds → skip github. mockConfirm .mockResolvedValueOnce(true) .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); + .mockResolvedValueOnce(true); mockInput .mockResolvedValueOnce("example.com") .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); - mockSelect.mockResolvedValueOnce("have-credentials"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("skip"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); await runDeploy({}); - - let config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - pending: { type: "oauth", provider: "github" }, - completedOAuthProviders: ["google"], - oauthProviders: ["google", "github"], + 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. @@ -987,17 +1255,13 @@ describe("deploy", () => { mockInput.mockReset(); mockPassword.mockReset(); mockPatchInstanceConfig.mockReset(); - mockConfirm.mockResolvedValueOnce(true); mockSelect.mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("github-client-id"); mockPassword.mockResolvedValueOnce("github-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - await runDeploy({ continue: true }); + await runDeploy({}); const err = stripAnsi(captured.err); - - config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toBeUndefined(); expect(mockPatchInstanceConfig).toHaveBeenCalledTimes(1); expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { connection_oauth_github: { @@ -1009,26 +1273,84 @@ describe("deploy", () => { expect(err).toContain("Production ready at https://example.com"); }); - test("DNS verification timeout outros as paused, not failed", async () => { + 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); - mockInput.mockResolvedValueOnce("example.com"); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); mockGetDeployStatus.mockResolvedValue({ status: "incomplete" }); - stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); await runDeploy({}); - - const config = await readConfig(); - expect(config.profiles[process.cwd()]?.deploy).toMatchObject({ - pending: { type: "dns" }, - domain: "example.com", + const err = stripAnsi(captured.err); + expect(err).toContain("DNS propagation can take time"); + expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(2); + expect(err).toContain("Host: clerk.example.com"); + expect(err).toContain("Value: frontend-api.clerk.services"); + expect(err).toContain("Skipping DNS verification for now."); + expect(err).toContain("Saved Google OAuth credentials"); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "google-secret", + }, }); - const terminalOutput = stderrSpy.mock.calls - .map((call: unknown[]) => String(call[0])) - .join(""); - expect(terminalOutput).toContain("Paused"); - expect(terminalOutput).not.toContain("Failed"); }); test("warns about enabled OAuth providers not yet supported by clerk deploy", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index d6b8219f..e7aa843b 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,13 +1,17 @@ import { isAgent } from "../../mode.ts"; -import { dim } from "../../lib/color.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; -import { confirm } from "../../lib/prompts.ts"; import { isInsideGutter, log, setPrefixTone, type PrefixTone } from "../../lib/log.ts"; import { sleep } from "../../lib/sleep.ts"; import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; import { CliError, UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; -import { resolveProfile, setProfile, type DeployOperationState } from "../../lib/config.ts"; -import { fetchInstanceConfig } from "../../lib/plapi.ts"; +import { resolveProfile, setProfile } from "../../lib/config.ts"; +import { + type Application, + fetchApplication, + fetchInstanceConfig, + listApplicationDomains, + type ApplicationDomain, +} from "../../lib/plapi.ts"; import { createProductionInstance as apiCreateProductionInstance, getDeployStatus, @@ -19,9 +23,10 @@ import { import { domainConnectUrl } from "./domain-connect.ts"; import { INTRO_PREAMBLE, - INVALID_CONTINUE_MESSAGE, NEXT_STEPS_BLOCK, OAUTH_SECTION_INTRO, + type DeployPlanStep, + domainAssociationSummary, dnsDashboardHandoff, dnsIntro, dnsRecords, @@ -32,24 +37,26 @@ import { } from "./copy.ts"; import { PROVIDER_LABELS, + PROVIDER_FIELDS, providerLabel, + providerSetupIntro, showOAuthWalkthrough, type OAuthProvider, } from "./providers.ts"; import { + chooseDnsVerificationAction, chooseOAuthCredentialAction, collectCustomDomain, collectOAuthCredentials, confirmContinueAfterDnsHandoff, - confirmOAuthSetupNow, + confirmCreateProductionInstance, confirmProceed, } from "./prompts.ts"; import { DeployPausedError, - activeDeployInProgressError, deployPausedError, - isDeployStateValid, type DeployContext, + type DeployOperationState, } from "./state.ts"; // TODO(deploy): rewrite to match the human flow described in @@ -131,18 +138,19 @@ Refer to the Clerk Platform API docs for detailed request/response schemas.`; type DeployOptions = { debug?: boolean; - continue?: boolean; - abort?: boolean; + testForceProductionInstance?: boolean; + testFailProductionInstanceCheck?: boolean; + testFailDomainLookup?: boolean; + testFailValidateCloning?: boolean; + testFailCreateProductionInstance?: boolean; + testFailDnsVerification?: boolean; + testFailOAuthSave?: boolean; }; const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000; const DEPLOY_STATUS_MAX_POLLS = 100; export async function deploy(options: DeployOptions = {}) { - if (options.continue && options.abort) { - throwUsageError("Cannot use --continue and --abort together."); - } - if (isAgent()) { log.data(DEPLOY_PROMPT); return; @@ -154,23 +162,8 @@ export async function deploy(options: DeployOptions = {}) { intro("clerk deploy", { tone: "active" }); try { - const ctx = await resolveDeployContext(); - - if (options.continue) { - await continueDeploy(ctx); - return; - } - - if (options.abort) { - await abortDeploy(ctx); - return; - } - - if (ctx.profile.deploy) { - throw activeDeployInProgressError(ctx.profile.deploy); - } - - await startDeploy(ctx); + const ctx = await resolveDeployContext(options); + await runDeploy(ctx); } catch (error) { if (error instanceof DeployPausedError && isInsideGutter()) { closeDeployGutter("error", "Paused"); @@ -194,34 +187,109 @@ function closeDeployGutter(tone: PrefixTone, messageOrSteps: string | readonly s outro(messageOrSteps); } -async function resolveDeployContext(): Promise { - return withSpinner("Resolving linked Clerk application...", async () => { - const resolved = await resolveProfile(process.cwd()); - if (!resolved) { - return { - profileKey: process.cwd(), - profile: { - workspaceId: "", - appId: "", - instances: { development: "" }, - }, - appId: "", - appLabel: "", - developmentInstanceId: "", - }; - } - +async function resolveDeployContext(options: DeployOptions): Promise { + const testFlags = resolveTestDeployFlags(options); + const resolved = await withSpinner("Resolving linked Clerk application...", () => + resolveProfile(process.cwd()), + ); + if (!resolved) { return { - profileKey: resolved.path, - profile: resolved.profile, - appId: resolved.profile.appId, - appLabel: resolved.profile.appName || resolved.profile.appId, - developmentInstanceId: resolved.profile.instances.development, + profileKey: process.cwd(), + profile: { + workspaceId: "", + appId: "", + instances: { development: "" }, + }, + appId: "", + appLabel: "", + developmentInstanceId: "", + ...testFlags, }; - }); + } + + return { + profileKey: resolved.path, + profile: resolved.profile, + ...testFlags, + ...(await withSpinner("Checking for production instance...", () => { + if (testFlags.testFailProductionInstanceCheck) { + throw testDeployFailure("production instance check"); + } + return resolveLiveApplicationContext(resolved.profile, { + forceMockProductionInstance: testFlags.testForceProductionInstance, + }); + })), + }; } -async function startDeploy(ctx: DeployContext): Promise { +function resolveTestDeployFlags( + options: DeployOptions, +): Pick< + DeployContext, + | "testForceProductionInstance" + | "testFailProductionInstanceCheck" + | "testFailDomainLookup" + | "testFailValidateCloning" + | "testFailCreateProductionInstance" + | "testFailDnsVerification" + | "testFailOAuthSave" +> { + return { + testForceProductionInstance: options.testForceProductionInstance === true, + testFailProductionInstanceCheck: options.testFailProductionInstanceCheck === true, + testFailDomainLookup: options.testFailDomainLookup === true, + testFailValidateCloning: options.testFailValidateCloning === true, + testFailCreateProductionInstance: options.testFailCreateProductionInstance === true, + testFailDnsVerification: options.testFailDnsVerification === true, + testFailOAuthSave: options.testFailOAuthSave === true, + }; +} + +function testDeployFailure(step: string): CliError { + return new CliError(`Simulated deploy failure: ${step}.`); +} + +async function resolveLiveApplicationContext( + profile: DeployContext["profile"], + options: { forceMockProductionInstance?: boolean } = {}, +): Promise<{ + appId: string; + appLabel: string; + developmentInstanceId: string; + productionInstanceId?: string; +}> { + const fetchedApp = await fetchApplication(profile.appId); + const app = options.forceMockProductionInstance + ? withMockProductionInstance(fetchedApp) + : fetchedApp; + const development = app.instances.find((entry) => entry.environment_type === "development"); + const production = app.instances.find((entry) => entry.environment_type === "production"); + return { + appId: app.application_id, + appLabel: app.name || profile.appName || app.application_id, + developmentInstanceId: development?.instance_id ?? profile.instances.development, + productionInstanceId: production?.instance_id, + }; +} + +function withMockProductionInstance(app: Application): Application { + if (app.instances.some((entry) => entry.environment_type === "production")) { + return app; + } + return { + ...app, + instances: [ + ...app.instances, + { + instance_id: "ins_prod_mock", + environment_type: "production", + publishable_key: "pk_live_test", + }, + ], + }; +} + +async function runDeploy(ctx: DeployContext): Promise { if (!ctx.appId || !ctx.developmentInstanceId) { log.blank(); log.warn( @@ -232,13 +300,15 @@ async function startDeploy(ctx: DeployContext): Promise { return; } - if (ctx.profile.instances.production) { - throw new CliError( - "This app already has a production instance configured. " + - "Run `clerk env pull --instance prod` to pull production keys, or finish any pending steps with `clerk deploy --continue`.", - ); + if (ctx.productionInstanceId) { + await reconcileExistingDeploy(ctx); + return; } + await startNewDeploy(ctx); +} + +async function startNewDeploy(ctx: DeployContext): Promise { const { known: oauthProviders, unknown: unknownOAuthProviders } = await loadDevelopmentOAuthProviders(ctx); @@ -247,10 +317,7 @@ async function startDeploy(ctx: DeployContext): Promise { log.blank(); log.info(INTRO_PREAMBLE); log.blank(); - for (const line of printPlan( - ctx.appLabel, - oauthProviders.map((provider) => PROVIDER_LABELS[provider]), - )) { + for (const line of printPlan(ctx.appLabel, buildNewDeployPlan(oauthProviders))) { log.info(line); } log.blank(); @@ -274,13 +341,20 @@ async function startDeploy(ctx: DeployContext): Promise { bar(); const domain = await collectCustomDomain(); + const plannedCnameTargets = plannedProductionCnameTargets(domain); + const shouldCreateProductionInstance = await confirmProductionInstanceCreation( + domain, + plannedCnameTargets, + ); + if (!shouldCreateProductionInstance) return; + const production = await createProductionInstance(ctx, domain); await persistProductionInstance(ctx, production.instance_id); log.blank(); const productionDomain = production.active_domain.name; let completedOAuthProviders: OAuthProvider[] = []; - const dnsDone = await runDnsSetup( + const dnsStatus = await runDnsSetup( ctx, { appId: ctx.appId, @@ -294,7 +368,7 @@ async function startDeploy(ctx: DeployContext): Promise { }, production.cname_targets, ); - if (!dnsDone) return; + if (!dnsStatus) return; bar(); completedOAuthProviders = await runOAuthSetup(ctx, { @@ -309,86 +383,84 @@ async function startDeploy(ctx: DeployContext): Promise { }); if (completedOAuthProviders.length < oauthProviders.length) return; - await finishDeploy(ctx, productionDomain, completedOAuthProviders); + await finishDeploy(ctx, productionDomain, completedOAuthProviders, dnsStatus); } -async function continueDeploy(ctx: DeployContext): Promise { - const state = ctx.profile.deploy; - if (!state) { +async function reconcileExistingDeploy(ctx: DeployContext): Promise { + const snapshot = await resolveLiveDeploySnapshot(ctx); + if (!snapshot) { log.blank(); - log.info("There is no paused deploy operation to continue."); - log.info("Run `clerk deploy` to start one."); + log.info("A production instance exists, but Clerk did not return a production domain yet."); + log.info("Run `clerk deploy` again after the domain is available from the API."); log.blank(); - closeDeployGutter("neutral", "Nothing to continue"); + closeDeployGutter("neutral", "No deploy actions available"); return; } - if (!isDeployStateValid(ctx, state)) { - log.blank(); - log.warn(INVALID_CONTINUE_MESSAGE); - log.blank(); - closeDeployGutter("error", "Cannot continue"); + log.blank(); + for (const line of printPlan(ctx.appLabel, buildLiveDeployPlan(snapshot))) { + log.info(line); + } + log.blank(); + + if (!snapshot.pending) { + log.info("No deploy actions remain."); + await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, "verified"); return; } - if (state.pending.type === "dns") { - const dnsDone = await runDnsVerification(ctx, state); - if (!dnsDone) return; + let dnsStatus: DnsVerificationResult = snapshot.dnsComplete ? "verified" : "pending"; + if (snapshot.pending.type === "dns") { + const nextDnsStatus = await runExistingDomainDnsVerification( + ctx, + snapshotToOperationState(snapshot, { type: "dns" }), + ); + if (!nextDnsStatus) return; + dnsStatus = nextDnsStatus; } if ( - state.pending.type === "oauth" || - state.oauthProviders.length > state.completedOAuthProviders.length + snapshot.pending.type === "oauth" || + snapshot.oauthProviders.length > snapshot.completedOAuthProviders.length ) { bar(); - const completed = await runOAuthSetup(ctx, state); - if (completed.length < state.oauthProviders.length) return; + const completed = await runOAuthSetup( + ctx, + snapshotToOperationState(snapshot, { + type: "oauth", + provider: + snapshot.oauthProviders.find( + (provider) => !snapshot.completedOAuthProviders.includes(provider), + ) ?? + snapshot.oauthProviders[0] ?? + "google", + }), + ); + if (completed.length < snapshot.oauthProviders.length) return; + snapshot.completedOAuthProviders = completed; } - await finishDeploy(ctx, state.domain, state.oauthProviders); + await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, dnsStatus); } -async function abortDeploy(ctx: DeployContext): Promise { - const state = ctx.profile.deploy; - if (!state) { - log.blank(); - log.info("There is no paused deploy operation to abort."); - log.blank(); - closeDeployGutter("neutral", "Nothing to abort"); - return; - } - - const confirmed = await confirm({ - message: "Abort the paused deploy operation?", - default: false, - }); - if (!confirmed) { - log.blank(); - log.info("Paused deploy abort cancelled."); - log.blank(); - log.info(pausedOperationNotice()); - log.blank(); - closeDeployGutter("error", "Paused"); - return; - } - - await clearDeployState(ctx); - log.blank(); - log.info("Cleared the paused deploy bookmark."); - log.blank(); - log.info( - dim("Note: this does not undo any changes already saved to your Clerk production instance."), - ); - log.info(dim("Use the dashboard to inspect or undo server-side changes.")); - log.blank(); - closeDeployGutter("cancel", "Aborted"); -} +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 { @@ -398,8 +470,152 @@ async function loadDevelopmentOAuthProviders( }); } +async function resolveLiveDeploySnapshot( + ctx: DeployContext, +): Promise { + const productionInstanceId = ctx.productionInstanceId; + if (!productionInstanceId) return undefined; + + const domain = await loadProductionDomain(ctx); + if (!domain) return undefined; + + const productionConfigPromise = ctx.testForceProductionInstance + ? Promise.resolve(mockProductionInstanceConfig()) + : fetchInstanceConfig(ctx.appId, productionInstanceId); + const [{ known: oauthProviders }, productionConfig, deployStatus] = await Promise.all([ + loadDevelopmentOAuthProviders(ctx), + productionConfigPromise, + getDeployStatus(ctx.appId, productionInstanceId), + ]); + const completedOAuthProviders = oauthProviders.filter((provider) => + hasProductionOAuthCredentials(productionConfig, provider), + ); + const pendingOAuthProvider = oauthProviders.find( + (provider) => !completedOAuthProviders.includes(provider), + ); + + const baseState = { + appId: ctx.appId, + developmentInstanceId: ctx.developmentInstanceId, + productionInstanceId, + productionDomainId: domain.id, + domain: domain.name, + oauthProviders, + completedOAuthProviders, + cnameTargets: domain.cname_targets ?? [], + }; + + const dnsComplete = deployStatus.status === "complete"; + const pending = !dnsComplete + ? ({ type: "dns" } as const) + : pendingOAuthProvider + ? ({ type: "oauth", provider: pendingOAuthProvider } as const) + : undefined; + + return { ...baseState, dnsComplete, pending }; +} + +async function loadProductionDomain(ctx: DeployContext): Promise { + if (ctx.testFailDomainLookup) { + throw testDeployFailure("production domain lookup"); + } + if (ctx.testForceProductionInstance) { + return mockProductionDomain(); + } + const domains = await listApplicationDomains(ctx.appId); + return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; +} + +function mockProductionDomain(): ApplicationDomain { + return { + object: "domain", + id: "dmn_prod_mock", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "accounts.example.com", value: "accounts.clerk.services", required: true }, + { + host: "clkmail.example.com", + value: "mail.example.com.nam1.clerk.services", + required: true, + }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }; +} + +function mockProductionInstanceConfig(): Record { + return {}; +} + +function hasProductionOAuthCredentials( + config: Record, + provider: OAuthProvider, +): boolean { + const value = config[`${OAUTH_KEY_PREFIX}${provider}`]; + if (!value || typeof value !== "object") return false; + const providerConfig = value as Record; + if (providerConfig.enabled !== true) return false; + return PROVIDER_FIELDS[provider].every((field) => { + const fieldValue = providerConfig[field.key]; + return typeof fieldValue === "string" && fieldValue.length > 0; + }); +} + const OAUTH_KEY_PREFIX = "connection_oauth_"; +function buildNewDeployPlan(oauthProviders: readonly OAuthProvider[]): DeployPlanStep[] { + return [ + { label: "Create production instance", status: "pending" }, + { label: "Choose a production domain you own", status: "pending" }, + { label: "Configure DNS records", status: "pending" }, + ...oauthProviders.map((provider) => ({ + label: `Configure ${PROVIDER_LABELS[provider]} OAuth credentials`, + status: "pending" as const, + })), + ]; +} + +function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { + return [ + { label: "Create production instance", status: "done" }, + { label: `Use production domain ${snapshot.domain}`, status: "done" }, + { label: "Configure DNS records", status: snapshot.dnsComplete ? "done" : "pending" }, + ...snapshot.oauthProviders.map((provider): DeployPlanStep => { + const status: DeployPlanStep["status"] = snapshot.completedOAuthProviders.includes(provider) + ? "done" + : "pending"; + return { + label: `Configure ${PROVIDER_LABELS[provider]} OAuth credentials`, + status, + }; + }), + ]; +} + +function snapshotToOperationState( + snapshot: LiveDeploySnapshot, + pending: DeployOperationState["pending"], +): DeployOperationState { + return { + appId: snapshot.appId, + developmentInstanceId: snapshot.developmentInstanceId, + productionInstanceId: snapshot.productionInstanceId, + productionDomainId: snapshot.productionDomainId, + domain: snapshot.domain, + pending, + oauthProviders: snapshot.oauthProviders, + completedOAuthProviders: snapshot.completedOAuthProviders, + cnameTargets: snapshot.cnameTargets, + }; +} + function discoverEnabledOAuthProviders(config: Record): DiscoveredOAuthProviders { const known: OAuthProvider[] = []; const unknown: string[] = []; @@ -419,6 +635,9 @@ function discoverEnabledOAuthProviders(config: Record): Discove async function runValidateCloning(ctx: DeployContext): Promise { await withSpinner("Validating subscription compatibility...", async () => { + if (ctx.testFailValidateCloning) { + throw testDeployFailure("cloning validation"); + } await validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }); }); } @@ -427,19 +646,53 @@ async function createProductionInstance( ctx: DeployContext, domain: string, ): Promise { - return withSpinner("Creating production instance...", async () => - apiCreateProductionInstance(ctx.appId, { + return withSpinner("Creating production instance...", async () => { + if (ctx.testFailCreateProductionInstance) { + throw testDeployFailure("production instance creation"); + } + return apiCreateProductionInstance(ctx.appId, { home_url: domain, clone_instance_id: ctx.developmentInstanceId, - }), - ); + }); + }); +} + +function plannedProductionCnameTargets(domain: string): CnameTarget[] { + return [ + { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, + { host: `accounts.${domain}`, value: "accounts.clerk.services", required: true }, + { + host: `clkmail.${domain}`, + value: `mail.${domain}.nam1.clerk.services`, + required: true, + }, + ]; +} + +async function confirmProductionInstanceCreation( + domain: string, + cnameTargets: readonly CnameTarget[], +): Promise { + for (const line of domainAssociationSummary(domain, cnameTargets)) log.info(line); + log.blank(); + const confirmed = await confirmCreateProductionInstance(); + if (confirmed) { + log.blank(); + return true; + } + + log.blank(); + log.info("No production instance was created."); + log.blank(); + closeDeployGutter("cancel", "Cancelled"); + return false; } async function runDnsSetup( ctx: DeployContext, state: DeployOperationState, cnameTargets: readonly CnameTarget[], -): Promise { +): Promise { for (const line of dnsIntro(state.domain)) log.info(line); log.blank(); for (const line of dnsRecords(cnameTargets)) log.info(line); @@ -451,7 +704,6 @@ async function runDnsSetup( log.blank(); } - await saveDeployState(ctx, state); for (const line of dnsDashboardHandoff(state.domain)) log.info(line); log.blank(); try { @@ -463,6 +715,26 @@ async function runDnsSetup( closeDeployGutter("error", "Paused"); return false; } + return await runDnsVerification(ctx, { ...state, cnameTargets }); + } catch (error) { + if (isPromptExitError(error)) { + throw deployPausedError(state, { interrupted: true }); + } + throw error; + } +} + +async function runExistingDomainDnsVerification( + ctx: DeployContext, + state: DeployOperationState, +): Promise { + try { + const action = await chooseDnsVerificationAction(); + if (action === "skip") { + log.blank(); + log.info("Skipping DNS verification for now."); + return "pending"; + } return await runDnsVerification(ctx, state); } catch (error) { if (isPromptExitError(error)) { @@ -475,15 +747,19 @@ async function runDnsSetup( async function runDnsVerification( ctx: DeployContext, state: DeployOperationState, -): Promise { - const productionInstanceId = state.productionInstanceId ?? ctx.profile.instances.production; +): Promise { + const productionInstanceId = + state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production; if (!productionInstanceId) { throwUsageError( - "Cannot verify DNS without a production instance. Run `clerk deploy --abort` and start again.", + "Cannot verify DNS because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.", ); } const verified = await withSpinner(`Verifying DNS for ${state.domain}...`, async () => { + if (ctx.testFailDnsVerification) { + return false; + } for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) { const result = await getDeployStatus(ctx.appId, productionInstanceId); if (result.status === "complete") return true; @@ -496,16 +772,28 @@ async function runDnsVerification( log.blank(); log.warn( `DNS, SSL, or mail verification is still pending for ${state.domain}. ` + - "Run `clerk deploy --continue` once DNS has propagated, or check the dashboard for the failing component.", + "Run `clerk deploy` again once DNS has propagated, or check the dashboard for the failing component.", + ); + log.info( + "DNS propagation can take time. Some providers may take several hours to serve the new records everywhere.", ); + if (state.cnameTargets && state.cnameTargets.length > 0) { + log.blank(); + for (const line of dnsRecords(state.cnameTargets)) log.info(line); + } log.blank(); - closeDeployGutter("error", "Paused"); - return false; + const action = await chooseDnsVerificationAction(); + if (action === "skip") { + log.blank(); + log.info("Skipping DNS verification for now."); + return "pending"; + } + return runDnsVerification(ctx, state); } log.blank(); for (const line of dnsVerified(state.domain)) log.success(line); - return true; + return "verified"; } async function runOAuthSetup( @@ -523,27 +811,15 @@ async function runOAuthSetup( log.blank(); } - for (const provider of state.oauthProviders.slice(startIndex) as OAuthProvider[]) { + const pendingProviders = state.oauthProviders.slice(startIndex) as OAuthProvider[]; + for (const provider of pendingProviders) { if (completed.has(provider)) continue; try { - const setupNow = await confirmOAuthSetupNow(provider); - if (!setupNow) { - await saveDeployState(ctx, { - ...state, - pending: { type: "oauth", provider }, - completedOAuthProviders: [...completed], - }); - log.blank(); - log.info(pausedOperationNotice()); - log.blank(); - closeDeployGutter("error", "Paused"); - return [...completed]; - } - - const productionInstanceId = state.productionInstanceId ?? ctx.profile.instances.production; + const productionInstanceId = + state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production; if (!productionInstanceId) { throwUsageError( - "Cannot save OAuth credentials without a production instance. Run `clerk deploy --abort` and start again.", + "Cannot save OAuth credentials because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.", ); } @@ -554,11 +830,6 @@ async function runOAuthSetup( productionInstanceId, ); if (!saved) { - await saveDeployState(ctx, { - ...state, - pending: { type: "oauth", provider }, - completedOAuthProviders: [...completed], - }); log.blank(); log.info(pausedOperationNotice()); log.blank(); @@ -572,17 +843,14 @@ async function runOAuthSetup( pending: { type: "oauth" as const, provider }, completedOAuthProviders: [...completed], }; - await saveDeployState(ctx, interruptedState); throw deployPausedError(interruptedState, { interrupted: true }); } throw error; } completed.add(provider); - await saveDeployState(ctx, { - ...state, - pending: { type: "oauth", provider }, - completedOAuthProviders: [...completed], - }); + if (pendingProviders.some((nextProvider) => !completed.has(nextProvider))) { + log.blank(); + } } return [...completed]; @@ -595,6 +863,9 @@ async function collectAndSaveOAuthCredentials( productionInstanceId: string, ): Promise { const label = PROVIDER_LABELS[provider]; + for (const line of providerSetupIntro(provider)) log.info(line); + log.blank(); + const choice = await chooseOAuthCredentialAction(provider); if (choice === "skip") { @@ -611,6 +882,9 @@ async function collectAndSaveOAuthCredentials( ); await withSpinner(`Saving ${label} OAuth credentials...`, async () => { + if (ctx.testFailOAuthSave) { + throw testDeployFailure("OAuth credential save"); + } await patchInstanceConfig(ctx.appId, productionInstanceId, { [`connection_oauth_${provider}`]: { enabled: true, @@ -618,7 +892,6 @@ async function collectAndSaveOAuthCredentials( }, }); }); - log.blank(); log.success(`Saved ${label} OAuth credentials`); return true; } @@ -632,37 +905,20 @@ async function persistProductionInstance(ctx: DeployContext, productionInstanceI }, }); ctx.profile.instances.production = productionInstanceId; -} - -async function saveDeployState(ctx: DeployContext, state: DeployOperationState): Promise { - const nextProfile = { - ...ctx.profile, - deploy: state, - instances: { - ...ctx.profile.instances, - ...(state.productionInstanceId ? { production: state.productionInstanceId } : {}), - }, - }; - await setProfile(ctx.profileKey, nextProfile); - ctx.profile = nextProfile; -} - -async function clearDeployState(ctx: DeployContext): Promise { - const { deploy: _deploy, ...profile } = ctx.profile; - await setProfile(ctx.profileKey, profile); - ctx.profile = profile; + ctx.productionInstanceId = productionInstanceId; } async function finishDeploy( ctx: DeployContext, domain: string, completedOAuthProviders: readonly string[], + dnsStatus: DnsVerificationResult, ): Promise { - await clearDeployState(ctx); log.blank(); for (const line of productionSummary( domain, completedOAuthProviders.map((provider) => providerLabel(provider)), + dnsStatus, )) { log.info(line); } diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index 75ac580f..fe38f7aa 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -11,6 +11,7 @@ import { } from "./providers.ts"; type OAuthCredentialAction = "have-credentials" | "walkthrough" | "google-json" | "skip"; +type DnsVerificationAction = "check" | "skip"; const PROVIDER_DOMAIN_SUFFIXES = [ ".clerk.app", @@ -55,13 +56,23 @@ export async function confirmContinueAfterDnsHandoff(): Promise { }); } -export async function confirmOAuthSetupNow(provider: OAuthProvider): Promise { +export async function confirmCreateProductionInstance(): Promise { return confirm({ - message: `Set up ${PROVIDER_LABELS[provider]} OAuth now?`, + message: "Create production instance?", default: true, }); } +export async function chooseDnsVerificationAction(): Promise { + return select({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], + }); +} + export async function chooseOAuthCredentialAction( provider: OAuthProvider, ): Promise { @@ -76,7 +87,7 @@ export async function chooseOAuthCredentialAction( }); } choices.push({ - name: "Skip for now and resume later (`clerk deploy --continue`)", + name: "Skip for now and run `clerk deploy` again later", value: "skip", }); diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index f158d593..dc4d3bc8 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -60,6 +60,24 @@ export const PROVIDER_REDIRECT_LABELS: Record = { 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, @@ -72,9 +90,18 @@ 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 = `https://clerk.com/docs/guides/configure/auth-strategies/social-connections/${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")}`); diff --git a/packages/cli-core/src/commands/deploy/state.ts b/packages/cli-core/src/commands/deploy/state.ts index 2289e762..5ea4ad06 100644 --- a/packages/cli-core/src/commands/deploy/state.ts +++ b/packages/cli-core/src/commands/deploy/state.ts @@ -1,9 +1,20 @@ -import { cyan } from "../../lib/color.ts"; import { CliError, EXIT_CODE } from "../../lib/errors.ts"; -import { log } from "../../lib/log.ts"; -import { activeDeployInProgressMessage, pausedMessage } from "./copy.ts"; +import { pausedMessage } from "./copy.ts"; +import type { CnameTarget } from "./api.ts"; import { providerLabel, type OAuthProvider } from "./providers.ts"; -import type { DeployOperationState, Profile } from "../../lib/config.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; @@ -11,12 +22,16 @@ export type DeployContext = { appId: string; appLabel: string; developmentInstanceId: string; + productionInstanceId?: string; + testForceProductionInstance?: boolean; + testFailProductionInstanceCheck?: boolean; + testFailDomainLookup?: boolean; + testFailValidateCloning?: boolean; + testFailCreateProductionInstance?: boolean; + testFailDnsVerification?: boolean; + testFailOAuthSave?: boolean; }; -export function isDeployStateValid(ctx: DeployContext, state: DeployOperationState): boolean { - return state.appId === ctx.appId && state.developmentInstanceId === ctx.developmentInstanceId; -} - export function pausedStepDescription(state: DeployOperationState): string { if (state.pending.type === "dns") { return `DNS verification for ${state.domain}`; @@ -24,20 +39,8 @@ export function pausedStepDescription(state: DeployOperationState): string { return `${providerLabel(state.pending.provider as OAuthProvider)} OAuth credential setup`; } -export function printPausedMessage(state: DeployOperationState): void { - log.info(`Deploy is paused for ${cyan(state.domain)}.`); - log.blank(); - log.info(pausedMessage(pausedStepDescription(state))); -} - export class DeployPausedError extends CliError {} -export function activeDeployInProgressError(state: DeployOperationState): DeployPausedError { - return new DeployPausedError(activeDeployInProgressMessage(pausedStepDescription(state)), { - exitCode: EXIT_CODE.GENERAL, - }); -} - export function deployPausedError( state: DeployOperationState, options?: { interrupted?: boolean }, diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index c3726159..9dd85de6 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -39,18 +39,6 @@ interface Profile { development: string; production?: string; }; - deploy?: DeployOperationState; -} - -interface DeployOperationState { - appId: string; - developmentInstanceId: string; - productionInstanceId?: string; - productionDomainId?: string; - domain: string; - pending: { type: "dns" } | { type: "oauth"; provider: string }; - oauthProviders: string[]; - completedOAuthProviders: string[]; } export function profileLabel(profile: Profile): string { @@ -374,4 +362,4 @@ export async function resolveAppContext( }; } -export type { Auth, Profile, ClerkConfig, AppContextOptions, DeployOperationState }; +export type { Auth, Profile, ClerkConfig, AppContextOptions }; diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 34e60ec4..e1ae2791 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -19,6 +19,7 @@ const { getDeployStatus, retryApplicationDomainSSL, retryApplicationDomainMail, + listApplicationDomains, } = await import("./plapi.ts"); const { AuthError, PlapiError } = await import("./errors.ts"); @@ -504,4 +505,42 @@ describe("plapi", () => { ); }); }); + + 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 ab28ed0b..5d355876 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -151,6 +151,26 @@ export type CnameTarget = { 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"; @@ -183,6 +203,14 @@ 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, From 5af2be8c067e0eee7725397443517a20c0ad0a7c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 13:52:50 -0600 Subject: [PATCH 05/20] fix(deploy): route test failures through api path --- packages/cli-core/src/cli-program.ts | 17 ++- .../src/commands/deploy/index.test.ts | 106 +++++++++++++----- .../cli-core/src/commands/deploy/index.ts | 99 ++++++++++------ packages/cli-core/src/lib/spinner.ts | 2 +- 4 files changed, 149 insertions(+), 75 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index c86d1b9b..8ca24aa1 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -930,43 +930,40 @@ Tutorial — enable completions for your shell: createOption( "--test-force-production-instance", "Force deploy to use a mocked production instance", - ).hideHelp(), + ), ) .addOption( createOption( "--test-fail-production-instance-check", "Simulate a deploy failure while checking for a production instance", - ).hideHelp(), + ), ) .addOption( createOption( "--test-fail-domain-lookup", "Simulate a deploy failure while loading the production domain", - ).hideHelp(), + ), ) .addOption( createOption( "--test-fail-validate-cloning", "Simulate a deploy failure while validating cloning", - ).hideHelp(), + ), ) .addOption( createOption( "--test-fail-create-production-instance", "Simulate a deploy failure while creating the production instance", - ).hideHelp(), + ), ) .addOption( - createOption( - "--test-fail-dns-verification", - "Simulate a deploy failure while verifying DNS", - ).hideHelp(), + createOption("--test-fail-dns-verification", "Simulate a deploy failure while verifying DNS"), ) .addOption( createOption( "--test-fail-oauth-save", "Simulate a deploy failure while saving OAuth credentials", - ).hideHelp(), + ), ) .action(deploy); diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index e3058d2a..02fe95da 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -3,7 +3,7 @@ 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 { EXIT_CODE, UserAbortError, type CliError } from "../../lib/errors.ts"; +import { CliError, EXIT_CODE, UserAbortError } from "../../lib/errors.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -197,6 +197,20 @@ describe("deploy", () => { return captured.run(() => deploy(options)); } + async function expectTestApiFailure(promise: Promise, message: string): Promise { + let error: Error | undefined; + try { + await promise; + } catch (caught) { + error = caught as Error; + } + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(CliError); + expect(error?.message).toContain(message); + return error!; + } + async function linkedProject(profile: Record = {}) { tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-")); _setConfigDir(tempDir); @@ -857,14 +871,47 @@ describe("deploy", () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - await expect(runDeploy({ testFailProductionInstanceCheck: true })).rejects.toThrow( + await expectTestApiFailure( + runDeploy({ testFailProductionInstanceCheck: true }), "Simulated deploy failure: production instance check.", ); - expect(mockFetchApplication).not.toHaveBeenCalled(); + expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789"); expect(mockFetchInstanceConfig).not.toHaveBeenCalled(); }); + test("--test-fail-production-instance-check prints one Failed status in interactive output", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); + const originalCi = process.env.CI; + const originalIsTty = process.stderr.isTTY; + Object.defineProperty(process.stderr, "isTTY", { configurable: true, value: true }); + delete process.env.CI; + + try { + await expectTestApiFailure( + runDeploy({ testFailProductionInstanceCheck: true }), + "Simulated deploy failure: production instance check.", + ); + } finally { + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: originalIsTty, + }); + if (originalCi === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCi; + } + } + + const terminalOutput = stripAnsi( + stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""), + ); + expect(terminalOutput.match(/\bFailed\b/g) ?? []).toHaveLength(1); + }); + test("--test-fail-domain-lookup simulates production domain lookup failure", async () => { await linkedProject(); mockLiveProduction({ @@ -873,22 +920,26 @@ describe("deploy", () => { }); mockIsAgent.mockReturnValue(false); - await expect(runDeploy({ testFailDomainLookup: true })).rejects.toThrow( + await expectTestApiFailure( + runDeploy({ testFailDomainLookup: true }), "Simulated deploy failure: production domain lookup.", ); - expect(mockListApplicationDomains).not.toHaveBeenCalled(); + expect(mockListApplicationDomains).toHaveBeenCalledWith("app_xyz789"); }); test("--test-fail-validate-cloning simulates cloning validation failure", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - await expect(runDeploy({ testFailValidateCloning: true })).rejects.toThrow( + await expectTestApiFailure( + runDeploy({ testFailValidateCloning: true }), "Simulated deploy failure: cloning validation.", ); - expect(mockValidateCloning).not.toHaveBeenCalled(); + expect(mockValidateCloning).toHaveBeenCalledWith("app_xyz789", { + clone_instance_id: "ins_dev_123", + }); expect(mockCreateProductionInstance).not.toHaveBeenCalled(); }); @@ -898,11 +949,15 @@ describe("deploy", () => { mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mockInput.mockResolvedValueOnce("example.com"); - await expect(runDeploy({ testFailCreateProductionInstance: true })).rejects.toThrow( + await expectTestApiFailure( + runDeploy({ testFailCreateProductionInstance: true }), "Simulated deploy failure: production instance creation.", ); - expect(mockCreateProductionInstance).not.toHaveBeenCalled(); + expect(mockCreateProductionInstance).toHaveBeenCalledWith("app_xyz789", { + home_url: "example.com", + clone_instance_id: "ins_dev_123", + }); }); test("--test-fail-dns-verification simulates DNS verification failure", async () => { @@ -920,23 +975,13 @@ describe("deploy", () => { mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - await runDeploy({ testFailDnsVerification: true }); - const err = stripAnsi(captured.err); + await expectTestApiFailure( + runDeploy({ testFailDnsVerification: true }), + "Simulated deploy failure: DNS verification.", + ); - expect(mockGetDeployStatus).not.toHaveBeenCalled(); - expect(err).toContain("DNS propagation can take time"); - expect(err).toContain("Add the following records at your DNS provider:"); - 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", - }, - }); + expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock"); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); }); test("--test-fail-oauth-save simulates OAuth credential save failure", async () => { @@ -953,11 +998,18 @@ describe("deploy", () => { mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); - await expect(runDeploy({ testFailOAuthSave: true })).rejects.toThrow( + await expectTestApiFailure( + runDeploy({ testFailOAuthSave: true }), "Simulated deploy failure: OAuth credential save.", ); - expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_123", { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "google-secret", + }, + }); }); test("plain deploy resumes DNS verification from live API state", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index e7aa843b..e8b3db23 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -3,7 +3,12 @@ import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { isInsideGutter, log, setPrefixTone, type PrefixTone } from "../../lib/log.ts"; import { sleep } from "../../lib/sleep.ts"; import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; -import { CliError, UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; +import { + PlapiError, + UserAbortError, + isPromptExitError, + throwUsageError, +} from "../../lib/errors.ts"; import { resolveProfile, setProfile } from "../../lib/config.ts"; import { type Application, @@ -211,14 +216,15 @@ async function resolveDeployContext(options: DeployOptions): Promise { - if (testFlags.testFailProductionInstanceCheck) { - throw testDeployFailure("production instance check"); - } - return resolveLiveApplicationContext(resolved.profile, { - forceMockProductionInstance: testFlags.testForceProductionInstance, - }); - })), + ...(await withSpinner("Checking for production instance...", () => + withTestFailureAfterApiCall( + resolveLiveApplicationContext(resolved.profile, { + forceMockProductionInstance: testFlags.testForceProductionInstance, + }), + testFlags.testFailProductionInstanceCheck, + "production instance check", + ), + )), }; } @@ -245,8 +251,24 @@ function resolveTestDeployFlags( }; } -function testDeployFailure(step: string): CliError { - return new CliError(`Simulated deploy failure: ${step}.`); +function simulatedDeployApiFailure(step: string): PlapiError { + return new PlapiError( + 500, + JSON.stringify({ errors: [{ message: `Simulated deploy failure: ${step}.` }] }), + "clerk deploy test flag", + ); +} + +async function withTestFailureAfterApiCall( + promise: Promise, + shouldFail: boolean | undefined, + step: string, +): Promise { + const result = await promise; + if (shouldFail) { + throw simulatedDeployApiFailure(step); + } + return result; } async function resolveLiveApplicationContext( @@ -516,13 +538,13 @@ async function resolveLiveDeploySnapshot( } async function loadProductionDomain(ctx: DeployContext): Promise { - if (ctx.testFailDomainLookup) { - throw testDeployFailure("production domain lookup"); - } if (ctx.testForceProductionInstance) { return mockProductionDomain(); } const domains = await listApplicationDomains(ctx.appId); + if (ctx.testFailDomainLookup) { + throw simulatedDeployApiFailure("production domain lookup"); + } return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; } @@ -635,10 +657,11 @@ function discoverEnabledOAuthProviders(config: Record): Discove async function runValidateCloning(ctx: DeployContext): Promise { await withSpinner("Validating subscription compatibility...", async () => { - if (ctx.testFailValidateCloning) { - throw testDeployFailure("cloning validation"); - } - await validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }); + await withTestFailureAfterApiCall( + validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }), + ctx.testFailValidateCloning, + "cloning validation", + ); }); } @@ -647,13 +670,14 @@ async function createProductionInstance( domain: string, ): Promise { return withSpinner("Creating production instance...", async () => { - if (ctx.testFailCreateProductionInstance) { - throw testDeployFailure("production instance creation"); - } - return apiCreateProductionInstance(ctx.appId, { - home_url: domain, - clone_instance_id: ctx.developmentInstanceId, - }); + return withTestFailureAfterApiCall( + apiCreateProductionInstance(ctx.appId, { + home_url: domain, + clone_instance_id: ctx.developmentInstanceId, + }), + ctx.testFailCreateProductionInstance, + "production instance creation", + ); }); } @@ -757,11 +781,11 @@ async function runDnsVerification( } const verified = await withSpinner(`Verifying DNS for ${state.domain}...`, async () => { - if (ctx.testFailDnsVerification) { - return false; - } for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) { const result = await getDeployStatus(ctx.appId, productionInstanceId); + if (ctx.testFailDnsVerification) { + throw simulatedDeployApiFailure("DNS verification"); + } if (result.status === "complete") return true; await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS); } @@ -882,15 +906,16 @@ async function collectAndSaveOAuthCredentials( ); await withSpinner(`Saving ${label} OAuth credentials...`, async () => { - if (ctx.testFailOAuthSave) { - throw testDeployFailure("OAuth credential save"); - } - await patchInstanceConfig(ctx.appId, productionInstanceId, { - [`connection_oauth_${provider}`]: { - enabled: true, - ...credentials, - }, - }); + await withTestFailureAfterApiCall( + patchInstanceConfig(ctx.appId, productionInstanceId, { + [`connection_oauth_${provider}`]: { + enabled: true, + ...credentials, + }, + }), + ctx.testFailOAuthSave, + "OAuth credential save", + ); }); log.success(`Saved ${label} OAuth credentials`); return true; diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 165556bf..4c23910b 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -115,7 +115,7 @@ export async function withSpinner( return result; } catch (error) { setPrefixTone("error"); - s.error("Failed"); + s.error(message.replace(/\.{3}$/, "")); throw error; } } From 4b1f1721058c163963da14ad3f0234b3a43f83ea Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 14:05:32 -0600 Subject: [PATCH 06/20] fix(deploy): remove gutter tone plumbing --- .../src/commands/deploy/index.test.ts | 8 +--- .../cli-core/src/commands/deploy/index.ts | 27 ++++++------ packages/cli-core/src/lib/log.test.ts | 20 --------- packages/cli-core/src/lib/log.ts | 42 ++++--------------- packages/cli-core/src/lib/spinner.test.ts | 32 -------------- packages/cli-core/src/lib/spinner.ts | 28 ++++--------- 6 files changed, 32 insertions(+), 125 deletions(-) delete mode 100644 packages/cli-core/src/lib/spinner.test.ts diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 02fe95da..2aa14454 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -579,7 +579,6 @@ describe("deploy", () => { .map((call: unknown[]) => String(call[0])) .join(""); expect(terminalOutput).toContain("Cancelled"); - expect(terminalOutput).toContain("\x1b[31m└"); expect(terminalOutput).not.toContain("Done"); }); @@ -598,7 +597,6 @@ describe("deploy", () => { .map((call: unknown[]) => String(call[0])) .join(""); expect(terminalOutput).toContain("Cancelled"); - expect(terminalOutput).toContain("\x1b[31m└"); expect(terminalOutput).not.toContain("Done"); }); @@ -696,7 +694,6 @@ describe("deploy", () => { .map((call: unknown[]) => String(call[0])) .join(""); expect(terminalOutput).toContain("Paused"); - expect(terminalOutput).toContain("\x1b[33m└"); expect(terminalOutput).not.toContain("Done"); }); @@ -880,7 +877,7 @@ describe("deploy", () => { expect(mockFetchInstanceConfig).not.toHaveBeenCalled(); }); - test("--test-fail-production-instance-check prints one Failed status in interactive output", async () => { + test("--test-fail-production-instance-check prints Failed in interactive output", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); @@ -909,7 +906,7 @@ describe("deploy", () => { const terminalOutput = stripAnsi( stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""), ); - expect(terminalOutput.match(/\bFailed\b/g) ?? []).toHaveLength(1); + expect(terminalOutput).toContain("Failed"); }); test("--test-fail-domain-lookup simulates production domain lookup failure", async () => { @@ -1121,7 +1118,6 @@ describe("deploy", () => { .map((call: unknown[]) => String(call[0])) .join(""); expect(terminalOutput).toContain("Paused"); - expect(terminalOutput).toContain("\x1b[33m└"); expect(terminalOutput).not.toContain("Done"); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index e8b3db23..fcaf3747 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,6 +1,6 @@ import { isAgent } from "../../mode.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; -import { isInsideGutter, log, setPrefixTone, type PrefixTone } 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 { @@ -165,16 +165,16 @@ export async function deploy(options: DeployOptions = {}) { setLogLevel("debug"); } - intro("clerk deploy", { tone: "active" }); + intro("clerk deploy"); try { const ctx = await resolveDeployContext(options); await runDeploy(ctx); } catch (error) { if (error instanceof DeployPausedError && isInsideGutter()) { - closeDeployGutter("error", "Paused"); + closeDeployGutter("Paused"); } if (isPromptExitError(error) && isInsideGutter()) { - closeDeployGutter("cancel", "Cancelled"); + closeDeployGutter("Cancelled"); throw new UserAbortError(); } throw error; @@ -182,13 +182,12 @@ export async function deploy(options: DeployOptions = {}) { // Successful and paused paths call outro themselves. This balances the // intro gutter if an unexpected error escapes. if (isInsideGutter()) { - closeDeployGutter("error", "Failed"); + closeDeployGutter("Failed"); } } } -function closeDeployGutter(tone: PrefixTone, messageOrSteps: string | readonly string[]): void { - setPrefixTone(tone); +function closeDeployGutter(messageOrSteps: string | readonly string[]): void { outro(messageOrSteps); } @@ -318,7 +317,7 @@ async function runDeploy(ctx: DeployContext): Promise { "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", ); log.blank(); - closeDeployGutter("error", "Link required"); + closeDeployGutter("Link required"); return; } @@ -357,7 +356,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { const proceed = await confirmProceed(); if (!proceed) { log.info("No changes were made."); - closeDeployGutter("cancel", "Cancelled"); + closeDeployGutter("Cancelled"); return; } @@ -415,7 +414,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { log.info("A production instance exists, but Clerk did not return a production domain yet."); log.info("Run `clerk deploy` again after the domain is available from the API."); log.blank(); - closeDeployGutter("neutral", "No deploy actions available"); + closeDeployGutter("No deploy actions available"); return; } @@ -708,7 +707,7 @@ async function confirmProductionInstanceCreation( log.blank(); log.info("No production instance was created."); log.blank(); - closeDeployGutter("cancel", "Cancelled"); + closeDeployGutter("Cancelled"); return false; } @@ -736,7 +735,7 @@ async function runDnsSetup( log.blank(); log.info(pausedOperationNotice()); log.blank(); - closeDeployGutter("error", "Paused"); + closeDeployGutter("Paused"); return false; } return await runDnsVerification(ctx, { ...state, cnameTargets }); @@ -857,7 +856,7 @@ async function runOAuthSetup( log.blank(); log.info(pausedOperationNotice()); log.blank(); - closeDeployGutter("error", "Paused"); + closeDeployGutter("Paused"); return [...completed]; } } catch (error) { @@ -950,7 +949,7 @@ async function finishDeploy( log.blank(); printNextSteps(); log.blank(); - closeDeployGutter("success", NEXT_STEPS.DEPLOY); + closeDeployGutter(NEXT_STEPS.DEPLOY); } function printNextSteps(): void { diff --git a/packages/cli-core/src/lib/log.test.ts b/packages/cli-core/src/lib/log.test.ts index ef06ff08..ddaafbeb 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -7,7 +7,6 @@ import { getLogLevel, pushPrefix, popPrefix, - setPrefixTone, type LogLevel, } from "./log.ts"; @@ -256,25 +255,6 @@ describe("blank", () => { expect(cap.stderr.length).toBe(1); expect(cap.stderr[0]).toContain("│"); }); - - test("colors pipe prefix from the active gutter tone", () => { - const cap = createCapture(); - - withCapturedLogs(cap, () => { - pushPrefix("active"); - log.info("working"); - setPrefixTone("error"); - log.info("needs attention"); - setPrefixTone("cancel"); - log.info("cancelled"); - popPrefix(); - }); - - expect(cap.stderr).toHaveLength(3); - expect(cap.stderr[0]).toContain("\x1b[36m│"); - expect(cap.stderr[1]).toContain("\x1b[33m│"); - expect(cap.stderr[2]).toContain("\x1b[31m│"); - }); }); describe("raw", () => { diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index 4073636c..3530f132 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -1,5 +1,5 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { cyan, dim, green, red, yellow } from "./color.ts"; +import { dim, green, red, yellow } from "./color.ts"; // ── Log level ──────────────────────────────────────────────────────────── @@ -30,50 +30,24 @@ function isLevelEnabled(level: LogLevel): boolean { // ── Pipe prefix state (for intro/outro flow) ────────────────────────────── const S_BAR = "│"; -export type PrefixTone = "neutral" | "active" | "error" | "cancel" | "success"; +let prefixDepth = 0; -const prefixTones: PrefixTone[] = []; - -export function pushPrefix(tone: PrefixTone = "neutral") { - prefixTones.push(tone); +export function pushPrefix() { + prefixDepth++; } export function popPrefix() { - prefixTones.pop(); -} - -export function setPrefixTone(tone: PrefixTone) { - if (prefixTones.length === 0) return; - prefixTones[prefixTones.length - 1] = tone; -} - -export function getPrefixTone(): PrefixTone { - return prefixTones[prefixTones.length - 1] ?? "neutral"; -} - -export function formatPrefixSymbol(symbol: string, tone: PrefixTone = getPrefixTone()): string { - switch (tone) { - case "active": - return cyan(symbol); - case "error": - return yellow(symbol); - case "cancel": - return red(symbol); - case "success": - return green(symbol); - case "neutral": - return dim(symbol); - } + prefixDepth = Math.max(0, prefixDepth - 1); } /** True while an intro/outro block is active and stderr output is gutter-prefixed. */ export function isInsideGutter(): boolean { - return prefixTones.length > 0; + return prefixDepth > 0; } function applyPrefix(msg: string): string { - if (prefixTones.length === 0) return msg; - const bar = formatPrefixSymbol(S_BAR); + if (prefixDepth === 0) return msg; + const bar = dim(S_BAR); if (!msg) return bar; return msg .split("\n") diff --git a/packages/cli-core/src/lib/spinner.test.ts b/packages/cli-core/src/lib/spinner.test.ts deleted file mode 100644 index b8560bf1..00000000 --- a/packages/cli-core/src/lib/spinner.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { setPrefixTone } from "./log.ts"; -import { intro, outro } from "./spinner.ts"; - -describe("gutter tone rendering", () => { - let stderrSpy: ReturnType | undefined; - const originalMode = process.env.CLERK_MODE; - - afterEach(() => { - stderrSpy?.mockRestore(); - stderrSpy = undefined; - if (originalMode === undefined) { - delete process.env.CLERK_MODE; - } else { - process.env.CLERK_MODE = originalMode; - } - }); - - test("uses active and error tones for intro and outro rails", () => { - process.env.CLERK_MODE = "human"; - stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); - - intro("clerk deploy", { tone: "active" }); - setPrefixTone("error"); - outro("Paused"); - - const output = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""); - expect(output).toContain("\x1b[36m┌"); - expect(output).toContain("\x1b[33m│"); - expect(output).toContain("\x1b[33m└"); - }); -}); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 4c23910b..d3bebfdc 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -1,13 +1,6 @@ import { isHuman } from "../mode.ts"; import { dim, cyan, green, red } from "./color.ts"; -import { - formatPrefixSymbol, - getPrefixTone, - popPrefix, - pushPrefix, - setPrefixTone, - type PrefixTone, -} from "./log.ts"; +import { pushPrefix, popPrefix } from "./log.ts"; const FRAMES = ["◒", "◐", "◓", "◑"]; const INTERVAL = 80; @@ -24,38 +17,36 @@ const isInteractive = () => stream.isTTY && !process.env.CI; // --- Public API --- /** Print intro bracket: ┌ title — prefixes log output with │ until outro(). */ -export function intro(title?: string, options: { tone?: PrefixTone } = {}) { +export function intro(title?: string) { if (!isHuman()) return; - const tone = options.tone ?? "neutral"; - const line = title ? `${formatPrefixSymbol(S_BAR_START, tone)} ${title}` : dim(S_BAR_START); + const line = title ? `${dim(S_BAR_START)} ${title}` : dim(S_BAR_START); stream.write(`${line}\n`); - pushPrefix(tone); + pushPrefix(); } /** Print outro bracket: └ message — 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; - const tone = getPrefixTone(); popPrefix(); - stream.write(`${formatPrefixSymbol(S_BAR, tone)}\n`); + stream.write(`${dim(S_BAR)}\n`); if (Array.isArray(messageOrSteps)) { - stream.write(`${formatPrefixSymbol(S_BAR_END, tone)} ${dim("Next steps")}\n`); + stream.write(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); for (const step of messageOrSteps) { stream.write(` ${cyan("\u2192")} ${step}\n`); } stream.write("\n"); } else { const label = messageOrSteps ?? "Done"; - stream.write(`${formatPrefixSymbol(S_BAR_END, tone)} ${label}\n\n`); + stream.write(`${dim(S_BAR_END)} ${label}\n\n`); } } /** Print a bar separator: │ */ export function bar() { if (!isHuman()) return; - stream.write(`${formatPrefixSymbol(S_BAR)}\n`); + stream.write(`${dim(S_BAR)}\n`); } function createSpinner() { @@ -114,8 +105,7 @@ export async function withSpinner( s.stop(doneMessage ?? message.replace(/\.{3}$/, "")); return result; } catch (error) { - setPrefixTone("error"); - s.error(message.replace(/\.{3}$/, "")); + s.error("Failed"); throw error; } } From a8a805b70043c7be162c674fc0e78642fd900335 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 11:25:57 -0600 Subject: [PATCH 07/20] fix(deploy): require human mode for production setup --- .../cli-core/src/commands/deploy/README.md | 14 +-- .../src/commands/deploy/index.test.ts | 55 ++------- .../cli-core/src/commands/deploy/index.ts | 116 ++---------------- packages/cli-core/src/lib/spinner.ts | 13 +- 4 files changed, 36 insertions(+), 162 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 1c41bcce..0d233543 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -9,7 +9,7 @@ Guides a user through deploying their Clerk application to production. ```sh 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 ``` ## Options @@ -20,15 +20,7 @@ clerk deploy --mode agent # Output agent prompt instead of interactive flow ## Agent Mode -> **TODO:** The `DEPLOY_PROMPT` string is hardcoded. It should probably fetch from the quickstart prompt in the Clerk docs instead. - -When running in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), this command outputs a structured prompt describing the full deployment flow instead of running the interactive wizard. The prompt includes: - -- Prerequisites and pre-flight checks -- Production domain collection and DNS setup -- 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: @@ -36,7 +28,7 @@ Agent mode is detected via the mode system (`src/mode.ts`), which checks in prio 2. `CLERK_MODE` environment variable 3. TTY detection (`process.stdout.isTTY`) -Agent mode does not call PLAPI. It prints `DEPLOY_PROMPT` and exits before the human-mode wizard starts. The prompt currently contains some stale endpoint guidance; see the TODO above `DEPLOY_PROMPT` in `index.ts` and `DEPLOY_MVP_UX_COPY_SPEC.md` §8.3. +Agent mode does not call PLAPI and exits before the human-mode wizard starts. ## PLAPI And Mocked Lifecycle diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 2aa14454..383af104 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -342,60 +342,23 @@ describe("deploy", () => { }); describe("agent mode", () => { - test("outputs deploy prompt and returns", async () => { + test("exits with human mode guidance", async () => { mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - - await runDeploy({}); - expect(captured.out).toContain("deploying a Clerk application to production"); - }); - - test("prompt includes all deployment steps", async () => { - mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - - await runDeploy({}); - - const output = captured.out; - expect(output).toContain("Prerequisites"); - expect(output).toContain("Validate Cloning"); - expect(output).toContain("Discover enabled OAuth providers"); - expect(output).toContain("Create the Production Instance"); - expect(output).toContain("Configure Social OAuth Providers"); - expect(output).toContain("Finalize"); - }); - - test("prompt includes API reference for new deploy lifecycle endpoints", async () => { - mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - - await runDeploy({}); - - const output = captured.out; - expect(output).toContain("/v1/platform/applications"); - expect(output).toContain("validate_cloning"); - expect(output).toContain("production_instance"); - expect(output).toContain("deploy_status"); - expect(output).toContain("ssl_retry"); - expect(output).toContain("mail_retry"); - }); - - test("prompt includes OAuth redirect URI pattern", async () => { - 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(); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index fcaf3747..34eaa544 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -64,83 +64,6 @@ import { type DeployOperationState, } from "./state.ts"; -// TODO(deploy): rewrite to match the human flow described in -// DEPLOY_MVP_UX_COPY_SPEC.md, or fetch from clerk.com/docs at runtime. -const DEPLOY_PROMPT = `You are deploying a Clerk application to production. Follow these steps: - -## Prerequisites - -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 - -## Step 1: Validate Cloning - -Confirm the development instance's features are covered by the application's subscription plan before starting any irreversible work. - -- Call \`POST /v1/platform/applications/{appID}/validate_cloning\` with body \`{ "clone_instance_id": "" }\`. -- 204 No Content means cloning is allowed. 402 Payment Required means the plan must be upgraded; surface the unsupported features to the user. - -## Step 2: Discover enabled OAuth providers - -Read the development instance config and pick out enabled social connections. - -- Call \`GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config\`. -- For each key matching \`connection_oauth_*\` whose value has \`enabled: true\`, collect production credentials in step 4. - -## Step 3: Create the Production Instance - -Provision the production instance, primary domain, and keys in one round-trip. - -- Collect a production domain the user owns (\`example.com\`). Reject provider domains (\`*.vercel.app\`, \`*.clerk.app\`, etc.). -- Call \`POST /v1/platform/applications/{appID}/production_instance\` with body \`{ "home_url": "", "clone_instance_id": "" }\`. -- The 201 response includes \`instance_id\`, \`active_domain\`, \`publishable_key\`, \`secret_key\`, and \`cname_targets\`. -- Show the user the \`cname_targets\` (\`{ host, value, required }\`) and offer Domain Connect handoff when the registrar supports it. -- Poll \`GET /v1/platform/applications/{appID}/instances/{instance_id}/deploy_status\` every ~3 seconds until \`status === "complete"\`. The literal path segments \`development\` or \`production\` may be used in place of an instance ID. -- When DNS or SSL stalls, expose the retry endpoints: - \`POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/ssl_retry\` - \`POST /v1/platform/applications/{appID}/domains/{domain_id_or_name}/mail_retry\` - -## Step 4: Configure Social OAuth Providers - -For each enabled provider discovered in step 2, prompt for production credentials. - -1. Required fields per provider: - - Most providers: \`client_id\` and \`client_secret\` - - Apple: also requires \`key_id\`, \`team_id\`, and the \`.p8\` private-key file - -2. When walking the user through OAuth app creation, supply: - - Authorized JavaScript origins: \`https://{domain}\` and \`https://www.{domain}\` - - Authorized redirect URI: \`https://accounts.{domain}/v1/oauth_callback\` - -3. Persist each provider: - \`PATCH /v1/platform/applications/{appID}/instances/{instance_id}/config\` - Body: \`{ "connection_oauth_{provider}": { "enabled": true, "client_id": "...", "client_secret": "..." } }\` - -Provider-specific documentation: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/{provider} - -## Step 5: Finalize - -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\` - -## API Reference - -| Method | Endpoint | Purpose | -|--------|----------|---------| -| POST | /v1/platform/applications/{appID}/validate_cloning | Pre-flight subscription/feature check | -| POST | /v1/platform/applications/{appID}/production_instance | Create prod instance + primary domain (returns keys + cname_targets) | -| GET | /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status | Poll DNS/SSL/Mail/Proxy progress | -| POST | /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry | Re-trigger SSL provisioning | -| POST | /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry | Re-trigger SendGrid mail verification | -| GET | /v1/platform/applications/{appID}/instances/{instanceID}/config | Read dev or prod instance config | -| PATCH | /v1/platform/applications/{appID}/instances/{instanceID}/config | Write OAuth credentials | - -Refer to the Clerk Platform API docs for detailed request/response schemas.`; - type DeployOptions = { debug?: boolean; testForceProductionInstance?: boolean; @@ -157,8 +80,9 @@ const DEPLOY_STATUS_MAX_POLLS = 100; export async function deploy(options: DeployOptions = {}) { if (isAgent()) { - log.data(DEPLOY_PROMPT); - return; + 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"); @@ -171,10 +95,10 @@ export async function deploy(options: DeployOptions = {}) { await runDeploy(ctx); } catch (error) { if (error instanceof DeployPausedError && isInsideGutter()) { - closeDeployGutter("Paused"); + outro("Paused"); } if (isPromptExitError(error) && isInsideGutter()) { - closeDeployGutter("Cancelled"); + outro("Cancelled"); throw new UserAbortError(); } throw error; @@ -182,15 +106,11 @@ export async function deploy(options: DeployOptions = {}) { // Successful and paused paths call outro themselves. This balances the // intro gutter if an unexpected error escapes. if (isInsideGutter()) { - closeDeployGutter("Failed"); + outro("Failed"); } } } -function closeDeployGutter(messageOrSteps: string | readonly string[]): void { - outro(messageOrSteps); -} - async function resolveDeployContext(options: DeployOptions): Promise { const testFlags = resolveTestDeployFlags(options); const resolved = await withSpinner("Resolving linked Clerk application...", () => @@ -316,8 +236,7 @@ async function runDeploy(ctx: DeployContext): Promise { log.warn( "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", ); - log.blank(); - closeDeployGutter("Link required"); + outro("Link required"); return; } @@ -356,7 +275,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { const proceed = await confirmProceed(); if (!proceed) { log.info("No changes were made."); - closeDeployGutter("Cancelled"); + outro("Cancelled"); return; } @@ -413,8 +332,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { log.blank(); log.info("A production instance exists, but Clerk did not return a production domain yet."); log.info("Run `clerk deploy` again after the domain is available from the API."); - log.blank(); - closeDeployGutter("No deploy actions available"); + outro("No deploy actions available"); return; } @@ -706,8 +624,7 @@ async function confirmProductionInstanceCreation( log.blank(); log.info("No production instance was created."); - log.blank(); - closeDeployGutter("Cancelled"); + outro("Cancelled"); return false; } @@ -734,8 +651,7 @@ async function runDnsSetup( if (!continueSetup) { log.blank(); log.info(pausedOperationNotice()); - log.blank(); - closeDeployGutter("Paused"); + outro("Paused"); return false; } return await runDnsVerification(ctx, { ...state, cnameTargets }); @@ -855,8 +771,7 @@ async function runOAuthSetup( if (!saved) { log.blank(); log.info(pausedOperationNotice()); - log.blank(); - closeDeployGutter("Paused"); + outro("Paused"); return [...completed]; } } catch (error) { @@ -947,11 +862,6 @@ async function finishDeploy( log.info(line); } log.blank(); - printNextSteps(); - log.blank(); - closeDeployGutter(NEXT_STEPS.DEPLOY); -} - -function printNextSteps(): void { log.info(NEXT_STEPS_BLOCK); + outro("Success"); } 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(); From 5ac4e7fbf33636e4485c1322192f7f205b4881ac Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 13:00:24 -0600 Subject: [PATCH 08/20] refactor(deploy): route lifecycle test failures through api mock --- .../cli-core/src/commands/deploy/api.test.ts | 42 +++++++++ packages/cli-core/src/commands/deploy/api.ts | 35 ++++++++ .../src/commands/deploy/index.test.ts | 72 ++++++++++++++- .../cli-core/src/commands/deploy/index.ts | 90 ++++++++++--------- .../cli-core/src/commands/deploy/state.ts | 4 - 5 files changed, 194 insertions(+), 49 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts index 8b4bec1b..a31dd22c 100644 --- a/packages/cli-core/src/commands/deploy/api.test.ts +++ b/packages/cli-core/src/commands/deploy/api.test.ts @@ -24,6 +24,7 @@ mock.module("../../lib/sleep.ts", () => ({ const deployApiModulePath = "./api.ts?adapter-test"; const { createProductionInstance, + configureMockDeployApi, getDeployStatus, patchInstanceConfig, validateCloning, @@ -78,4 +79,45 @@ describe("deploy api adapter", () => { expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "complete" }); expect(mockPlapiGetDeployStatus).not.toHaveBeenCalled(); }); + + test("mock deploy api can fail lifecycle operations with PLAPI-shaped errors", async () => { + configureMockDeployApi({ + failValidateCloning: true, + failCreateProductionInstance: true, + failDnsVerification: true, + failOAuthSave: true, + }); + + await expect(validateCloning("app_123", { clone_instance_id: "ins_dev_123" })).rejects.toThrow( + "Simulated deploy failure: cloning validation.", + ); + await expect( + createProductionInstance("app_123", { + home_url: "example.com", + clone_instance_id: "ins_dev_123", + }), + ).rejects.toThrow("Simulated deploy failure: production instance creation."); + await expect(getDeployStatus("app_123", "ins_prod_123")).rejects.toThrow( + "Simulated deploy failure: DNS verification.", + ); + await expect( + patchInstanceConfig("app_123", "ins_prod_123", { + connection_oauth_google: { enabled: true }, + }), + ).rejects.toThrow("Simulated deploy failure: OAuth credential save."); + + expect(mockPlapiValidateCloning).not.toHaveBeenCalled(); + expect(mockPlapiCreateProductionInstance).not.toHaveBeenCalled(); + expect(mockPlapiGetDeployStatus).not.toHaveBeenCalled(); + expect(mockPlapiPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("reset mock deploy api clears lifecycle failure flags", async () => { + configureMockDeployApi({ failValidateCloning: true }); + _resetDeployStatusMock(); + + await expect( + validateCloning("app_123", { clone_instance_id: "ins_dev_123" }), + ).resolves.toBeUndefined(); + }); }); diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts index 3de5ca2a..99a79257 100644 --- a/packages/cli-core/src/commands/deploy/api.ts +++ b/packages/cli-core/src/commands/deploy/api.ts @@ -9,6 +9,7 @@ */ import { sleep } from "../../lib/sleep.ts"; +import { PlapiError } from "../../lib/errors.ts"; import { createProductionInstance as liveCreateProductionInstance, getDeployStatus as liveGetDeployStatus, @@ -54,6 +55,27 @@ const MOCK_SECRET_KEY = "MOCKED_NOT_REAL_FIXME"; const MOCK_LATENCY_MS = 2000; const MOCK_INCOMPLETE_POLLS = 2; +type DeployApiMockOptions = { + failValidateCloning?: boolean; + failCreateProductionInstance?: boolean; + failDnsVerification?: boolean; + failOAuthSave?: boolean; +}; + +let mockOptions: DeployApiMockOptions = {}; + +export function configureMockDeployApi(options: DeployApiMockOptions = {}): void { + mockOptions = { ...options }; +} + +function simulatedDeployApiFailure(step: string): PlapiError { + return new PlapiError( + 500, + JSON.stringify({ errors: [{ message: `Simulated deploy failure: ${step}.` }] }), + "clerk deploy test flag", + ); +} + async function simulateServerLatency(): Promise { await sleep(MOCK_LATENCY_MS); } @@ -74,11 +96,15 @@ const deployStatusPollCounts = new Map(); export function _resetDeployStatusMock(): void { deployStatusPollCounts.clear(); + configureMockDeployApi(); } export const mockDeployApi: DeployApi = { async createProductionInstance(_applicationId, params) { await simulateServerLatency(); + if (mockOptions.failCreateProductionInstance) { + throw simulatedDeployApiFailure("production instance creation"); + } return { instance_id: MOCK_PRODUCTION_INSTANCE_ID, environment_type: "production", @@ -94,10 +120,16 @@ export const mockDeployApi: DeployApi = { async validateCloning() { await simulateServerLatency(); + if (mockOptions.failValidateCloning) { + throw simulatedDeployApiFailure("cloning validation"); + } }, async getDeployStatus(applicationId, envOrInsId) { await simulateServerLatency(); + if (mockOptions.failDnsVerification) { + throw simulatedDeployApiFailure("DNS verification"); + } const key = `${applicationId}:${envOrInsId}`; const count = (deployStatusPollCounts.get(key) ?? 0) + 1; deployStatusPollCounts.set(key, count); @@ -116,6 +148,9 @@ export const mockDeployApi: DeployApi = { async patchInstanceConfig() { await simulateServerLatency(); + if (mockOptions.failOAuthSave) { + throw simulatedDeployApiFailure("OAuth credential save"); + } return {}; }, }; diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 383af104..bf9af46d 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -32,8 +32,27 @@ const mockValidateCloning = mock(); const mockGetDeployStatus = mock(); const mockRetrySSL = mock(); const mockRetryMail = mock(); +const mockConfigureMockDeployApi = mock(); const mockDomainConnectUrl = mock(); +type DeployApiMockOptions = { + failValidateCloning?: boolean; + failCreateProductionInstance?: boolean; + failDnsVerification?: boolean; + failOAuthSave?: boolean; +}; + +let mockDeployApiOptions: DeployApiMockOptions = {}; + +function configureMockDeployApi(options: DeployApiMockOptions = {}) { + mockConfigureMockDeployApi(options); + mockDeployApiOptions = { ...options }; +} + +function simulatedDeployApiFailure(step: string): Error { + return new Error(`Simulated deploy failure: ${step}.`); +} + mock.module("@inquirer/prompts", () => ({ ...promptsStubs, select: (...args: unknown[]) => mockSelect(...args), @@ -55,15 +74,46 @@ mock.module("../../lib/plapi.ts", () => ({ fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), -})); - -mock.module("./api.ts", () => ({ createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), validateCloning: (...args: unknown[]) => mockValidateCloning(...args), getDeployStatus: (...args: unknown[]) => mockGetDeployStatus(...args), + patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args), retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), - patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), +})); + +mock.module("./api.ts", () => ({ + configureMockDeployApi, + createProductionInstance: (...args: unknown[]) => { + const result = mockCreateProductionInstance(...args); + if (mockDeployApiOptions.failCreateProductionInstance) { + throw simulatedDeployApiFailure("production instance creation"); + } + return result; + }, + validateCloning: (...args: unknown[]) => { + const result = mockValidateCloning(...args); + if (mockDeployApiOptions.failValidateCloning) { + throw simulatedDeployApiFailure("cloning validation"); + } + return result; + }, + getDeployStatus: (...args: unknown[]) => { + const result = mockGetDeployStatus(...args); + if (mockDeployApiOptions.failDnsVerification) { + throw simulatedDeployApiFailure("DNS verification"); + } + return result; + }, + retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args), + retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), + patchInstanceConfig: (...args: unknown[]) => { + const result = mockPatchInstanceConfig(...args); + if (mockDeployApiOptions.failOAuthSave) { + throw simulatedDeployApiFailure("OAuth credential save"); + } + return result; + }, })); mock.module("./domain-connect.ts", () => ({ @@ -188,6 +238,8 @@ describe("deploy", () => { mockGetDeployStatus.mockReset(); mockRetrySSL.mockReset(); mockRetryMail.mockReset(); + mockConfigureMockDeployApi.mockReset(); + mockDeployApiOptions = {}; mockDomainConnectUrl.mockReset(); consoleSpy?.mockRestore(); stderrSpy?.mockRestore(); @@ -900,6 +952,9 @@ describe("deploy", () => { expect(mockValidateCloning).toHaveBeenCalledWith("app_xyz789", { clone_instance_id: "ins_dev_123", }); + expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( + expect.objectContaining({ failValidateCloning: true }), + ); expect(mockCreateProductionInstance).not.toHaveBeenCalled(); }); @@ -918,6 +973,9 @@ describe("deploy", () => { home_url: "example.com", clone_instance_id: "ins_dev_123", }); + expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( + expect.objectContaining({ failCreateProductionInstance: true }), + ); }); test("--test-fail-dns-verification simulates DNS verification failure", async () => { @@ -941,6 +999,9 @@ describe("deploy", () => { ); expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock"); + expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( + expect.objectContaining({ failDnsVerification: true }), + ); expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); }); @@ -970,6 +1031,9 @@ describe("deploy", () => { client_secret: "google-secret", }, }); + expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( + expect.objectContaining({ failOAuthSave: true }), + ); }); test("plain deploy resumes DNS verification from live API state", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 34eaa544..20bf73ca 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,5 +1,4 @@ import { isAgent } from "../../mode.ts"; -import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { sleep } from "../../lib/sleep.ts"; import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; @@ -18,6 +17,7 @@ import { type ApplicationDomain, } from "../../lib/plapi.ts"; import { + configureMockDeployApi, createProductionInstance as apiCreateProductionInstance, getDeployStatus, patchInstanceConfig, @@ -75,6 +75,16 @@ type DeployOptions = { testFailOAuthSave?: boolean; }; +type DeployTestFlags = Pick< + DeployContext, + "testForceProductionInstance" | "testFailProductionInstanceCheck" | "testFailDomainLookup" +> & { + testFailValidateCloning?: boolean; + testFailCreateProductionInstance?: boolean; + testFailDnsVerification?: boolean; + testFailOAuthSave?: boolean; +}; + const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000; const DEPLOY_STATUS_MAX_POLLS = 100; @@ -113,9 +123,11 @@ export async function deploy(options: DeployOptions = {}) { async function resolveDeployContext(options: DeployOptions): Promise { const testFlags = resolveTestDeployFlags(options); + configureDeployApiMocks(testFlags); const resolved = await withSpinner("Resolving linked Clerk application...", () => resolveProfile(process.cwd()), ); + const commandTestFlags = resolveCommandTestFlags(testFlags); if (!resolved) { return { profileKey: process.cwd(), @@ -127,14 +139,14 @@ async function resolveDeployContext(options: DeployOptions): Promise withTestFailureAfterApiCall( resolveLiveApplicationContext(resolved.profile, { @@ -147,18 +159,7 @@ async function resolveDeployContext(options: DeployOptions): Promise { +function resolveTestDeployFlags(options: DeployOptions): DeployTestFlags { return { testForceProductionInstance: options.testForceProductionInstance === true, testFailProductionInstanceCheck: options.testFailProductionInstanceCheck === true, @@ -170,6 +171,28 @@ function resolveTestDeployFlags( }; } +function resolveCommandTestFlags( + testFlags: DeployTestFlags, +): Pick< + DeployContext, + "testForceProductionInstance" | "testFailProductionInstanceCheck" | "testFailDomainLookup" +> { + return { + testForceProductionInstance: testFlags.testForceProductionInstance, + testFailProductionInstanceCheck: testFlags.testFailProductionInstanceCheck, + testFailDomainLookup: testFlags.testFailDomainLookup, + }; +} + +function configureDeployApiMocks(testFlags: DeployTestFlags): void { + configureMockDeployApi({ + failValidateCloning: testFlags.testFailValidateCloning, + failCreateProductionInstance: testFlags.testFailCreateProductionInstance, + failDnsVerification: testFlags.testFailDnsVerification, + failOAuthSave: testFlags.testFailOAuthSave, + }); +} + function simulatedDeployApiFailure(step: string): PlapiError { return new PlapiError( 500, @@ -574,11 +597,7 @@ function discoverEnabledOAuthProviders(config: Record): Discove async function runValidateCloning(ctx: DeployContext): Promise { await withSpinner("Validating subscription compatibility...", async () => { - await withTestFailureAfterApiCall( - validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }), - ctx.testFailValidateCloning, - "cloning validation", - ); + await validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }); }); } @@ -587,14 +606,10 @@ async function createProductionInstance( domain: string, ): Promise { return withSpinner("Creating production instance...", async () => { - return withTestFailureAfterApiCall( - apiCreateProductionInstance(ctx.appId, { - home_url: domain, - clone_instance_id: ctx.developmentInstanceId, - }), - ctx.testFailCreateProductionInstance, - "production instance creation", - ); + return apiCreateProductionInstance(ctx.appId, { + home_url: domain, + clone_instance_id: ctx.developmentInstanceId, + }); }); } @@ -698,9 +713,6 @@ async function runDnsVerification( const verified = await withSpinner(`Verifying DNS for ${state.domain}...`, async () => { for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) { const result = await getDeployStatus(ctx.appId, productionInstanceId); - if (ctx.testFailDnsVerification) { - throw simulatedDeployApiFailure("DNS verification"); - } if (result.status === "complete") return true; await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS); } @@ -820,16 +832,12 @@ async function collectAndSaveOAuthCredentials( ); await withSpinner(`Saving ${label} OAuth credentials...`, async () => { - await withTestFailureAfterApiCall( - patchInstanceConfig(ctx.appId, productionInstanceId, { - [`connection_oauth_${provider}`]: { - enabled: true, - ...credentials, - }, - }), - ctx.testFailOAuthSave, - "OAuth credential save", - ); + await patchInstanceConfig(ctx.appId, productionInstanceId, { + [`connection_oauth_${provider}`]: { + enabled: true, + ...credentials, + }, + }); }); log.success(`Saved ${label} OAuth credentials`); return true; diff --git a/packages/cli-core/src/commands/deploy/state.ts b/packages/cli-core/src/commands/deploy/state.ts index 5ea4ad06..db76c318 100644 --- a/packages/cli-core/src/commands/deploy/state.ts +++ b/packages/cli-core/src/commands/deploy/state.ts @@ -26,10 +26,6 @@ export type DeployContext = { testForceProductionInstance?: boolean; testFailProductionInstanceCheck?: boolean; testFailDomainLookup?: boolean; - testFailValidateCloning?: boolean; - testFailCreateProductionInstance?: boolean; - testFailDnsVerification?: boolean; - testFailOAuthSave?: boolean; }; export function pausedStepDescription(state: DeployOperationState): string { From f598e638cd29ff47c19e19ef92e6866e6685380d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:30:09 -0600 Subject: [PATCH 09/20] refactor(deploy): extract mock api into its own module --- .../cli-core/src/commands/deploy/README.md | 4 +- .../cli-core/src/commands/deploy/api.test.ts | 13 +- packages/cli-core/src/commands/deploy/api.ts | 114 +--------- .../cli-core/src/commands/deploy/index.ts | 104 +-------- .../cli-core/src/commands/deploy/mock.test.ts | 163 ++++++++++++++ packages/cli-core/src/commands/deploy/mock.ts | 207 ++++++++++++++++++ 6 files changed, 391 insertions(+), 214 deletions(-) create mode 100644 packages/cli-core/src/commands/deploy/mock.test.ts create mode 100644 packages/cli-core/src/commands/deploy/mock.ts diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 0d233543..235d8c2b 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,6 +1,6 @@ # Deploy Command -> **API-resolved state, mocked lifecycle.** Human mode resolves the linked application, production domains, deploy status, and instance config from the API layer on each run. Application/domain/config reads use live PLAPI helpers; production lifecycle calls (`validate_cloning`, `production_instance`, `deploy_status`, `ssl_retry`, `mail_retry`) plus production config PATCH still go through `commands/deploy/api.ts`, where they are mocked with the real Platform API request/response shapes. +> **API-resolved state, mocked lifecycle.** Human mode resolves the linked application, production domains, deploy status, and instance config from the API layer on each run. Application/domain/config reads use live PLAPI helpers; production lifecycle calls (`validate_cloning`, `production_instance`, `deploy_status`, `ssl_retry`, `mail_retry`) plus production config PATCH still go through the dispatchers in `commands/deploy/api.ts`, which route to `commands/deploy/mock.ts`, where they are mocked with the real Platform API request/response shapes. All test-flag plumbing and failure-injection helpers also live in `mock.ts` so the surface to delete when the real backend lands is contained to one file. Guides a user through deploying their Clerk application to production. @@ -47,7 +47,7 @@ The production-instance lifecycle still calls the helpers in `commands/deploy/ap This keeps `clerk deploy` from drifting away from the server-side source of truth once these endpoints are backed by production data. Each run resolves the current production instance, domain, deploy status, and OAuth config from the API layer, then prints a checked-off plan before completing the next unfinished action. Re-running `clerk deploy` after production is fully configured shows every deploy action checked off and prints production next steps. -Mocked lifecycle endpoints in `commands/deploy/api.ts` pause for ~2s before returning so spinners and the deploy-status poll feel like real network calls. +Mocked lifecycle endpoints in `commands/deploy/mock.ts` pause for ~2s before returning so spinners and the deploy-status poll feel like real network calls. If the user presses Ctrl-C after the production instance has been created, the wizard tells them to run `clerk deploy` again and exits with SIGINT code 130. The next run derives the current DNS or OAuth step from API state and resumes without starting another production instance. diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts index a31dd22c..fb9cc7f0 100644 --- a/packages/cli-core/src/commands/deploy/api.test.ts +++ b/packages/cli-core/src/commands/deploy/api.test.ts @@ -22,14 +22,11 @@ mock.module("../../lib/sleep.ts", () => ({ })); const deployApiModulePath = "./api.ts?adapter-test"; -const { - createProductionInstance, - configureMockDeployApi, - getDeployStatus, - patchInstanceConfig, - validateCloning, - _resetDeployStatusMock, -} = (await import(deployApiModulePath)) as typeof import("./api.ts"); +const apiModule = (await import(deployApiModulePath)) as typeof import("./api.ts"); +const mockModule = (await import("./mock.ts")) as typeof import("./mock.ts"); +const { createProductionInstance, getDeployStatus, patchInstanceConfig, validateCloning } = + apiModule; +const { configureMockDeployApi, _resetDeployStatusMock } = mockModule; describe("deploy api adapter", () => { beforeEach(() => { diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts index 99a79257..60dd1c69 100644 --- a/packages/cli-core/src/commands/deploy/api.ts +++ b/packages/cli-core/src/commands/deploy/api.ts @@ -8,8 +8,6 @@ * locally. */ -import { sleep } from "../../lib/sleep.ts"; -import { PlapiError } from "../../lib/errors.ts"; import { createProductionInstance as liveCreateProductionInstance, getDeployStatus as liveGetDeployStatus, @@ -23,6 +21,9 @@ import { type ProductionInstanceResponse, type ValidateCloningParams, } from "../../lib/plapi.ts"; +import { mockDeployApi } from "./mock.ts"; + +export { configureMockDeployApi } from "./mock.ts"; export type { CnameTarget, @@ -32,7 +33,7 @@ export type { ValidateCloningParams, } from "../../lib/plapi.ts"; -type DeployApi = { +export type DeployApi = { createProductionInstance: ( applicationId: string, params: CreateProductionInstanceParams, @@ -48,113 +49,6 @@ type DeployApi = { ) => Promise>; }; -const MOCK_PRODUCTION_INSTANCE_ID = "MOCKED_NOT_REAL_FIXME"; -const MOCK_DOMAIN_ID = "MOCKED_NOT_REAL_FIXME"; -const MOCK_PUBLISHABLE_KEY = "MOCKED_NOT_REAL_FIXME"; -const MOCK_SECRET_KEY = "MOCKED_NOT_REAL_FIXME"; -const MOCK_LATENCY_MS = 2000; -const MOCK_INCOMPLETE_POLLS = 2; - -type DeployApiMockOptions = { - failValidateCloning?: boolean; - failCreateProductionInstance?: boolean; - failDnsVerification?: boolean; - failOAuthSave?: boolean; -}; - -let mockOptions: DeployApiMockOptions = {}; - -export function configureMockDeployApi(options: DeployApiMockOptions = {}): void { - mockOptions = { ...options }; -} - -function simulatedDeployApiFailure(step: string): PlapiError { - return new PlapiError( - 500, - JSON.stringify({ errors: [{ message: `Simulated deploy failure: ${step}.` }] }), - "clerk deploy test flag", - ); -} - -async function simulateServerLatency(): Promise { - await sleep(MOCK_LATENCY_MS); -} - -function defaultCnameTargets(domain: string): CnameTarget[] { - return [ - { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, - { host: `accounts.${domain}`, value: "accounts.clerk.services", required: true }, - { - host: `clkmail.${domain}`, - value: `mail.${domain}.nam1.clerk.services`, - required: true, - }, - ]; -} - -const deployStatusPollCounts = new Map(); - -export function _resetDeployStatusMock(): void { - deployStatusPollCounts.clear(); - configureMockDeployApi(); -} - -export const mockDeployApi: DeployApi = { - async createProductionInstance(_applicationId, params) { - await simulateServerLatency(); - if (mockOptions.failCreateProductionInstance) { - throw simulatedDeployApiFailure("production instance creation"); - } - return { - instance_id: MOCK_PRODUCTION_INSTANCE_ID, - environment_type: "production", - active_domain: { - id: MOCK_DOMAIN_ID, - name: params.home_url, - }, - secret_key: MOCK_SECRET_KEY, - publishable_key: MOCK_PUBLISHABLE_KEY, - cname_targets: defaultCnameTargets(params.home_url), - }; - }, - - async validateCloning() { - await simulateServerLatency(); - if (mockOptions.failValidateCloning) { - throw simulatedDeployApiFailure("cloning validation"); - } - }, - - async getDeployStatus(applicationId, envOrInsId) { - await simulateServerLatency(); - if (mockOptions.failDnsVerification) { - throw simulatedDeployApiFailure("DNS verification"); - } - const key = `${applicationId}:${envOrInsId}`; - const count = (deployStatusPollCounts.get(key) ?? 0) + 1; - deployStatusPollCounts.set(key, count); - return { - status: count > MOCK_INCOMPLETE_POLLS ? "complete" : "incomplete", - }; - }, - - async retryApplicationDomainSSL() { - await simulateServerLatency(); - }, - - async retryApplicationDomainMail() { - await simulateServerLatency(); - }, - - async patchInstanceConfig() { - await simulateServerLatency(); - if (mockOptions.failOAuthSave) { - throw simulatedDeployApiFailure("OAuth credential save"); - } - return {}; - }, -}; - export const liveDeployApi: DeployApi = { createProductionInstance: liveCreateProductionInstance, validateCloning: liveValidateCloning, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 20bf73ca..0b110655 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -2,15 +2,9 @@ import { isAgent } from "../../mode.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { sleep } from "../../lib/sleep.ts"; import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; -import { - PlapiError, - UserAbortError, - isPromptExitError, - throwUsageError, -} from "../../lib/errors.ts"; +import { UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; import { resolveProfile, setProfile } from "../../lib/config.ts"; import { - type Application, fetchApplication, fetchInstanceConfig, listApplicationDomains, @@ -25,6 +19,15 @@ import { type CnameTarget, type ProductionInstanceResponse, } from "./api.ts"; +import { + mockProductionDomain, + mockProductionInstanceConfig, + resolveTestDeployFlags, + simulatedDeployApiFailure, + withMockProductionInstance, + withTestFailureAfterApiCall, + type DeployTestFlags, +} from "./mock.ts"; import { domainConnectUrl } from "./domain-connect.ts"; import { INTRO_PREAMBLE, @@ -75,16 +78,6 @@ type DeployOptions = { testFailOAuthSave?: boolean; }; -type DeployTestFlags = Pick< - DeployContext, - "testForceProductionInstance" | "testFailProductionInstanceCheck" | "testFailDomainLookup" -> & { - testFailValidateCloning?: boolean; - testFailCreateProductionInstance?: boolean; - testFailDnsVerification?: boolean; - testFailOAuthSave?: boolean; -}; - const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000; const DEPLOY_STATUS_MAX_POLLS = 100; @@ -159,18 +152,6 @@ async function resolveDeployContext(options: DeployOptions): Promise( - promise: Promise, - shouldFail: boolean | undefined, - step: string, -): Promise { - const result = await promise; - if (shouldFail) { - throw simulatedDeployApiFailure(step); - } - return result; -} - async function resolveLiveApplicationContext( profile: DeployContext["profile"], options: { forceMockProductionInstance?: boolean } = {}, @@ -236,23 +197,6 @@ async function resolveLiveApplicationContext( }; } -function withMockProductionInstance(app: Application): Application { - if (app.instances.some((entry) => entry.environment_type === "production")) { - return app; - } - return { - ...app, - instances: [ - ...app.instances, - { - instance_id: "ins_prod_mock", - environment_type: "production", - publishable_key: "pk_live_test", - }, - ], - }; -} - async function runDeploy(ctx: DeployContext): Promise { if (!ctx.appId || !ctx.developmentInstanceId) { log.blank(); @@ -488,34 +432,6 @@ async function loadProductionDomain(ctx: DeployContext): Promise !domain.is_satellite) ?? domains.data[0]; } -function mockProductionDomain(): ApplicationDomain { - return { - object: "domain", - id: "dmn_prod_mock", - name: "example.com", - is_satellite: false, - is_provider_domain: false, - frontend_api_url: "https://clerk.example.com", - accounts_portal_url: "https://accounts.example.com", - development_origin: "", - cname_targets: [ - { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, - { host: "accounts.example.com", value: "accounts.clerk.services", required: true }, - { - host: "clkmail.example.com", - value: "mail.example.com.nam1.clerk.services", - required: true, - }, - ], - created_at: "2026-05-06T00:00:00Z", - updated_at: "2026-05-06T00:00:00Z", - }; -} - -function mockProductionInstanceConfig(): Record { - return {}; -} - function hasProductionOAuthCredentials( config: Record, provider: OAuthProvider, diff --git a/packages/cli-core/src/commands/deploy/mock.test.ts b/packages/cli-core/src/commands/deploy/mock.test.ts new file mode 100644 index 00000000..a4a711f2 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/mock.test.ts @@ -0,0 +1,163 @@ +import { test, expect, describe } from "bun:test"; +import type { Application } from "../../lib/plapi.ts"; +import { PlapiError } from "../../lib/errors.ts"; +import { + resolveTestDeployFlags, + withMockProductionInstance, + withTestFailureAfterApiCall, +} from "./mock.ts"; + +describe("resolveTestDeployFlags", () => { + test("normalizes every undefined flag to false", () => { + expect(resolveTestDeployFlags({})).toEqual({ + testForceProductionInstance: false, + testFailProductionInstanceCheck: false, + testFailDomainLookup: false, + testFailValidateCloning: false, + testFailCreateProductionInstance: false, + testFailDnsVerification: false, + testFailOAuthSave: false, + }); + }); + + test("preserves true flags and leaves siblings false", () => { + expect( + resolveTestDeployFlags({ + testForceProductionInstance: true, + testFailDnsVerification: true, + }), + ).toEqual({ + testForceProductionInstance: true, + testFailProductionInstanceCheck: false, + testFailDomainLookup: false, + testFailValidateCloning: false, + testFailCreateProductionInstance: false, + testFailDnsVerification: true, + testFailOAuthSave: false, + }); + }); + + test("coerces non-true truthy values to false (strict identity check)", () => { + // The implementation uses `=== true`, so anything other than literal `true` + // (including a stray non-boolean leaking through the option parser) must + // normalize to false rather than be passed through. + const result = resolveTestDeployFlags({ + testForceProductionInstance: 1 as unknown as boolean, + testFailOAuthSave: "yes" as unknown as boolean, + }); + expect(result.testForceProductionInstance).toBe(false); + expect(result.testFailOAuthSave).toBe(false); + }); +}); + +describe("withTestFailureAfterApiCall", () => { + test("resolves with the awaited value when shouldFail is falsy", async () => { + await expect(withTestFailureAfterApiCall(Promise.resolve("ok"), false, "step")).resolves.toBe( + "ok", + ); + await expect( + withTestFailureAfterApiCall(Promise.resolve("ok"), undefined, "step"), + ).resolves.toBe("ok"); + }); + + test("awaits the promise before throwing when shouldFail is true", async () => { + let resolved = false; + const pending = (async () => { + await Promise.resolve(); + resolved = true; + return "value"; + })(); + + await expect( + withTestFailureAfterApiCall(pending, true, "production instance check"), + ).rejects.toBeInstanceOf(PlapiError); + expect(resolved).toBe(true); + }); + + test("throws a PlapiError carrying the step in its message", async () => { + let error: unknown; + try { + await withTestFailureAfterApiCall(Promise.resolve(null), true, "DNS verification"); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(PlapiError); + expect((error as Error).message).toContain("Simulated deploy failure: DNS verification."); + }); + + test("does not swallow rejections from the upstream promise", async () => { + const upstreamFailure = new Error("upstream boom"); + await expect( + withTestFailureAfterApiCall(Promise.reject(upstreamFailure), true, "step"), + ).rejects.toBe(upstreamFailure); + }); +}); + +describe("withMockProductionInstance", () => { + function app(instances: Application["instances"]): Application { + return { + application_id: "app_xyz789", + name: "my-saas-app", + instances, + }; + } + + test("returns the input unchanged when a production instance already exists", () => { + const existing = app([ + { + instance_id: "ins_dev_123", + environment_type: "development", + publishable_key: "pk_test_123", + }, + { + instance_id: "ins_prod_real", + environment_type: "production", + publishable_key: "pk_live_real", + }, + ]); + + const result = withMockProductionInstance(existing); + expect(result).toBe(existing); + expect(result.instances).toBe(existing.instances); + }); + + test("appends a mock production instance when none is present", () => { + const devOnly = app([ + { + instance_id: "ins_dev_123", + environment_type: "development", + publishable_key: "pk_test_123", + }, + ]); + + const result = withMockProductionInstance(devOnly); + + // Original input is not mutated. + expect(devOnly.instances).toHaveLength(1); + expect(result).not.toBe(devOnly); + expect(result.instances).toHaveLength(2); + expect(result.instances[0]).toEqual(devOnly.instances[0]!); + expect(result.instances[1]).toEqual({ + instance_id: "ins_prod_mock", + environment_type: "production", + publishable_key: "pk_live_test", + }); + }); + + test("appends production even when only non-development instances exist", () => { + const stagingOnly = app([ + { + instance_id: "ins_staging_123", + environment_type: "staging", + publishable_key: "pk_staging_123", + }, + ]); + + const result = withMockProductionInstance(stagingOnly); + expect(result.instances).toHaveLength(2); + expect( + result.instances.some((instance) => instance.environment_type === "production"), + ).toBe(true); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/mock.ts b/packages/cli-core/src/commands/deploy/mock.ts new file mode 100644 index 00000000..f0d58036 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/mock.ts @@ -0,0 +1,207 @@ +/** + * Test-only mocked deploy lifecycle. + * + * The deploy command runs against this in-process mock until the production- + * instance backend is built. All test-flag plumbing and failure-injection + * helpers also live here so the surface to delete when the real backend + * lands is obvious. + */ + +import { sleep } from "../../lib/sleep.ts"; +import { PlapiError } from "../../lib/errors.ts"; +import type { Application, ApplicationDomain } from "../../lib/plapi.ts"; +import type { CnameTarget, DeployApi } from "./api.ts"; + +const MOCK_PRODUCTION_INSTANCE_ID = "MOCKED_NOT_REAL_FIXME"; +const MOCK_DOMAIN_ID = "MOCKED_NOT_REAL_FIXME"; +const MOCK_PUBLISHABLE_KEY = "MOCKED_NOT_REAL_FIXME"; +const MOCK_SECRET_KEY = "MOCKED_NOT_REAL_FIXME"; +const MOCK_LATENCY_MS = 2000; +const MOCK_INCOMPLETE_POLLS = 2; + +export type DeployApiMockOptions = { + failValidateCloning?: boolean; + failCreateProductionInstance?: boolean; + failDnsVerification?: boolean; + failOAuthSave?: boolean; +}; + +export type DeployTestFlags = { + testForceProductionInstance?: boolean; + testFailProductionInstanceCheck?: boolean; + testFailDomainLookup?: boolean; + testFailValidateCloning?: boolean; + testFailCreateProductionInstance?: boolean; + testFailDnsVerification?: boolean; + testFailOAuthSave?: boolean; +}; + +let mockOptions: DeployApiMockOptions = {}; + +export function configureMockDeployApi(options: DeployApiMockOptions = {}): void { + mockOptions = { ...options }; +} + +export function resolveTestDeployFlags(options: { + testForceProductionInstance?: boolean; + testFailProductionInstanceCheck?: boolean; + testFailDomainLookup?: boolean; + testFailValidateCloning?: boolean; + testFailCreateProductionInstance?: boolean; + testFailDnsVerification?: boolean; + testFailOAuthSave?: boolean; +}): DeployTestFlags { + return { + testForceProductionInstance: options.testForceProductionInstance === true, + testFailProductionInstanceCheck: options.testFailProductionInstanceCheck === true, + testFailDomainLookup: options.testFailDomainLookup === true, + testFailValidateCloning: options.testFailValidateCloning === true, + testFailCreateProductionInstance: options.testFailCreateProductionInstance === true, + testFailDnsVerification: options.testFailDnsVerification === true, + testFailOAuthSave: options.testFailOAuthSave === true, + }; +} + +export function simulatedDeployApiFailure(step: string): PlapiError { + return new PlapiError( + 500, + JSON.stringify({ errors: [{ message: `Simulated deploy failure: ${step}.` }] }), + "clerk deploy test flag", + ); +} + +export async function withTestFailureAfterApiCall( + promise: Promise, + shouldFail: boolean | undefined, + step: string, +): Promise { + const result = await promise; + if (shouldFail) { + throw simulatedDeployApiFailure(step); + } + return result; +} + +async function simulateServerLatency(): Promise { + await sleep(MOCK_LATENCY_MS); +} + +function defaultCnameTargets(domain: string): CnameTarget[] { + return [ + { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, + { host: `accounts.${domain}`, value: "accounts.clerk.services", required: true }, + { + host: `clkmail.${domain}`, + value: `mail.${domain}.nam1.clerk.services`, + required: true, + }, + ]; +} + +const deployStatusPollCounts = new Map(); + +export function _resetDeployStatusMock(): void { + deployStatusPollCounts.clear(); + configureMockDeployApi(); +} + +export const mockDeployApi: DeployApi = { + async createProductionInstance(_applicationId, params) { + await simulateServerLatency(); + if (mockOptions.failCreateProductionInstance) { + throw simulatedDeployApiFailure("production instance creation"); + } + return { + instance_id: MOCK_PRODUCTION_INSTANCE_ID, + environment_type: "production", + active_domain: { + id: MOCK_DOMAIN_ID, + name: params.home_url, + }, + secret_key: MOCK_SECRET_KEY, + publishable_key: MOCK_PUBLISHABLE_KEY, + cname_targets: defaultCnameTargets(params.home_url), + }; + }, + + async validateCloning() { + await simulateServerLatency(); + if (mockOptions.failValidateCloning) { + throw simulatedDeployApiFailure("cloning validation"); + } + }, + + async getDeployStatus(applicationId, envOrInsId) { + await simulateServerLatency(); + if (mockOptions.failDnsVerification) { + throw simulatedDeployApiFailure("DNS verification"); + } + const key = `${applicationId}:${envOrInsId}`; + const count = (deployStatusPollCounts.get(key) ?? 0) + 1; + deployStatusPollCounts.set(key, count); + return { + status: count > MOCK_INCOMPLETE_POLLS ? "complete" : "incomplete", + }; + }, + + async retryApplicationDomainSSL() { + await simulateServerLatency(); + }, + + async retryApplicationDomainMail() { + await simulateServerLatency(); + }, + + async patchInstanceConfig() { + await simulateServerLatency(); + if (mockOptions.failOAuthSave) { + throw simulatedDeployApiFailure("OAuth credential save"); + } + return {}; + }, +}; + +export function withMockProductionInstance(app: Application): Application { + if (app.instances.some((entry) => entry.environment_type === "production")) { + return app; + } + return { + ...app, + instances: [ + ...app.instances, + { + instance_id: "ins_prod_mock", + environment_type: "production", + publishable_key: "pk_live_test", + }, + ], + }; +} + +export function mockProductionDomain(): ApplicationDomain { + return { + object: "domain", + id: "dmn_prod_mock", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "accounts.example.com", value: "accounts.clerk.services", required: true }, + { + host: "clkmail.example.com", + value: "mail.example.com.nam1.clerk.services", + required: true, + }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }; +} + +export function mockProductionInstanceConfig(): Record { + return {}; +} From 347a01af92f2d5f59ef08060fd4b63501e815141 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 19 May 2026 18:35:50 -0300 Subject: [PATCH 10/20] refactor(deploy): route lifecycle through live PLAPI and map errors Switch the deploy command from the in-process mock lifecycle to the live PLAPI endpoints. Add a typed error mapper that translates known PLAPI failures (plan_insufficient, home_url_taken, ssl_retry_throttled, etc.) into CliError with stable codes, with a recovery path for the production_instance_exists case so the wizard re-derives state instead of surfacing the error. Surface per-component progress (DNS / SSL / mail) during deploy_status polling, and drop the four hidden --test-fail-* CLI options now that failure injection is routed through the test's module mock. Collapse the api/mock indirection layer to a thin re-export of the plapi endpoints + a no-op configureMockDeployApi stub for the test seam, and strip the dead mockDeployApi implementation that the indirection used to back. --- README.md | 1 + packages/cli-core/src/cli-program.test.ts | 4 - packages/cli-core/src/cli-program.ts | 23 +-- .../cli-core/src/commands/deploy/README.md | 57 +++--- .../cli-core/src/commands/deploy/api.test.ts | 148 +++++++-------- packages/cli-core/src/commands/deploy/api.ts | 91 +++------- packages/cli-core/src/commands/deploy/copy.ts | 40 +++++ .../cli-core/src/commands/deploy/errors.ts | 169 ++++++++++++++++++ .../src/commands/deploy/index.test.ts | 114 ++++++++---- .../cli-core/src/commands/deploy/index.ts | 111 ++++++++---- .../cli-core/src/commands/deploy/mock.test.ts | 6 +- packages/cli-core/src/commands/deploy/mock.ts | 119 +----------- packages/cli-core/src/lib/errors.ts | 16 ++ packages/cli-core/src/lib/plapi.test.ts | 12 +- packages/cli-core/src/lib/plapi.ts | 3 + 15 files changed, 515 insertions(+), 399 deletions(-) create mode 100644 packages/cli-core/src/commands/deploy/errors.ts 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 67ecb7e9..2c2ac424 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -55,10 +55,6 @@ test("deploy exposes the expected options", () => { "--test-force-production-instance", "--test-fail-production-instance-check", "--test-fail-domain-lookup", - "--test-fail-validate-cloning", - "--test-fail-create-production-instance", - "--test-fail-dns-verification", - "--test-fail-oauth-save", ]); }); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 8ca24aa1..78cb8011 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -923,7 +923,7 @@ Tutorial — enable completions for your shell: .action(update); program - .command("deploy", { hidden: true }) + .command("deploy") .description("Deploy a Clerk application to production") .option("--debug", "Show detailed deployment debug output") .addOption( @@ -944,27 +944,6 @@ Tutorial — enable completions for your shell: "Simulate a deploy failure while loading the production domain", ), ) - .addOption( - createOption( - "--test-fail-validate-cloning", - "Simulate a deploy failure while validating cloning", - ), - ) - .addOption( - createOption( - "--test-fail-create-production-instance", - "Simulate a deploy failure while creating the production instance", - ), - ) - .addOption( - createOption("--test-fail-dns-verification", "Simulate a deploy failure while verifying DNS"), - ) - .addOption( - createOption( - "--test-fail-oauth-save", - "Simulate a deploy failure while saving OAuth credentials", - ), - ) .action(deploy); registerExtras(program); diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 235d8c2b..3af963f2 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,6 +1,6 @@ # Deploy Command -> **API-resolved state, mocked lifecycle.** Human mode resolves the linked application, production domains, deploy status, and instance config from the API layer on each run. Application/domain/config reads use live PLAPI helpers; production lifecycle calls (`validate_cloning`, `production_instance`, `deploy_status`, `ssl_retry`, `mail_retry`) plus production config PATCH still go through the dispatchers in `commands/deploy/api.ts`, which route to `commands/deploy/mock.ts`, where they are mocked with the real Platform API request/response shapes. All test-flag plumbing and failure-injection helpers also live in `mock.ts` so the surface to delete when the real backend lands is contained to one file. +> **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`, `ssl_retry`, `mail_retry`) route through `commands/deploy/api.ts` to the live PLAPI helpers in `lib/plapi.ts`. PLAPI error codes are translated to typed `CliError`s by `commands/deploy/errors.ts`. The mock helpers in `commands/deploy/mock.ts` remain for tests that mock `lib/plapi.ts` directly. Guides a user through deploying their Clerk application to production. @@ -30,24 +30,21 @@ Agent mode is detected via the mode system (`src/mode.ts`), which checks in prio Agent mode does not call PLAPI and exits before the human-mode wizard starts. -## PLAPI And Mocked Lifecycle +## PLAPI Lifecycle -Human mode reads deploy state through the API layer: application instances, production domains, development config, production config, and deploy status. It does not write deploy progress to the CLI config profile. The only config compatibility write is the ordinary linked-profile `instances.production` value. +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. -The production-instance lifecycle still calls the helpers in `commands/deploy/api.ts`. They use the exact request/response shapes published in the Platform API OpenAPI spec, but the bodies are produced locally so the wizard can simulate server-side deploy states while the production-instance backend remains mocked. +| Step | Endpoint | 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`. | +| 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`. CLI polls every 3s up to ~5 minutes. | +| 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. | -| Step | Endpoint | Mocked state | -| -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| Validate cloning | `POST /v1/platform/applications/{appID}/validate_cloning` | Resolves to 204; the helper exists so 402 `UnsupportedSubscriptionPlanFeatures` errors can later short-circuit before summary. | -| Create production instance | `POST /v1/platform/applications/{appID}/production_instance` | Returns `instance_id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key`, and `cname_targets[]`. | -| Poll deploy status | `GET /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Returns `incomplete` for the first two polls per `(appID, instanceID)` pair, then `complete`. CLI polls every 3s. | -| Retry SSL provisioning | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | Resolves to 204; helper exposed for use when `deploy_status` stalls. | -| Retry mail verification | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | Resolves to 204; helper exposed for use when `deploy_status` stalls. | -| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Resolves to `{}` without hitting the network. | - -This keeps `clerk deploy` from drifting away from the server-side source of truth once these endpoints are backed by production data. Each run resolves the current production instance, domain, deploy status, and OAuth config from the API layer, then prints a checked-off plan before completing the next unfinished action. Re-running `clerk deploy` after production is fully configured shows every deploy action checked off and prints production next steps. - -Mocked lifecycle endpoints in `commands/deploy/mock.ts` pause for ~2s before returning so spinners and the deploy-status poll feel like real network calls. +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. @@ -117,20 +114,20 @@ sequenceDiagram ## API Endpoints -All endpoints are on the **Platform API** (`/v1/platform/...`). The "Real" rows are live HTTP calls today; the "Mock" rows are wired through `commands/deploy/api.ts` with shapes that match the published OpenAPI spec exactly. - -| Step | Method | Endpoint | Status | Helper | -| -------------------------- | ------- | ------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Auth | n/a | Local config | Real | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | -| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Real | `fetchInstanceConfig` from `lib/plapi.ts`. Used to discover enabled `connection_oauth_*` providers in dev. | -| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Mock | `patchInstanceConfig` in `commands/deploy/api.ts`. Writes production OAuth credentials once switched to live PLAPI. | -| Read application | `GET` | `/v1/platform/applications/{appID}` | Real | `fetchApplication` from `lib/plapi.ts`. Resolves live development and production instance IDs. | -| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | Real | `listApplicationDomains` from `lib/plapi.ts`. Recovers production domain name and CNAME targets on each run. | -| Validate cloning | `POST` | `/v1/platform/applications/{appID}/validate_cloning` | Mock | `validateCloning` in `commands/deploy/api.ts`. Pre-flights subscription/feature support before plan summary. | -| Create production instance | `POST` | `/v1/platform/applications/{appID}/production_instance` | Mock | `createProductionInstance` in `commands/deploy/api.ts`. Returns prod instance, primary domain, keys, and `cname_targets[]`. | -| Poll deploy status | `GET` | `/v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Mock | `getDeployStatus` in `commands/deploy/api.ts`. CLI polls every 3 seconds while the production instance is provisioning DNS, SSL, and mail. | -| Retry SSL provisioning | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | Mock | `retryApplicationDomainSSL` in `commands/deploy/api.ts`. Available for surfacing to the user when `deploy_status` stalls. | -| Retry mail verification | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | Mock | `retryApplicationDomainMail` in `commands/deploy/api.ts`. Same as above, for SendGrid mail. Rejected on satellite domains. | +All endpoints are on the **Platform API** (`/v1/platform/...`) and are live HTTP calls. The lifecycle endpoints route through `commands/deploy/api.ts` to the helpers in `lib/plapi.ts`. + +| 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[]`. | +| Poll deploy status | `GET` | `/v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | `getDeployStatus`. Polls every 3s; surfaces `dns_ok`/`ssl_ok`/`mail_ok` to the user on timeout. | +| 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 diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts index fb9cc7f0..cb8c9c97 100644 --- a/packages/cli-core/src/commands/deploy/api.test.ts +++ b/packages/cli-core/src/commands/deploy/api.test.ts @@ -6,7 +6,6 @@ const mockPlapiGetDeployStatus = mock(); const mockPlapiPatchInstanceConfig = mock(); const mockPlapiRetryApplicationDomainSSL = mock(); const mockPlapiRetryApplicationDomainMail = mock(); -const mockSleep = mock(); mock.module("../../lib/plapi.ts", () => ({ createProductionInstance: (...args: unknown[]) => mockPlapiCreateProductionInstance(...args), @@ -17,104 +16,93 @@ mock.module("../../lib/plapi.ts", () => ({ retryApplicationDomainMail: (...args: unknown[]) => mockPlapiRetryApplicationDomainMail(...args), })); -mock.module("../../lib/sleep.ts", () => ({ - sleep: (...args: unknown[]) => mockSleep(...args), -})); - const deployApiModulePath = "./api.ts?adapter-test"; const apiModule = (await import(deployApiModulePath)) as typeof import("./api.ts"); -const mockModule = (await import("./mock.ts")) as typeof import("./mock.ts"); -const { createProductionInstance, getDeployStatus, patchInstanceConfig, validateCloning } = - apiModule; -const { configureMockDeployApi, _resetDeployStatusMock } = mockModule; +const { + createProductionInstance, + getDeployStatus, + patchInstanceConfig, + retryApplicationDomainMail, + retryApplicationDomainSSL, + validateCloning, +} = apiModule; -describe("deploy api adapter", () => { +describe("deploy api adapter (live routing)", () => { beforeEach(() => { - mockPlapiCreateProductionInstance.mockImplementation(() => { - throw new Error("live createProductionInstance should not be called"); - }); - mockPlapiValidateCloning.mockImplementation(() => { - throw new Error("live validateCloning should not be called"); - }); - mockPlapiGetDeployStatus.mockImplementation(() => { - throw new Error("live getDeployStatus should not be called"); - }); - mockPlapiPatchInstanceConfig.mockImplementation(() => { - throw new Error("live patchInstanceConfig should not be called"); - }); - mockPlapiRetryApplicationDomainSSL.mockImplementation(() => { - throw new Error("live retryApplicationDomainSSL should not be called"); + mockPlapiCreateProductionInstance.mockReset(); + mockPlapiValidateCloning.mockReset(); + mockPlapiGetDeployStatus.mockReset(); + mockPlapiPatchInstanceConfig.mockReset(); + mockPlapiRetryApplicationDomainSSL.mockReset(); + mockPlapiRetryApplicationDomainMail.mockReset(); + }); + + test("createProductionInstance delegates to lib/plapi.ts", async () => { + mockPlapiCreateProductionInstance.mockResolvedValue({ + instance_id: "ins_prod_live", + environment_type: "production", + active_domain: { id: "dmn_live", name: "example.com" }, + publishable_key: "pk_live_test", + cname_targets: [], }); - mockPlapiRetryApplicationDomainMail.mockImplementation(() => { - throw new Error("live retryApplicationDomainMail should not be called"); + + const result = await createProductionInstance("app_123", { + home_url: "example.com", + clone_instance_id: "ins_dev_123", }); - mockSleep.mockResolvedValue(undefined); - _resetDeployStatusMock(); - }); - test("uses mocked deploy lifecycle operations by default", async () => { - const production = await createProductionInstance("app_123", { + expect(mockPlapiCreateProductionInstance).toHaveBeenCalledWith("app_123", { home_url: "example.com", clone_instance_id: "ins_dev_123", }); + expect(result.instance_id).toBe("ins_prod_live"); + }); + + test("validateCloning delegates to lib/plapi.ts", async () => { + mockPlapiValidateCloning.mockResolvedValue(undefined); await validateCloning("app_123", { clone_instance_id: "ins_dev_123" }); - await patchInstanceConfig("app_123", production.instance_id, { - connection_oauth_google: { enabled: true }, + expect(mockPlapiValidateCloning).toHaveBeenCalledWith("app_123", { + clone_instance_id: "ins_dev_123", }); - - expect(production.instance_id).toBe("MOCKED_NOT_REAL_FIXME"); - expect(production.active_domain.name).toBe("example.com"); - expect(production.cname_targets).toHaveLength(3); - expect(mockPlapiCreateProductionInstance).not.toHaveBeenCalled(); - expect(mockPlapiValidateCloning).not.toHaveBeenCalled(); - expect(mockPlapiPatchInstanceConfig).not.toHaveBeenCalled(); }); - test("mock deploy status represents incomplete then complete server state", async () => { - expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "incomplete" }); - expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "incomplete" }); - expect(await getDeployStatus("app_123", "ins_prod_123")).toEqual({ status: "complete" }); - expect(mockPlapiGetDeployStatus).not.toHaveBeenCalled(); + test("getDeployStatus delegates to lib/plapi.ts and surfaces booleans", async () => { + mockPlapiGetDeployStatus.mockResolvedValue({ + status: "incomplete", + dns_ok: true, + ssl_ok: false, + mail_ok: false, + }); + const result = await getDeployStatus("app_123", "production"); + expect(mockPlapiGetDeployStatus).toHaveBeenCalledWith("app_123", "production"); + expect(result).toEqual({ + status: "incomplete", + dns_ok: true, + ssl_ok: false, + mail_ok: false, + }); }); - test("mock deploy api can fail lifecycle operations with PLAPI-shaped errors", async () => { - configureMockDeployApi({ - failValidateCloning: true, - failCreateProductionInstance: true, - failDnsVerification: true, - failOAuthSave: true, + test("patchInstanceConfig delegates to lib/plapi.ts", async () => { + mockPlapiPatchInstanceConfig.mockResolvedValue({ ok: true }); + const result = await patchInstanceConfig("app_123", "ins_prod_live", { + connection_oauth_google: { enabled: true }, }); - - await expect(validateCloning("app_123", { clone_instance_id: "ins_dev_123" })).rejects.toThrow( - "Simulated deploy failure: cloning validation.", - ); - await expect( - createProductionInstance("app_123", { - home_url: "example.com", - clone_instance_id: "ins_dev_123", - }), - ).rejects.toThrow("Simulated deploy failure: production instance creation."); - await expect(getDeployStatus("app_123", "ins_prod_123")).rejects.toThrow( - "Simulated deploy failure: DNS verification.", - ); - await expect( - patchInstanceConfig("app_123", "ins_prod_123", { - connection_oauth_google: { enabled: true }, - }), - ).rejects.toThrow("Simulated deploy failure: OAuth credential save."); - - expect(mockPlapiValidateCloning).not.toHaveBeenCalled(); - expect(mockPlapiCreateProductionInstance).not.toHaveBeenCalled(); - expect(mockPlapiGetDeployStatus).not.toHaveBeenCalled(); - expect(mockPlapiPatchInstanceConfig).not.toHaveBeenCalled(); + expect(mockPlapiPatchInstanceConfig).toHaveBeenCalledWith("app_123", "ins_prod_live", { + connection_oauth_google: { enabled: true }, + }); + expect(result).toEqual({ ok: true }); }); - test("reset mock deploy api clears lifecycle failure flags", async () => { - configureMockDeployApi({ failValidateCloning: true }); - _resetDeployStatusMock(); + test("retryApplicationDomainSSL delegates to lib/plapi.ts", async () => { + mockPlapiRetryApplicationDomainSSL.mockResolvedValue(undefined); + await retryApplicationDomainSSL("app_123", "example.com"); + expect(mockPlapiRetryApplicationDomainSSL).toHaveBeenCalledWith("app_123", "example.com"); + }); - await expect( - validateCloning("app_123", { clone_instance_id: "ins_dev_123" }), - ).resolves.toBeUndefined(); + test("retryApplicationDomainMail delegates to lib/plapi.ts", async () => { + mockPlapiRetryApplicationDomainMail.mockResolvedValue(undefined); + await retryApplicationDomainMail("app_123", "example.com"); + expect(mockPlapiRetryApplicationDomainMail).toHaveBeenCalledWith("app_123", "example.com"); }); }); diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts index 60dd1c69..5cad71a2 100644 --- a/packages/cli-core/src/commands/deploy/api.ts +++ b/packages/cli-core/src/commands/deploy/api.ts @@ -1,29 +1,19 @@ /** - * Deploy command API adapter. + * Deploy command API surface. * - * Live endpoint wrappers live in `lib/plapi.ts`, but the deploy lifecycle - * remains mocked while the production-instance backend settles. Keep this - * adapter as the switch point: the command resolves deploy progress through - * API-shaped calls, while these lifecycle operations simulate backend states - * locally. + * Re-exports the PLAPI endpoints the deploy lifecycle calls so the test suite + * can substitute the whole adapter via `mock.module("./api.ts", ...)` without + * mocking each plapi call site individually. */ -import { - createProductionInstance as liveCreateProductionInstance, - getDeployStatus as liveGetDeployStatus, - patchInstanceConfig as livePatchInstanceConfig, - retryApplicationDomainMail as liveRetryApplicationDomainMail, - retryApplicationDomainSSL as liveRetryApplicationDomainSSL, - validateCloning as liveValidateCloning, - type CnameTarget, - type CreateProductionInstanceParams, - type DeployStatusResponse, - type ProductionInstanceResponse, - type ValidateCloningParams, +export { + createProductionInstance, + getDeployStatus, + patchInstanceConfig, + retryApplicationDomainMail, + retryApplicationDomainSSL, + validateCloning, } from "../../lib/plapi.ts"; -import { mockDeployApi } from "./mock.ts"; - -export { configureMockDeployApi } from "./mock.ts"; export type { CnameTarget, @@ -33,52 +23,17 @@ export type { ValidateCloningParams, } from "../../lib/plapi.ts"; -export type DeployApi = { - createProductionInstance: ( - applicationId: string, - params: CreateProductionInstanceParams, - ) => Promise; - validateCloning: (applicationId: string, params: ValidateCloningParams) => Promise; - getDeployStatus: (applicationId: string, envOrInsId: string) => Promise; - retryApplicationDomainSSL: (applicationId: string, domainIdOrName: string) => Promise; - retryApplicationDomainMail: (applicationId: string, domainIdOrName: string) => Promise; - patchInstanceConfig: ( - applicationId: string, - instanceId: string, - config: Record, - ) => Promise>; -}; - -export const liveDeployApi: DeployApi = { - createProductionInstance: liveCreateProductionInstance, - validateCloning: liveValidateCloning, - getDeployStatus: liveGetDeployStatus, - retryApplicationDomainSSL: liveRetryApplicationDomainSSL, - retryApplicationDomainMail: liveRetryApplicationDomainMail, - patchInstanceConfig: livePatchInstanceConfig, +export type DeployApiMockOptions = { + failValidateCloning?: boolean; + failCreateProductionInstance?: boolean; + failDnsVerification?: boolean; + failOAuthSave?: boolean; }; -const activeDeployApi: DeployApi = mockDeployApi; - -export const createProductionInstance = ( - applicationId: string, - params: CreateProductionInstanceParams, -) => activeDeployApi.createProductionInstance(applicationId, params); - -export const validateCloning = (applicationId: string, params: ValidateCloningParams) => - activeDeployApi.validateCloning(applicationId, params); - -export const getDeployStatus = (applicationId: string, envOrInsId: string) => - activeDeployApi.getDeployStatus(applicationId, envOrInsId); - -export const retryApplicationDomainSSL = (applicationId: string, domainIdOrName: string) => - activeDeployApi.retryApplicationDomainSSL(applicationId, domainIdOrName); - -export const retryApplicationDomainMail = (applicationId: string, domainIdOrName: string) => - activeDeployApi.retryApplicationDomainMail(applicationId, domainIdOrName); - -export const patchInstanceConfig = ( - applicationId: string, - instanceId: string, - config: Record, -) => activeDeployApi.patchInstanceConfig(applicationId, instanceId, config); +/** + * No-op in production. Tests replace this via `mock.module("./api.ts", ...)` + * to intercept the call and inject lifecycle failures into the mocked + * `createProductionInstance` / `validateCloning` / `getDeployStatus` / + * `patchInstanceConfig` exports above. + */ +export function configureMockDeployApi(_options: DeployApiMockOptions = {}): void {} diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 2a66f1e9..fabcd1d9 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -104,6 +104,46 @@ export function dnsVerified(domain: string): string[] { return [`DNS verified for ${domain}.`]; } +export type DeployComponentStatus = { + dns: boolean; + ssl: boolean; + mail: boolean; +}; + +/** + * 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. 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 bf9af46d..1db7f05e 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -187,32 +187,40 @@ describe("deploy", () => { total_count: 1, }); mockValidateCloning.mockResolvedValue(undefined); - mockGetDeployStatus.mockResolvedValue({ status: "complete" }); + mockGetDeployStatus.mockResolvedValue({ + status: "complete", + dns_ok: true, + ssl_ok: true, + mail_ok: true, + }); mockCreateProductionInstance.mockImplementation( - (_appId: string, params: { home_url: string }) => ({ - instance_id: "ins_prod_mock", - environment_type: "production" as const, - active_domain: { id: "dmn_prod_mock", name: params.home_url }, - publishable_key: "pk_live_test", - secret_key: "sk_live_test", - cname_targets: [ - { - host: `clerk.${params.home_url}`, - value: "frontend-api.clerk.services", - required: true, - }, - { - host: `accounts.${params.home_url}`, - value: "accounts.clerk.services", - required: true, - }, - { - host: `clkmail.${params.home_url}`, - value: `mail.${params.home_url}.nam1.clerk.services`, - required: true, - }, - ], - }), + (_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); }); @@ -523,8 +531,13 @@ describe("deploy", () => { mockSelect.mockResolvedValueOnce("have-credentials"); mockPassword.mockResolvedValueOnce("google-secret"); mockGetDeployStatus - .mockResolvedValueOnce({ status: "incomplete" }) - .mockResolvedValueOnce({ status: "complete" }); + .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({}); @@ -970,7 +983,7 @@ describe("deploy", () => { ); expect(mockCreateProductionInstance).toHaveBeenCalledWith("app_xyz789", { - home_url: "example.com", + home_url: "https://example.com", clone_instance_id: "ins_dev_123", }); expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( @@ -1046,8 +1059,13 @@ describe("deploy", () => { productionConfig: {}, }); mockGetDeployStatus - .mockResolvedValueOnce({ status: "incomplete" }) - .mockResolvedValueOnce({ status: "complete" }); + .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"); @@ -1080,7 +1098,12 @@ describe("deploy", () => { instanceId: "ins_prod_123", productionConfig: {}, }); - mockGetDeployStatus.mockResolvedValue({ status: "incomplete" }); + 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"); @@ -1164,8 +1187,13 @@ describe("deploy", () => { mockPatchInstanceConfig.mockResolvedValueOnce({}); mockGetDeployStatus.mockReset(); mockGetDeployStatus - .mockResolvedValueOnce({ status: "incomplete" }) - .mockResolvedValueOnce({ status: "complete" }); + .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({}); @@ -1237,8 +1265,13 @@ describe("deploy", () => { mockPatchInstanceConfig.mockResolvedValueOnce({}); mockGetDeployStatus.mockReset(); mockGetDeployStatus - .mockResolvedValueOnce({ status: "incomplete" }) - .mockResolvedValueOnce({ status: "complete" }); + .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); @@ -1409,11 +1442,18 @@ describe("deploy", () => { .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - mockGetDeployStatus.mockResolvedValue({ status: "incomplete" }); + 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 time"); + 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"); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 0b110655..e8f3058e 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -33,7 +33,10 @@ import { INTRO_PREAMBLE, NEXT_STEPS_BLOCK, OAUTH_SECTION_INTRO, + type DeployComponentStatus, type DeployPlanStep, + deployComponentStatus, + deployStatusPendingFooter, domainAssociationSummary, dnsDashboardHandoff, dnsIntro, @@ -43,6 +46,7 @@ import { printPlan, productionSummary, } from "./copy.ts"; +import { mapDeployError } from "./errors.ts"; import { PROVIDER_LABELS, PROVIDER_FIELDS, @@ -255,7 +259,21 @@ async function startNewDeploy(ctx: DeployContext): Promise { ); if (!shouldCreateProductionInstance) return; - const production = await createProductionInstance(ctx, domain); + 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(); @@ -391,7 +409,7 @@ async function resolveLiveDeploySnapshot( const [{ known: oauthProviders }, productionConfig, deployStatus] = await Promise.all([ loadDevelopmentOAuthProviders(ctx), productionConfigPromise, - getDeployStatus(ctx.appId, productionInstanceId), + mapDeployError(getDeployStatus(ctx.appId, productionInstanceId)), ]); const completedOAuthProviders = oauthProviders.filter((provider) => hasProductionOAuthCredentials(productionConfig, provider), @@ -513,19 +531,24 @@ function discoverEnabledOAuthProviders(config: Record): Discove async function runValidateCloning(ctx: DeployContext): Promise { await withSpinner("Validating subscription compatibility...", async () => { - await validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }); + await mapDeployError( + validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }), + ); }); } async function createProductionInstance( ctx: DeployContext, domain: string, -): Promise { +): Promise { return withSpinner("Creating production instance...", async () => { - return apiCreateProductionInstance(ctx.appId, { - home_url: domain, - clone_instance_id: ctx.developmentInstanceId, - }); + return mapDeployError( + apiCreateProductionInstance(ctx.appId, { + home_url: `https://${domain}`, + clone_instance_id: ctx.developmentInstanceId, + }), + { onProductionInstanceExists: async () => "exists" }, + ); }); } @@ -626,41 +649,53 @@ async function runDnsVerification( ); } - const verified = await withSpinner(`Verifying DNS for ${state.domain}...`, async () => { - for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) { - const result = await getDeployStatus(ctx.appId, productionInstanceId); - if (result.status === "complete") return true; - await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS); - } - return false; - }); + const outcome = await withSpinner(`Verifying production setup for ${state.domain}...`, () => + pollDeployStatus(ctx.appId, productionInstanceId), + ); - if (!verified) { + if (outcome.verified) { log.blank(); - log.warn( - `DNS, SSL, or mail verification is still pending for ${state.domain}. ` + - "Run `clerk deploy` again once DNS has propagated, or check the dashboard for the failing component.", - ); - log.info( - "DNS propagation can take time. Some providers may take several hours to serve the new records everywhere.", - ); - if (state.cnameTargets && state.cnameTargets.length > 0) { - log.blank(); - for (const line of dnsRecords(state.cnameTargets)) log.info(line); - } - log.blank(); - const action = await chooseDnsVerificationAction(); - if (action === "skip") { - log.blank(); - log.info("Skipping DNS verification for now."); - return "pending"; - } - return runDnsVerification(ctx, state); + for (const line of dnsVerified(state.domain)) log.success(line); + log.info(deployComponentStatus(outcome.status)); + return "verified"; } log.blank(); - for (const line of dnsVerified(state.domain)) log.success(line); - return "verified"; + log.info(deployComponentStatus(outcome.status)); + log.blank(); + for (const line of deployStatusPendingFooter(state.domain, outcome.status)) { + log.warn(line); + } + 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); +} + +type DeployStatusOutcome = + | { verified: true; status: DeployComponentStatus } + | { verified: false; status: DeployComponentStatus }; + +async function pollDeployStatus( + appId: string, + productionInstanceId: string, +): Promise { + let status: DeployComponentStatus = { dns: false, ssl: false, mail: false }; + for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) { + const result = await mapDeployError(getDeployStatus(appId, productionInstanceId)); + status = { dns: result.dns_ok, ssl: result.ssl_ok, mail: result.mail_ok }; + if (result.status === "complete") return { verified: true, status }; + await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS); + } + return { verified: false, status }; } async function runOAuthSetup( diff --git a/packages/cli-core/src/commands/deploy/mock.test.ts b/packages/cli-core/src/commands/deploy/mock.test.ts index a4a711f2..54828232 100644 --- a/packages/cli-core/src/commands/deploy/mock.test.ts +++ b/packages/cli-core/src/commands/deploy/mock.test.ts @@ -156,8 +156,8 @@ describe("withMockProductionInstance", () => { const result = withMockProductionInstance(stagingOnly); expect(result.instances).toHaveLength(2); - expect( - result.instances.some((instance) => instance.environment_type === "production"), - ).toBe(true); + expect(result.instances.some((instance) => instance.environment_type === "production")).toBe( + true, + ); }); }); diff --git a/packages/cli-core/src/commands/deploy/mock.ts b/packages/cli-core/src/commands/deploy/mock.ts index f0d58036..25c50170 100644 --- a/packages/cli-core/src/commands/deploy/mock.ts +++ b/packages/cli-core/src/commands/deploy/mock.ts @@ -1,30 +1,12 @@ /** - * Test-only mocked deploy lifecycle. + * Test-only fixtures and failure-injection helpers for the deploy lifecycle. * - * The deploy command runs against this in-process mock until the production- - * instance backend is built. All test-flag plumbing and failure-injection - * helpers also live here so the surface to delete when the real backend - * lands is obvious. + * Used by the `--test-*` flags so the e2e harness can drive the wizard + * deterministically. Production code never reads these. */ -import { sleep } from "../../lib/sleep.ts"; import { PlapiError } from "../../lib/errors.ts"; import type { Application, ApplicationDomain } from "../../lib/plapi.ts"; -import type { CnameTarget, DeployApi } from "./api.ts"; - -const MOCK_PRODUCTION_INSTANCE_ID = "MOCKED_NOT_REAL_FIXME"; -const MOCK_DOMAIN_ID = "MOCKED_NOT_REAL_FIXME"; -const MOCK_PUBLISHABLE_KEY = "MOCKED_NOT_REAL_FIXME"; -const MOCK_SECRET_KEY = "MOCKED_NOT_REAL_FIXME"; -const MOCK_LATENCY_MS = 2000; -const MOCK_INCOMPLETE_POLLS = 2; - -export type DeployApiMockOptions = { - failValidateCloning?: boolean; - failCreateProductionInstance?: boolean; - failDnsVerification?: boolean; - failOAuthSave?: boolean; -}; export type DeployTestFlags = { testForceProductionInstance?: boolean; @@ -36,21 +18,7 @@ export type DeployTestFlags = { testFailOAuthSave?: boolean; }; -let mockOptions: DeployApiMockOptions = {}; - -export function configureMockDeployApi(options: DeployApiMockOptions = {}): void { - mockOptions = { ...options }; -} - -export function resolveTestDeployFlags(options: { - testForceProductionInstance?: boolean; - testFailProductionInstanceCheck?: boolean; - testFailDomainLookup?: boolean; - testFailValidateCloning?: boolean; - testFailCreateProductionInstance?: boolean; - testFailDnsVerification?: boolean; - testFailOAuthSave?: boolean; -}): DeployTestFlags { +export function resolveTestDeployFlags(options: DeployTestFlags): DeployTestFlags { return { testForceProductionInstance: options.testForceProductionInstance === true, testFailProductionInstanceCheck: options.testFailProductionInstanceCheck === true, @@ -82,85 +50,6 @@ export async function withTestFailureAfterApiCall( return result; } -async function simulateServerLatency(): Promise { - await sleep(MOCK_LATENCY_MS); -} - -function defaultCnameTargets(domain: string): CnameTarget[] { - return [ - { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, - { host: `accounts.${domain}`, value: "accounts.clerk.services", required: true }, - { - host: `clkmail.${domain}`, - value: `mail.${domain}.nam1.clerk.services`, - required: true, - }, - ]; -} - -const deployStatusPollCounts = new Map(); - -export function _resetDeployStatusMock(): void { - deployStatusPollCounts.clear(); - configureMockDeployApi(); -} - -export const mockDeployApi: DeployApi = { - async createProductionInstance(_applicationId, params) { - await simulateServerLatency(); - if (mockOptions.failCreateProductionInstance) { - throw simulatedDeployApiFailure("production instance creation"); - } - return { - instance_id: MOCK_PRODUCTION_INSTANCE_ID, - environment_type: "production", - active_domain: { - id: MOCK_DOMAIN_ID, - name: params.home_url, - }, - secret_key: MOCK_SECRET_KEY, - publishable_key: MOCK_PUBLISHABLE_KEY, - cname_targets: defaultCnameTargets(params.home_url), - }; - }, - - async validateCloning() { - await simulateServerLatency(); - if (mockOptions.failValidateCloning) { - throw simulatedDeployApiFailure("cloning validation"); - } - }, - - async getDeployStatus(applicationId, envOrInsId) { - await simulateServerLatency(); - if (mockOptions.failDnsVerification) { - throw simulatedDeployApiFailure("DNS verification"); - } - const key = `${applicationId}:${envOrInsId}`; - const count = (deployStatusPollCounts.get(key) ?? 0) + 1; - deployStatusPollCounts.set(key, count); - return { - status: count > MOCK_INCOMPLETE_POLLS ? "complete" : "incomplete", - }; - }, - - async retryApplicationDomainSSL() { - await simulateServerLatency(); - }, - - async retryApplicationDomainMail() { - await simulateServerLatency(); - }, - - async patchInstanceConfig() { - await simulateServerLatency(); - if (mockOptions.failOAuthSave) { - throw simulatedDeployApiFailure("OAuth credential save"); - } - return {}; - }, -}; - export function withMockProductionInstance(app: Application): Application { if (app.instances.some((entry) => entry.environment_type === "production")) { return app; diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 4917be42..b754b8dd 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -46,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]; diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index e1ae2791..1c07f004 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -455,7 +455,10 @@ describe("plapi", () => { stubFetch(async (input, init) => { capturedMethod = init?.method ?? "GET"; capturedUrl = input.toString(); - return new Response(JSON.stringify({ status: "complete" }), { status: 200 }); + 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"); @@ -464,7 +467,12 @@ describe("plapi", () => { expect(capturedUrl).toBe( "https://api.clerk.com/v1/platform/applications/app_abc/instances/production/deploy_status", ); - expect(result).toEqual({ status: "complete" }); + expect(result).toEqual({ + status: "complete", + dns_ok: true, + ssl_ok: true, + mail_ok: true, + }); }); }); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 5d355876..a0a168ee 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -194,6 +194,9 @@ 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 { From 5d718317104702b0fb95ffcad8bfb8b19a847397 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 19 May 2026 08:17:05 -0600 Subject: [PATCH 11/20] feat(deploy): replace DNS handoff confirm with verify-or-skip choice Swap the "Continue to OAuth setup?" yes/no prompt during initial production setup for the same verify/skip select used when resuming. Choosing skip records DNS as pending and continues to OAuth instead of pausing the deploy, so the dashboard remains the single place to monitor propagation. --- packages/cli-core/src/commands/deploy/copy.ts | 2 +- .../src/commands/deploy/index.test.ts | 109 +++++++----------- .../cli-core/src/commands/deploy/index.ts | 10 +- .../cli-core/src/commands/deploy/prompts.ts | 7 -- 4 files changed, 49 insertions(+), 79 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index fabcd1d9..df9cef06 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -96,7 +96,7 @@ function cnameTargetLabel(host: string): string { export function dnsDashboardHandoff(domain: string): string[] { return [ `Check the Domains section in the Clerk Dashboard for ${domain} to monitor DNS propagation and SSL issuance.`, - "You can continue to the remaining setup now, or pause and run `clerk deploy` again later.", + "You can verify DNS now, or skip and continue. DNS propagation can take time.", ]; } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 1db7f05e..e1a2f332 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -430,11 +430,9 @@ describe("deploy", () => { describe("human mode", () => { function mockHumanFlow() { mockIsAgent.mockReturnValue(false); - // Proceed → pause after DNS handoff. - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); + // Proceed → create instance → skip DNS verification → pause at OAuth. + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); mockInput.mockResolvedValueOnce("example.com"); } @@ -518,17 +516,13 @@ describe("deploy", () => { test("DNS verification polls getDeployStatus until complete", async () => { await linkedProject(); - // Proceed → continue after DNS handoff → complete OAuth. + // Proceed → create instance → check DNS now → complete OAuth. mockIsAgent.mockReturnValue(false); - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mockInput .mockResolvedValueOnce("example.com") .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); - mockSelect.mockResolvedValueOnce("have-credentials"); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); mockPassword.mockResolvedValueOnce("google-secret"); mockGetDeployStatus .mockResolvedValueOnce({ @@ -642,13 +636,11 @@ describe("deploy", () => { expect(err).toContain("Production keys only work on your production domain"); }); - test("DNS setup prints dashboard handoff and asks before continuing", async () => { + test("DNS setup prints dashboard handoff and asks about verifying DNS", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); mockInput.mockResolvedValueOnce("example.com"); await runDeploy({}); @@ -661,23 +653,23 @@ describe("deploy", () => { expect(err).toContain("Add the following records at your DNS provider"); expect(err).toContain("Check the Domains section in the Clerk Dashboard"); expect(err).toContain("propagation and SSL issuance"); - expect(err).toContain("run `clerk deploy` again later"); - expect(mockConfirm).toHaveBeenCalledTimes(3); + expect(err).toContain("DNS propagation can take time"); + expect(err).toContain("Skipping DNS verification for now."); + expect(mockConfirm).toHaveBeenCalledTimes(2); expect(mockConfirm).toHaveBeenCalledWith({ message: "Create production instance?", default: true, }); - expect(mockConfirm).toHaveBeenCalledWith({ - message: "Continue to OAuth setup?", - default: true, - }); expect(mockConfirm).not.toHaveBeenCalledWith({ - message: "Configure and verify DNS now?", + message: "Continue to OAuth setup?", default: true, }); - expect(mockConfirm).not.toHaveBeenCalledWith({ - message: "Have the DNS records been added?", - default: true, + expect(mockSelect).toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], }); }); @@ -702,10 +694,8 @@ describe("deploy", () => { test("Ctrl-C at the DNS handoff reports paused", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockRejectedValueOnce(promptExitError()); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockRejectedValueOnce(promptExitError()); mockInput.mockResolvedValueOnce("example.com"); stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); @@ -994,17 +984,9 @@ describe("deploy", () => { test("--test-fail-dns-verification simulates DNS verification failure", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); - mockInput - .mockResolvedValueOnce("example.com") - .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); - mockPassword.mockResolvedValueOnce("google-secret"); - mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("check"); + mockInput.mockResolvedValueOnce("example.com"); await expectTestApiFailure( runDeploy({ testFailDnsVerification: true }), @@ -1133,19 +1115,18 @@ describe("deploy", () => { }); }); - test("DNS handoff reports plain deploy for later continuation", async () => { + test("DNS handoff points users to the Clerk Dashboard for propagation status", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(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("run `clerk deploy` again later"); + 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 () => { @@ -1240,18 +1221,17 @@ describe("deploy", () => { expect(mockInput).not.toHaveBeenCalled(); }); - test("custom-domain DNS setup can pause and later resume", async () => { + test("custom-domain DNS setup can skip verification and later resume", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(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(); @@ -1328,15 +1308,15 @@ describe("deploy", () => { connection_oauth_google: { enabled: true }, connection_oauth_github: { enabled: true }, }); - // Proceed → create prod → continue after DNS → enter google creds → skip github. - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); + // 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("have-credentials").mockResolvedValueOnce("skip"); + mockSelect + .mockResolvedValueOnce("check") + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("skip"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); @@ -1431,12 +1411,11 @@ describe("deploy", () => { test("DNS verification timeout can skip and continue configuring production", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect + .mockResolvedValueOnce("check") + .mockResolvedValueOnce("skip") + .mockResolvedValueOnce("have-credentials"); mockInput .mockResolvedValueOnce("example.com") .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index e8f3058e..95db2ae2 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -60,7 +60,6 @@ import { chooseOAuthCredentialAction, collectCustomDomain, collectOAuthCredentials, - confirmContinueAfterDnsHandoff, confirmCreateProductionInstance, confirmProceed, } from "./prompts.ts"; @@ -601,12 +600,11 @@ async function runDnsSetup( for (const line of dnsDashboardHandoff(state.domain)) log.info(line); log.blank(); try { - const continueSetup = await confirmContinueAfterDnsHandoff(); - if (!continueSetup) { + const action = await chooseDnsVerificationAction(); + if (action === "skip") { log.blank(); - log.info(pausedOperationNotice()); - outro("Paused"); - return false; + log.info("Skipping DNS verification for now."); + return "pending"; } return await runDnsVerification(ctx, { ...state, cnameTargets }); } catch (error) { diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index fe38f7aa..428e211b 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -49,13 +49,6 @@ export function validateDomain(value: string): true | string { return true; } -export async function confirmContinueAfterDnsHandoff(): Promise { - return confirm({ - message: "Continue to OAuth setup?", - default: true, - }); -} - export async function confirmCreateProductionInstance(): Promise { return confirm({ message: "Create production instance?", From ebe7092a48780f95abe28658d4d98cd5470f026b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 19 May 2026 11:09:29 -0600 Subject: [PATCH 12/20] feat(deploy): refresh DNS verification for current API surface - Tolerate getDeployStatus failures inside the reconcile-path snapshot read so the deploy continues to the verify-or-skip prompt instead of failing before the user can react. - Split the snapshot fetch into separate "Reading development configuration" and "Reading production configuration" spinners so the gutter reflects what is actually being loaded. - Add triggerDomainDnsCheck (POST .../dns_check) and call it best-effort when the user picks "Check DNS now" so an active check job is kicked rather than waiting on background reconciliation. - Type the dns_ok/ssl_ok/mail_ok booleans on DeployStatusResponse and use them to name the specific pending component in the timeout warning. --- .../cli-core/src/commands/deploy/README.md | 12 +++- .../cli-core/src/commands/deploy/api.test.ts | 10 ++++ packages/cli-core/src/commands/deploy/api.ts | 1 + .../src/commands/deploy/index.test.ts | 58 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 57 +++++++++++++++--- packages/cli-core/src/lib/plapi.ts | 11 ++++ 6 files changed, 139 insertions(+), 10 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 3af963f2..9f217c94 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,6 +1,6 @@ # Deploy Command -> **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`, `ssl_retry`, `mail_retry`) route through `commands/deploy/api.ts` to the live PLAPI helpers in `lib/plapi.ts`. PLAPI error codes are translated to typed `CliError`s by `commands/deploy/errors.ts`. The mock helpers in `commands/deploy/mock.ts` remain for tests that mock `lib/plapi.ts` directly. +> **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`) route through `commands/deploy/api.ts` to the live PLAPI helpers in `lib/plapi.ts`. PLAPI error codes are translated to typed `CliError`s by `commands/deploy/errors.ts`. The mock helpers in `commands/deploy/mock.ts` remain for tests that mock `lib/plapi.ts` directly. Guides a user through deploying their Clerk application to production. @@ -39,6 +39,7 @@ Human mode reads and writes deploy state through the Platform API on every run. | 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`. CLI polls every 3s up to ~5 minutes. | | 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. | @@ -86,10 +87,16 @@ sequenceDiagram CLI->>User: Add these CNAME records to your DNS provider + %% 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 + %% Poll deploy status loop every 3s until status == "complete" CLI->>API: GET /v1/platform/applications/{appID}/instances/{instance_id}/deploy_status - API-->>CLI: { status: "incomplete" | "complete" } + API-->>CLI: { status, dns_ok, ssl_ok, mail_ok } end opt Stalled provisioning @@ -125,6 +132,7 @@ All endpoints are on the **Platform API** (`/v1/platform/...`) and are live HTTP | 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`. Polls every 3s; surfaces `dns_ok`/`ssl_ok`/`mail_ok` to the user on timeout. | | 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`. | diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts index cb8c9c97..21e36c44 100644 --- a/packages/cli-core/src/commands/deploy/api.test.ts +++ b/packages/cli-core/src/commands/deploy/api.test.ts @@ -4,6 +4,7 @@ const mockPlapiCreateProductionInstance = mock(); const mockPlapiValidateCloning = mock(); const mockPlapiGetDeployStatus = mock(); const mockPlapiPatchInstanceConfig = mock(); +const mockPlapiTriggerDomainDnsCheck = mock(); const mockPlapiRetryApplicationDomainSSL = mock(); const mockPlapiRetryApplicationDomainMail = mock(); @@ -12,6 +13,7 @@ mock.module("../../lib/plapi.ts", () => ({ validateCloning: (...args: unknown[]) => mockPlapiValidateCloning(...args), getDeployStatus: (...args: unknown[]) => mockPlapiGetDeployStatus(...args), patchInstanceConfig: (...args: unknown[]) => mockPlapiPatchInstanceConfig(...args), + triggerDomainDnsCheck: (...args: unknown[]) => mockPlapiTriggerDomainDnsCheck(...args), retryApplicationDomainSSL: (...args: unknown[]) => mockPlapiRetryApplicationDomainSSL(...args), retryApplicationDomainMail: (...args: unknown[]) => mockPlapiRetryApplicationDomainMail(...args), })); @@ -24,6 +26,7 @@ const { patchInstanceConfig, retryApplicationDomainMail, retryApplicationDomainSSL, + triggerDomainDnsCheck, validateCloning, } = apiModule; @@ -33,6 +36,7 @@ describe("deploy api adapter (live routing)", () => { mockPlapiValidateCloning.mockReset(); mockPlapiGetDeployStatus.mockReset(); mockPlapiPatchInstanceConfig.mockReset(); + mockPlapiTriggerDomainDnsCheck.mockReset(); mockPlapiRetryApplicationDomainSSL.mockReset(); mockPlapiRetryApplicationDomainMail.mockReset(); }); @@ -94,6 +98,12 @@ describe("deploy api adapter (live routing)", () => { expect(result).toEqual({ ok: true }); }); + test("triggerDomainDnsCheck delegates to lib/plapi.ts", async () => { + mockPlapiTriggerDomainDnsCheck.mockResolvedValue(undefined); + await triggerDomainDnsCheck("app_123", "example.com"); + expect(mockPlapiTriggerDomainDnsCheck).toHaveBeenCalledWith("app_123", "example.com"); + }); + test("retryApplicationDomainSSL delegates to lib/plapi.ts", async () => { mockPlapiRetryApplicationDomainSSL.mockResolvedValue(undefined); await retryApplicationDomainSSL("app_123", "example.com"); diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts index 5cad71a2..7275ee24 100644 --- a/packages/cli-core/src/commands/deploy/api.ts +++ b/packages/cli-core/src/commands/deploy/api.ts @@ -12,6 +12,7 @@ export { patchInstanceConfig, retryApplicationDomainMail, retryApplicationDomainSSL, + triggerDomainDnsCheck, validateCloning, } from "../../lib/plapi.ts"; diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index e1a2f332..6fe27ef4 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -30,6 +30,7 @@ const mockListApplicationDomains = mock(); const mockCreateProductionInstance = mock(); const mockValidateCloning = mock(); const mockGetDeployStatus = mock(); +const mockTriggerDomainDnsCheck = mock(); const mockRetrySSL = mock(); const mockRetryMail = mock(); const mockConfigureMockDeployApi = mock(); @@ -78,6 +79,7 @@ mock.module("../../lib/plapi.ts", () => ({ 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), })); @@ -105,6 +107,7 @@ mock.module("./api.ts", () => ({ } return result; }, + triggerDomainDnsCheck: (...args: unknown[]) => mockTriggerDomainDnsCheck(...args), retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args), retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), patchInstanceConfig: (...args: unknown[]) => { @@ -244,6 +247,7 @@ describe("deploy", () => { mockCreateProductionInstance.mockReset(); mockValidateCloning.mockReset(); mockGetDeployStatus.mockReset(); + mockTriggerDomainDnsCheck.mockReset(); mockRetrySSL.mockReset(); mockRetryMail.mockReset(); mockConfigureMockDeployApi.mockReset(); @@ -1000,6 +1004,34 @@ describe("deploy", () => { expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); }); + test("--test-fail-dns-verification defers to the verify prompt when reconciling an existing production instance", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: {}, + }); + mockSelect.mockResolvedValueOnce("check"); + + await expectTestApiFailure( + runDeploy({ testFailDnsVerification: true }), + "Simulated deploy failure: DNS verification.", + ); + + expect(mockSelect).toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], + }); + expect(mockGetDeployStatus).toHaveBeenCalledTimes(2); + expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_123"); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + test("--test-fail-oauth-save simulates OAuth credential save failure", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, @@ -1067,10 +1099,36 @@ describe("deploy", () => { { 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("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" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 95db2ae2..8d856425 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -15,8 +15,10 @@ import { createProductionInstance as apiCreateProductionInstance, getDeployStatus, patchInstanceConfig, + triggerDomainDnsCheck, validateCloning, type CnameTarget, + type DeployStatusResponse, type ProductionInstanceResponse, } from "./api.ts"; import { @@ -402,14 +404,8 @@ async function resolveLiveDeploySnapshot( const domain = await loadProductionDomain(ctx); if (!domain) return undefined; - const productionConfigPromise = ctx.testForceProductionInstance - ? Promise.resolve(mockProductionInstanceConfig()) - : fetchInstanceConfig(ctx.appId, productionInstanceId); - const [{ known: oauthProviders }, productionConfig, deployStatus] = await Promise.all([ - loadDevelopmentOAuthProviders(ctx), - productionConfigPromise, - mapDeployError(getDeployStatus(ctx.appId, productionInstanceId)), - ]); + const { known: oauthProviders } = await loadDevelopmentOAuthProviders(ctx); + const { productionConfig, deployStatus } = await loadProductionState(ctx, productionInstanceId); const completedOAuthProviders = oauthProviders.filter((provider) => hasProductionOAuthCredentials(productionConfig, provider), ); @@ -438,6 +434,39 @@ async function resolveLiveDeploySnapshot( 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 }; + } +} + +async function loadProductionState( + ctx: DeployContext, + productionInstanceId: string, +): Promise<{ + productionConfig: Record; + deployStatus: DeployStatusResponse; +}> { + return withSpinner("Reading production configuration...", async () => { + const productionConfigPromise = ctx.testForceProductionInstance + ? Promise.resolve(mockProductionInstanceConfig()) + : fetchInstanceConfig(ctx.appId, productionInstanceId); + const [productionConfig, deployStatus] = await Promise.all([ + productionConfigPromise, + loadInitialDeployStatus(ctx.appId, productionInstanceId), + ]); + return { productionConfig, deployStatus }; + }); +} + async function loadProductionDomain(ctx: DeployContext): Promise { if (ctx.testForceProductionInstance) { return mockProductionDomain(); @@ -647,6 +676,8 @@ async function runDnsVerification( ); } + await requestDomainDnsCheck(ctx.appId, state.productionDomainId ?? state.domain); + const outcome = await withSpinner(`Verifying production setup for ${state.domain}...`, () => pollDeployStatus(ctx.appId, productionInstanceId), ); @@ -696,6 +727,16 @@ async function pollDeployStatus( return { verified: false, status }; } +async function requestDomainDnsCheck(appId: string, domainIdOrName: string): Promise { + try { + await triggerDomainDnsCheck(appId, domainIdOrName); + } catch (error) { + log.debug( + `deploy: dns_check trigger failed, falling back to passive polling: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + async function runOAuthSetup( ctx: DeployContext, state: DeployOperationState, diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index a0a168ee..5cf077f7 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -249,6 +249,17 @@ export async function getDeployStatus( 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, From 6051ad248627b9e212ad3fa601e7b9062caf19aa Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:03:47 -0600 Subject: [PATCH 13/20] refactor(deploy): drop ./api.ts and ./mock.ts test indirection Switch deploy command imports back to lib/plapi.ts directly and remove the ./mock.ts harness plus the --test-force-* / --test-fail-* CLI flags it backed. The wrappers were added for an earlier mockable-deploy-API experiment that is no longer needed. --- packages/cli-core/src/cli-program.test.ts | 7 +- packages/cli-core/src/cli-program.ts | 18 -- .../cli-core/src/commands/deploy/README.md | 6 +- .../cli-core/src/commands/deploy/api.test.ts | 118 -------- packages/cli-core/src/commands/deploy/api.ts | 40 --- packages/cli-core/src/commands/deploy/copy.ts | 10 +- .../src/commands/deploy/index.test.ts | 272 ------------------ .../cli-core/src/commands/deploy/index.ts | 112 +------- .../cli-core/src/commands/deploy/mock.test.ts | 163 ----------- packages/cli-core/src/commands/deploy/mock.ts | 96 ------- .../cli-core/src/commands/deploy/state.ts | 5 +- 11 files changed, 22 insertions(+), 825 deletions(-) delete mode 100644 packages/cli-core/src/commands/deploy/api.test.ts delete mode 100644 packages/cli-core/src/commands/deploy/api.ts delete mode 100644 packages/cli-core/src/commands/deploy/mock.test.ts delete mode 100644 packages/cli-core/src/commands/deploy/mock.ts diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 2c2ac424..481fdd46 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -50,12 +50,7 @@ test("deploy exposes the expected options", () => { const deploy = program.commands.find((command) => command.name() === "deploy")!; const optionNames = deploy.options.map((option) => option.long); - expect(optionNames).toEqual([ - "--debug", - "--test-force-production-instance", - "--test-fail-production-instance-check", - "--test-fail-domain-lookup", - ]); + expect(optionNames).toEqual(["--debug"]); }); describe("parseIntegerOption (via users list --limit / --offset)", () => { diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 78cb8011..46b8eabf 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -926,24 +926,6 @@ Tutorial — enable completions for your shell: .command("deploy") .description("Deploy a Clerk application to production") .option("--debug", "Show detailed deployment debug output") - .addOption( - createOption( - "--test-force-production-instance", - "Force deploy to use a mocked production instance", - ), - ) - .addOption( - createOption( - "--test-fail-production-instance-check", - "Simulate a deploy failure while checking for a production instance", - ), - ) - .addOption( - createOption( - "--test-fail-domain-lookup", - "Simulate a deploy failure while loading the production domain", - ), - ) .action(deploy); registerExtras(program); diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 9f217c94..8163ddd4 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,6 +1,6 @@ # Deploy Command -> **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`) route through `commands/deploy/api.ts` to the live PLAPI helpers in `lib/plapi.ts`. PLAPI error codes are translated to typed `CliError`s by `commands/deploy/errors.ts`. The mock helpers in `commands/deploy/mock.ts` remain for tests that mock `lib/plapi.ts` directly. +> **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. @@ -39,7 +39,7 @@ Human mode reads and writes deploy state through the Platform API on every run. | 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). | +| 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`. CLI polls every 3s up to ~5 minutes. | | 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. | @@ -121,7 +121,7 @@ sequenceDiagram ## API Endpoints -All endpoints are on the **Platform API** (`/v1/platform/...`) and are live HTTP calls. The lifecycle endpoints route through `commands/deploy/api.ts` to the helpers in `lib/plapi.ts`. +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 | | -------------------------- | ------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts deleted file mode 100644 index 21e36c44..00000000 --- a/packages/cli-core/src/commands/deploy/api.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { test, expect, describe, beforeEach, mock } from "bun:test"; - -const mockPlapiCreateProductionInstance = mock(); -const mockPlapiValidateCloning = mock(); -const mockPlapiGetDeployStatus = mock(); -const mockPlapiPatchInstanceConfig = mock(); -const mockPlapiTriggerDomainDnsCheck = mock(); -const mockPlapiRetryApplicationDomainSSL = mock(); -const mockPlapiRetryApplicationDomainMail = mock(); - -mock.module("../../lib/plapi.ts", () => ({ - createProductionInstance: (...args: unknown[]) => mockPlapiCreateProductionInstance(...args), - validateCloning: (...args: unknown[]) => mockPlapiValidateCloning(...args), - getDeployStatus: (...args: unknown[]) => mockPlapiGetDeployStatus(...args), - patchInstanceConfig: (...args: unknown[]) => mockPlapiPatchInstanceConfig(...args), - triggerDomainDnsCheck: (...args: unknown[]) => mockPlapiTriggerDomainDnsCheck(...args), - retryApplicationDomainSSL: (...args: unknown[]) => mockPlapiRetryApplicationDomainSSL(...args), - retryApplicationDomainMail: (...args: unknown[]) => mockPlapiRetryApplicationDomainMail(...args), -})); - -const deployApiModulePath = "./api.ts?adapter-test"; -const apiModule = (await import(deployApiModulePath)) as typeof import("./api.ts"); -const { - createProductionInstance, - getDeployStatus, - patchInstanceConfig, - retryApplicationDomainMail, - retryApplicationDomainSSL, - triggerDomainDnsCheck, - validateCloning, -} = apiModule; - -describe("deploy api adapter (live routing)", () => { - beforeEach(() => { - mockPlapiCreateProductionInstance.mockReset(); - mockPlapiValidateCloning.mockReset(); - mockPlapiGetDeployStatus.mockReset(); - mockPlapiPatchInstanceConfig.mockReset(); - mockPlapiTriggerDomainDnsCheck.mockReset(); - mockPlapiRetryApplicationDomainSSL.mockReset(); - mockPlapiRetryApplicationDomainMail.mockReset(); - }); - - test("createProductionInstance delegates to lib/plapi.ts", async () => { - mockPlapiCreateProductionInstance.mockResolvedValue({ - instance_id: "ins_prod_live", - environment_type: "production", - active_domain: { id: "dmn_live", name: "example.com" }, - publishable_key: "pk_live_test", - cname_targets: [], - }); - - const result = await createProductionInstance("app_123", { - home_url: "example.com", - clone_instance_id: "ins_dev_123", - }); - - expect(mockPlapiCreateProductionInstance).toHaveBeenCalledWith("app_123", { - home_url: "example.com", - clone_instance_id: "ins_dev_123", - }); - expect(result.instance_id).toBe("ins_prod_live"); - }); - - test("validateCloning delegates to lib/plapi.ts", async () => { - mockPlapiValidateCloning.mockResolvedValue(undefined); - await validateCloning("app_123", { clone_instance_id: "ins_dev_123" }); - expect(mockPlapiValidateCloning).toHaveBeenCalledWith("app_123", { - clone_instance_id: "ins_dev_123", - }); - }); - - test("getDeployStatus delegates to lib/plapi.ts and surfaces booleans", async () => { - mockPlapiGetDeployStatus.mockResolvedValue({ - status: "incomplete", - dns_ok: true, - ssl_ok: false, - mail_ok: false, - }); - const result = await getDeployStatus("app_123", "production"); - expect(mockPlapiGetDeployStatus).toHaveBeenCalledWith("app_123", "production"); - expect(result).toEqual({ - status: "incomplete", - dns_ok: true, - ssl_ok: false, - mail_ok: false, - }); - }); - - test("patchInstanceConfig delegates to lib/plapi.ts", async () => { - mockPlapiPatchInstanceConfig.mockResolvedValue({ ok: true }); - const result = await patchInstanceConfig("app_123", "ins_prod_live", { - connection_oauth_google: { enabled: true }, - }); - expect(mockPlapiPatchInstanceConfig).toHaveBeenCalledWith("app_123", "ins_prod_live", { - connection_oauth_google: { enabled: true }, - }); - expect(result).toEqual({ ok: true }); - }); - - test("triggerDomainDnsCheck delegates to lib/plapi.ts", async () => { - mockPlapiTriggerDomainDnsCheck.mockResolvedValue(undefined); - await triggerDomainDnsCheck("app_123", "example.com"); - expect(mockPlapiTriggerDomainDnsCheck).toHaveBeenCalledWith("app_123", "example.com"); - }); - - test("retryApplicationDomainSSL delegates to lib/plapi.ts", async () => { - mockPlapiRetryApplicationDomainSSL.mockResolvedValue(undefined); - await retryApplicationDomainSSL("app_123", "example.com"); - expect(mockPlapiRetryApplicationDomainSSL).toHaveBeenCalledWith("app_123", "example.com"); - }); - - test("retryApplicationDomainMail delegates to lib/plapi.ts", async () => { - mockPlapiRetryApplicationDomainMail.mockResolvedValue(undefined); - await retryApplicationDomainMail("app_123", "example.com"); - expect(mockPlapiRetryApplicationDomainMail).toHaveBeenCalledWith("app_123", "example.com"); - }); -}); diff --git a/packages/cli-core/src/commands/deploy/api.ts b/packages/cli-core/src/commands/deploy/api.ts deleted file mode 100644 index 7275ee24..00000000 --- a/packages/cli-core/src/commands/deploy/api.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Deploy command API surface. - * - * Re-exports the PLAPI endpoints the deploy lifecycle calls so the test suite - * can substitute the whole adapter via `mock.module("./api.ts", ...)` without - * mocking each plapi call site individually. - */ - -export { - createProductionInstance, - getDeployStatus, - patchInstanceConfig, - retryApplicationDomainMail, - retryApplicationDomainSSL, - triggerDomainDnsCheck, - validateCloning, -} from "../../lib/plapi.ts"; - -export type { - CnameTarget, - CreateProductionInstanceParams, - DeployStatusResponse, - ProductionInstanceResponse, - ValidateCloningParams, -} from "../../lib/plapi.ts"; - -export type DeployApiMockOptions = { - failValidateCloning?: boolean; - failCreateProductionInstance?: boolean; - failDnsVerification?: boolean; - failOAuthSave?: boolean; -}; - -/** - * No-op in production. Tests replace this via `mock.module("./api.ts", ...)` - * to intercept the call and inject lifecycle failures into the mocked - * `createProductionInstance` / `validateCloning` / `getDeployStatus` / - * `patchInstanceConfig` exports above. - */ -export function configureMockDeployApi(_options: DeployApiMockOptions = {}): void {} diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index df9cef06..73fd3f5e 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -1,5 +1,5 @@ import { bold, cyan, dim, green, yellow } from "../../lib/color.ts"; -import type { CnameTarget } from "./api.ts"; +import type { CnameTarget } from "../../lib/plapi.ts"; export type DeployPlanStep = { label: string; @@ -44,14 +44,12 @@ export function dnsIntro(domain: string): string[] { ]; } -export function domainAssociationSummary( - domain: string, - targets: readonly CnameTarget[], -): string[] { +export function domainAssociationSummary(domain: string): string[] { + const hosts = [`clerk.${domain}`, `accounts.${domain}`, `clkmail.${domain}`]; return [ `Clerk will associate these subdomains with ${cyan(domain)}:`, "", - ...targets.map((target) => ` ${cnameTargetLabel(target.host)} ${target.host}`), + ...hosts.map((host) => ` ${cnameTargetLabel(host)} ${host}`), "", "This will create a Clerk production instance for your application.", ]; diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 6fe27ef4..23d47721 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -33,27 +33,8 @@ const mockGetDeployStatus = mock(); const mockTriggerDomainDnsCheck = mock(); const mockRetrySSL = mock(); const mockRetryMail = mock(); -const mockConfigureMockDeployApi = mock(); const mockDomainConnectUrl = mock(); -type DeployApiMockOptions = { - failValidateCloning?: boolean; - failCreateProductionInstance?: boolean; - failDnsVerification?: boolean; - failOAuthSave?: boolean; -}; - -let mockDeployApiOptions: DeployApiMockOptions = {}; - -function configureMockDeployApi(options: DeployApiMockOptions = {}) { - mockConfigureMockDeployApi(options); - mockDeployApiOptions = { ...options }; -} - -function simulatedDeployApiFailure(step: string): Error { - return new Error(`Simulated deploy failure: ${step}.`); -} - mock.module("@inquirer/prompts", () => ({ ...promptsStubs, select: (...args: unknown[]) => mockSelect(...args), @@ -84,41 +65,6 @@ mock.module("../../lib/plapi.ts", () => ({ retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), })); -mock.module("./api.ts", () => ({ - configureMockDeployApi, - createProductionInstance: (...args: unknown[]) => { - const result = mockCreateProductionInstance(...args); - if (mockDeployApiOptions.failCreateProductionInstance) { - throw simulatedDeployApiFailure("production instance creation"); - } - return result; - }, - validateCloning: (...args: unknown[]) => { - const result = mockValidateCloning(...args); - if (mockDeployApiOptions.failValidateCloning) { - throw simulatedDeployApiFailure("cloning validation"); - } - return result; - }, - getDeployStatus: (...args: unknown[]) => { - const result = mockGetDeployStatus(...args); - if (mockDeployApiOptions.failDnsVerification) { - throw simulatedDeployApiFailure("DNS verification"); - } - return result; - }, - triggerDomainDnsCheck: (...args: unknown[]) => mockTriggerDomainDnsCheck(...args), - retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args), - retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), - patchInstanceConfig: (...args: unknown[]) => { - const result = mockPatchInstanceConfig(...args); - if (mockDeployApiOptions.failOAuthSave) { - throw simulatedDeployApiFailure("OAuth credential save"); - } - return result; - }, -})); - mock.module("./domain-connect.ts", () => ({ domainConnectUrl: (...args: unknown[]) => mockDomainConnectUrl(...args), })); @@ -250,8 +196,6 @@ describe("deploy", () => { mockTriggerDomainDnsCheck.mockReset(); mockRetrySSL.mockReset(); mockRetryMail.mockReset(); - mockConfigureMockDeployApi.mockReset(); - mockDeployApiOptions = {}; mockDomainConnectUrl.mockReset(); consoleSpy?.mockRestore(); stderrSpy?.mockRestore(); @@ -261,20 +205,6 @@ describe("deploy", () => { return captured.run(() => deploy(options)); } - async function expectTestApiFailure(promise: Promise, message: string): Promise { - let error: Error | undefined; - try { - await promise; - } catch (caught) { - error = caught as Error; - } - - expect(error).toBeInstanceOf(Error); - expect(error).not.toBeInstanceOf(CliError); - expect(error?.message).toContain(message); - return error!; - } - async function linkedProject(profile: Record = {}) { tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-")); _setConfigDir(tempDir); @@ -861,208 +791,6 @@ describe("deploy", () => { expect(mockSelect).not.toHaveBeenCalled(); }); - test("--test-force-production-instance makes app retrieval include mocked production", async () => { - await linkedProject(); - mockIsAgent.mockReturnValue(false); - mockSelect.mockResolvedValueOnce("skip"); - mockListApplicationDomains.mockRejectedValueOnce( - new Error("domains should be mocked when forcing production"), - ); - mockFetchInstanceConfig.mockImplementation((_appId: string, instanceIdOrEnv: string) => { - if (instanceIdOrEnv === "ins_prod_mock") { - throw new Error("production config should be mocked when forcing production"); - } - return { connection_oauth_google: { enabled: true } }; - }); - - await runDeploy({ testForceProductionInstance: true }); - const err = stripAnsi(captured.err); - - expect(err).toContain("[x] Create production instance"); - expect(err).toContain("Use production domain example.com"); - expect(mockCreateProductionInstance).not.toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789"); - expect(mockListApplicationDomains).not.toHaveBeenCalled(); - expect(mockFetchInstanceConfig).not.toHaveBeenCalledWith("app_xyz789", "ins_prod_mock"); - }); - - test("--test-fail-production-instance-check simulates production instance lookup failure", async () => { - await linkedProject(); - mockIsAgent.mockReturnValue(false); - - await expectTestApiFailure( - runDeploy({ testFailProductionInstanceCheck: true }), - "Simulated deploy failure: production instance check.", - ); - - expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789"); - expect(mockFetchInstanceConfig).not.toHaveBeenCalled(); - }); - - test("--test-fail-production-instance-check prints Failed in interactive output", async () => { - await linkedProject(); - mockIsAgent.mockReturnValue(false); - stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); - const originalCi = process.env.CI; - const originalIsTty = process.stderr.isTTY; - Object.defineProperty(process.stderr, "isTTY", { configurable: true, value: true }); - delete process.env.CI; - - try { - await expectTestApiFailure( - runDeploy({ testFailProductionInstanceCheck: true }), - "Simulated deploy failure: production instance check.", - ); - } finally { - Object.defineProperty(process.stderr, "isTTY", { - configurable: true, - value: originalIsTty, - }); - if (originalCi === undefined) { - delete process.env.CI; - } else { - process.env.CI = originalCi; - } - } - - const terminalOutput = stripAnsi( - stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""), - ); - expect(terminalOutput).toContain("Failed"); - }); - - test("--test-fail-domain-lookup simulates production domain lookup failure", async () => { - await linkedProject(); - mockLiveProduction({ - instanceId: "ins_prod_from_api", - productionConfig: {}, - }); - mockIsAgent.mockReturnValue(false); - - await expectTestApiFailure( - runDeploy({ testFailDomainLookup: true }), - "Simulated deploy failure: production domain lookup.", - ); - - expect(mockListApplicationDomains).toHaveBeenCalledWith("app_xyz789"); - }); - - test("--test-fail-validate-cloning simulates cloning validation failure", async () => { - await linkedProject(); - mockIsAgent.mockReturnValue(false); - - await expectTestApiFailure( - runDeploy({ testFailValidateCloning: true }), - "Simulated deploy failure: cloning validation.", - ); - - expect(mockValidateCloning).toHaveBeenCalledWith("app_xyz789", { - clone_instance_id: "ins_dev_123", - }); - expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( - expect.objectContaining({ failValidateCloning: true }), - ); - expect(mockCreateProductionInstance).not.toHaveBeenCalled(); - }); - - test("--test-fail-create-production-instance simulates production creation failure", async () => { - await linkedProject(); - mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - mockInput.mockResolvedValueOnce("example.com"); - - await expectTestApiFailure( - runDeploy({ testFailCreateProductionInstance: true }), - "Simulated deploy failure: production instance creation.", - ); - - expect(mockCreateProductionInstance).toHaveBeenCalledWith("app_xyz789", { - home_url: "https://example.com", - clone_instance_id: "ins_dev_123", - }); - expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( - expect.objectContaining({ failCreateProductionInstance: true }), - ); - }); - - test("--test-fail-dns-verification simulates DNS verification failure", async () => { - await linkedProject(); - mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("check"); - mockInput.mockResolvedValueOnce("example.com"); - - await expectTestApiFailure( - runDeploy({ testFailDnsVerification: true }), - "Simulated deploy failure: DNS verification.", - ); - - expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock"); - expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( - expect.objectContaining({ failDnsVerification: true }), - ); - expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); - }); - - test("--test-fail-dns-verification defers to the verify prompt when reconciling an existing production instance", async () => { - await linkedProject({ - instances: { development: "ins_dev_123", production: "ins_prod_123" }, - }); - mockIsAgent.mockReturnValue(false); - mockLiveProduction({ - instanceId: "ins_prod_123", - productionConfig: {}, - }); - mockSelect.mockResolvedValueOnce("check"); - - await expectTestApiFailure( - runDeploy({ testFailDnsVerification: true }), - "Simulated deploy failure: DNS verification.", - ); - - expect(mockSelect).toHaveBeenCalledWith({ - message: "DNS verification", - choices: [ - { name: "Check DNS now", value: "check" }, - { name: "Skip DNS verification for now", value: "skip" }, - ], - }); - expect(mockGetDeployStatus).toHaveBeenCalledTimes(2); - expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_123"); - expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); - }); - - test("--test-fail-oauth-save simulates OAuth credential save failure", async () => { - await linkedProject({ - instances: { development: "ins_dev_123", production: "ins_prod_123" }, - }); - mockLiveProduction({ - instanceId: "ins_prod_123", - productionConfig: {}, - }); - mockIsAgent.mockReturnValue(false); - mockSelect.mockResolvedValueOnce("have-credentials"); - mockConfirm.mockResolvedValueOnce(true); - mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); - mockPassword.mockResolvedValueOnce("google-secret"); - - await expectTestApiFailure( - runDeploy({ testFailOAuthSave: true }), - "Simulated deploy failure: OAuth credential save.", - ); - - expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_123", { - connection_oauth_google: { - enabled: true, - client_id: "google-client-id.apps.googleusercontent.com", - client_secret: "google-secret", - }, - }); - expect(mockConfigureMockDeployApi).toHaveBeenCalledWith( - expect.objectContaining({ failOAuthSave: true }), - ); - }); - test("plain deploy resumes DNS verification from live API state", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 8d856425..376dcc8b 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -5,31 +5,19 @@ 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, - listApplicationDomains, - type ApplicationDomain, -} from "../../lib/plapi.ts"; -import { - configureMockDeployApi, - createProductionInstance as apiCreateProductionInstance, getDeployStatus, + listApplicationDomains, patchInstanceConfig, triggerDomainDnsCheck, validateCloning, + type ApplicationDomain, type CnameTarget, type DeployStatusResponse, type ProductionInstanceResponse, -} from "./api.ts"; -import { - mockProductionDomain, - mockProductionInstanceConfig, - resolveTestDeployFlags, - simulatedDeployApiFailure, - withMockProductionInstance, - withTestFailureAfterApiCall, - type DeployTestFlags, -} from "./mock.ts"; +} from "../../lib/plapi.ts"; import { domainConnectUrl } from "./domain-connect.ts"; import { INTRO_PREAMBLE, @@ -74,13 +62,6 @@ import { type DeployOptions = { debug?: boolean; - testForceProductionInstance?: boolean; - testFailProductionInstanceCheck?: boolean; - testFailDomainLookup?: boolean; - testFailValidateCloning?: boolean; - testFailCreateProductionInstance?: boolean; - testFailDnsVerification?: boolean; - testFailOAuthSave?: boolean; }; const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000; @@ -99,7 +80,7 @@ export async function deploy(options: DeployOptions = {}) { intro("clerk deploy"); try { - const ctx = await resolveDeployContext(options); + const ctx = await resolveDeployContext(); await runDeploy(ctx); } catch (error) { if (error instanceof DeployPausedError && isInsideGutter()) { @@ -119,13 +100,10 @@ export async function deploy(options: DeployOptions = {}) { } } -async function resolveDeployContext(options: DeployOptions): Promise { - const testFlags = resolveTestDeployFlags(options); - configureDeployApiMocks(testFlags); +async function resolveDeployContext(): Promise { const resolved = await withSpinner("Resolving linked Clerk application...", () => resolveProfile(process.cwd()), ); - const commandTestFlags = resolveCommandTestFlags(testFlags); if (!resolved) { return { profileKey: process.cwd(), @@ -137,61 +115,25 @@ async function resolveDeployContext(options: DeployOptions): Promise - withTestFailureAfterApiCall( - resolveLiveApplicationContext(resolved.profile, { - forceMockProductionInstance: testFlags.testForceProductionInstance, - }), - testFlags.testFailProductionInstanceCheck, - "production instance check", - ), + resolveLiveApplicationContext(resolved.profile), )), }; } -function resolveCommandTestFlags( - testFlags: DeployTestFlags, -): Pick< - DeployContext, - "testForceProductionInstance" | "testFailProductionInstanceCheck" | "testFailDomainLookup" -> { - return { - testForceProductionInstance: testFlags.testForceProductionInstance, - testFailProductionInstanceCheck: testFlags.testFailProductionInstanceCheck, - testFailDomainLookup: testFlags.testFailDomainLookup, - }; -} - -function configureDeployApiMocks(testFlags: DeployTestFlags): void { - configureMockDeployApi({ - failValidateCloning: testFlags.testFailValidateCloning, - failCreateProductionInstance: testFlags.testFailCreateProductionInstance, - failDnsVerification: testFlags.testFailDnsVerification, - failOAuthSave: testFlags.testFailOAuthSave, - }); -} - -async function resolveLiveApplicationContext( - profile: DeployContext["profile"], - options: { forceMockProductionInstance?: boolean } = {}, -): Promise<{ +async function resolveLiveApplicationContext(profile: DeployContext["profile"]): Promise<{ appId: string; appLabel: string; developmentInstanceId: string; productionInstanceId?: string; }> { - const fetchedApp = await fetchApplication(profile.appId); - const app = options.forceMockProductionInstance - ? withMockProductionInstance(fetchedApp) - : fetchedApp; + 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 { @@ -253,11 +195,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { bar(); const domain = await collectCustomDomain(); - const plannedCnameTargets = plannedProductionCnameTargets(domain); - const shouldCreateProductionInstance = await confirmProductionInstanceCreation( - domain, - plannedCnameTargets, - ); + const shouldCreateProductionInstance = await confirmProductionInstanceCreation(domain); if (!shouldCreateProductionInstance) return; const productionOrExists = await createProductionInstance(ctx, domain); @@ -456,11 +394,8 @@ async function loadProductionState( deployStatus: DeployStatusResponse; }> { return withSpinner("Reading production configuration...", async () => { - const productionConfigPromise = ctx.testForceProductionInstance - ? Promise.resolve(mockProductionInstanceConfig()) - : fetchInstanceConfig(ctx.appId, productionInstanceId); const [productionConfig, deployStatus] = await Promise.all([ - productionConfigPromise, + fetchInstanceConfig(ctx.appId, productionInstanceId), loadInitialDeployStatus(ctx.appId, productionInstanceId), ]); return { productionConfig, deployStatus }; @@ -468,13 +403,7 @@ async function loadProductionState( } async function loadProductionDomain(ctx: DeployContext): Promise { - if (ctx.testForceProductionInstance) { - return mockProductionDomain(); - } const domains = await listApplicationDomains(ctx.appId); - if (ctx.testFailDomainLookup) { - throw simulatedDeployApiFailure("production domain lookup"); - } return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; } @@ -580,23 +509,8 @@ async function createProductionInstance( }); } -function plannedProductionCnameTargets(domain: string): CnameTarget[] { - return [ - { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, - { host: `accounts.${domain}`, value: "accounts.clerk.services", required: true }, - { - host: `clkmail.${domain}`, - value: `mail.${domain}.nam1.clerk.services`, - required: true, - }, - ]; -} - -async function confirmProductionInstanceCreation( - domain: string, - cnameTargets: readonly CnameTarget[], -): Promise { - for (const line of domainAssociationSummary(domain, cnameTargets)) log.info(line); +async function confirmProductionInstanceCreation(domain: string): Promise { + for (const line of domainAssociationSummary(domain)) log.info(line); log.blank(); const confirmed = await confirmCreateProductionInstance(); if (confirmed) { diff --git a/packages/cli-core/src/commands/deploy/mock.test.ts b/packages/cli-core/src/commands/deploy/mock.test.ts deleted file mode 100644 index 54828232..00000000 --- a/packages/cli-core/src/commands/deploy/mock.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import type { Application } from "../../lib/plapi.ts"; -import { PlapiError } from "../../lib/errors.ts"; -import { - resolveTestDeployFlags, - withMockProductionInstance, - withTestFailureAfterApiCall, -} from "./mock.ts"; - -describe("resolveTestDeployFlags", () => { - test("normalizes every undefined flag to false", () => { - expect(resolveTestDeployFlags({})).toEqual({ - testForceProductionInstance: false, - testFailProductionInstanceCheck: false, - testFailDomainLookup: false, - testFailValidateCloning: false, - testFailCreateProductionInstance: false, - testFailDnsVerification: false, - testFailOAuthSave: false, - }); - }); - - test("preserves true flags and leaves siblings false", () => { - expect( - resolveTestDeployFlags({ - testForceProductionInstance: true, - testFailDnsVerification: true, - }), - ).toEqual({ - testForceProductionInstance: true, - testFailProductionInstanceCheck: false, - testFailDomainLookup: false, - testFailValidateCloning: false, - testFailCreateProductionInstance: false, - testFailDnsVerification: true, - testFailOAuthSave: false, - }); - }); - - test("coerces non-true truthy values to false (strict identity check)", () => { - // The implementation uses `=== true`, so anything other than literal `true` - // (including a stray non-boolean leaking through the option parser) must - // normalize to false rather than be passed through. - const result = resolveTestDeployFlags({ - testForceProductionInstance: 1 as unknown as boolean, - testFailOAuthSave: "yes" as unknown as boolean, - }); - expect(result.testForceProductionInstance).toBe(false); - expect(result.testFailOAuthSave).toBe(false); - }); -}); - -describe("withTestFailureAfterApiCall", () => { - test("resolves with the awaited value when shouldFail is falsy", async () => { - await expect(withTestFailureAfterApiCall(Promise.resolve("ok"), false, "step")).resolves.toBe( - "ok", - ); - await expect( - withTestFailureAfterApiCall(Promise.resolve("ok"), undefined, "step"), - ).resolves.toBe("ok"); - }); - - test("awaits the promise before throwing when shouldFail is true", async () => { - let resolved = false; - const pending = (async () => { - await Promise.resolve(); - resolved = true; - return "value"; - })(); - - await expect( - withTestFailureAfterApiCall(pending, true, "production instance check"), - ).rejects.toBeInstanceOf(PlapiError); - expect(resolved).toBe(true); - }); - - test("throws a PlapiError carrying the step in its message", async () => { - let error: unknown; - try { - await withTestFailureAfterApiCall(Promise.resolve(null), true, "DNS verification"); - } catch (caught) { - error = caught; - } - - expect(error).toBeInstanceOf(PlapiError); - expect((error as Error).message).toContain("Simulated deploy failure: DNS verification."); - }); - - test("does not swallow rejections from the upstream promise", async () => { - const upstreamFailure = new Error("upstream boom"); - await expect( - withTestFailureAfterApiCall(Promise.reject(upstreamFailure), true, "step"), - ).rejects.toBe(upstreamFailure); - }); -}); - -describe("withMockProductionInstance", () => { - function app(instances: Application["instances"]): Application { - return { - application_id: "app_xyz789", - name: "my-saas-app", - instances, - }; - } - - test("returns the input unchanged when a production instance already exists", () => { - const existing = app([ - { - instance_id: "ins_dev_123", - environment_type: "development", - publishable_key: "pk_test_123", - }, - { - instance_id: "ins_prod_real", - environment_type: "production", - publishable_key: "pk_live_real", - }, - ]); - - const result = withMockProductionInstance(existing); - expect(result).toBe(existing); - expect(result.instances).toBe(existing.instances); - }); - - test("appends a mock production instance when none is present", () => { - const devOnly = app([ - { - instance_id: "ins_dev_123", - environment_type: "development", - publishable_key: "pk_test_123", - }, - ]); - - const result = withMockProductionInstance(devOnly); - - // Original input is not mutated. - expect(devOnly.instances).toHaveLength(1); - expect(result).not.toBe(devOnly); - expect(result.instances).toHaveLength(2); - expect(result.instances[0]).toEqual(devOnly.instances[0]!); - expect(result.instances[1]).toEqual({ - instance_id: "ins_prod_mock", - environment_type: "production", - publishable_key: "pk_live_test", - }); - }); - - test("appends production even when only non-development instances exist", () => { - const stagingOnly = app([ - { - instance_id: "ins_staging_123", - environment_type: "staging", - publishable_key: "pk_staging_123", - }, - ]); - - const result = withMockProductionInstance(stagingOnly); - expect(result.instances).toHaveLength(2); - expect(result.instances.some((instance) => instance.environment_type === "production")).toBe( - true, - ); - }); -}); diff --git a/packages/cli-core/src/commands/deploy/mock.ts b/packages/cli-core/src/commands/deploy/mock.ts deleted file mode 100644 index 25c50170..00000000 --- a/packages/cli-core/src/commands/deploy/mock.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Test-only fixtures and failure-injection helpers for the deploy lifecycle. - * - * Used by the `--test-*` flags so the e2e harness can drive the wizard - * deterministically. Production code never reads these. - */ - -import { PlapiError } from "../../lib/errors.ts"; -import type { Application, ApplicationDomain } from "../../lib/plapi.ts"; - -export type DeployTestFlags = { - testForceProductionInstance?: boolean; - testFailProductionInstanceCheck?: boolean; - testFailDomainLookup?: boolean; - testFailValidateCloning?: boolean; - testFailCreateProductionInstance?: boolean; - testFailDnsVerification?: boolean; - testFailOAuthSave?: boolean; -}; - -export function resolveTestDeployFlags(options: DeployTestFlags): DeployTestFlags { - return { - testForceProductionInstance: options.testForceProductionInstance === true, - testFailProductionInstanceCheck: options.testFailProductionInstanceCheck === true, - testFailDomainLookup: options.testFailDomainLookup === true, - testFailValidateCloning: options.testFailValidateCloning === true, - testFailCreateProductionInstance: options.testFailCreateProductionInstance === true, - testFailDnsVerification: options.testFailDnsVerification === true, - testFailOAuthSave: options.testFailOAuthSave === true, - }; -} - -export function simulatedDeployApiFailure(step: string): PlapiError { - return new PlapiError( - 500, - JSON.stringify({ errors: [{ message: `Simulated deploy failure: ${step}.` }] }), - "clerk deploy test flag", - ); -} - -export async function withTestFailureAfterApiCall( - promise: Promise, - shouldFail: boolean | undefined, - step: string, -): Promise { - const result = await promise; - if (shouldFail) { - throw simulatedDeployApiFailure(step); - } - return result; -} - -export function withMockProductionInstance(app: Application): Application { - if (app.instances.some((entry) => entry.environment_type === "production")) { - return app; - } - return { - ...app, - instances: [ - ...app.instances, - { - instance_id: "ins_prod_mock", - environment_type: "production", - publishable_key: "pk_live_test", - }, - ], - }; -} - -export function mockProductionDomain(): ApplicationDomain { - return { - object: "domain", - id: "dmn_prod_mock", - name: "example.com", - is_satellite: false, - is_provider_domain: false, - frontend_api_url: "https://clerk.example.com", - accounts_portal_url: "https://accounts.example.com", - development_origin: "", - cname_targets: [ - { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, - { host: "accounts.example.com", value: "accounts.clerk.services", required: true }, - { - host: "clkmail.example.com", - value: "mail.example.com.nam1.clerk.services", - required: true, - }, - ], - created_at: "2026-05-06T00:00:00Z", - updated_at: "2026-05-06T00:00:00Z", - }; -} - -export function mockProductionInstanceConfig(): Record { - return {}; -} diff --git a/packages/cli-core/src/commands/deploy/state.ts b/packages/cli-core/src/commands/deploy/state.ts index db76c318..e1fe5c5d 100644 --- a/packages/cli-core/src/commands/deploy/state.ts +++ b/packages/cli-core/src/commands/deploy/state.ts @@ -1,6 +1,6 @@ import { CliError, EXIT_CODE } from "../../lib/errors.ts"; import { pausedMessage } from "./copy.ts"; -import type { CnameTarget } from "./api.ts"; +import type { CnameTarget } from "../../lib/plapi.ts"; import { providerLabel, type OAuthProvider } from "./providers.ts"; import type { Profile } from "../../lib/config.ts"; @@ -23,9 +23,6 @@ export type DeployContext = { appLabel: string; developmentInstanceId: string; productionInstanceId?: string; - testForceProductionInstance?: boolean; - testFailProductionInstanceCheck?: boolean; - testFailDomainLookup?: boolean; }; export function pausedStepDescription(state: DeployOperationState): string { From 50f9be22c0308c398d5af9f4a241ff96e895d9b8 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:06:25 -0600 Subject: [PATCH 14/20] feat(deploy): add bindZoneFile formatter for DNS records export --- .../cli-core/src/commands/deploy/copy.test.ts | 54 +++++++++++++++++++ packages/cli-core/src/commands/deploy/copy.ts | 18 +++++++ 2 files changed, 72 insertions(+) create mode 100644 packages/cli-core/src/commands/deploy/copy.test.ts 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..82c015f0 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -0,0 +1,54 @@ +import { test, expect, describe } from "bun:test"; +import { bindZoneFile } 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.."); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 73fd3f5e..7d533ed8 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -199,3 +199,21 @@ export function pausedOperationNotice(): string { 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`; +} From ecf3db88ca009bf83ae1d7f4c2a4a2f325e7a14a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:10:36 -0600 Subject: [PATCH 15/20] feat(deploy): add per-component spinner label lookup --- .../cli-core/src/commands/deploy/copy.test.ts | 25 ++++++++++++++- packages/cli-core/src/commands/deploy/copy.ts | 31 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 82c015f0..13064336 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from "bun:test"; -import { bindZoneFile } from "./copy.ts"; +import { bindZoneFile, deployComponentLabels } from "./copy.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; describe("bindZoneFile", () => { @@ -52,3 +52,26 @@ describe("bindZoneFile", () => { 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 index 7d533ed8..a859c416 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -108,6 +108,37 @@ export type DeployComponentStatus = { 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 From 48afb1bdaca08d2ede8deadaae2391563659e318 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:22:20 -0600 Subject: [PATCH 16/20] feat(deploy): sequence DNS verification feedback per component Replace the single-spinner pollDeployStatus loop with a chained mail/dns/ssl spinner sequence that emits a per-component success line as each boolean flips true. Add a defensive status === "complete" check after all three components succeed so the proxy_ok server-side case fails closed rather than reporting verified. When all DNS components are resolved but the server has not yet marked the deployment complete, exit the verification path without reaching finishDeploy. --- .../src/commands/deploy/index.test.ts | 74 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 61 +++++++++++---- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 23d47721..1fb4f3eb 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -477,6 +477,80 @@ describe("deploy", () => { 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(); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 376dcc8b..2ab8203b 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -25,13 +25,14 @@ import { OAUTH_SECTION_INTRO, type DeployComponentStatus, type DeployPlanStep, + DEPLOY_COMPONENT_ORDER, + deployComponentLabels, deployComponentStatus, deployStatusPendingFooter, domainAssociationSummary, dnsDashboardHandoff, dnsIntro, dnsRecords, - dnsVerified, pausedOperationNotice, printPlan, productionSummary, @@ -581,7 +582,7 @@ async function runExistingDomainDnsVerification( async function runDnsVerification( ctx: DeployContext, state: DeployOperationState, -): Promise { +): Promise { const productionInstanceId = state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production; if (!productionInstanceId) { @@ -592,13 +593,10 @@ async function runDnsVerification( await requestDomainDnsCheck(ctx.appId, state.productionDomainId ?? state.domain); - const outcome = await withSpinner(`Verifying production setup for ${state.domain}...`, () => - pollDeployStatus(ctx.appId, productionInstanceId), - ); + const outcome = await pollDeployStatus(ctx.appId, productionInstanceId, state.domain); if (outcome.verified) { log.blank(); - for (const line of dnsVerified(state.domain)) log.success(line); log.info(deployComponentStatus(outcome.status)); return "verified"; } @@ -609,6 +607,15 @@ async function runDnsVerification( for (const line of deployStatusPendingFooter(state.domain, outcome.status)) { log.warn(line); } + + // 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; + } + if (state.cnameTargets && state.cnameTargets.length > 0) { log.blank(); for (const line of dnsRecords(state.cnameTargets)) log.info(line); @@ -630,15 +637,41 @@ type DeployStatusOutcome = async function pollDeployStatus( appId: string, productionInstanceId: string, + domain: string, ): Promise { - let status: DeployComponentStatus = { dns: false, ssl: false, mail: false }; - for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) { - const result = await mapDeployError(getDeployStatus(appId, productionInstanceId)); - status = { dns: result.dns_ok, ssl: result.ssl_ok, mail: result.mail_ok }; - if (result.status === "complete") return { verified: true, status }; - await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS); - } - return { verified: false, status }; + 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 requestDomainDnsCheck(appId: string, domainIdOrName: string): Promise { From 289b4a1d36aaaa409f3adf6c58eb1f8745a5998c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:27:58 -0600 Subject: [PATCH 17/20] feat(deploy): show DNS records on the resume verification path --- .../src/commands/deploy/index.test.ts | 31 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 14 +++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 1fb4f3eb..4618a660 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -906,6 +906,37 @@ describe("deploy", () => { 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("DNS verification timeout names the specific pending components from deploy_status", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 2ab8203b..6e4743f6 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -563,6 +563,20 @@ 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(); + try { const action = await chooseDnsVerificationAction(); if (action === "skip") { From 9111d0db4f6a34e65877edb6b86e0af7e75411e2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:37:02 -0600 Subject: [PATCH 18/20] feat(deploy): offer BIND zone export after DNS records After the DNS records block in both runDnsSetup and runExistingDomainDnsVerification, prompt the user (default: no) to export the records as a clerk-.zone BIND zone file. --- .../src/commands/deploy/index.test.ts | 132 +++++++++++++++++- .../cli-core/src/commands/deploy/index.ts | 19 +++ .../cli-core/src/commands/deploy/prompts.ts | 7 + 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4618a660..d1eda7fa 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -243,6 +243,7 @@ describe("deploy", () => { domainId?: string; productionConfig?: Record; developmentConfig?: Record; + cnameTargets?: readonly { host: string; value: string; required: boolean }[]; } = {}, ) { const instanceId = options.instanceId ?? "ins_prod_mock"; @@ -254,6 +255,9 @@ describe("deploy", () => { 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 }, + ]; mockFetchApplication.mockResolvedValue({ application_id: "app_xyz789", @@ -282,9 +286,7 @@ describe("deploy", () => { frontend_api_url: `https://clerk.${domain}`, accounts_portal_url: `https://accounts.${domain}`, development_origin: "", - cname_targets: [ - { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, - ], + cname_targets: cnameTargets, created_at: "2026-05-06T00:00:00Z", updated_at: "2026-05-06T00:00:00Z", }, @@ -647,7 +649,10 @@ describe("deploy", () => { test("DNS setup prints dashboard handoff and asks about verifying DNS", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + 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"); @@ -663,11 +668,15 @@ describe("deploy", () => { 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(2); + 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, @@ -937,6 +946,119 @@ describe("deploy", () => { 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" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 6e4743f6..beab32df 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -30,6 +30,7 @@ import { deployComponentStatus, deployStatusPendingFooter, domainAssociationSummary, + bindZoneFile, dnsDashboardHandoff, dnsIntro, dnsRecords, @@ -52,6 +53,7 @@ import { collectCustomDomain, collectOAuthCredentials, confirmCreateProductionInstance, + confirmExportBindZone, confirmProceed, } from "./prompts.ts"; import { @@ -543,6 +545,8 @@ async function runDnsSetup( 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") { @@ -576,6 +580,8 @@ async function runExistingDomainDnsVerification( } 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(); @@ -688,6 +694,19 @@ async function pollDeployStatus( 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}`); +} + async function requestDomainDnsCheck(appId: string, domainIdOrName: string): Promise { try { await triggerDomainDnsCheck(appId, domainIdOrName); diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index 428e211b..254de6d7 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -66,6 +66,13 @@ export async function chooseDnsVerificationAction(): Promise { + return confirm({ + message: "Export DNS records as a BIND zone file?", + default: false, + }); +} + export async function chooseOAuthCredentialAction( provider: OAuthProvider, ): Promise { From db15cf97a1f290e640b6263909a46e71654ac8d2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:40:27 -0600 Subject: [PATCH 19/20] docs(deploy): document per-component verification and BIND export --- .../cli-core/src/commands/deploy/README.md | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 8163ddd4..8fb504c3 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -4,6 +4,10 @@ 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 @@ -34,16 +38,18 @@ Agent mode does not call PLAPI and exits before the human-mode wizard starts. 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`. CLI polls every 3s up to ~5 minutes. | -| 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. | +| 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. @@ -123,19 +129,19 @@ sequenceDiagram 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`. Polls every 3s; surfaces `dns_ok`/`ssl_ok`/`mail_ok` to the user on timeout. | -| 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`. | +| 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 From a3d8b46308f064a2c0fe3179cd030a3ed9e5b9cd Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:42:38 -0600 Subject: [PATCH 20/20] docs(changeset): document clerk deploy production wizard --- .changeset/deploy-wizard.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/deploy-wizard.md 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.