From 5d9fa8a1c64924d364f7c9118cb57ff630589c3b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 5 May 2026 18:14:49 -0600 Subject: [PATCH 01/52] 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 a5cc787d..e9ed3af2 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 = { @@ -216,6 +217,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 4c840ea505abe66570d52665b720efbee2c9b9e0 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 08:13:00 -0600 Subject: [PATCH 02/52] 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 4ce12ff980f99762539cfe7c16c9a1fc45d98534 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 09:34:43 -0600 Subject: [PATCH 03/52] 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 34e6d0f5edb6f2e3471f325dee73e215a45a37a1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 13:21:25 -0600 Subject: [PATCH 04/52] 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 ec9bb584ed9b96d4c9e2f5a706f20756cc29485a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 13:52:50 -0600 Subject: [PATCH 05/52] 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 b23fae66db506c627fed93140b177d1202a2f38c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 14:05:32 -0600 Subject: [PATCH 06/52] 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 b440934dc2a67ebf0e7d3187a40ae724f1c517af Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 11:25:57 -0600 Subject: [PATCH 07/52] 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 dbb69ab5bf9f98f39362de7ff4ef42583b33e054 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 13:00:24 -0600 Subject: [PATCH 08/52] 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 cc63c6370017fbb6f719fbbc8edd5b4186d0b711 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:30:09 -0600 Subject: [PATCH 09/52] 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 87528e437812beaed2fb67a3104669327fa89db3 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 19 May 2026 18:35:50 -0300 Subject: [PATCH 10/52] 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 e9ed3af2..5ff7d8d5 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 3ea1cb246b7ea3e478f2ba2bba44675a22958b36 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 19 May 2026 08:17:05 -0600 Subject: [PATCH 11/52] 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 412c6483de8553289ed3b720ac903e5b8381250e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 19 May 2026 11:09:29 -0600 Subject: [PATCH 12/52] 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 eb2784630ad70b94f465bb5d355164f0e60ea256 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:03:47 -0600 Subject: [PATCH 13/52] 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 0860638109239915ca4ad3fd1ef9ed1db09060c6 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:06:25 -0600 Subject: [PATCH 14/52] 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 0e1fd41252006a791b87f8856e4f30aef9aab132 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:10:36 -0600 Subject: [PATCH 15/52] 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 13094cb8921a0a620aa9786223bfc107163fb8f2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:22:20 -0600 Subject: [PATCH 16/52] 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 e850c0e60b94e90a395d20efddf0716fdbab4bf7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:27:58 -0600 Subject: [PATCH 17/52] 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 93747bec573a7777317a875c0512a497b3d0faa7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:37:02 -0600 Subject: [PATCH 18/52] 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 b9461db3c883d1a22e0694ebfdaff1b21b096800 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:40:27 -0600 Subject: [PATCH 19/52] 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 6ea078984de289a5e28bd840b3a9c4579ca3673a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 20 May 2026 13:42:38 -0600 Subject: [PATCH 20/52] 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. From d9fc05eda7f0874a515db7aa8beb6f23e1abde06 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:55:42 -0600 Subject: [PATCH 21/52] refactor(plapi): drop unused is_secondary from CreateProductionInstanceParams --- packages/cli-core/src/lib/plapi.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 5cf077f7..b65ea312 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -183,7 +183,6 @@ export type ProductionInstanceResponse = { export type CreateProductionInstanceParams = { home_url: string; clone_instance_id?: string; - is_secondary?: boolean; }; export type ValidateCloningParams = { From f65cea49918cb290ab6982c7868ede4501278643 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 21 May 2026 09:53:35 -0600 Subject: [PATCH 22/52] refactor(plapi): allow null active_domain and guard in deploy wizard --- .../src/commands/deploy/index.test.ts | 24 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 10 +++++++- packages/cli-core/src/lib/plapi.ts | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index d1eda7fa..0dc5ff86 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -708,6 +708,30 @@ describe("deploy", () => { }); }); + test("throws a CliError when createProductionInstance returns no active_domain", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("example.com"); + mockCreateProductionInstance.mockResolvedValueOnce({ + instance_id: "ins_prod_mock", + environment_type: "production" as const, + active_domain: null, + publishable_key: "pk_live_test", + secret_key: "sk_live_test", + cname_targets: [], + }); + + let thrown: unknown; + try { + await runDeploy({}); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(CliError); + expect((thrown as CliError).message).toContain("did not return a domain"); + }); + test("Ctrl-C at the DNS handoff reports paused", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index beab32df..6408c192 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -2,7 +2,7 @@ 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 { UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; +import { CliError, UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; import { resolveProfile, setProfile } from "../../lib/config.ts"; import { createProductionInstance as apiCreateProductionInstance, @@ -217,6 +217,14 @@ async function startNewDeploy(ctx: DeployContext): Promise { } const production = productionOrExists; await persistProductionInstance(ctx, production.instance_id); + + if (!production.active_domain) { + throw new CliError( + "Production instance was created but Clerk did not return a domain. " + + "Run `clerk deploy` again to retry domain provisioning.", + ); + } + log.blank(); const productionDomain = production.active_domain.name; diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index b65ea312..fc2428e3 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -174,7 +174,7 @@ export type ListApplicationDomainsResponse = { export type ProductionInstanceResponse = { instance_id: string; environment_type: "production"; - active_domain: DomainSummary; + active_domain: DomainSummary | null; secret_key?: string; publishable_key: string; cname_targets: CnameTarget[]; From c749d58d74e12c7ac5982a4ffb4c31f0c8aec17b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 21 May 2026 15:30:44 -0600 Subject: [PATCH 23/52] fix(deploy): remove Domain Connect prompt and address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Domain Connect URL helper returned Cloudflare's template for every domain regardless of the actual registrar — a misleading prompt for any user not on Cloudflare. Remove it entirely until NS-based registrar detection lands as its own change. Other cleanups from the same review pass: runDeploy throws a NOT_LINKED CliError on unlinked directories instead of warning and exiting zero; startNewDeploy's 409 production_instance_exists recovery now persists the recovered production instance id to the profile; OAuth skip routes through deployPausedError so its exit code matches Ctrl-C (1 instead of silent 0); runDnsVerification loops on timeout retry instead of recursing; and runOAuthSetup drops the redundant startIndex slice since the completed set already skips previously-saved providers. --- .../cli-core/src/commands/deploy/README.md | 2 +- .../src/commands/deploy/domain-connect.ts | 12 -- .../src/commands/deploy/index.test.ts | 129 ++++++++++++++---- .../cli-core/src/commands/deploy/index.ts | 124 ++++++++--------- 4 files changed, 166 insertions(+), 101 deletions(-) delete mode 100644 packages/cli-core/src/commands/deploy/domain-connect.ts diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 8fb504c3..98c3b684 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -4,7 +4,7 @@ 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. +When the CLI reaches the DNS configuration step, it displays the required CNAME records and 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. diff --git a/packages/cli-core/src/commands/deploy/domain-connect.ts b/packages/cli-core/src/commands/deploy/domain-connect.ts deleted file mode 100644 index 21be3795..00000000 --- a/packages/cli-core/src/commands/deploy/domain-connect.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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 a38208ef..00d2d5f9 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 { useCaptureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts"; -import { CliError, EXIT_CODE, UserAbortError } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, EXIT_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -33,7 +33,6 @@ const mockGetDeployStatus = mock(); const mockTriggerDomainDnsCheck = mock(); const mockRetrySSL = mock(); const mockRetryMail = mock(); -const mockDomainConnectUrl = mock(); mock.module("@inquirer/prompts", () => ({ ...promptsStubs, @@ -65,10 +64,6 @@ mock.module("../../lib/plapi.ts", () => ({ retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), })); -mock.module("./domain-connect.ts", () => ({ - domainConnectUrl: (...args: unknown[]) => mockDomainConnectUrl(...args), -})); - mock.module("../../lib/sleep.ts", () => ({ sleep: () => Promise.resolve(), })); @@ -169,7 +164,6 @@ describe("deploy", () => { }; }, ); - mockDomainConnectUrl.mockReturnValue(undefined); }); afterEach(async () => { @@ -193,7 +187,6 @@ describe("deploy", () => { mockTriggerDomainDnsCheck.mockReset(); mockRetrySSL.mockReset(); mockRetryMail.mockReset(); - mockDomainConnectUrl.mockReset(); consoleSpy?.mockRestore(); }); @@ -201,6 +194,16 @@ describe("deploy", () => { return deploy(options); } + async function runDeployUntilPause(options: Parameters[0] = {}) { + try { + await runDeploy(options); + } catch (error) { + if (!(error instanceof CliError) || !error.message.includes("Deploy paused")) { + throw error; + } + } + } + async function linkedProject(profile: Record = {}) { tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-")); _setConfigDir(tempDir); @@ -370,7 +373,7 @@ describe("deploy", () => { async function runDnsHandoff() { mockHumanFlow(); - await runDeploy({}); + await runDeployUntilPause(); mockLiveProduction(); captured.clear(); mockConfirm.mockReset(); @@ -390,7 +393,7 @@ describe("deploy", () => { mockHumanFlow(); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await runDeploy({}); + await runDeployUntilPause(); const allOutput = captured.out; expect(allOutput).not.toContain("deploying a Clerk application to production"); @@ -400,7 +403,7 @@ describe("deploy", () => { await linkedProject(); mockHumanFlow(); - await runDeploy({}); + await runDeployUntilPause(); expect(mockValidateCloning).toHaveBeenCalledWith("app_xyz789", { clone_instance_id: "ins_dev_123", @@ -411,7 +414,7 @@ describe("deploy", () => { await linkedProject(); mockHumanFlow(); - await runDeploy({}); + await runDeployUntilPause(); const err = stripAnsi(captured.err); const productionCheckIndex = err.indexOf("Checking for production instance..."); @@ -432,7 +435,7 @@ describe("deploy", () => { unrelated_key: "ignored", }); - await runDeploy({}); + await runDeployUntilPause(); const err = stripAnsi(captured.err); expect(mockFetchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_dev_123"); @@ -550,7 +553,7 @@ describe("deploy", () => { await linkedProject(); mockHumanFlow(); - await runDeploy({}); + await runDeployUntilPause(); const err = stripAnsi(captured.err); expect(mockConfirm).toHaveBeenCalledWith({ message: "Proceed?", default: true }); @@ -565,7 +568,7 @@ describe("deploy", () => { await linkedProject(); mockHumanFlow(); - await runDeploy({}); + await runDeployUntilPause(); const firstInputArg = mockInput.mock.calls[0]?.[0] as { message: string; @@ -643,7 +646,7 @@ describe("deploy", () => { mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); mockInput.mockResolvedValueOnce("example.com"); - await runDeploy({}); + await runDeployUntilPause(); const err = stripAnsi(captured.err); expect(err).toContain("Clerk will associate these subdomains with example.com"); expect(err).toContain("clerk.example.com"); @@ -1143,7 +1146,7 @@ describe("deploy", () => { mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); mockInput.mockResolvedValueOnce("example.com"); - await runDeploy({}); + await runDeployUntilPause(); const err = stripAnsi(captured.err); expect(err).toContain("Check the Domains section in the Clerk Dashboard"); expect(err).toContain("DNS propagation can take time"); @@ -1246,7 +1249,7 @@ describe("deploy", () => { mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); mockInput.mockResolvedValueOnce("example.com"); - await runDeploy({}); + await runDeployUntilPause(); mockLiveProduction(); expect(stripAnsi(captured.err)).toContain("Check the Domains section in the Clerk Dashboard"); expect(stripAnsi(captured.err)).toContain("Skipping DNS verification for now."); @@ -1296,10 +1299,17 @@ describe("deploy", () => { await runDnsHandoff(); mockSelect.mockResolvedValueOnce("skip"); - await runDeploy({}); + let pauseError: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + pauseError = caught as CliError; + } + expect(pauseError?.message).toContain("Deploy paused at: Google OAuth credential setup"); + expect(pauseError?.message).toContain("Run `clerk deploy` again"); + expect(pauseError?.exitCode).toBe(EXIT_CODE.GENERAL); const pausedErr = stripAnsi(captured.err); - expect(pausedErr).toContain("Deploy paused"); - expect(pausedErr).toContain("Run `clerk deploy` again"); + expect(pausedErr).toContain("Paused"); captured.clear(); mockConfirm.mockReset(); @@ -1338,7 +1348,14 @@ describe("deploy", () => { mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - await runDeploy({}); + let pauseError: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + pauseError = caught as CliError; + } + expect(pauseError?.message).toContain("Deploy paused at: GitHub OAuth credential setup"); + expect(pauseError?.exitCode).toBe(EXIT_CODE.GENERAL); mockLiveProduction({ developmentConfig: { connection_oauth_google: { enabled: true }, @@ -1474,7 +1491,7 @@ describe("deploy", () => { connection_oauth_facebook: { enabled: true }, }); - await runDeploy({}); + await runDeployUntilPause(); const err = stripAnsi(captured.err); expect(err).toContain("Configure Google OAuth credentials"); @@ -1483,5 +1500,71 @@ describe("deploy", () => { expect(err).toContain("facebook"); expect(err).toContain("Configure them from the Clerk Dashboard before going live"); }); + + test("unlinked directory throws NOT_LINKED instead of warning and exiting 0", async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-")); + _setConfigDir(tempDir); + mockIsAgent.mockReturnValue(false); + + let thrown: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + thrown = caught as CliError; + } + expect(thrown).toBeInstanceOf(CliError); + expect(thrown?.code).toBe(ERROR_CODE.NOT_LINKED); + expect(thrown?.message).toContain("No Clerk project linked"); + expect(mockFetchApplication).not.toHaveBeenCalled(); + }); + + test("recovers from 409 production_instance_exists and persists the recovered instance id", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("example.com"); + mockCreateProductionInstance.mockReset(); + mockCreateProductionInstance.mockRejectedValueOnce( + new PlapiError( + 409, + JSON.stringify({ errors: [{ code: "production_instance_exists", message: "exists" }] }), + ), + ); + // First fetchApplication call (during resolveDeployContext) sees no production + // instance, so the wizard takes the startNewDeploy path and hits the 409. + mockFetchApplication.mockResolvedValueOnce({ + application_id: "app_xyz789", + name: "my-saas-app", + instances: [ + { + instance_id: "ins_dev_123", + environment_type: "development", + publishable_key: "pk_test_123", + }, + ], + }); + // Subsequent reads (the recovery refresh + reconcile) see the production + // instance that another flow created, with google OAuth already configured. + mockLiveProduction({ + instanceId: "ins_prod_recovered", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); + + await runDeploy({}); + + const err = stripAnsi(captured.err); + expect(err).toContain("A production instance already exists"); + expect(err).toContain("Resuming the existing deploy"); + expect(err).toContain("Production ready at https://example.com"); + expect(mockFetchApplication.mock.calls.length).toBeGreaterThanOrEqual(2); + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_recovered"); + }); }); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 6408c192..ee213cfe 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -2,7 +2,13 @@ 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 { CliError, UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; +import { + CliError, + ERROR_CODE, + UserAbortError, + isPromptExitError, + throwUsageError, +} from "../../lib/errors.ts"; import { resolveProfile, setProfile } from "../../lib/config.ts"; import { createProductionInstance as apiCreateProductionInstance, @@ -18,7 +24,6 @@ import { type DeployStatusResponse, type ProductionInstanceResponse, } from "../../lib/plapi.ts"; -import { domainConnectUrl } from "./domain-connect.ts"; import { INTRO_PREAMBLE, NEXT_STEPS_BLOCK, @@ -34,7 +39,6 @@ import { dnsDashboardHandoff, dnsIntro, dnsRecords, - pausedOperationNotice, printPlan, productionSummary, } from "./copy.ts"; @@ -149,12 +153,10 @@ async function resolveLiveApplicationContext(profile: DeployContext["profile"]): async function runDeploy(ctx: DeployContext): Promise { if (!ctx.appId || !ctx.developmentInstanceId) { - log.blank(); - log.warn( + throw new CliError( "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", + { code: ERROR_CODE.NOT_LINKED }, ); - outro("Link required"); - return; } if (ctx.productionInstanceId) { @@ -212,6 +214,9 @@ async function startNewDeploy(ctx: DeployContext): Promise { resolveLiveApplicationContext(ctx.profile), ); ctx.productionInstanceId = refreshed.productionInstanceId; + if (refreshed.productionInstanceId) { + await persistProductionInstance(ctx, refreshed.productionInstanceId); + } await reconcileExistingDeploy(ctx); return; } @@ -545,12 +550,6 @@ async function runDnsSetup( 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(); - } - for (const line of dnsDashboardHandoff(state.domain)) log.info(line); log.blank(); await offerBindZoneExport(state.domain, cnameTargets); @@ -581,11 +580,6 @@ async function runExistingDomainDnsVerification( for (const line of dnsRecords(state.cnameTargets)) log.info(line); log.blank(); } - const connectUrl = domainConnectUrl(state.domain); - if (connectUrl) { - log.info(`Domain Connect: ${connectUrl}`); - log.blank(); - } for (const line of dnsDashboardHandoff(state.domain)) log.info(line); log.blank(); await offerBindZoneExport(state.domain, state.cnameTargets); @@ -619,43 +613,44 @@ async function runDnsVerification( ); } - await requestDomainDnsCheck(ctx.appId, state.productionDomainId ?? state.domain); + while (true) { + await requestDomainDnsCheck(ctx.appId, state.productionDomainId ?? state.domain); - const outcome = await pollDeployStatus(ctx.appId, productionInstanceId, state.domain); + const outcome = await pollDeployStatus(ctx.appId, productionInstanceId, state.domain); + + if (outcome.verified) { + log.blank(); + log.info(deployComponentStatus(outcome.status)); + return "verified"; + } - if (outcome.verified) { log.blank(); log.info(deployComponentStatus(outcome.status)); - return "verified"; - } - - log.blank(); - log.info(deployComponentStatus(outcome.status)); - log.blank(); - for (const line of deployStatusPendingFooter(state.domain, outcome.status)) { - log.warn(line); - } + log.blank(); + 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; - } + // 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); - } - log.blank(); - const action = await chooseDnsVerificationAction(); - if (action === "skip") { + if (state.cnameTargets && state.cnameTargets.length > 0) { + log.blank(); + for (const line of dnsRecords(state.cnameTargets)) log.info(line); + } log.blank(); - log.info("Skipping DNS verification for now."); - return "pending"; + const action = await chooseDnsVerificationAction(); + if (action === "skip") { + log.blank(); + log.info("Skipping DNS verification for now."); + return "pending"; + } } - return runDnsVerification(ctx, state); } type DeployStatusOutcome = @@ -730,18 +725,14 @@ async function runOAuthSetup( 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; + const providers = state.oauthProviders as OAuthProvider[]; - if (state.oauthProviders.length > 0) { + if (providers.length > 0) { log.info(OAUTH_SECTION_INTRO); log.blank(); } - const pendingProviders = state.oauthProviders.slice(startIndex) as OAuthProvider[]; - for (const provider of pendingProviders) { + for (const provider of providers) { if (completed.has(provider)) continue; try { const productionInstanceId = @@ -759,24 +750,27 @@ async function runOAuthSetup( productionInstanceId, ); if (!saved) { - log.blank(); - log.info(pausedOperationNotice()); - outro("Paused"); - return [...completed]; + throw deployPausedError({ + ...state, + pending: { type: "oauth", provider }, + completedOAuthProviders: [...completed], + }); } } catch (error) { if (isPromptExitError(error)) { - const interruptedState = { - ...state, - pending: { type: "oauth" as const, provider }, - completedOAuthProviders: [...completed], - }; - throw deployPausedError(interruptedState, { interrupted: true }); + throw deployPausedError( + { + ...state, + pending: { type: "oauth", provider }, + completedOAuthProviders: [...completed], + }, + { interrupted: true }, + ); } throw error; } completed.add(provider); - if (pendingProviders.some((nextProvider) => !completed.has(nextProvider))) { + if (providers.some((nextProvider) => !completed.has(nextProvider))) { log.blank(); } } From 13ea5f4d1a937f5b3fe3958b7240eb1962a2cc5b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 25 May 2026 12:27:56 -0600 Subject: [PATCH 24/52] fix(deploy): use domain status for verification Switch production deploy verification to the application domain status endpoint and remove obsolete retry-specific handling. Cap domain status polling to the shared 10-second budget while keeping 3-second intervals. --- packages/cli-core/src/commands/auth/login.ts | 1 + .../cli-core/src/commands/deploy/README.md | 81 ++---- packages/cli-core/src/commands/deploy/copy.ts | 10 +- .../cli-core/src/commands/deploy/errors.ts | 37 --- .../src/commands/deploy/index.test.ts | 264 +++++++++--------- .../cli-core/src/commands/deploy/index.ts | 106 ++++--- packages/cli-core/src/lib/errors.ts | 6 - packages/cli-core/src/lib/plapi.test.ts | 97 +------ packages/cli-core/src/lib/plapi.ts | 81 ++---- 9 files changed, 245 insertions(+), 438 deletions(-) diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index c2c3eab6..92e6673b 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -57,6 +57,7 @@ async function performOAuthFlow(): Promise { // Critical fallback: the OAuth callback can't complete unless the user // reaches the authorize URL somehow. const urlString = authorizeUrl.toString(); + log.debug(`Opening browser to URL: ${urlString}`); const result = await openBrowser(urlString); if (!result.ok) { log.warn( diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 98c3b684..e37ed703 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,12 +1,12 @@ # 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`) call the helpers in `lib/plapi.ts` directly. PLAPI error codes are translated to typed `CliError`s by `commands/deploy/errors.ts`. +> **Live PLAPI lifecycle.** Human mode resolves the linked application, production domains, domain status, and instance config from the Platform API on each run. The production-instance lifecycle calls (`instances` and domain `status`) call the helpers in `lib/plapi.ts` directly. PLAPI error codes are translated to typed `CliError`s by `commands/deploy/errors.ts`. Guides a user through deploying their Clerk application to production. When the CLI reaches the DNS configuration step, it displays the required CNAME records and 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. +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 10-second cap. ## Usage @@ -36,22 +36,18 @@ Agent mode does not call PLAPI and exits before the human-mode wizard starts. ## PLAPI Lifecycle -Human mode reads and writes deploy state through the Platform API on every run. The CLI does not persist deploy progress locally — the only profile write is the ordinary `instances.production` value once the production instance has been created. +Human mode reads and writes deploy state through the Platform API on every run. The CLI does not persist deploy progress locally; the only profile write is the ordinary `instances.production` value once the production instance has been created. -| Step | Endpoint | Behavior | -| -------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Validate cloning | `POST /v1/platform/applications/{appID}/validate_cloning` | 204 on success. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. | -| Create production instance | `POST /v1/platform/applications/{appID}/production_instance` | Returns `instance_id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and `cname_targets[]`. | -| | | 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | -| Trigger DNS check | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | Fired best-effort once per "Check DNS now" selection to actively kick the check job. Idempotent (no-op if a check is in flight). | -| Poll deploy status | `GET /v1/platform/applications/{appID}/instances/{envOrInsID}/deploy_status` | Returns `status` plus the three component booleans `dns_ok`, `ssl_ok`, `mail_ok`. The CLI drives three sequential per-component spinners (mail, DNS, SSL) that all share the same five-minute wall-clock cap; each spinner emits a success line as its boolean flips true. A `proxy_ok` defensive check guards the DNS spinner. Polls every 3s. | -| Retry SSL provisioning | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/ssl_retry` | 204 on success. 409 `ssl_retry_throttled` carries `meta.retry_after_seconds` (12-min per-domain throttle). | -| Retry mail verification | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/mail_retry` | 204 on success. 409 `mail_retry_inflight` → poll `deploy_status`. 403 `operation_not_allowed_on_satellite_domain` for satellites. | -| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Returns the updated config snapshot. Used to persist production `connection_oauth_*` credentials. | +| Step | Endpoint | Behavior | +| -------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create production instance | `POST /v1/platform/applications/{appID}/instances` | Returns `instance_id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and `cname_targets[]`. | +| | | Performs clone feature compatibility validation before creating production resources. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | +| Poll domain status | `GET /v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | Returns aggregate `status` plus nested DNS, SSL, mail, and proxy component status. The CLI drives three sequential per-component spinners (mail, DNS, SSL) that all share the same 10-second cap; each spinner emits a success line as its component flips complete. The aggregate `status` guards proxy and other server-side readiness gates. It performs one immediate status read, then polls every 3s. | +| 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. +PLAPI errors are translated to typed `CliError`s by `commands/deploy/errors.ts`. The CLI does not auto-retry SSL or mail provisioning. When domain status polling times out with SSL or mail still incomplete, 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. @@ -73,46 +69,21 @@ sequenceDiagram CLI->>API: GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config?keys=connection_oauth_* API-->>CLI: { connection_oauth_google: { enabled: true }, ... } - %% 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 - %% Plan summary + domain CLI->>User: Plan summary CLI->>User: Production domain (e.g. example.com) User->>CLI: example.com - %% Create production instance + domain in one round-trip - CLI->>API: POST /v1/platform/applications/{appID}/production_instance { home_url, clone_instance_id } + %% Create production instance + domain in one round-trip, including clone validation + CLI->>API: POST /v1/platform/applications/{appID}/instances { home_url, clone_instance_id } API-->>CLI: { instance_id, active_domain, publishable_key, secret_key, cname_targets } 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 + %% Poll domain status loop every 3s until status == "complete" - CLI->>API: GET /v1/platform/applications/{appID}/instances/{instance_id}/deploy_status - API-->>CLI: { status, dns_ok, ssl_ok, mail_ok } - end - - 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 + CLI->>API: GET /v1/platform/applications/{appID}/domains/{domain_id_or_name}/status + API-->>CLI: { status, dns, ssl, mail, proxy } end %% OAuth credential loop @@ -129,19 +100,15 @@ 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`. 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`. | +| 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. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and `cname_targets[]`. | +| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the mail, DNS, and SSL sequential spinners in `pollDeployStatus`; each spinner resolves independently within the shared 10-second cap. | ## OAuth Provider Config Format diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index a859c416..eda42004 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -141,9 +141,9 @@ export function deployComponentLabels( /** * Status line for the three independent components Clerk verifies after - * `production_instance` is created: DNS propagation, SSL issuance via Let's + * the 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. + * schedule, see the deploy endpoints handbook for timing details. */ export function deployComponentStatus(status: DeployComponentStatus): string { const mark = (ok: boolean) => (ok ? green("✓") : yellow("pending")); @@ -151,8 +151,8 @@ export function deployComponentStatus(status: DeployComponentStatus): string { } /** - * Footer printed when `deploy_status` polling times out before all three - * booleans flip true. The user keeps the deploy state; rerunning + * Footer printed when domain status polling times out before all three + * components are complete. The user keeps the deploy state; rerunning * `clerk deploy` resumes from whichever component is still pending. */ export function deployStatusPendingFooter(domain: string, status: DeployComponentStatus): string[] { @@ -169,7 +169,7 @@ export function deployStatusPendingFooter(domain: string, status: DeployComponen return [ lead, "DNS propagation can take several hours depending on your provider.", - "Run `clerk deploy` again to resume — the production instance is already created.", + "Run `clerk deploy` again to resume. The production instance is already created.", ]; } diff --git a/packages/cli-core/src/commands/deploy/errors.ts b/packages/cli-core/src/commands/deploy/errors.ts index 873b5b4a..ae21ef01 100644 --- a/packages/cli-core/src/commands/deploy/errors.ts +++ b/packages/cli-core/src/commands/deploy/errors.ts @@ -83,29 +83,6 @@ function translatePlapiError(error: PlapiError): CliError | PlapiError { ); } - 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). " + @@ -153,17 +130,3 @@ function readParamName(meta: Record | null): string | 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 00d2d5f9..c8bee990 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -28,11 +28,8 @@ const mockFetchInstanceConfig = mock(); const mockFetchApplication = mock(); const mockListApplicationDomains = mock(); const mockCreateProductionInstance = mock(); -const mockValidateCloning = mock(); -const mockGetDeployStatus = mock(); -const mockTriggerDomainDnsCheck = mock(); -const mockRetrySSL = mock(); -const mockRetryMail = mock(); +const mockGetApplicationDomainStatus = mock(); +const mockSleep = mock(); mock.module("@inquirer/prompts", () => ({ ...promptsStubs, @@ -56,16 +53,15 @@ mock.module("../../lib/plapi.ts", () => ({ fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), - validateCloning: (...args: unknown[]) => mockValidateCloning(...args), - getDeployStatus: (...args: unknown[]) => mockGetDeployStatus(...args), + getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), - triggerDomainDnsCheck: (...args: unknown[]) => mockTriggerDomainDnsCheck(...args), - retryApplicationDomainSSL: (...args: unknown[]) => mockRetrySSL(...args), - retryApplicationDomainMail: (...args: unknown[]) => mockRetryMail(...args), })); mock.module("../../lib/sleep.ts", () => ({ - sleep: () => Promise.resolve(), + sleep: (...args: unknown[]) => { + mockSleep(...args); + return Promise.resolve(); + }, })); const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts"); @@ -82,6 +78,25 @@ function promptExitError(): Error { return error; } +function domainStatus({ + status, + dns, + ssl, + mail, +}: { + status: "complete" | "incomplete"; + dns: boolean; + ssl: boolean; + mail: boolean; +}) { + return { + status, + dns: { status: dns ? "complete" : "not_started", cnames: {} }, + ssl: { status: ssl ? "complete" : "not_started", required: true, failure_hints: [] }, + mail: { status: mail ? "complete" : "not_started", required: true }, + }; +} + describe("deploy", () => { let consoleSpy: ReturnType; const captured = useCaptureLog(); @@ -128,13 +143,9 @@ describe("deploy", () => { ], total_count: 1, }); - mockValidateCloning.mockResolvedValue(undefined); - mockGetDeployStatus.mockResolvedValue({ - status: "complete", - dns_ok: true, - ssl_ok: true, - mail_ok: true, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); mockCreateProductionInstance.mockImplementation( (_appId: string, params: { home_url: string }) => { const hostname = params.home_url.replace(/^https?:\/\//, ""); @@ -182,11 +193,8 @@ describe("deploy", () => { mockFetchApplication.mockReset(); mockListApplicationDomains.mockReset(); mockCreateProductionInstance.mockReset(); - mockValidateCloning.mockReset(); - mockGetDeployStatus.mockReset(); - mockTriggerDomainDnsCheck.mockReset(); - mockRetrySSL.mockReset(); - mockRetryMail.mockReset(); + mockGetApplicationDomainStatus.mockReset(); + mockSleep.mockReset(); consoleSpy?.mockRestore(); }); @@ -399,15 +407,17 @@ describe("deploy", () => { expect(allOutput).not.toContain("deploying a Clerk application to production"); }); - test("calls validate_cloning preflight before plan summary", async () => { + test("creates production instance without a separate clone-validation preflight", async () => { await linkedProject(); mockHumanFlow(); await runDeployUntilPause(); - expect(mockValidateCloning).toHaveBeenCalledWith("app_xyz789", { + expect(mockCreateProductionInstance).toHaveBeenCalledWith("app_xyz789", { clone_instance_id: "ins_dev_123", + home_url: "https://example.com", }); + expect(stripAnsi(captured.err)).not.toContain("Validating subscription compatibility"); }); test("checks for an existing production instance before reading development config", async () => { @@ -446,7 +456,7 @@ describe("deploy", () => { expect(err).toContain("Configure them from the Clerk Dashboard before going live"); }); - test("DNS verification polls getDeployStatus until complete", async () => { + test("DNS verification polls getApplicationDomainStatus until complete", async () => { await linkedProject(); // Proceed → create instance → check DNS now → complete OAuth. mockIsAgent.mockReturnValue(false); @@ -456,25 +466,48 @@ describe("deploy", () => { .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); mockPassword.mockResolvedValueOnce("google-secret"); - mockGetDeployStatus - .mockResolvedValueOnce({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }) - .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); mockPatchInstanceConfig.mockResolvedValueOnce({}); await runDeploy({}); const err = stripAnsi(captured.err); - expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock"); - expect(mockGetDeployStatus.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledWith("app_xyz789", "dmn_prod_mock"); + expect(mockGetApplicationDomainStatus.mock.calls.length).toBeGreaterThanOrEqual(2); expect(err).toContain("DNS verified for example.com"); expect(err).toContain("Production ready at https://example.com"); }); + test("DNS verification caps polling to the 10 second budget", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect + .mockResolvedValueOnce("check") + .mockResolvedValueOnce("skip") + .mockResolvedValueOnce("have-credentials"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(4); + expect(mockSleep).toHaveBeenCalledTimes(3); + expect(mockSleep).toHaveBeenCalledWith(3000); + }); + test("DNS verification emits per-component spinner labels in mail/dns/ssl order", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -487,26 +520,19 @@ describe("deploy", () => { .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 }); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: true }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); mockPatchInstanceConfig.mockResolvedValueOnce({}); await runDeploy({}); @@ -533,12 +559,9 @@ describe("deploy", () => { 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, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: true, mail: true }), + ); mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: skip (wired in Task 5) mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip"); @@ -894,14 +917,13 @@ describe("deploy", () => { instanceId: "ins_prod_123", productionConfig: {}, }); - mockGetDeployStatus - .mockResolvedValueOnce({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }) - .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); @@ -921,7 +943,6 @@ 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"); }); @@ -935,12 +956,9 @@ describe("deploy", () => { instanceId: "ins_prod_123", productionConfig: {}, }); - mockGetDeployStatus.mockResolvedValue({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: 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"); @@ -966,12 +984,9 @@ describe("deploy", () => { instanceId: "ins_prod_123", productionConfig: {}, }); - mockGetDeployStatus.mockResolvedValue({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); mockConfirm.mockResolvedValueOnce(true); // BIND export prompt: yes mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); @@ -1007,12 +1022,9 @@ describe("deploy", () => { instanceId: "ins_prod_123", productionConfig: {}, }); - mockGetDeployStatus.mockResolvedValue({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: no mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); @@ -1042,12 +1054,9 @@ describe("deploy", () => { productionConfig: {}, cnameTargets: [], // override: domain has no CNAME targets }); - mockGetDeployStatus.mockResolvedValue({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); @@ -1070,7 +1079,7 @@ describe("deploy", () => { } }); - test("DNS verification timeout names the specific pending components from deploy_status", async () => { + test("DNS verification timeout names the specific pending components from domain status", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, }); @@ -1080,12 +1089,9 @@ describe("deploy", () => { developmentConfig: {}, productionConfig: {}, }); - mockGetDeployStatus.mockResolvedValue({ - status: "incomplete", - dns_ok: true, - ssl_ok: false, - mail_ok: false, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: false }), + ); mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip"); await runDeploy({}); @@ -1104,12 +1110,9 @@ describe("deploy", () => { instanceId: "ins_prod_123", productionConfig: {}, }); - mockGetDeployStatus.mockResolvedValue({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); mockConfirm.mockResolvedValueOnce(true); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); @@ -1129,7 +1132,7 @@ describe("deploy", () => { { name: "Skip DNS verification for now", value: "skip" }, ], }); - expect(mockGetDeployStatus).toHaveBeenCalledTimes(1); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(1); expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_123", { connection_oauth_google: { enabled: true, @@ -1187,15 +1190,14 @@ describe("deploy", () => { mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - mockGetDeployStatus.mockReset(); - mockGetDeployStatus - .mockResolvedValueOnce({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }) - .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + mockGetApplicationDomainStatus.mockReset(); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); await runDeploy({}); @@ -1264,15 +1266,14 @@ describe("deploy", () => { mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - mockGetDeployStatus.mockReset(); - mockGetDeployStatus - .mockResolvedValueOnce({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }) - .mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }); + mockGetApplicationDomainStatus.mockReset(); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); await runDeploy({}); const err = stripAnsi(captured.err); @@ -1456,12 +1457,9 @@ describe("deploy", () => { .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - mockGetDeployStatus.mockResolvedValue({ - status: "incomplete", - dns_ok: false, - ssl_ok: false, - mail_ok: false, - }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); await runDeploy({}); const err = stripAnsi(captured.err); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index ee213cfe..2c7fef3c 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -14,14 +14,12 @@ import { createProductionInstance as apiCreateProductionInstance, fetchApplication, fetchInstanceConfig, - getDeployStatus, + getApplicationDomainStatus, listApplicationDomains, patchInstanceConfig, - triggerDomainDnsCheck, - validateCloning, type ApplicationDomain, type CnameTarget, - type DeployStatusResponse, + type DomainStatusResponse, type ProductionInstanceResponse, } from "../../lib/plapi.ts"; import { @@ -72,7 +70,9 @@ type DeployOptions = { }; const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000; -const DEPLOY_STATUS_MAX_POLLS = 100; +const DEPLOY_STATUS_MAX_WAIT_MS = 10_000; +const DEPLOY_STATUS_MAX_POLLS = + Math.floor(DEPLOY_STATUS_MAX_WAIT_MS / DEPLOY_STATUS_POLL_INTERVAL_MS) + 1; export async function deploy(options: DeployOptions = {}) { if (isAgent()) { @@ -171,8 +171,6 @@ async function startNewDeploy(ctx: DeployContext): Promise { const { known: oauthProviders, unknown: unknownOAuthProviders } = await loadDevelopmentOAuthProviders(ctx); - await runValidateCloning(ctx); - log.blank(); log.info(INTRO_PREAMBLE); log.blank(); @@ -359,7 +357,11 @@ async function resolveLiveDeploySnapshot( if (!domain) return undefined; const { known: oauthProviders } = await loadDevelopmentOAuthProviders(ctx); - const { productionConfig, deployStatus } = await loadProductionState(ctx, productionInstanceId); + const { productionConfig, deployStatus } = await loadProductionState( + ctx, + productionInstanceId, + domain.id, + ); const completedOAuthProviders = oauthProviders.filter((provider) => hasProductionOAuthCredentials(productionConfig, provider), ); @@ -390,34 +392,44 @@ async function resolveLiveDeploySnapshot( async function loadInitialDeployStatus( appId: string, - productionInstanceId: string, -): Promise { + domainIdOrName: string, +): Promise { try { - return await getDeployStatus(appId, productionInstanceId); + return await getApplicationDomainStatus(appId, domainIdOrName); } catch (error) { log.debug( - `deploy: snapshot deploy-status read failed, treating DNS as pending: ${error instanceof Error ? error.message : String(error)}`, + `deploy: snapshot domain-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 }; + return pendingDomainStatus(); } } async function loadProductionState( ctx: DeployContext, productionInstanceId: string, + domainIdOrName: string, ): Promise<{ productionConfig: Record; - deployStatus: DeployStatusResponse; + deployStatus: DomainStatusResponse; }> { return withSpinner("Reading production configuration...", async () => { const [productionConfig, deployStatus] = await Promise.all([ fetchInstanceConfig(ctx.appId, productionInstanceId), - loadInitialDeployStatus(ctx.appId, productionInstanceId), + loadInitialDeployStatus(ctx.appId, domainIdOrName), ]); return { productionConfig, deployStatus }; }); } +function pendingDomainStatus(): DomainStatusResponse { + return { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "not_started", required: true }, + mail: { status: "not_started", required: true }, + }; +} + async function loadProductionDomain(ctx: DeployContext): Promise { const domains = await listApplicationDomains(ctx.appId); return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; @@ -502,14 +514,6 @@ function discoverEnabledOAuthProviders(config: Record): Discove return { known, unknown }; } -async function runValidateCloning(ctx: DeployContext): Promise { - await withSpinner("Validating subscription compatibility...", async () => { - await mapDeployError( - validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }), - ); - }); -} - async function createProductionInstance( ctx: DeployContext, domain: string, @@ -605,18 +609,10 @@ async function runDnsVerification( ctx: DeployContext, state: DeployOperationState, ): Promise { - const productionInstanceId = - state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production; - if (!productionInstanceId) { - throwUsageError( - "Cannot verify DNS because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.", - ); - } + const domainIdOrName = state.productionDomainId ?? state.domain; while (true) { - await requestDomainDnsCheck(ctx.appId, state.productionDomainId ?? state.domain); - - const outcome = await pollDeployStatus(ctx.appId, productionInstanceId, state.domain); + const outcome = await pollDeployStatus(ctx.appId, domainIdOrName, state.domain); if (outcome.verified) { log.blank(); @@ -659,17 +655,15 @@ type DeployStatusOutcome = async function pollDeployStatus( appId: string, - productionInstanceId: string, + domainIdOrName: string, domain: string, ): Promise { - let response = await mapDeployError(getDeployStatus(appId, productionInstanceId)); - let status: DeployComponentStatus = { - dns: response.dns_ok, - ssl: response.ssl_ok, - mail: response.mail_ok, - }; + let response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + let status = deployComponentStatusFromDomainStatus(response); let pollsRemaining = DEPLOY_STATUS_MAX_POLLS - 1; + log.debug(`Response from domain status endpoint: ${JSON.stringify(response)}`); + for (const component of DEPLOY_COMPONENT_ORDER) { const labels = deployComponentLabels(component, domain); const flipped = await withSpinner(labels.progress, async () => { @@ -677,12 +671,8 @@ async function pollDeployStatus( 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, - }; + response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + status = deployComponentStatusFromDomainStatus(response); if (status[component]) return true; } return false; @@ -697,6 +687,20 @@ async function pollDeployStatus( return { verified: true, status }; } +function deployComponentStatusFromDomainStatus( + response: DomainStatusResponse, +): DeployComponentStatus { + return { + dns: response.dns?.status === "complete", + ssl: response.ssl ? checkStatusComplete(response.ssl) : false, + mail: checkStatusComplete(response.mail), + }; +} + +function checkStatusComplete(check: { status: string; required: boolean } | undefined): boolean { + return !check?.required || check.status === "complete"; +} + async function offerBindZoneExport( domain: string, cnameTargets: readonly CnameTarget[] | undefined, @@ -710,16 +714,6 @@ async function offerBindZoneExport( log.success(`Wrote ${filePath}`); } -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/errors.ts b/packages/cli-core/src/lib/errors.ts index 5ff7d8d5..05d2f158 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -54,12 +54,6 @@ export const ERROR_CODE = { 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; diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 1c07f004..a4d1ecac 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -15,10 +15,7 @@ const { listApplications, createApplication, createProductionInstance, - validateCloning, - getDeployStatus, - retryApplicationDomainSSL, - retryApplicationDomainMail, + getApplicationDomainStatus, listApplicationDomains, } = await import("./plapi.ts"); const { AuthError, PlapiError } = await import("./errors.ts"); @@ -388,7 +385,7 @@ describe("plapi", () => { }); describe("createProductionInstance", () => { - test("sends POST to production_instance with clone params", async () => { + test("sends POST to instances with clone params", async () => { let capturedMethod = ""; let capturedUrl = ""; let capturedBody = ""; @@ -415,9 +412,7 @@ describe("plapi", () => { }); expect(capturedMethod).toBe("POST"); - expect(capturedUrl).toBe( - "https://api.clerk.com/v1/platform/applications/app_abc/production_instance", - ); + expect(capturedUrl).toBe("https://api.clerk.com/v1/platform/applications/app_abc/instances"); expect(JSON.parse(capturedBody)).toEqual({ home_url: "example.com", clone_instance_id: "ins_dev_123", @@ -426,91 +421,29 @@ describe("plapi", () => { }); }); - 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 () => { + describe("getApplicationDomainStatus", () => { + test("sends GET to domain status and returns parsed status", async () => { let capturedMethod = ""; let capturedUrl = ""; - stubFetch(async (input, init) => { - capturedMethod = init?.method ?? "GET"; - capturedUrl = input.toString(); - return new Response( - JSON.stringify({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true }), - { status: 200 }, - ); - }); - - const result = await getDeployStatus("app_abc", "production"); - - expect(capturedMethod).toBe("GET"); - expect(capturedUrl).toBe( - "https://api.clerk.com/v1/platform/applications/app_abc/instances/production/deploy_status", - ); - expect(result).toEqual({ + const responseBody = { status: "complete", - dns_ok: true, - ssl_ok: true, - mail_ok: true, - }); - }); - }); - - describe("retryApplicationDomainSSL", () => { - test("sends POST to ssl_retry and accepts empty success response", async () => { - let capturedMethod = ""; - let capturedUrl = ""; + dns: { status: "complete", cnames: {} }, + ssl: { status: "complete", required: true, failure_hints: [] }, + mail: { status: "complete", required: true }, + } as const; 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 }); + return new Response(JSON.stringify(responseBody), { status: 200 }); }); - await retryApplicationDomainMail("app_abc", "example.com"); + const result = await getApplicationDomainStatus("app_abc", "dmn_123"); - expect(capturedMethod).toBe("POST"); + expect(capturedMethod).toBe("GET"); expect(capturedUrl).toBe( - "https://api.clerk.com/v1/platform/applications/app_abc/domains/example.com/mail_retry", + "https://api.clerk.com/v1/platform/applications/app_abc/domains/dmn_123/status", ); + expect(result).toEqual(responseBody); }); }); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index fc2428e3..511561d6 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -67,7 +67,7 @@ export async function getAuthToken(): Promise { /** * Local wrapper that adds the standard Bearer auth + Accept headers and * throws PlapiError on non-ok responses. Debug logging is centralized in - * `loggedFetch` — don't add inline `log.debug` calls here or in callers. + * `loggedFetch`; don't add inline `log.debug` calls here or in callers. */ async function plapiFetch(method: string, url: URL, init?: { body?: string }): Promise { const token = await getAuthToken(); @@ -185,17 +185,21 @@ export type CreateProductionInstanceParams = { clone_instance_id?: string; }; -export type ValidateCloningParams = { - clone_instance_id: string; -}; - export type DeployStatus = "complete" | "incomplete"; -export type DeployStatusResponse = { +type DomainCheckStatus = { + status: string; + required: boolean; +}; + +export type DomainStatusResponse = { status: DeployStatus; - dns_ok: boolean; - ssl_ok: boolean; - mail_ok: boolean; + dns?: { + status: string; + }; + ssl?: DomainCheckStatus; + mail?: DomainCheckStatus; + proxy?: DomainCheckStatus; }; export async function fetchApplication(applicationId: string): Promise { @@ -217,68 +221,21 @@ export async function createProductionInstance( applicationId: string, params: CreateProductionInstanceParams, ): Promise { - const url = new URL( - `/v1/platform/applications/${applicationId}/production_instance`, - getPlapiBaseUrl(), - ); + const url = new URL(`/v1/platform/applications/${applicationId}/instances`, getPlapiBaseUrl()); const response = await plapiFetch("POST", url, { body: JSON.stringify(params) }); return response.json() as Promise; } -export async function validateCloning( - applicationId: string, - params: ValidateCloningParams, -): Promise { - const url = new URL( - `/v1/platform/applications/${applicationId}/validate_cloning`, - getPlapiBaseUrl(), - ); - await plapiFetch("POST", url, { body: JSON.stringify(params) }); -} - -export async function getDeployStatus( - applicationId: string, - envOrInsId: string, -): Promise { - const url = new URL( - `/v1/platform/applications/${applicationId}/instances/${envOrInsId}/deploy_status`, - getPlapiBaseUrl(), - ); - const response = await plapiFetch("GET", url); - return response.json() as Promise; -} - -export async function triggerDomainDnsCheck( - applicationId: string, - domainIdOrName: string, -): Promise { - const url = new URL( - `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/dns_check`, - getPlapiBaseUrl(), - ); - await plapiFetch("POST", url); -} - -export async function retryApplicationDomainSSL( +export async function getApplicationDomainStatus( applicationId: string, domainIdOrName: string, -): Promise { +): Promise { const url = new URL( - `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/ssl_retry`, + `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/status`, 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); + const response = await plapiFetch("GET", url); + return response.json() as Promise; } async function sendInstanceConfig( From cf0ee36a376dd88ba268758f5da51a91b72222b3 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 25 May 2026 15:12:26 -0600 Subject: [PATCH 25/52] fix(deploy): defer DNS verification until after OAuth --- .../cli-core/src/commands/deploy/README.md | 16 +-- .../cli-core/src/commands/deploy/copy.test.ts | 34 ++++- packages/cli-core/src/commands/deploy/copy.ts | 21 +++- .../src/commands/deploy/index.test.ts | 92 +++++++++----- .../cli-core/src/commands/deploy/index.ts | 117 +++++++++--------- .../cli-core/src/commands/deploy/prompts.ts | 10 ++ 6 files changed, 192 insertions(+), 98 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index e37ed703..ce1debe5 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -4,9 +4,9 @@ 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 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. +When the CLI reaches the DNS configuration step, it displays the required CNAME records and 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. On new deploys, this handoff happens before OAuth setup so DNS propagation can start while the user configures providers. -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 10-second cap. +After all OAuth providers are configured, DNS verification 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 10-second cap. If polling times out, the retry handoff lists only DNS records tied to still-pending DNS-backed checks. For example, when DNS and mail are verified but SSL is still pending, the CLI shows green checks for the verified components and does not reprint already verified records. ## Usage @@ -80,12 +80,6 @@ sequenceDiagram CLI->>User: Add these CNAME records to your DNS provider - %% Poll domain status - loop every 3s until status == "complete" - CLI->>API: GET /v1/platform/applications/{appID}/domains/{domain_id_or_name}/status - API-->>CLI: { status, dns, ssl, mail, proxy } - end - %% OAuth credential loop loop Each enabled social provider CLI->>User: Provider credentials @@ -93,6 +87,12 @@ sequenceDiagram API-->>CLI: { before, after, config_version } end + %% Poll domain status + loop every 3s until status == "complete" + CLI->>API: GET /v1/platform/applications/{appID}/domains/{domain_id_or_name}/status + API-->>CLI: { status, dns, ssl, mail, proxy } + end + CLI->>User: Production ready at https://{domain} ``` diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 13064336..0f2bdd49 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, deployComponentLabels } from "./copy.ts"; +import { bindZoneFile, deployComponentLabels, pendingDnsRecords } from "./copy.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; describe("bindZoneFile", () => { @@ -75,3 +75,35 @@ describe("deployComponentLabels", () => { }); }); }); + +describe("pendingDnsRecords", () => { + 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, + }, + ]; + + test("returns no records when only SSL remains pending", () => { + expect(pendingDnsRecords(targets, { dns: true, ssl: false, mail: true })).toEqual([]); + }); + + test("returns only email records when mail remains pending", () => { + const output = pendingDnsRecords(targets, { dns: true, ssl: true, mail: false }).join("\n"); + + expect(output).toContain("clkmail.example.com"); + expect(output).not.toContain("clerk.example.com"); + expect(output).not.toContain("accounts.example.com"); + }); + + test("returns non-email records when DNS remains pending", () => { + const output = pendingDnsRecords(targets, { dns: false, ssl: true, mail: true }).join("\n"); + + expect(output).toContain("clerk.example.com"); + expect(output).toContain("accounts.example.com"); + expect(output).not.toContain("clkmail.example.com"); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index eda42004..40e7197a 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -75,6 +75,25 @@ export function dnsRecords(targets: readonly CnameTarget[]): string[] { return lines; } +export function pendingDnsRecords( + targets: readonly CnameTarget[], + status: DeployComponentStatus, +): string[] { + const pendingTargets = targets.filter((target) => cnameTargetPending(target, status)); + if (pendingTargets.length === 0) return []; + return dnsRecords(pendingTargets); +} + +function cnameTargetPending(target: CnameTarget, status: DeployComponentStatus): boolean { + if (isMailCnameTarget(target)) return !status.mail; + return !status.dns; +} + +function isMailCnameTarget(target: CnameTarget): boolean { + const prefix = target.host.split(".", 1)[0]; + return prefix === "clkmail" || prefix === "clk" || prefix === "clk2"; +} + function cnameTargetLabel(host: string): string { const prefix = host.split(".", 1)[0]; switch (prefix) { @@ -94,7 +113,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 verify DNS now, or skip and continue. DNS propagation can take time.", + "After OAuth setup, you can verify DNS or skip and finish. 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 c8bee990..4795a451 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -373,9 +373,9 @@ describe("deploy", () => { describe("human mode", () => { function mockHumanFlow() { mockIsAgent.mockReturnValue(false); - // Proceed → create instance → skip DNS verification → pause at OAuth. + // Proceed → create instance → show DNS records → pause at OAuth. mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + mockSelect.mockResolvedValueOnce("skip"); mockInput.mockResolvedValueOnce("example.com"); } @@ -464,7 +464,7 @@ describe("deploy", () => { mockInput .mockResolvedValueOnce("example.com") .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); - mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); mockPassword.mockResolvedValueOnce("google-secret"); mockGetApplicationDomainStatus .mockResolvedValueOnce( @@ -492,9 +492,9 @@ describe("deploy", () => { .mockResolvedValueOnce("example.com") .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockSelect + .mockResolvedValueOnce("have-credentials") .mockResolvedValueOnce("check") - .mockResolvedValueOnce("skip") - .mockResolvedValueOnce("have-credentials"); + .mockResolvedValueOnce("skip"); mockPassword.mockResolvedValueOnce("google-secret"); mockGetApplicationDomainStatus.mockResolvedValue( domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), @@ -518,7 +518,7 @@ describe("deploy", () => { mockInput .mockResolvedValueOnce("example.com") .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); - mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); mockPassword.mockResolvedValueOnce("google-secret"); mockGetApplicationDomainStatus .mockResolvedValueOnce( @@ -582,7 +582,7 @@ 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 DNS records"); + expect(err).toContain("[ ] Verify DNS records"); expect(err).toContain("[ ] Configure Google OAuth credentials"); expect(err).toContain("Check the Domains section in the Clerk Dashboard"); }); @@ -659,7 +659,7 @@ describe("deploy", () => { expect(err).toContain("Production keys only work on your production domain"); }); - test("DNS setup prints dashboard handoff and asks about verifying DNS", async () => { + test("DNS setup prints dashboard handoff before OAuth without asking for verification", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm @@ -680,7 +680,6 @@ describe("deploy", () => { expect(err).toContain("Check the Domains section in the Clerk Dashboard"); expect(err).toContain("propagation and SSL issuance"); expect(err).toContain("DNS propagation can take time"); - expect(err).toContain("Skipping DNS verification for now."); expect(mockConfirm).toHaveBeenCalledTimes(3); expect(mockConfirm).toHaveBeenCalledWith({ message: "Create production instance?", @@ -694,7 +693,7 @@ describe("deploy", () => { message: "Continue to OAuth setup?", default: true, }); - expect(mockSelect).toHaveBeenCalledWith({ + expect(mockSelect).not.toHaveBeenCalledWith({ message: "DNS verification", choices: [ { name: "Check DNS now", value: "check" }, @@ -748,8 +747,10 @@ describe("deploy", () => { test("Ctrl-C at the DNS handoff reports paused", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); - mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - mockSelect.mockRejectedValueOnce(promptExitError()); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(promptExitError()); mockInput.mockResolvedValueOnce("example.com"); let error: CliError | undefined; @@ -900,7 +901,7 @@ describe("deploy", () => { 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] Verify DNS records"); expect(err).toContain("[x] Configure Google OAuth credentials"); expect(err).toContain("No deploy actions remain."); expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789"); @@ -925,7 +926,7 @@ describe("deploy", () => { domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); @@ -933,7 +934,7 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain("[x] Create production instance"); - expect(err).toContain("[ ] Configure DNS records"); + expect(err).toContain("[ ] Verify DNS records"); expect(err).toContain("[ ] Configure Google OAuth credentials"); expect(err).toContain("DNS verified for example.com"); expect(mockSelect).toHaveBeenCalledWith({ @@ -954,6 +955,7 @@ describe("deploy", () => { mockIsAgent.mockReturnValue(false); mockLiveProduction({ instanceId: "ins_prod_123", + developmentConfig: {}, productionConfig: {}, }); mockGetApplicationDomainStatus.mockResolvedValue( @@ -982,6 +984,7 @@ describe("deploy", () => { mockIsAgent.mockReturnValue(false); mockLiveProduction({ instanceId: "ins_prod_123", + developmentConfig: {}, productionConfig: {}, }); mockGetApplicationDomainStatus.mockResolvedValue( @@ -1020,6 +1023,7 @@ describe("deploy", () => { mockIsAgent.mockReturnValue(false); mockLiveProduction({ instanceId: "ins_prod_123", + developmentConfig: {}, productionConfig: {}, }); mockGetApplicationDomainStatus.mockResolvedValue( @@ -1051,6 +1055,7 @@ describe("deploy", () => { mockIsAgent.mockReturnValue(false); mockLiveProduction({ instanceId: "ins_prod_123", + developmentConfig: {}, productionConfig: {}, cnameTargets: [], // override: domain has no CNAME targets }); @@ -1101,6 +1106,33 @@ describe("deploy", () => { expect(err).not.toContain("DNS, SSL, mail still pending"); }); + test("DNS verification timeout does not reprint DNS records when only SSL remains pending", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + mockInput.mockResolvedValueOnce("example.com"); + mockSelect + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("check") + .mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), + ); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("DNS: ✓ SSL: pending Mail: ✓"); + expect(err).toContain("SSL still pending for example.com"); + expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(1); + }); + test("plain deploy can skip DNS verification and continue configuring production", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, @@ -1113,7 +1145,7 @@ describe("deploy", () => { mockGetApplicationDomainStatus.mockResolvedValue( domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), ); - mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("skip"); mockConfirm.mockResolvedValueOnce(true); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); @@ -1153,7 +1185,7 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain("Check the Domains section in the Clerk Dashboard"); expect(err).toContain("DNS propagation can take time"); - expect(err).toContain("Skipping DNS verification for now."); + expect(err).toContain("Configure Google OAuth for production"); }); test("Ctrl-C during OAuth setup reports plain deploy continuation", async () => { @@ -1186,7 +1218,7 @@ describe("deploy", () => { }); mockIsAgent.mockReturnValue(false); mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); @@ -1238,7 +1270,7 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain("[x] Create production instance"); - expect(err).toContain("[x] Configure DNS records"); + expect(err).toContain("[x] Verify DNS records"); expect(err).toContain("No deploy actions remain."); expect(mockSelect).not.toHaveBeenCalled(); expect(mockInput).not.toHaveBeenCalled(); @@ -1254,7 +1286,7 @@ describe("deploy", () => { await runDeployUntilPause(); mockLiveProduction(); expect(stripAnsi(captured.err)).toContain("Check the Domains section in the Clerk Dashboard"); - expect(stripAnsi(captured.err)).toContain("Skipping DNS verification for now."); + expect(stripAnsi(captured.err)).toContain("Configure Google OAuth for production"); captured.clear(); mockConfirm.mockReset(); @@ -1262,7 +1294,7 @@ describe("deploy", () => { mockInput.mockReset(); mockPassword.mockReset(); mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); @@ -1337,15 +1369,12 @@ describe("deploy", () => { connection_oauth_google: { enabled: true }, connection_oauth_github: { enabled: true }, }); - // Proceed → create prod → check DNS → enter google creds → skip github. + // Proceed → create prod → enter google creds → skip github before DNS verification. mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mockInput .mockResolvedValueOnce("example.com") .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); - mockSelect - .mockResolvedValueOnce("check") - .mockResolvedValueOnce("have-credentials") - .mockResolvedValueOnce("skip"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("skip"); mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); @@ -1449,9 +1478,9 @@ describe("deploy", () => { mockIsAgent.mockReturnValue(false); mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mockSelect + .mockResolvedValueOnce("have-credentials") .mockResolvedValueOnce("check") - .mockResolvedValueOnce("skip") - .mockResolvedValueOnce("have-credentials"); + .mockResolvedValueOnce("skip"); mockInput .mockResolvedValueOnce("example.com") .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); @@ -1471,6 +1500,13 @@ describe("deploy", () => { expect(err).toContain("Value: frontend-api.clerk.services"); expect(err).toContain("Skipping DNS verification for now."); expect(err).toContain("Saved Google OAuth credentials"); + expect(mockSelect).toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Skip DNS verification for now", value: "skip" }, + { name: "Check again", value: "check" }, + ], + }); expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { connection_oauth_google: { enabled: true, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 2c7fef3c..58dbdc10 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -37,6 +37,7 @@ import { dnsDashboardHandoff, dnsIntro, dnsRecords, + pendingDnsRecords, printPlan, productionSummary, } from "./copy.ts"; @@ -51,6 +52,7 @@ import { } from "./providers.ts"; import { chooseDnsVerificationAction, + chooseDnsVerificationRetryAction, chooseOAuthCredentialAction, collectCustomDomain, collectOAuthCredentials, @@ -232,24 +234,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { const productionDomain = production.active_domain.name; let completedOAuthProviders: OAuthProvider[] = []; - const dnsStatus = await runDnsSetup( - ctx, - { - appId: ctx.appId, - developmentInstanceId: ctx.developmentInstanceId, - productionInstanceId: production.instance_id, - productionDomainId: production.active_domain.id, - domain: productionDomain, - pending: { type: "dns" }, - oauthProviders, - completedOAuthProviders, - }, - production.cname_targets, - ); - if (!dnsStatus) return; - - bar(); - completedOAuthProviders = await runOAuthSetup(ctx, { + const operationState: DeployOperationState = { appId: ctx.appId, developmentInstanceId: ctx.developmentInstanceId, productionInstanceId: production.instance_id, @@ -258,9 +243,26 @@ async function startNewDeploy(ctx: DeployContext): Promise { pending: { type: "oauth", provider: oauthProviders[0] ?? "google" }, oauthProviders, completedOAuthProviders, - }); + cnameTargets: production.cname_targets, + }; + + await runDnsRecordHandoff( + { ...operationState, pending: { type: "dns" } }, + production.cname_targets, + ); + + bar(); + completedOAuthProviders = await runOAuthSetup(ctx, operationState); if (completedOAuthProviders.length < oauthProviders.length) return; + bar(); + const dnsStatus = await runDnsVerificationPrompt(ctx, { + ...operationState, + pending: { type: "dns" }, + completedOAuthProviders, + }); + if (!dnsStatus) return; + await finishDeploy(ctx, productionDomain, completedOAuthProviders, dnsStatus); } @@ -287,14 +289,6 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { } let dnsStatus: DnsVerificationResult = snapshot.dnsComplete ? "verified" : "pending"; - if (snapshot.pending.type === "dns") { - const nextDnsStatus = await runExistingDomainDnsVerification( - ctx, - snapshotToOperationState(snapshot, { type: "dns" }), - ); - if (!nextDnsStatus) return; - dnsStatus = nextDnsStatus; - } if ( snapshot.pending.type === "oauth" || @@ -317,6 +311,15 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { snapshot.completedOAuthProviders = completed; } + if (!snapshot.dnsComplete) { + const nextDnsStatus = await runExistingDomainDnsVerification( + ctx, + snapshotToOperationState(snapshot, { type: "dns" }), + ); + if (!nextDnsStatus) return; + dnsStatus = nextDnsStatus; + } + await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, dnsStatus); } @@ -381,10 +384,10 @@ async function resolveLiveDeploySnapshot( }; const dnsComplete = deployStatus.status === "complete"; - const pending = !dnsComplete - ? ({ type: "dns" } as const) - : pendingOAuthProvider - ? ({ type: "oauth", provider: pendingOAuthProvider } as const) + const pending = pendingOAuthProvider + ? ({ type: "oauth", provider: pendingOAuthProvider } as const) + : !dnsComplete + ? ({ type: "dns" } as const) : undefined; return { ...baseState, dnsComplete, pending }; @@ -455,11 +458,11 @@ function buildNewDeployPlan(oauthProviders: readonly OAuthProvider[]): DeployPla 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, })), + { label: "Verify DNS records", status: "pending" }, ]; } @@ -467,7 +470,6 @@ 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" @@ -477,6 +479,7 @@ function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { status, }; }), + { label: "Verify DNS records", status: snapshot.dnsComplete ? "done" : "pending" }, ]; } @@ -544,28 +547,22 @@ async function confirmProductionInstanceCreation(domain: string): Promise { +): Promise { for (const line of dnsIntro(state.domain)) log.info(line); log.blank(); - for (const line of dnsRecords(cnameTargets)) log.info(line); - log.blank(); + if (cnameTargets.length > 0) { + for (const line of dnsRecords(cnameTargets)) log.info(line); + log.blank(); + } for (const line of dnsDashboardHandoff(state.domain)) log.info(line); log.blank(); - await offerBindZoneExport(state.domain, cnameTargets); - log.blank(); try { - const action = await chooseDnsVerificationAction(); - if (action === "skip") { - log.blank(); - log.info("Skipping DNS verification for now."); - return "pending"; - } - return await runDnsVerification(ctx, { ...state, cnameTargets }); + await offerBindZoneExport(state.domain, cnameTargets); + log.blank(); } catch (error) { if (isPromptExitError(error)) { throw deployPausedError(state, { interrupted: true }); @@ -578,17 +575,14 @@ 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(); - } - for (const line of dnsDashboardHandoff(state.domain)) log.info(line); - log.blank(); - await offerBindZoneExport(state.domain, state.cnameTargets); - log.blank(); + await runDnsRecordHandoff(state, state.cnameTargets ?? []); + return runDnsVerificationPrompt(ctx, state); +} +async function runDnsVerificationPrompt( + ctx: DeployContext, + state: DeployOperationState, +): Promise { try { const action = await chooseDnsVerificationAction(); if (action === "skip") { @@ -635,12 +629,15 @@ async function runDnsVerification( return false; } - if (state.cnameTargets && state.cnameTargets.length > 0) { + const pendingRecords = state.cnameTargets + ? pendingDnsRecords(state.cnameTargets, outcome.status) + : []; + if (pendingRecords.length > 0) { log.blank(); - for (const line of dnsRecords(state.cnameTargets)) log.info(line); + for (const line of pendingRecords) log.info(line); } log.blank(); - const action = await chooseDnsVerificationAction(); + const action = await chooseDnsVerificationRetryAction(); if (action === "skip") { log.blank(); log.info("Skipping DNS verification for now."); diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index 254de6d7..ce093964 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -66,6 +66,16 @@ export async function chooseDnsVerificationAction(): Promise { + return select({ + message: "DNS verification", + choices: [ + { name: "Skip DNS verification for now", value: "skip" }, + { name: "Check again", value: "check" }, + ], + }); +} + export async function confirmExportBindZone(): Promise { return confirm({ message: "Export DNS records as a BIND zone file?", From 4d6a0fa110b37b3275f56b85114cf5d4533e467e Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 08:54:01 -0600 Subject: [PATCH 26/52] fix(deploy): refresh dns checks before polling Trigger a fresh domain DNS check before status polling and retry status reads with exponential backoff while updating the spinner countdown. --- .../cli-core/src/commands/deploy/copy.test.ts | 15 ++++- packages/cli-core/src/commands/deploy/copy.ts | 9 +++ .../src/commands/deploy/index.test.ts | 44 +++++++++++-- .../cli-core/src/commands/deploy/index.ts | 63 +++++++++++++++---- packages/cli-core/src/lib/plapi.test.ts | 29 +++++++++ packages/cli-core/src/lib/plapi.ts | 17 +++++ packages/cli-core/src/lib/spinner.test.ts | 45 +++++++++++++ packages/cli-core/src/lib/spinner.ts | 26 +++++--- 8 files changed, 224 insertions(+), 24 deletions(-) create mode 100644 packages/cli-core/src/lib/spinner.test.ts diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 0f2bdd49..cd423289 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -1,5 +1,10 @@ import { test, expect, describe } from "bun:test"; -import { bindZoneFile, deployComponentLabels, pendingDnsRecords } from "./copy.ts"; +import { + bindZoneFile, + deployComponentLabels, + deployStatusRetryMessage, + pendingDnsRecords, +} from "./copy.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; describe("bindZoneFile", () => { @@ -76,6 +81,14 @@ describe("deployComponentLabels", () => { }); }); +describe("deployStatusRetryMessage", () => { + test("includes current retry count and countdown", () => { + expect(deployStatusRetryMessage("Verifying mail sender for example.com...", 2, 5, 30)).toBe( + "Verifying mail sender for example.com... (2/5 Retrying in 30s)", + ); + }); +}); + describe("pendingDnsRecords", () => { const targets: CnameTarget[] = [ { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 40e7197a..dbaf23f8 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -169,6 +169,15 @@ export function deployComponentStatus(status: DeployComponentStatus): string { return `DNS: ${mark(status.dns)} SSL: ${mark(status.ssl)} Mail: ${mark(status.mail)}`; } +export function deployStatusRetryMessage( + message: string, + currentRetry: number, + totalRetries: number, + seconds: number, +): string { + return `${message} (${currentRetry}/${totalRetries} Retrying in ${seconds}s)`; +} + /** * Footer printed when domain status polling times out before all three * components are complete. The user keeps the deploy state; rerunning diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4795a451..5dcb6a80 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -29,6 +29,7 @@ const mockFetchApplication = mock(); const mockListApplicationDomains = mock(); const mockCreateProductionInstance = mock(); const mockGetApplicationDomainStatus = mock(); +const mockTriggerApplicationDomainDNSCheck = mock(); const mockSleep = mock(); mock.module("@inquirer/prompts", () => ({ @@ -54,6 +55,8 @@ mock.module("../../lib/plapi.ts", () => ({ listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), + triggerApplicationDomainDNSCheck: (...args: unknown[]) => + mockTriggerApplicationDomainDNSCheck(...args), patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), })); @@ -175,6 +178,9 @@ describe("deploy", () => { }; }, ); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); }); afterEach(async () => { @@ -194,6 +200,7 @@ describe("deploy", () => { mockListApplicationDomains.mockReset(); mockCreateProductionInstance.mockReset(); mockGetApplicationDomainStatus.mockReset(); + mockTriggerApplicationDomainDNSCheck.mockReset(); mockSleep.mockReset(); consoleSpy?.mockRestore(); }); @@ -484,7 +491,36 @@ describe("deploy", () => { expect(err).toContain("Production ready at https://example.com"); }); - test("DNS verification caps polling to the 10 second budget", async () => { + test("DNS verification triggers a fresh DNS check before polling status", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockTriggerApplicationDomainDNSCheck.mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockGetApplicationDomainStatus.mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith( + "app_xyz789", + "dmn_prod_mock", + ); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledWith("app_xyz789", "dmn_prod_mock"); + expect(mockTriggerApplicationDomainDNSCheck.mock.invocationCallOrder[0]).toBeLessThan( + mockGetApplicationDomainStatus.mock.invocationCallOrder[0]!, + ); + }); + + test("DNS verification retries status polling five times with exponential backoff", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); @@ -503,9 +539,9 @@ describe("deploy", () => { await runDeploy({}); - expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(4); - expect(mockSleep).toHaveBeenCalledTimes(3); - expect(mockSleep).toHaveBeenCalledWith(3000); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(6); + expect(mockSleep).toHaveBeenCalledTimes(93); + expect(mockSleep.mock.calls.every(([delay]) => delay === 1000)).toBe(true); }); test("DNS verification emits per-component spinner labels in mail/dns/ssl order", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 58dbdc10..385f27f6 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,10 +1,11 @@ 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 { bar, intro, outro, withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; import { CliError, ERROR_CODE, + PlapiError, UserAbortError, isPromptExitError, throwUsageError, @@ -17,6 +18,7 @@ import { getApplicationDomainStatus, listApplicationDomains, patchInstanceConfig, + triggerApplicationDomainDNSCheck, type ApplicationDomain, type CnameTarget, type DomainStatusResponse, @@ -31,6 +33,7 @@ import { DEPLOY_COMPONENT_ORDER, deployComponentLabels, deployComponentStatus, + deployStatusRetryMessage, deployStatusPendingFooter, domainAssociationSummary, bindZoneFile, @@ -71,10 +74,9 @@ type DeployOptions = { debug?: boolean; }; -const DEPLOY_STATUS_POLL_INTERVAL_MS = 3000; -const DEPLOY_STATUS_MAX_WAIT_MS = 10_000; -const DEPLOY_STATUS_MAX_POLLS = - Math.floor(DEPLOY_STATUS_MAX_WAIT_MS / DEPLOY_STATUS_POLL_INTERVAL_MS) + 1; +const DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS = 3000; +const DEPLOY_STATUS_MAX_RETRIES = 5; +const DEPLOY_STATUS_BACKOFF_FACTOR = 2; export async function deploy(options: DeployOptions = {}) { if (isAgent()) { @@ -655,19 +657,26 @@ async function pollDeployStatus( domainIdOrName: string, domain: string, ): Promise { + await triggerDeployStatusCheck(appId, domainIdOrName); let response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); let status = deployComponentStatusFromDomainStatus(response); - let pollsRemaining = DEPLOY_STATUS_MAX_POLLS - 1; - - log.debug(`Response from domain status endpoint: ${JSON.stringify(response)}`); + let retriesRemaining = DEPLOY_STATUS_MAX_RETRIES; + let nextRetryDelay = DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS; for (const component of DEPLOY_COMPONENT_ORDER) { const labels = deployComponentLabels(component, domain); - const flipped = await withSpinner(labels.progress, async () => { + const flipped = await withSpinner(labels.progress, async (spinner) => { if (status[component]) return true; - while (pollsRemaining > 0) { - await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS); - pollsRemaining--; + while (retriesRemaining > 0) { + await sleepWithRetryCountdown( + labels.progress, + DEPLOY_STATUS_MAX_RETRIES - retriesRemaining + 1, + DEPLOY_STATUS_MAX_RETRIES, + nextRetryDelay, + spinner, + ); + retriesRemaining--; + nextRetryDelay *= DEPLOY_STATUS_BACKOFF_FACTOR; response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); status = deployComponentStatusFromDomainStatus(response); if (status[component]) return true; @@ -684,6 +693,36 @@ async function pollDeployStatus( return { verified: true, status }; } +async function sleepWithRetryCountdown( + message: string, + currentRetry: number, + totalRetries: number, + delayMs: number, + spinner: SpinnerControls, +): Promise { + let remainingMs = delayMs; + while (remainingMs > 0) { + const tickMs = Math.min(1000, remainingMs); + spinner.update( + deployStatusRetryMessage(message, currentRetry, totalRetries, Math.ceil(remainingMs / 1000)), + ); + await sleep(tickMs); + remainingMs -= tickMs; + } +} + +async function triggerDeployStatusCheck(appId: string, domainIdOrName: string): Promise { + try { + await mapDeployError(triggerApplicationDomainDNSCheck(appId, domainIdOrName)); + } catch (error) { + if (error instanceof PlapiError && error.status === 409 && error.code === "conflict") { + log.debug("DNS check is already in flight; continuing to poll domain status."); + return; + } + throw error; + } +} + function deployComponentStatusFromDomainStatus( response: DomainStatusResponse, ): DeployComponentStatus { diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index a4d1ecac..692e3fae 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -16,6 +16,7 @@ const { createApplication, createProductionInstance, getApplicationDomainStatus, + triggerApplicationDomainDNSCheck, listApplicationDomains, } = await import("./plapi.ts"); const { AuthError, PlapiError } = await import("./errors.ts"); @@ -447,6 +448,34 @@ describe("plapi", () => { }); }); + describe("triggerApplicationDomainDNSCheck", () => { + test("sends POST to domain dns_check and returns parsed status", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + const responseBody = { + status: "incomplete", + domain_id: "dmn_123", + last_run_at: 1779739200000, + dns: { status: "not_started", cnames: {} }, + ssl: { status: "not_started", required: true, failure_hints: [] }, + mail: { status: "not_started", required: true }, + } as const; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await triggerApplicationDomainDNSCheck("app_abc", "dmn_123"); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/domains/dmn_123/dns_check", + ); + expect(result).toEqual(responseBody); + }); + }); + describe("listApplicationDomains", () => { test("sends GET to application domains and returns parsed domains", async () => { let capturedMethod = ""; diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 511561d6..f561c181 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -202,6 +202,11 @@ export type DomainStatusResponse = { proxy?: DomainCheckStatus; }; +export type TriggerDNSCheckResponse = DomainStatusResponse & { + domain_id: string; + last_run_at: number | null; +}; + export async function fetchApplication(applicationId: string): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); url.searchParams.set("include_secret_keys", "true"); @@ -238,6 +243,18 @@ export async function getApplicationDomainStatus( return response.json() as Promise; } +export async function triggerApplicationDomainDNSCheck( + applicationId: string, + domainIdOrName: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/dns_check`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("POST", url); + return response.json() as Promise; +} + async function sendInstanceConfig( method: "PUT" | "PATCH", applicationId: string, 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..535315b1 --- /dev/null +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -0,0 +1,45 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../test/lib/stubs.ts"; + +mock.module("../mode.ts", () => ({ + getMode: () => "human", + isAgent: () => false, + isHuman: () => true, + setMode: () => {}, +})); + +const { withSpinner } = await import("./spinner.ts"); + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, "g"), ""); +} + +describe("withSpinner", () => { + const captured = useCaptureLog(); + const originalCI = process.env.CI; + const originalIsTTY = process.stderr.isTTY; + + beforeEach(() => { + delete process.env.CI; + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: true, + }); + }); + + afterEach(() => { + process.env.CI = originalCI; + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: originalIsTTY, + }); + }); + + test("lets callbacks update the active spinner message", async () => { + await withSpinner("Checking status...", async ({ update }) => { + update("Checking status... Retrying in 5"); + }); + + expect(stripAnsi(captured.err)).toContain("Checking status... Retrying in 5"); + }); +}); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 27dfd98e..2ac74c68 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -61,18 +61,26 @@ function createSpinner() { const interactive = isInteractive(); let timer: ReturnType | undefined; let frame = 0; + let currentMessage = ""; + + const render = () => { + const char = cyan(FRAMES[frame++ % FRAMES.length]!); + log.ui(`\r\x1b[K${char} ${currentMessage}`); + }; return { start(message: string) { + currentMessage = message; if (!interactive) { log.ui(`${S_STEP_DONE} ${message}\n`); return; } log.ui("\x1b[?25l"); // hide cursor - timer = setInterval(() => { - const char = cyan(FRAMES[frame++ % FRAMES.length]!); - log.ui(`\r\x1b[K${char} ${message}`); - }, INTERVAL); + timer = setInterval(render, INTERVAL); + }, + update(message: string) { + currentMessage = message; + if (interactive) render(); }, stop(finalMessage?: string) { if (timer) { @@ -99,17 +107,21 @@ function createSpinner() { }; } +export type SpinnerControls = { + update(message: string): void; +}; + export async function withSpinner( message: string, - fn: () => Promise, + fn: (controls: SpinnerControls) => Promise, doneMessage?: string, ): Promise { - if (!isHuman()) return fn(); + if (!isHuman()) return fn({ update: () => {} }); const s = createSpinner(); s.start(message); try { - const result = await fn(); + const result = await fn({ update: s.update }); s.stop(doneMessage ?? message.replace(/\.{3}$/, "")); return result; } catch (error) { From 15400462e15779322255bf9003e224d606b67fe1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 10:28:25 -0600 Subject: [PATCH 27/52] fix(deploy): report dns verification pauses --- .../src/commands/deploy/index.test.ts | 48 ++++++++++++++- .../cli-core/src/commands/deploy/index.ts | 59 ++++++++----------- 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 5dcb6a80..ddba3298 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -544,6 +544,40 @@ describe("deploy", () => { expect(mockSleep.mock.calls.every(([delay]) => delay === 1000)).toBe(true); }); + test("Ctrl-C at the DNS retry prompt reports paused", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockSelect.mockResolvedValueOnce("check").mockRejectedValueOnce(promptExitError()); + + let error: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + error = caught as CliError; + } + + expect(error?.message).toContain("Deploy paused at: DNS verification"); + expect(error?.exitCode).toBe(EXIT_CODE.SIGINT); + const terminalOutput = stripAnsi(captured.err); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).not.toContain("Done"); + }); + test("DNS verification emits per-component spinner labels in mail/dns/ssl order", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -584,7 +618,7 @@ describe("deploy", () => { expect(dnsIdx).toBeLessThan(sslIdx); }); - test("DNS verification fails closed when status stays incomplete despite all exposed booleans true (proxy_ok case)", async () => { + test("DNS verification pauses when status stays incomplete despite all exposed booleans true (proxy_ok case)", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, }); @@ -599,12 +633,20 @@ describe("deploy", () => { domainStatus({ status: "incomplete", dns: true, ssl: true, mail: true }), ); mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: skip (wired in Task 5) - mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip"); + mockSelect.mockResolvedValueOnce("check"); - await runDeploy({}); + let error: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + error = caught as CliError; + } const err = stripAnsi(captured.err); + expect(error?.message).toContain("Deploy paused at: DNS verification"); + expect(error?.exitCode).toBe(EXIT_CODE.GENERAL); expect(err).toContain("Production setup for example.com is still finalizing."); + expect(err).toContain("Paused"); expect(err).not.toContain("Production ready at"); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 385f27f6..ae933339 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -263,7 +263,6 @@ async function startNewDeploy(ctx: DeployContext): Promise { pending: { type: "dns" }, completedOAuthProviders, }); - if (!dnsStatus) return; await finishDeploy(ctx, productionDomain, completedOAuthProviders, dnsStatus); } @@ -297,9 +296,9 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { snapshot.oauthProviders.length > snapshot.completedOAuthProviders.length ) { bar(); - const completed = await runOAuthSetup( - ctx, - snapshotToOperationState(snapshot, { + const completed = await runOAuthSetup(ctx, { + ...snapshot, + pending: { type: "oauth", provider: snapshot.oauthProviders.find( @@ -307,18 +306,17 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { ) ?? snapshot.oauthProviders[0] ?? "google", - }), - ); + }, + }); if (completed.length < snapshot.oauthProviders.length) return; snapshot.completedOAuthProviders = completed; } if (!snapshot.dnsComplete) { - const nextDnsStatus = await runExistingDomainDnsVerification( - ctx, - snapshotToOperationState(snapshot, { type: "dns" }), - ); - if (!nextDnsStatus) return; + const nextDnsStatus = await runExistingDomainDnsVerification(ctx, { + ...snapshot, + pending: { type: "dns" }, + }); dnsStatus = nextDnsStatus; } @@ -485,23 +483,6 @@ function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { ]; } -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[] = []; @@ -576,7 +557,7 @@ async function runDnsRecordHandoff( async function runExistingDomainDnsVerification( ctx: DeployContext, state: DeployOperationState, -): Promise { +): Promise { await runDnsRecordHandoff(state, state.cnameTargets ?? []); return runDnsVerificationPrompt(ctx, state); } @@ -584,7 +565,7 @@ async function runExistingDomainDnsVerification( async function runDnsVerificationPrompt( ctx: DeployContext, state: DeployOperationState, -): Promise { +): Promise { try { const action = await chooseDnsVerificationAction(); if (action === "skip") { @@ -604,7 +585,7 @@ async function runDnsVerificationPrompt( async function runDnsVerification( ctx: DeployContext, state: DeployOperationState, -): Promise { +): Promise { const domainIdOrName = state.productionDomainId ?? state.domain; while (true) { @@ -624,11 +605,9 @@ async function runDnsVerification( } // 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. + // deployment complete, the user cannot influence the remaining wait. if (outcome.status.dns && outcome.status.ssl && outcome.status.mail) { - return false; + throw deployPausedError(state); } const pendingRecords = state.cnameTargets @@ -639,7 +618,15 @@ async function runDnsVerification( for (const line of pendingRecords) log.info(line); } log.blank(); - const action = await chooseDnsVerificationRetryAction(); + let action: Awaited>; + try { + action = await chooseDnsVerificationRetryAction(); + } catch (error) { + if (isPromptExitError(error)) { + throw deployPausedError(state, { interrupted: true }); + } + throw error; + } if (action === "skip") { log.blank(); log.info("Skipping DNS verification for now."); From 4b6e0e0ae0e56cbad25b8f1bf0ae1b9595240ad8 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 10:51:07 -0600 Subject: [PATCH 28/52] feat(deploy): type instance config schemas --- packages/cli-core/src/lib/plapi.test.ts | 34 +++++++++++++++++++++++++ packages/cli-core/src/lib/plapi.ts | 25 ++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 692e3fae..11156018 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -10,6 +10,7 @@ mock.module("./credential-store.ts", () => ({ const { fetchApplication, fetchInstanceConfig, + fetchInstanceConfigSchema, putInstanceConfig, patchInstanceConfig, listApplications, @@ -126,6 +127,39 @@ describe("plapi", () => { expect(requestedUrl).toStartWith("https://api.clerk.com/"); }); + describe("fetchInstanceConfigSchema", () => { + test("constructs schema URL without keys", async () => { + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify({ properties: {} }), { status: 200 }); + }); + + await fetchInstanceConfigSchema("app_abc", "ins_def"); + + expect(requestedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_def/config/schema", + ); + }); + + test("appends repeated keys query params", async () => { + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify({ properties: {} }), { status: 200 }); + }); + + await fetchInstanceConfigSchema("app_abc", "ins_def", [ + "connection_oauth_google,connection_oauth_discord", + "connection_oauth_apple", + ]); + + expect(requestedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_def/config/schema?keys=connection_oauth_google&keys=connection_oauth_discord&keys=connection_oauth_apple", + ); + }); + }); + describe("putInstanceConfig", () => { test("sends PUT method with correct URL", async () => { let capturedMethod = ""; diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index f561c181..1c5ade15 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -99,18 +99,39 @@ function appendKeys(url: URL, keys?: string[]): void { } } +export type ConfigSchemaProperty = { + type?: string; + description?: string; + default?: unknown; + enum?: string[]; + readOnly?: boolean; + properties?: Record; + required?: string[]; + minLength?: number; + maxLength?: number; + pattern?: string; + "x-clerk-sensitive"?: boolean; +}; + +export type InstanceConfigSchema = { + $schema?: string; + $id?: string; + type?: string; + properties?: Record; +}; + export async function fetchInstanceConfigSchema( applicationId: string, instanceId: string, keys?: string[], -): Promise> { +): Promise { const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config/schema`, getPlapiBaseUrl(), ); appendKeys(url, keys); const response = await plapiFetch("GET", url); - return response.json() as Promise>; + return response.json() as Promise; } export async function fetchInstanceConfig( From 3724c5790ceb107a9f990692b5a45a77589a81f4 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 11:00:03 -0600 Subject: [PATCH 29/52] feat(deploy): derive oauth provider descriptors from schema --- .../cli-core/src/commands/deploy/index.ts | 10 +- .../cli-core/src/commands/deploy/prompts.ts | 14 +- .../src/commands/deploy/providers.test.ts | 187 +++++++ .../cli-core/src/commands/deploy/providers.ts | 494 +++++++++++++++--- 4 files changed, 619 insertions(+), 86 deletions(-) create mode 100644 packages/cli-core/src/commands/deploy/providers.test.ts diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index ae933339..afc4a6fd 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -47,7 +47,7 @@ import { import { mapDeployError } from "./errors.ts"; import { PROVIDER_LABELS, - PROVIDER_FIELDS, + providerFields, providerLabel, providerSetupIntro, showOAuthWalkthrough, @@ -446,7 +446,7 @@ function hasProductionOAuthCredentials( if (!value || typeof value !== "object") return false; const providerConfig = value as Record; if (providerConfig.enabled !== true) return false; - return PROVIDER_FIELDS[provider].every((field) => { + return providerFields(provider).every((field) => { const fieldValue = providerConfig[field.key]; return typeof fieldValue === "string" && fieldValue.length > 0; }); @@ -459,7 +459,7 @@ function buildNewDeployPlan(oauthProviders: readonly OAuthProvider[]): DeployPla { label: "Create production instance", status: "pending" }, { label: "Choose a production domain you own", status: "pending" }, ...oauthProviders.map((provider) => ({ - label: `Configure ${PROVIDER_LABELS[provider]} OAuth credentials`, + label: `Configure ${providerLabel(provider)} OAuth credentials`, status: "pending" as const, })), { label: "Verify DNS records", status: "pending" }, @@ -475,7 +475,7 @@ function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { ? "done" : "pending"; return { - label: `Configure ${PROVIDER_LABELS[provider]} OAuth credentials`, + label: `Configure ${providerLabel(provider)} OAuth credentials`, status, }; }), @@ -801,7 +801,7 @@ async function collectAndSaveOAuthCredentials( domain: string, productionInstanceId: string, ): Promise { - const label = PROVIDER_LABELS[provider]; + const label = providerLabel(provider); for (const line of providerSetupIntro(provider)) log.info(line); log.blank(); diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index ce093964..e0495836 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -4,9 +4,9 @@ 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, + providerCredentialLabel, + providerFields, + providerLabel, type OAuthProvider, } from "./providers.ts"; @@ -87,7 +87,7 @@ export async function chooseOAuthCredentialAction( provider: OAuthProvider, ): Promise { const choices: Array<{ name: string; value: OAuthCredentialAction }> = [ - { name: PROVIDER_CREDENTIAL_LABELS[provider], value: "have-credentials" }, + { name: providerCredentialLabel(provider), value: "have-credentials" }, { name: "Walk me through creating them", value: "walkthrough" }, ]; if (provider === "google") { @@ -102,7 +102,7 @@ export async function chooseOAuthCredentialAction( }); return select({ - message: `${PROVIDER_LABELS[provider]} OAuth`, + message: `${providerLabel(provider)} OAuth`, choices, }); } @@ -128,9 +128,9 @@ export async function collectOAuthCredentials( return collectGoogleJsonCredentials(); } - const label = PROVIDER_LABELS[provider]; + const label = providerLabel(provider); const credentials: Record = {}; - for (const field of PROVIDER_FIELDS[provider]) { + for (const field of providerFields(provider)) { const message = `${label} OAuth ${field.label}`; let value: string; if (field.filePath) { diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts new file mode 100644 index 00000000..c1b70d78 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from "bun:test"; +import { + buildOAuthProviderDescriptors, + isDeployOAuthProviderExcluded, + providerFields, + providerLabel, + type OAuthProviderDescriptor, +} from "./providers.ts"; +import type { InstanceConfigSchema } from "../../lib/plapi.ts"; + +const oauthSchema = (properties: Record) => ({ + type: "object", + description: "OAuth SSO connection configuration", + properties: { + enabled: { type: "boolean", default: false }, + authenticatable: { type: "boolean", default: true }, + block_email_subaddresses: { type: "boolean", default: false }, + ...properties, + }, +}); + +const basicOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "OAuth client ID" }, + client_secret: { + type: "string", + description: "OAuth client secret", + "x-clerk-sensitive": true, + }, +}); + +const schemaResponse = (properties: Record): InstanceConfigSchema => ({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://clerk.com/schemas/platform-config/2025-01-01", + type: "object", + properties: properties as InstanceConfigSchema["properties"], +}); + +function descriptorByProvider( + descriptors: readonly OAuthProviderDescriptor[], + provider: string, +): OAuthProviderDescriptor { + const descriptor = descriptors.find((item) => item.provider === provider); + if (!descriptor) throw new Error(`missing descriptor for ${provider}`); + return descriptor; +} + +describe("deploy OAuth provider descriptors", () => { + test("builds a descriptor for public providers from schema and shared metadata", () => { + const result = buildOAuthProviderDescriptors( + ["discord"], + schemaResponse({ connection_oauth_discord: basicOAuthSchema }), + ); + + expect(result.excluded).toEqual([]); + expect(result.unsupported).toEqual([]); + const discord = descriptorByProvider(result.supported, "discord"); + expect(discord.label).toBe("Discord"); + expect(discord.configKey).toBe("connection_oauth_discord"); + expect(discord.docsUrl).toContain("/discord"); + expect(discord.fields.map((field) => field.key)).toEqual(["client_id", "client_secret"]); + expect(discord.fields.map((field) => field.label)).toEqual(["Client ID", "Client Secret"]); + expect(discord.fields[1]).toMatchObject({ secret: true }); + expect(discord.requiredCredentialKeys).toEqual(["client_id", "client_secret"]); + }); + + test("excludes customer-specific providers", () => { + expect(isDeployOAuthProviderExcluded("expressen")).toBe(true); + expect(isDeployOAuthProviderExcluded("enstall")).toBe(true); + + const result = buildOAuthProviderDescriptors( + ["google", "expressen", "enstall"], + schemaResponse({ connection_oauth_google: basicOAuthSchema }), + ); + + expect(result.supported.map((item) => item.provider)).toEqual(["google"]); + expect(result.excluded).toEqual(["expressen", "enstall"]); + }); + + test("marks missing schema as unsupported", () => { + const result = buildOAuthProviderDescriptors(["discord"], schemaResponse({})); + + expect(result.supported).toEqual([]); + expect(result.unsupported).toEqual(["discord"]); + }); + + test("uses enum fields for provider-specific select prompts", () => { + const result = buildOAuthProviderDescriptors( + ["linear"], + schemaResponse({ + connection_oauth_linear: oauthSchema({ + client_id: { type: "string", description: "Linear OAuth client ID" }, + client_secret: { + type: "string", + description: "Linear OAuth client secret", + "x-clerk-sensitive": true, + }, + actor: { + type: "string", + description: "Actor type for Linear OAuth token", + enum: ["user", "application"], + default: "user", + }, + }), + }), + ); + + const linear = descriptorByProvider(result.supported, "linear"); + expect(linear.fields).toContainEqual({ + key: "actor", + label: "Actor", + description: "Actor type for Linear OAuth token", + type: "select", + options: ["user", "application"], + defaultValue: "user", + secret: false, + filePath: false, + }); + }); + + test("applies Apple production credential overrides", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ + connection_oauth_apple: oauthSchema({ + client_id: { type: "string", description: "Apple Services ID" }, + client_secret: { + type: "string", + description: "Apple Private Key", + "x-clerk-sensitive": true, + }, + key_id: { type: "string", description: "Apple Key ID" }, + team_id: { type: "string", description: "Apple Team ID" }, + bundle_id: { + type: "string", + description: "iOS app Bundle ID for native Sign in with Apple", + }, + }), + }), + ); + + const apple = descriptorByProvider(result.supported, "apple"); + expect(apple.fields.map((field) => field.key)).toEqual([ + "client_id", + "team_id", + "key_id", + "client_secret", + ]); + expect(apple.fields.map((field) => field.label)).toEqual([ + "Apple Services ID", + "Apple Team ID", + "Apple Key ID", + "Apple Private Key - path to .p8 file", + ]); + expect(apple.fields.find((field) => field.key === "client_secret")).toMatchObject({ + filePath: true, + label: "Apple Private Key - path to .p8 file", + }); + expect(apple.requiredCredentialKeys).toEqual([ + "client_id", + "team_id", + "key_id", + "client_secret", + ]); + }); + + test("preserves existing compatibility prompt labels", () => { + expect(providerFields("google").map((field) => field.label)).toEqual([ + "Client ID", + "Client Secret", + ]); + expect(providerFields("microsoft").map((field) => field.label)).toEqual([ + "Application (Client) ID", + "Client Secret", + ]); + expect(providerFields("apple").map((field) => field.label)).toEqual([ + "Apple Services ID", + "Apple Team ID", + "Apple Key ID", + "Apple Private Key - path to .p8 file", + ]); + }); + + test("providerLabel falls back to title-cased unknown slugs", () => { + expect(providerLabel("linkedin_oidc")).toBe("LinkedIn"); + expect(providerLabel("new_provider")).toBe("New Provider"); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index dc4d3bc8..f28c92e8 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -1,9 +1,23 @@ +import { OAUTH_PROVIDERS } from "@clerk/shared/oauth"; import { bold, cyan, dim, yellow, red } from "../../lib/color.ts"; import { log } from "../../lib/log.ts"; import { openBrowser } from "../../lib/open.ts"; +import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi.ts"; -export type OAuthProvider = "google" | "github" | "microsoft" | "apple" | "linear"; +const DEFAULT_DOCS_URL_PREFIX = + "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; +const COMPATIBLE_OAUTH_PROVIDERS = ["google", "github", "microsoft", "apple", "linear"] as const; +type CompatibleOAuthProvider = (typeof COMPATIBLE_OAUTH_PROVIDERS)[number]; + +/** + * OAuth provider slug used by deploy. + */ +export type OAuthProvider = string; + +/** + * Existing prompt field shape consumed by the current deploy flow. + */ export type OAuthField = { key: string; label: string; @@ -11,105 +25,437 @@ export type OAuthField = { filePath?: boolean; }; -export const PROVIDER_LABELS: Record = { - google: "Google", - github: "GitHub", - microsoft: "Microsoft", - apple: "Apple", - linear: "Linear", +/** + * Prompt field metadata derived from the production config schema. + */ +export type OAuthPromptField = { + key: string; + label: string; + description?: string; + type: "text" | "password" | "select"; + options?: string[]; + defaultValue?: string; + secret: boolean; + filePath: boolean; }; -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 }, - ], +/** + * Complete deploy metadata for configuring an OAuth provider. + */ +export type OAuthProviderDescriptor = { + provider: string; + configKey: string; + label: string; + docsUrl: string; + credentialLabel: string; + redirectLabel: string; + setupCopy: string; + gotcha: string | null; + fields: OAuthPromptField[]; + requiredCredentialKeys: string[]; + credentialSources: Array<"manual" | "google-json">; }; -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", +/** + * Descriptor builder output split by deploy support status. + */ +export type OAuthProviderDescriptorResult = { + supported: OAuthProviderDescriptor[]; + excluded: string[]; + unsupported: string[]; }; -export const PROVIDER_REDIRECT_LABELS: Record = { - google: "Authorized Redirect URI", - github: "Authorization Callback URL", - microsoft: "Redirect URI", - apple: "Return URL", - linear: "Callback URL", +type ProviderOverride = { + credentialLabel?: string; + redirectLabel?: string; + setupCopy?: string; + gotcha?: string | null; + credentialSources?: Array<"manual" | "google-json">; + fieldOrder?: string[]; + fieldLabels?: Record; + filePathFields?: string[]; + omittedFields?: string[]; + requiredCredentialKeys?: string[]; }; -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", +const PROVIDER_OVERRIDES: Record & + Record = { + google: { + credentialLabel: "I already have my Client ID and Client Secret", + redirectLabel: "Authorized Redirect URI", + setupCopy: + "Production Google sign-in requires custom OAuth credentials from Google Cloud Console.", + gotcha: `${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.`, + credentialSources: ["manual", "google-json"], + }, + github: { + credentialLabel: "I already have my Client ID and Client Secret", + redirectLabel: "Authorization Callback URL", + setupCopy: "Production GitHub sign-in requires a GitHub OAuth app and custom credentials.", + gotcha: null, + }, + microsoft: { + credentialLabel: "I already have my Application (Client) ID and Client Secret", + redirectLabel: "Redirect URI", + setupCopy: + "Production Microsoft sign-in requires a Microsoft Entra ID app and custom credentials.", + gotcha: `${red("WARNING")} Microsoft client secrets expire (default 6 months, max 24). Set a calendar reminder to rotate before expiration or sign-in will break.`, + fieldLabels: { + client_id: "Application (Client) ID", + }, + }, + apple: { + credentialLabel: "I already have my Services ID, Team ID, Key ID, and .p8 file", + redirectLabel: "Return URL", + setupCopy: + "Production Apple sign-in requires an Apple Services ID, Team ID, Key ID, and private key file.", + gotcha: `${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.`, + fieldOrder: ["client_id", "team_id", "key_id", "client_secret"], + fieldLabels: { + client_id: "Apple Services ID", + team_id: "Apple Team ID", + key_id: "Apple Key ID", + client_secret: "Apple Private Key - path to .p8 file", + }, + filePathFields: ["client_secret"], + omittedFields: ["bundle_id"], + requiredCredentialKeys: ["client_id", "team_id", "key_id", "client_secret"], + }, + linear: { + credentialLabel: "I already have my Client ID and Client Secret", + redirectLabel: "Callback URL", + setupCopy: "Production Linear sign-in requires a Linear OAuth app and custom credentials.", + gotcha: `${yellow("IMPORTANT")} You must be a workspace admin in Linear to create OAuth apps.`, + }, }; -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.", -}; +const EXCLUDED_DEPLOY_OAUTH_PROVIDERS = new Set(["expressen", "enstall"]); +const SYSTEM_FIELD_KEYS = new Set(["enabled", "authenticatable", "block_email_subaddresses"]); +const DEFAULT_FIELD_ORDER = ["client_id", "client_secret"]; -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.`, -}; +const SHARED_OAUTH_METADATA = new Map( + OAUTH_PROVIDERS.map((provider) => [provider.provider, provider]), +); + +const COMPATIBLE_PROVIDER_DESCRIPTORS = buildCompatibilityDescriptors(); + +/** + * Compatibility labels for the deploy flow that still consumes provider maps. + */ +export const PROVIDER_LABELS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.label]), +) as Record; + +/** + * Compatibility fields for the deploy flow that still consumes provider maps. + */ +export const PROVIDER_FIELDS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ + descriptor.provider, + descriptor.fields.map((field) => ({ + key: field.key, + label: field.label, + secret: field.secret || undefined, + filePath: field.filePath || undefined, + })), + ]), +) as Record; + +/** + * Compatibility credential action labels for the current prompt flow. + */ +export const PROVIDER_CREDENTIAL_LABELS: Record = + Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ + descriptor.provider, + descriptor.credentialLabel, + ]), + ) as Record; + +/** + * Compatibility redirect labels for the current walkthrough flow. + */ +export const PROVIDER_REDIRECT_LABELS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ + descriptor.provider, + descriptor.redirectLabel, + ]), +) as Record; + +/** + * Compatibility docs URLs for the current walkthrough flow. + */ +export const PROVIDER_DOC_URLS: Record = Object.fromEntries( + COMPATIBLE_OAUTH_PROVIDERS.map((provider) => [ + provider, + `${DEFAULT_DOCS_URL_PREFIX}/${provider}`, + ]), +) as Record; + +/** + * Compatibility setup copy for the current walkthrough flow. + */ +export const PROVIDER_SETUP_COPY: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.setupCopy]), +) as Record; + +/** + * Compatibility gotchas for the current walkthrough flow. + */ +export const PROVIDER_GOTCHAS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.gotcha]), +) as Record; + +/** + * Determine whether a provider is intentionally unavailable in deploy. + */ +export function isDeployOAuthProviderExcluded(provider: string): boolean { + return EXCLUDED_DEPLOY_OAUTH_PROVIDERS.has(provider); +} + +/** + * Build deploy OAuth provider descriptors from the instance config schema. + */ +export function buildOAuthProviderDescriptors( + providers: readonly string[], + schema: InstanceConfigSchema, +): OAuthProviderDescriptorResult { + const supported: OAuthProviderDescriptor[] = []; + const excluded: string[] = []; + const unsupported: string[] = []; + + for (const provider of providers) { + if (isDeployOAuthProviderExcluded(provider)) { + excluded.push(provider); + continue; + } + + const descriptor = buildOAuthProviderDescriptor(provider, schema); + if (!descriptor) { + unsupported.push(provider); + continue; + } + + supported.push(descriptor); + } + + return { supported, excluded, unsupported }; +} + +function buildOAuthProviderDescriptor( + provider: string, + schema: InstanceConfigSchema, +): OAuthProviderDescriptor | null { + const configKey = `connection_oauth_${provider}`; + const configSchema = schema.properties?.[configKey]; + if (configSchema?.type !== "object" || !configSchema.properties) return null; + + const override = PROVIDER_OVERRIDES[provider] ?? {}; + const omittedFields = new Set(override.omittedFields ?? []); + const fields: OAuthPromptField[] = []; + + for (const [key, property] of Object.entries(configSchema.properties)) { + if (SYSTEM_FIELD_KEYS.has(key) || omittedFields.has(key) || property.readOnly) continue; + + const field = buildPromptField(key, property, override); + if (!field) return null; + fields.push(field); + } + + fields.sort((a, b) => compareFields(a.key, b.key, override.fieldOrder)); + + const fieldKeys = new Set(fields.map((field) => field.key)); + const requiredCredentialKeys = + override.requiredCredentialKeys ?? defaultRequiredCredentialKeys(fieldKeys); + if (requiredCredentialKeys.length === 0) return null; + if (requiredCredentialKeys.some((key) => !fieldKeys.has(key))) return null; + + const label = providerLabel(provider); + return { + provider, + configKey, + label, + docsUrl: providerDocsUrl(provider), + credentialLabel: + override.credentialLabel ?? + `I already have my ${credentialListLabel(requiredCredentialKeys)}`, + redirectLabel: override.redirectLabel ?? "Redirect URI", + setupCopy: + override.setupCopy ?? `Production ${label} sign-in requires custom OAuth credentials.`, + gotcha: override.gotcha ?? null, + fields, + requiredCredentialKeys, + credentialSources: override.credentialSources ?? ["manual"], + }; +} + +function buildPromptField( + key: string, + property: ConfigSchemaProperty, + override: ProviderOverride, +): OAuthPromptField | null { + if (property.type !== "string") return null; + + const stringEnum = + property.enum?.every((value) => typeof value === "string") === true ? property.enum : undefined; + if (property.enum && !stringEnum) return null; + + const secret = property["x-clerk-sensitive"] === true; + return { + key, + label: override.fieldLabels?.[key] ?? fieldLabel(key), + description: property.description, + type: stringEnum ? "select" : secret ? "password" : "text", + options: stringEnum, + defaultValue: typeof property.default === "string" ? property.default : undefined, + secret, + filePath: override.filePathFields?.includes(key) === true, + }; +} + +function compareFields(a: string, b: string, overrideOrder: string[] | undefined): number { + const order = overrideOrder ?? DEFAULT_FIELD_ORDER; + const aIndex = order.indexOf(a); + const bIndex = order.indexOf(b); + if (aIndex !== -1 || bIndex !== -1) { + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + } + return a.localeCompare(b); +} + +function defaultRequiredCredentialKeys(fieldKeys: Set): string[] { + return DEFAULT_FIELD_ORDER.filter((key) => fieldKeys.has(key)); +} +function providerDocsUrl(provider: string): string { + return ( + SHARED_OAUTH_METADATA.get(provider)?.docsUrl ?? + `${DEFAULT_DOCS_URL_PREFIX}/${provider.replaceAll("_", "-")}` + ); +} + +function fieldLabel(key: string): string { + if (key === "client_id") return "Client ID"; + return key + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function credentialListLabel(requiredCredentialKeys: readonly string[]): string { + const labels = requiredCredentialKeys.map(fieldLabel); + if (labels.length === 1) return labels[0] ?? ""; + if (labels.length === 2) return `${labels[0]} and ${labels[1]}`; + return `${labels.slice(0, -1).join(", ")}, and ${labels.at(-1)}`; +} + +function buildCompatibilityDescriptors(): OAuthProviderDescriptor[] { + const schemas = Object.fromEntries( + Object.keys(PROVIDER_OVERRIDES).map((provider) => [ + `connection_oauth_${provider}`, + { + type: "object", + properties: compatibilityProviderProperties(provider), + }, + ]), + ); + + return buildOAuthProviderDescriptors(Object.keys(PROVIDER_OVERRIDES), { + type: "object", + properties: schemas, + }).supported; +} + +function compatibilityProviderProperties(provider: string): Record { + const override = PROVIDER_OVERRIDES[provider]; + const keys = override?.requiredCredentialKeys ?? DEFAULT_FIELD_ORDER; + return Object.fromEntries( + keys.map((key) => [ + key, + { + type: "string", + "x-clerk-sensitive": key === "client_secret", + }, + ]), + ); +} + +/** + * Human-readable provider label, with shared metadata preferred. + */ export function providerLabel(provider: string): string { - return PROVIDER_LABELS[provider as OAuthProvider] ?? provider; + return SHARED_OAUTH_METADATA.get(provider)?.name ?? fieldLabel(provider); } -export function providerSetupIntro(provider: OAuthProvider): string[] { - const label = PROVIDER_LABELS[provider]; +/** + * Prompt fields for existing deploy callers, falling back to standard OAuth credentials. + */ +export function providerFields(provider: OAuthProvider): OAuthField[] { + if (isCompatibleOAuthProvider(provider)) return PROVIDER_FIELDS[provider]; return [ - bold(`Configure ${label} OAuth for production`), - PROVIDER_SETUP_COPY[provider], - dim(`Reference: ${PROVIDER_DOC_URLS[provider]}`), + { key: "client_id", label: "Client ID" }, + { key: "client_secret", label: "Client Secret", secret: true }, ]; } +/** + * Credential action label for existing deploy callers. + */ +export function providerCredentialLabel(provider: OAuthProvider): string { + if (isCompatibleOAuthProvider(provider)) return PROVIDER_CREDENTIAL_LABELS[provider]; + return "I already have my Client ID and Client Secret"; +} + +function providerRedirectLabel(provider: OAuthProvider): string { + if (isCompatibleOAuthProvider(provider)) return PROVIDER_REDIRECT_LABELS[provider]; + return "Redirect URI"; +} + +function providerLegacyDocsUrl(provider: OAuthProvider): string { + if (isCompatibleOAuthProvider(provider)) return PROVIDER_DOC_URLS[provider]; + return providerDocsUrl(provider); +} + +function providerSetupCopy(provider: OAuthProvider): string { + if (isCompatibleOAuthProvider(provider)) return PROVIDER_SETUP_COPY[provider]; + return `Production ${providerLabel(provider)} sign-in requires custom OAuth credentials.`; +} + +function providerGotcha(provider: OAuthProvider): string | null { + if (isCompatibleOAuthProvider(provider)) return PROVIDER_GOTCHAS[provider]; + return null; +} + +function isCompatibleOAuthProvider(provider: string): provider is CompatibleOAuthProvider { + return (COMPATIBLE_OAUTH_PROVIDERS as readonly string[]).includes(provider); +} + +/** + * Build the provider setup intro shown before credential collection. + */ +export function providerSetupIntro(provider: OAuthProvider): string[] { + const label = providerLabel(provider); + const setupCopy = providerSetupCopy(provider); + const docsUrl = providerLegacyDocsUrl(provider); + return [bold(`Configure ${label} OAuth for production`), setupCopy, dim(`Reference: ${docsUrl}`)]; +} + +/** + * Show OAuth provider walkthrough values and open provider docs. + */ export async function showOAuthWalkthrough(provider: OAuthProvider, domain: string): Promise { - const label = PROVIDER_LABELS[provider]; - const docsUrl = PROVIDER_DOC_URLS[provider]; + const label = providerLabel(provider); + const docsUrl = providerLegacyDocsUrl(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(` ${dim(providerRedirectLabel(provider))}`); log.info(` ${cyan(`https://accounts.${domain}/v1/oauth_callback`)}`); - const gotcha = PROVIDER_GOTCHAS[provider]; + const gotcha = providerGotcha(provider); if (gotcha) { log.blank(); log.info(gotcha); From 8d5d132bec8914c301d47aaa23664d03166cb248 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 11:15:22 -0600 Subject: [PATCH 30/52] feat(deploy): use schema-driven oauth setup --- .../src/commands/deploy/index.test.ts | 101 ++++++++++- .../cli-core/src/commands/deploy/index.ts | 157 ++++++++++-------- .../cli-core/src/commands/deploy/prompts.ts | 61 ++++--- .../src/commands/deploy/providers.test.ts | 24 ++- .../cli-core/src/commands/deploy/providers.ts | 62 ++++--- 5 files changed, 271 insertions(+), 134 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index ddba3298..f1f08865 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -25,6 +25,7 @@ const mockConfirm = mock(); const mockPassword = mock(); const mockPatchInstanceConfig = mock(); const mockFetchInstanceConfig = mock(); +const mockFetchInstanceConfigSchema = mock(); const mockFetchApplication = mock(); const mockListApplicationDomains = mock(); const mockCreateProductionInstance = mock(); @@ -51,6 +52,7 @@ mock.module("../../lib/listage.ts", () => ({ mock.module("../../lib/plapi.ts", () => ({ fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), + fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), @@ -100,6 +102,59 @@ function domainStatus({ }; } +const oauthSchema = (properties: Record) => ({ + type: "object", + description: "OAuth SSO connection configuration", + properties: { + enabled: { type: "boolean", default: false }, + authenticatable: { type: "boolean", default: true }, + block_email_subaddresses: { type: "boolean", default: false }, + ...properties, + }, +}); + +const basicOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "OAuth client ID" }, + client_secret: { + type: "string", + description: "OAuth client secret", + "x-clerk-sensitive": true, + }, +}); + +const appleOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "Apple Services ID" }, + client_secret: { + type: "string", + description: "Apple Private Key", + "x-clerk-sensitive": true, + }, + key_id: { type: "string", description: "Apple Key ID" }, + team_id: { type: "string", description: "Apple Team ID" }, + bundle_id: { + type: "string", + description: "iOS app Bundle ID for native Sign in with Apple", + }, +}); + +const schemaResponse = (properties: Record) => ({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://clerk.com/schemas/platform-config/2025-01-01", + type: "object", + properties, +}); + +function schemaForEnabledOAuth(config: Record) { + const properties: Record = {}; + for (const [key, value] of Object.entries(config)) { + if (!key.startsWith("connection_oauth_")) continue; + if (!value || typeof value !== "object") continue; + if ((value as Record).enabled !== true) continue; + properties[key] = key === "connection_oauth_apple" ? appleOAuthSchema : basicOAuthSchema; + } + return schemaResponse(properties); +} + describe("deploy", () => { let consoleSpy: ReturnType; const captured = useCaptureLog(); @@ -111,6 +166,11 @@ describe("deploy", () => { mockFetchInstanceConfig.mockResolvedValue({ connection_oauth_google: { enabled: true }, }); + mockFetchInstanceConfigSchema.mockResolvedValue( + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + }), + ); mockFetchApplication.mockResolvedValue({ application_id: "app_xyz789", name: "my-saas-app", @@ -196,6 +256,7 @@ describe("deploy", () => { mockPassword.mockReset(); mockPatchInstanceConfig.mockReset(); mockFetchInstanceConfig.mockReset(); + mockFetchInstanceConfigSchema.mockReset(); mockFetchApplication.mockReset(); mockListApplicationDomains.mockReset(); mockCreateProductionInstance.mockReset(); @@ -313,6 +374,7 @@ describe("deploy", () => { } return developmentConfig; }); + mockFetchInstanceConfigSchema.mockResolvedValue(schemaForEnabledOAuth(developmentConfig)); } test("provider setup intro includes docs-backed copy for each OAuth provider", () => { @@ -451,6 +513,12 @@ describe("deploy", () => { connection_oauth_unknown: { enabled: true }, unrelated_key: "ignored", }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + connection_oauth_github: basicOAuthSchema, + }), + ); await runDeployUntilPause(); const err = stripAnsi(captured.err); @@ -459,7 +527,7 @@ 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).toContain("not yet supported by `clerk deploy`: unknown"); + expect(err).toContain("not yet supported by automated `clerk deploy` setup: unknown"); expect(err).toContain("Configure them from the Clerk Dashboard before going live"); }); @@ -1318,7 +1386,7 @@ describe("deploy", () => { "Production Google sign-in requires custom OAuth credentials from Google Cloud Console.", ); expect(err).toContain( - "Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/google", + "Reference: https://clerk.com/docs/authentication/social-connections/google", ); expect(mockConfirm).not.toHaveBeenCalledWith({ message: "Set up Google OAuth now?", @@ -1447,6 +1515,12 @@ describe("deploy", () => { connection_oauth_google: { enabled: true }, connection_oauth_github: { enabled: true }, }); + mockFetchInstanceConfigSchema.mockResolvedValue( + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + connection_oauth_github: basicOAuthSchema, + }), + ); // Proceed → create prod → enter google creds → skip github before DNS verification. mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mockInput @@ -1594,23 +1668,34 @@ describe("deploy", () => { }); }); - test("warns about enabled OAuth providers not yet supported by clerk deploy", async () => { + test("discovers and supports enabled OAuth providers from API schema", async () => { await linkedProject(); mockHumanFlow(); mockFetchInstanceConfig.mockResolvedValueOnce({ connection_oauth_google: { enabled: true }, connection_oauth_discord: { enabled: true }, - connection_oauth_facebook: { enabled: true }, + connection_oauth_microsoft: { enabled: false }, + unrelated_key: "ignored", }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + connection_oauth_discord: basicOAuthSchema, + }), + ); await runDeployUntilPause(); const err = stripAnsi(captured.err); + expect(mockFetchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_dev_123"); + expect(mockFetchInstanceConfigSchema).toHaveBeenCalledWith("app_xyz789", "ins_dev_123", [ + "connection_oauth_google", + "connection_oauth_discord", + ]); 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"); + expect(err).toContain("Configure Discord OAuth credentials"); + expect(err).not.toContain("Configure Microsoft OAuth credentials"); + expect(err).not.toContain("not yet supported by `clerk deploy`: discord"); }); test("unlinked directory throws NOT_LINKED instead of warning and exiting 0", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index afc4a6fd..b6bb5faa 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -15,6 +15,7 @@ import { createProductionInstance as apiCreateProductionInstance, fetchApplication, fetchInstanceConfig, + fetchInstanceConfigSchema, getApplicationDomainStatus, listApplicationDomains, patchInstanceConfig, @@ -46,12 +47,14 @@ import { } from "./copy.ts"; import { mapDeployError } from "./errors.ts"; import { - PROVIDER_LABELS, - providerFields, + OAUTH_KEY_PREFIX, + buildOAuthProviderDescriptors, + isDeployOAuthProviderSupported, providerLabel, providerSetupIntro, showOAuthWalkthrough, type OAuthProvider, + type OAuthProviderDescriptor, } from "./providers.ts"; import { chooseDnsVerificationAction, @@ -172,8 +175,7 @@ async function runDeploy(ctx: DeployContext): Promise { } async function startNewDeploy(ctx: DeployContext): Promise { - const { known: oauthProviders, unknown: unknownOAuthProviders } = - await loadDevelopmentOAuthProviders(ctx); + const { descriptors: oauthProviders, unsupported } = await loadDevelopmentOAuthProviders(ctx); log.blank(); log.info(INTRO_PREAMBLE); @@ -183,16 +185,15 @@ async function startNewDeploy(ctx: DeployContext): Promise { } log.blank(); - if (unknownOAuthProviders.length > 0) { + if (unsupported.length > 0) { log.warn( - `These OAuth providers are enabled in development but not yet supported by \`clerk deploy\`: ${unknownOAuthProviders.join(", ")}.`, + `These OAuth providers are enabled in development but not yet supported by automated \`clerk deploy\` setup: ${unsupported.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."); @@ -242,8 +243,8 @@ async function startNewDeploy(ctx: DeployContext): Promise { productionInstanceId: production.instance_id, productionDomainId: production.active_domain.id, domain: productionDomain, - pending: { type: "oauth", provider: oauthProviders[0] ?? "google" }, - oauthProviders, + pending: { type: "oauth", provider: oauthProviders[0]?.provider ?? "google" }, + oauthProviders: oauthProviders.map((descriptor) => descriptor.provider), completedOAuthProviders, cnameTargets: production.cname_targets, }; @@ -254,7 +255,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { ); bar(); - completedOAuthProviders = await runOAuthSetup(ctx, operationState); + completedOAuthProviders = await runOAuthSetup(ctx, operationState, oauthProviders); if (completedOAuthProviders.length < oauthProviders.length) return; bar(); @@ -296,18 +297,22 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { snapshot.oauthProviders.length > snapshot.completedOAuthProviders.length ) { bar(); - const completed = await runOAuthSetup(ctx, { - ...snapshot, - pending: { - type: "oauth", - provider: - snapshot.oauthProviders.find( - (provider) => !snapshot.completedOAuthProviders.includes(provider), - ) ?? - snapshot.oauthProviders[0] ?? - "google", + const completed = await runOAuthSetup( + ctx, + { + ...snapshot, + pending: { + type: "oauth", + provider: + snapshot.oauthProviders.find( + (provider) => !snapshot.completedOAuthProviders.includes(provider), + ) ?? + snapshot.oauthProviders[0] ?? + "google", + }, }, - }); + snapshot.oauthProviderDescriptors, + ); if (completed.length < snapshot.oauthProviders.length) return; snapshot.completedOAuthProviders = completed; } @@ -329,14 +334,15 @@ type LiveDeploySnapshot = Omit< > & { pending?: DeployOperationState["pending"]; oauthProviders: OAuthProvider[]; + oauthProviderDescriptors: OAuthProviderDescriptor[]; completedOAuthProviders: OAuthProvider[]; cnameTargets?: readonly CnameTarget[]; dnsComplete: boolean; }; type DiscoveredOAuthProviders = { - known: OAuthProvider[]; - unknown: string[]; + descriptors: OAuthProviderDescriptor[]; + unsupported: string[]; }; type DnsVerificationResult = "verified" | "pending"; @@ -346,7 +352,19 @@ async function loadDevelopmentOAuthProviders( ): Promise { return withSpinner("Reading development configuration...", async () => { const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); - return discoverEnabledOAuthProviders(config); + const providerSlugs = discoverEnabledOAuthProviderSlugs(config); + const schemaKeys = providerSlugs + .filter((provider) => isDeployOAuthProviderSupported(provider)) + .map((provider) => `${OAUTH_KEY_PREFIX}${provider}`); + const schema = + schemaKeys.length > 0 + ? await fetchInstanceConfigSchema(ctx.appId, ctx.developmentInstanceId, schemaKeys) + : { properties: {} }; + const result = buildOAuthProviderDescriptors(providerSlugs, schema); + return { + descriptors: result.supported, + unsupported: result.unsupported, + }; }); } @@ -359,17 +377,18 @@ async function resolveLiveDeploySnapshot( const domain = await loadProductionDomain(ctx); if (!domain) return undefined; - const { known: oauthProviders } = await loadDevelopmentOAuthProviders(ctx); + const { descriptors: oauthProviderDescriptors } = await loadDevelopmentOAuthProviders(ctx); + const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); const { productionConfig, deployStatus } = await loadProductionState( ctx, productionInstanceId, domain.id, ); - const completedOAuthProviders = oauthProviders.filter((provider) => - hasProductionOAuthCredentials(productionConfig, provider), - ); - const pendingOAuthProvider = oauthProviders.find( - (provider) => !completedOAuthProviders.includes(provider), + const completedOAuthProviders = oauthProviderDescriptors + .filter((descriptor) => hasProductionOAuthCredentials(productionConfig, descriptor)) + .map((descriptor) => descriptor.provider); + const pendingOAuthDescriptor = oauthProviderDescriptors.find( + (descriptor) => !completedOAuthProviders.includes(descriptor.provider), ); const baseState = { @@ -379,13 +398,14 @@ async function resolveLiveDeploySnapshot( productionDomainId: domain.id, domain: domain.name, oauthProviders, + oauthProviderDescriptors, completedOAuthProviders, cnameTargets: domain.cname_targets ?? [], }; const dnsComplete = deployStatus.status === "complete"; - const pending = pendingOAuthProvider - ? ({ type: "oauth", provider: pendingOAuthProvider } as const) + const pending = pendingOAuthDescriptor + ? ({ type: "oauth", provider: pendingOAuthDescriptor.provider } as const) : !dnsComplete ? ({ type: "dns" } as const) : undefined; @@ -440,26 +460,24 @@ async function loadProductionDomain(ctx: DeployContext): Promise, - provider: OAuthProvider, + descriptor: OAuthProviderDescriptor, ): boolean { - const value = config[`${OAUTH_KEY_PREFIX}${provider}`]; + const value = config[descriptor.configKey]; if (!value || typeof value !== "object") return false; const providerConfig = value as Record; if (providerConfig.enabled !== true) return false; - return providerFields(provider).every((field) => { - const fieldValue = providerConfig[field.key]; + return descriptor.requiredCredentialKeys.every((key) => { + const fieldValue = providerConfig[key]; return typeof fieldValue === "string" && fieldValue.length > 0; }); } -const OAUTH_KEY_PREFIX = "connection_oauth_"; - -function buildNewDeployPlan(oauthProviders: readonly OAuthProvider[]): DeployPlanStep[] { +function buildNewDeployPlan(oauthProviders: readonly OAuthProviderDescriptor[]): DeployPlanStep[] { return [ { label: "Create production instance", status: "pending" }, { label: "Choose a production domain you own", status: "pending" }, - ...oauthProviders.map((provider) => ({ - label: `Configure ${providerLabel(provider)} OAuth credentials`, + ...oauthProviders.map((descriptor) => ({ + label: `Configure ${descriptor.label} OAuth credentials`, status: "pending" as const, })), { label: "Verify DNS records", status: "pending" }, @@ -470,12 +488,14 @@ function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { return [ { label: "Create production instance", status: "done" }, { label: `Use production domain ${snapshot.domain}`, status: "done" }, - ...snapshot.oauthProviders.map((provider): DeployPlanStep => { - const status: DeployPlanStep["status"] = snapshot.completedOAuthProviders.includes(provider) + ...snapshot.oauthProviderDescriptors.map((descriptor): DeployPlanStep => { + const status: DeployPlanStep["status"] = snapshot.completedOAuthProviders.includes( + descriptor.provider, + ) ? "done" : "pending"; return { - label: `Configure ${providerLabel(provider)} OAuth credentials`, + label: `Configure ${descriptor.label} OAuth credentials`, status, }; }), @@ -483,21 +503,15 @@ function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { ]; } -function discoverEnabledOAuthProviders(config: Record): DiscoveredOAuthProviders { - const known: OAuthProvider[] = []; - const unknown: string[] = []; +function discoverEnabledOAuthProviderSlugs(config: Record): string[] { + const providers: string[] = []; for (const [key, value] of Object.entries(config)) { if (!key.startsWith(OAUTH_KEY_PREFIX)) continue; if (!value || typeof value !== "object") continue; if ((value as Record).enabled !== true) continue; - const provider = key.slice(OAUTH_KEY_PREFIX.length); - if (provider in PROVIDER_LABELS) { - known.push(provider as OAuthProvider); - } else { - unknown.push(provider); - } + providers.push(key.slice(OAUTH_KEY_PREFIX.length)); } - return { known, unknown }; + return providers; } async function createProductionInstance( @@ -740,17 +754,17 @@ async function offerBindZoneExport( async function runOAuthSetup( ctx: DeployContext, state: DeployOperationState, + descriptors: readonly OAuthProviderDescriptor[], ): Promise { const completed = new Set(state.completedOAuthProviders as OAuthProvider[]); - const providers = state.oauthProviders as OAuthProvider[]; - if (providers.length > 0) { + if (descriptors.length > 0) { log.info(OAUTH_SECTION_INTRO); log.blank(); } - for (const provider of providers) { - if (completed.has(provider)) continue; + for (const descriptor of descriptors) { + if (completed.has(descriptor.provider)) continue; try { const productionInstanceId = state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production; @@ -762,14 +776,14 @@ async function runOAuthSetup( const saved = await collectAndSaveOAuthCredentials( ctx, - provider, + descriptor, state.domain, productionInstanceId, ); if (!saved) { throw deployPausedError({ ...state, - pending: { type: "oauth", provider }, + pending: { type: "oauth", provider: descriptor.provider }, completedOAuthProviders: [...completed], }); } @@ -778,7 +792,7 @@ async function runOAuthSetup( throw deployPausedError( { ...state, - pending: { type: "oauth", provider }, + pending: { type: "oauth", provider: descriptor.provider }, completedOAuthProviders: [...completed], }, { interrupted: true }, @@ -786,8 +800,8 @@ async function runOAuthSetup( } throw error; } - completed.add(provider); - if (providers.some((nextProvider) => !completed.has(nextProvider))) { + completed.add(descriptor.provider); + if (descriptors.some((nextDescriptor) => !completed.has(nextDescriptor.provider))) { log.blank(); } } @@ -797,38 +811,37 @@ async function runOAuthSetup( async function collectAndSaveOAuthCredentials( ctx: DeployContext, - provider: OAuthProvider, + descriptor: OAuthProviderDescriptor, domain: string, productionInstanceId: string, ): Promise { - const label = providerLabel(provider); - for (const line of providerSetupIntro(provider)) log.info(line); + for (const line of providerSetupIntro(descriptor)) log.info(line); log.blank(); - const choice = await chooseOAuthCredentialAction(provider); + const choice = await chooseOAuthCredentialAction(descriptor); if (choice === "skip") { return false; } if (choice === "walkthrough") { - await showOAuthWalkthrough(provider, domain); + await showOAuthWalkthrough(descriptor, domain); } const credentials = await collectOAuthCredentials( - provider, + descriptor, choice === "google-json" ? "google-json" : "manual", ); - await withSpinner(`Saving ${label} OAuth credentials...`, async () => { + await withSpinner(`Saving ${descriptor.label} OAuth credentials...`, async () => { await patchInstanceConfig(ctx.appId, productionInstanceId, { - [`connection_oauth_${provider}`]: { + [descriptor.configKey]: { enabled: true, ...credentials, }, }); }); - log.success(`Saved ${label} OAuth credentials`); + log.success(`Saved ${descriptor.label} OAuth credentials`); return true; } diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index e0495836..da9f5f26 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -3,12 +3,7 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { select } from "../../lib/listage.ts"; import { confirm } from "../../lib/prompts.ts"; -import { - providerCredentialLabel, - providerFields, - providerLabel, - type OAuthProvider, -} from "./providers.ts"; +import { type OAuthPromptField, type OAuthProviderDescriptor } from "./providers.ts"; type OAuthCredentialAction = "have-credentials" | "walkthrough" | "google-json" | "skip"; type DnsVerificationAction = "check" | "skip"; @@ -84,13 +79,13 @@ export async function confirmExportBindZone(): Promise { } export async function chooseOAuthCredentialAction( - provider: OAuthProvider, + descriptor: OAuthProviderDescriptor, ): Promise { const choices: Array<{ name: string; value: OAuthCredentialAction }> = [ - { name: providerCredentialLabel(provider), value: "have-credentials" }, + { name: descriptor.credentialLabel, value: "have-credentials" }, { name: "Walk me through creating them", value: "walkthrough" }, ]; - if (provider === "google") { + if (descriptor.credentialSources.includes("google-json")) { choices.push({ name: "Load credentials from a Google Cloud Console JSON file", value: "google-json", @@ -102,7 +97,7 @@ export async function chooseOAuthCredentialAction( }); return select({ - message: `${providerLabel(provider)} OAuth`, + message: `${descriptor.label} OAuth`, choices, }); } @@ -121,31 +116,47 @@ export async function chooseExistingProductionAction(): Promise< } export async function collectOAuthCredentials( - provider: OAuthProvider, + descriptor: OAuthProviderDescriptor, source: "manual" | "google-json" = "manual", ): Promise> { - if (provider === "google" && source === "google-json") { + if (descriptor.provider === "google" && source === "google-json") { return collectGoogleJsonCredentials(); } - const label = providerLabel(provider); const credentials: Record = {}; - for (const field of providerFields(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(); + for (const field of descriptor.fields) { + credentials[field.key] = await collectOAuthField(descriptor, field); } return credentials; } +async function collectOAuthField( + descriptor: OAuthProviderDescriptor, + field: OAuthPromptField, +): Promise { + const message = `${descriptor.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.type === "select") { + value = await select({ + message, + choices: (field.options ?? []).map((option) => ({ name: option, value: option })), + default: field.defaultValue, + }); + } else if (field.secret) { + value = await password({ message, validate: required(field.label) }); + } else { + value = await input({ + message, + default: field.defaultValue, + validate: required(field.label), + }); + } + return value.trim(); +} + function validateSecretFilePath(label: string) { return async (path: string): Promise => { if (!path.trim()) return `${label} is required`; diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index c1b70d78..a0232461 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { buildOAuthProviderDescriptors, - isDeployOAuthProviderExcluded, providerFields, providerLabel, type OAuthProviderDescriptor, @@ -51,7 +50,6 @@ describe("deploy OAuth provider descriptors", () => { schemaResponse({ connection_oauth_discord: basicOAuthSchema }), ); - expect(result.excluded).toEqual([]); expect(result.unsupported).toEqual([]); const discord = descriptorByProvider(result.supported, "discord"); expect(discord.label).toBe("Discord"); @@ -63,17 +61,27 @@ describe("deploy OAuth provider descriptors", () => { expect(discord.requiredCredentialKeys).toEqual(["client_id", "client_secret"]); }); - test("excludes customer-specific providers", () => { - expect(isDeployOAuthProviderExcluded("expressen")).toBe(true); - expect(isDeployOAuthProviderExcluded("enstall")).toBe(true); + test("marks providers outside the public deploy allowlist as unsupported", () => { + const result = buildOAuthProviderDescriptors( + ["google", "example_private_provider"], + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + connection_oauth_example_private_provider: basicOAuthSchema, + }), + ); + expect(result.supported.map((item) => item.provider)).toEqual(["google"]); + expect(result.unsupported).toEqual(["example_private_provider"]); + }); + + test("Google descriptor exposes manual and JSON credential sources", () => { const result = buildOAuthProviderDescriptors( - ["google", "expressen", "enstall"], + ["google"], schemaResponse({ connection_oauth_google: basicOAuthSchema }), ); - expect(result.supported.map((item) => item.provider)).toEqual(["google"]); - expect(result.excluded).toEqual(["expressen", "enstall"]); + const google = descriptorByProvider(result.supported, "google"); + expect(google.credentialSources).toEqual(["manual", "google-json"]); }); test("marks missing schema as unsupported", () => { diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index f28c92e8..9e9513b2 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -6,8 +6,17 @@ import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi const DEFAULT_DOCS_URL_PREFIX = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; +const DEPLOY_OAUTH_PROVIDER_ALLOWLIST = [ + "google", + "github", + "microsoft", + "apple", + "linear", + "discord", +] as const; const COMPATIBLE_OAUTH_PROVIDERS = ["google", "github", "microsoft", "apple", "linear"] as const; +type DeployOAuthProvider = (typeof DEPLOY_OAUTH_PROVIDER_ALLOWLIST)[number]; type CompatibleOAuthProvider = (typeof COMPATIBLE_OAUTH_PROVIDERS)[number]; /** @@ -61,7 +70,6 @@ export type OAuthProviderDescriptor = { */ export type OAuthProviderDescriptorResult = { supported: OAuthProviderDescriptor[]; - excluded: string[]; unsupported: string[]; }; @@ -78,7 +86,7 @@ type ProviderOverride = { requiredCredentialKeys?: string[]; }; -const PROVIDER_OVERRIDES: Record & +const PROVIDER_OVERRIDES: Partial> & Record = { google: { credentialLabel: "I already have my Client ID and Client Secret", @@ -129,9 +137,9 @@ const PROVIDER_OVERRIDES: Record & }, }; -const EXCLUDED_DEPLOY_OAUTH_PROVIDERS = new Set(["expressen", "enstall"]); const SYSTEM_FIELD_KEYS = new Set(["enabled", "authenticatable", "block_email_subaddresses"]); const DEFAULT_FIELD_ORDER = ["client_id", "client_secret"]; +export const OAUTH_KEY_PREFIX = "connection_oauth_"; const SHARED_OAUTH_METADATA = new Map( OAUTH_PROVIDERS.map((provider) => [provider.provider, provider]), @@ -207,10 +215,10 @@ export const PROVIDER_GOTCHAS: Record = ) as Record; /** - * Determine whether a provider is intentionally unavailable in deploy. + * Determine whether automated deploy can configure this public OAuth provider. */ -export function isDeployOAuthProviderExcluded(provider: string): boolean { - return EXCLUDED_DEPLOY_OAUTH_PROVIDERS.has(provider); +export function isDeployOAuthProviderSupported(provider: string): boolean { + return (DEPLOY_OAUTH_PROVIDER_ALLOWLIST as readonly string[]).includes(provider); } /** @@ -221,12 +229,11 @@ export function buildOAuthProviderDescriptors( schema: InstanceConfigSchema, ): OAuthProviderDescriptorResult { const supported: OAuthProviderDescriptor[] = []; - const excluded: string[] = []; const unsupported: string[] = []; for (const provider of providers) { - if (isDeployOAuthProviderExcluded(provider)) { - excluded.push(provider); + if (!isDeployOAuthProviderSupported(provider)) { + unsupported.push(provider); continue; } @@ -239,14 +246,14 @@ export function buildOAuthProviderDescriptors( supported.push(descriptor); } - return { supported, excluded, unsupported }; + return { supported, unsupported }; } function buildOAuthProviderDescriptor( provider: string, schema: InstanceConfigSchema, ): OAuthProviderDescriptor | null { - const configKey = `connection_oauth_${provider}`; + const configKey = `${OAUTH_KEY_PREFIX}${provider}`; const configSchema = schema.properties?.[configKey]; if (configSchema?.type !== "object" || !configSchema.properties) return null; @@ -354,7 +361,7 @@ function credentialListLabel(requiredCredentialKeys: readonly string[]): string function buildCompatibilityDescriptors(): OAuthProviderDescriptor[] { const schemas = Object.fromEntries( Object.keys(PROVIDER_OVERRIDES).map((provider) => [ - `connection_oauth_${provider}`, + `${OAUTH_KEY_PREFIX}${provider}`, { type: "object", properties: compatibilityProviderProperties(provider), @@ -432,30 +439,43 @@ function isCompatibleOAuthProvider(provider: string): provider is CompatibleOAut return (COMPATIBLE_OAUTH_PROVIDERS as readonly string[]).includes(provider); } +function providerDescriptorFromInput( + provider: OAuthProvider | OAuthProviderDescriptor, +): OAuthProviderDescriptor | undefined { + return typeof provider === "string" ? undefined : provider; +} + /** * Build the provider setup intro shown before credential collection. */ -export function providerSetupIntro(provider: OAuthProvider): string[] { - const label = providerLabel(provider); - const setupCopy = providerSetupCopy(provider); - const docsUrl = providerLegacyDocsUrl(provider); +export function providerSetupIntro(provider: OAuthProvider | OAuthProviderDescriptor): string[] { + const descriptor = providerDescriptorFromInput(provider); + const slug = descriptor?.provider ?? (provider as OAuthProvider); + const label = descriptor?.label ?? providerLabel(slug); + const setupCopy = descriptor?.setupCopy ?? providerSetupCopy(slug); + const docsUrl = descriptor?.docsUrl ?? providerLegacyDocsUrl(slug); return [bold(`Configure ${label} OAuth for production`), setupCopy, dim(`Reference: ${docsUrl}`)]; } /** * Show OAuth provider walkthrough values and open provider docs. */ -export async function showOAuthWalkthrough(provider: OAuthProvider, domain: string): Promise { - const label = providerLabel(provider); - const docsUrl = providerLegacyDocsUrl(provider); +export async function showOAuthWalkthrough( + provider: OAuthProvider | OAuthProviderDescriptor, + domain: string, +): Promise { + const descriptor = providerDescriptorFromInput(provider); + const slug = descriptor?.provider ?? (provider as OAuthProvider); + const label = descriptor?.label ?? providerLabel(slug); + const docsUrl = descriptor?.docsUrl ?? providerLegacyDocsUrl(slug); 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(providerRedirectLabel(provider))}`); + log.info(` ${dim(descriptor?.redirectLabel ?? providerRedirectLabel(slug))}`); log.info(` ${cyan(`https://accounts.${domain}/v1/oauth_callback`)}`); - const gotcha = providerGotcha(provider); + const gotcha = descriptor?.gotcha ?? providerGotcha(slug); if (gotcha) { log.blank(); log.info(gotcha); From c8bf98358863e874ccc12c01f60646b4ee9ff9ff Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 11:26:00 -0600 Subject: [PATCH 31/52] feat(deploy): resume oauth setup from schema descriptors --- .../src/commands/deploy/index.test.ts | 55 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 17 +----- .../cli-core/src/commands/deploy/providers.ts | 18 ++++++ 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index f1f08865..56c65779 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -531,6 +531,31 @@ describe("deploy", () => { expect(err).toContain("Configure them from the Clerk Dashboard before going live"); }); + test("warns when enabled provider schema is not usable", async () => { + await linkedProject(); + mockHumanFlow(); + mockFetchInstanceConfig.mockResolvedValueOnce({ + connection_oauth_discord: { enabled: true }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ + connection_oauth_discord: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "number" }, + }, + }, + }), + ); + + await runDeployUntilPause(); + const err = stripAnsi(captured.err); + + expect(err).toContain("not yet supported by automated `clerk deploy` setup: discord"); + expect(err).not.toContain("Configure Discord OAuth credentials"); + }); + test("DNS verification polls getApplicationDomainStatus until complete", async () => { await linkedProject(); // Proceed → create instance → check DNS now → complete OAuth. @@ -1055,6 +1080,36 @@ describe("deploy", () => { expect(mockSelect).not.toHaveBeenCalled(); }); + test("resume treats redacted sensitive OAuth credentials as present", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_discord" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_discord", + developmentConfig: { + connection_oauth_discord: { enabled: true }, + }, + productionConfig: { + connection_oauth_discord: { + enabled: true, + client_id: "discord-client-id", + client_secret: "••••••••", + }, + }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ connection_oauth_discord: basicOAuthSchema }), + ); + mockIsAgent.mockReturnValue(false); + mockSelect.mockResolvedValueOnce("next-steps"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("[x] Configure Discord OAuth credentials"); + expect(mockPassword).not.toHaveBeenCalled(); + }); + 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 b6bb5faa..014933b1 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -49,6 +49,7 @@ import { mapDeployError } from "./errors.ts"; import { OAUTH_KEY_PREFIX, buildOAuthProviderDescriptors, + hasProviderRequiredCredentials, isDeployOAuthProviderSupported, providerLabel, providerSetupIntro, @@ -385,7 +386,7 @@ async function resolveLiveDeploySnapshot( domain.id, ); const completedOAuthProviders = oauthProviderDescriptors - .filter((descriptor) => hasProductionOAuthCredentials(productionConfig, descriptor)) + .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) .map((descriptor) => descriptor.provider); const pendingOAuthDescriptor = oauthProviderDescriptors.find( (descriptor) => !completedOAuthProviders.includes(descriptor.provider), @@ -458,20 +459,6 @@ async function loadProductionDomain(ctx: DeployContext): Promise !domain.is_satellite) ?? domains.data[0]; } -function hasProductionOAuthCredentials( - config: Record, - descriptor: OAuthProviderDescriptor, -): boolean { - const value = config[descriptor.configKey]; - if (!value || typeof value !== "object") return false; - const providerConfig = value as Record; - if (providerConfig.enabled !== true) return false; - return descriptor.requiredCredentialKeys.every((key) => { - const fieldValue = providerConfig[key]; - return typeof fieldValue === "string" && fieldValue.length > 0; - }); -} - function buildNewDeployPlan(oauthProviders: readonly OAuthProviderDescriptor[]): DeployPlanStep[] { return [ { label: "Create production instance", status: "pending" }, diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 9e9513b2..10274bb6 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -249,6 +249,24 @@ export function buildOAuthProviderDescriptors( return { supported, unsupported }; } +/** + * Determine whether production config already contains every credential + * required by a schema-derived provider descriptor. + */ +export function hasProviderRequiredCredentials( + config: Record, + descriptor: OAuthProviderDescriptor, +): boolean { + const value = config[descriptor.configKey]; + if (!value || typeof value !== "object") return false; + const providerConfig = value as Record; + if (providerConfig.enabled !== true) return false; + return descriptor.requiredCredentialKeys.every((key) => { + const fieldValue = providerConfig[key]; + return typeof fieldValue === "string" && fieldValue.length > 0; + }); +} + function buildOAuthProviderDescriptor( provider: string, schema: InstanceConfigSchema, From 06061e7231b77eeeffd57f09de6fdf9db9f23e14 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 11:32:23 -0600 Subject: [PATCH 32/52] feat(deploy): preserve oauth provider special prompts --- .../src/commands/deploy/index.test.ts | 61 +++++++++++++++++++ .../cli-core/src/commands/deploy/prompts.ts | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 56c65779..4b8059ab 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -137,6 +137,21 @@ const appleOAuthSchema = oauthSchema({ }, }); +const linearOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "Linear OAuth client ID" }, + client_secret: { + type: "string", + description: "Linear OAuth client secret", + "x-clerk-sensitive": true, + }, + actor: { + type: "string", + description: "Linear OAuth actor", + enum: ["user", "application"], + default: "user", + }, +}); + const schemaResponse = (properties: Record) => ({ $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://clerk.com/schemas/platform-config/2025-01-01", @@ -987,6 +1002,9 @@ describe("deploy", () => { }, }, }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); mockIsAgent.mockReturnValue(false); const invalidP8Path = join(tempDir, "not-a-key.p8"); @@ -1008,6 +1026,16 @@ describe("deploy", () => { await runDeploy({}); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_apple", { + connection_oauth_apple: { + enabled: true, + client_id: "apple-services-id", + team_id: "apple-team-id", + key_id: "apple-key-id", + client_secret: + "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n", + }, + }); const p8Input = mockInput.mock.calls.find((call) => String((call[0] as { message?: string }).message).includes("Apple Private Key"), )?.[0] as { validate: (value: string) => Promise }; @@ -1020,6 +1048,39 @@ describe("deploy", () => { await expect(p8Input.validate(relativeP8Path)).resolves.toBe(true); }); + test("Linear OAuth actor is collected from a select prompt", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + await runDnsHandoff(); + mockLiveProduction({ + developmentConfig: { + connection_oauth_linear: { enabled: true }, + }, + productionConfig: { + connection_oauth_linear: { enabled: true }, + }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ connection_oauth_linear: linearOAuthSchema }), + ); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("application"); + mockInput.mockResolvedValueOnce("linear-client-id"); + mockPassword.mockResolvedValueOnce("linear-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { + connection_oauth_linear: { + enabled: true, + client_id: "linear-client-id", + client_secret: "linear-secret", + actor: "application", + }, + }); + }); + test("Google OAuth JSON file prompt validates path and shape before continuing", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index da9f5f26..0a69bc7b 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -154,7 +154,7 @@ async function collectOAuthField( validate: required(field.label), }); } - return value.trim(); + return field.filePath ? value : value.trim(); } function validateSecretFilePath(label: string) { From dda5ef193758bba555481d5ea8da4d3c3095c7ef Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 11:37:19 -0600 Subject: [PATCH 33/52] docs(deploy): document schema-driven oauth setup --- .changeset/deploy-wizard.md | 2 +- .../cli-core/src/commands/deploy/README.md | 46 ++++++++++--------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/.changeset/deploy-wizard.md b/.changeset/deploy-wizard.md index 46fa126d..bd457622 100644 --- a/.changeset/deploy-wizard.md +++ b/.changeset/deploy-wizard.md @@ -8,4 +8,4 @@ Add `clerk deploy`, an interactive wizard that promotes a Clerk application from - 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. +- Uses provider schemas to collect production OAuth credentials for broader built-in provider support. diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index ce1debe5..61db5247 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -100,15 +100,16 @@ 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. | -| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and `cname_targets[]`. | -| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the mail, DNS, and SSL sequential spinners in `pollDeployStatus`; each spinner resolves independently within the shared 10-second cap. | +| 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. | +| Read instance config schema | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config/schema` | `fetchInstanceConfigSchema`. Reads schemas for supported OAuth config keys so deploy can derive credential prompts. | +| 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. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and `cname_targets[]`. | +| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the mail, DNS, and SSL sequential spinners in `pollDeployStatus`; each spinner resolves independently within the shared 10-second cap. | ## OAuth Provider Config Format @@ -126,25 +127,26 @@ PATCH /v1/platform/applications/{appID}/instances/production/config } ``` -### Provider-specific required fields +### OAuth provider support -| 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` | +`clerk deploy` discovers enabled built-in OAuth providers from development config keys named `connection_oauth_{provider}`. For public built-in providers supported by the deploy flow, the command reads `GET /config/schema` and derives credential prompts from the provider schema. -Production instances return `422` if you try to enable a provider without credentials. +Most providers ask for `client_id` and `client_secret`. Provider-specific schema fields are prompted when they are safe for deploy setup, such as Linear `actor`. + +The CLI keeps small local overrides for provider setup details that schema does not fully describe: -### Google OAuth `client_id` validation +| Provider | Override | +| --------- | ------------------------------------------------------------------------------------------ | +| Google | Optional Google Cloud Console JSON import and OAuth consent screen warning | +| Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | +| Microsoft | Client secret expiry warning | +| Linear | Workspace admin warning | -Google enforces a pattern: `^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$` +For Google, the wizard can load `client_id` and `client_secret` from the top-level `web` object in a Google Cloud Console OAuth client JSON file, or from `installed` for desktop-style client downloads. The file contents are used in memory and are not written to CLI config. -### Google OAuth JSON import +Providers not currently supported by automated deploy setup are cloned to production without automated credential setup. Configure those providers from the Clerk Dashboard before going live. -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. +Production instances return `422` if you try to enable a provider without credentials. ## Helpful values for OAuth walkthrough From 819ec4e9c28da2ced0fa81c664631ad9feb9cd11 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 11:48:06 -0600 Subject: [PATCH 34/52] fix(deploy): warn generically for unsupported oauth providers --- .../cli-core/src/commands/deploy/README.md | 4 +- .../src/commands/deploy/index.test.ts | 57 ++++++++++++++++++- .../cli-core/src/commands/deploy/index.ts | 31 ++++++---- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 61db5247..25eebf5b 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -68,6 +68,8 @@ sequenceDiagram %% 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 }, ... } + CLI->>API: GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config/schema?keys=connection_oauth_google,... + API-->>CLI: { properties: { connection_oauth_google: { properties: ... } } } %% Plan summary + domain CLI->>User: Plan summary @@ -81,7 +83,7 @@ sequenceDiagram CLI->>User: Add these CNAME records to your DNS provider %% OAuth credential loop - loop Each enabled social provider + loop Each supported schema-backed OAuth 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 } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4b8059ab..ad11312e 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -542,7 +542,10 @@ 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).toContain("not yet supported by automated `clerk deploy` setup: unknown"); + expect(err).toContain( + "1 OAuth provider is enabled in development but not yet supported by automated `clerk deploy` setup.", + ); + expect(err).not.toContain("unknown"); expect(err).toContain("Configure them from the Clerk Dashboard before going live"); }); @@ -567,7 +570,11 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); - expect(err).toContain("not yet supported by automated `clerk deploy` setup: discord"); + expect(err).toContain( + "1 OAuth provider is enabled in development but not yet supported by automated `clerk deploy` setup.", + ); + expect(err).not.toContain("discord"); + expect(err).not.toContain("Discord"); expect(err).not.toContain("Configure Discord OAuth credentials"); }); @@ -1141,6 +1148,50 @@ describe("deploy", () => { expect(mockSelect).not.toHaveBeenCalled(); }); + test("existing production warns generically when an enabled provider schema is not usable", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_discord" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_discord", + developmentConfig: { + connection_oauth_discord: { enabled: true }, + }, + productionConfig: { + connection_oauth_discord: { enabled: true }, + }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ + connection_oauth_discord: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "number" }, + }, + }, + }), + ); + mockIsAgent.mockReturnValue(false); + + await runDeploy({}); + const err = stripAnsi(captured.err); + const warningIndex = err.indexOf( + "1 OAuth provider is enabled in development but not yet supported by automated `clerk deploy` setup.", + ); + const noActionsIndex = err.indexOf("No deploy actions remain."); + const readyIndex = err.indexOf("Production ready at https://example.com"); + + expect(warningIndex).toBeGreaterThan(-1); + expect(noActionsIndex).toBeGreaterThan(-1); + expect(readyIndex).toBeGreaterThan(-1); + expect(warningIndex).toBeLessThan(noActionsIndex); + expect(warningIndex).toBeLessThan(readyIndex); + expect(err).not.toContain("discord"); + expect(err).not.toContain("Discord"); + expect(err).not.toContain("Configure Discord OAuth credentials"); + }); + test("resume treats redacted sensitive OAuth credentials as present", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_discord" }, @@ -1811,7 +1862,7 @@ describe("deploy", () => { expect(err).toContain("Configure Google OAuth credentials"); expect(err).toContain("Configure Discord OAuth credentials"); expect(err).not.toContain("Configure Microsoft OAuth credentials"); - expect(err).not.toContain("not yet supported by `clerk deploy`: discord"); + expect(err).not.toContain("not yet supported by automated `clerk deploy` setup"); }); test("unlinked directory throws NOT_LINKED instead of warning and exiting 0", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 014933b1..94238867 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -186,15 +186,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { } log.blank(); - if (unsupported.length > 0) { - log.warn( - `These OAuth providers are enabled in development but not yet supported by automated \`clerk deploy\` setup: ${unsupported.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(); - } + warnUnsupportedOAuthProviders(unsupported.length); const proceed = await confirmProceed(); if (!proceed) { log.info("No changes were made."); @@ -285,6 +277,8 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { } log.blank(); + warnUnsupportedOAuthProviders(snapshot.unsupportedOAuthProviderCount); + if (!snapshot.pending) { log.info("No deploy actions remain."); await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, "verified"); @@ -339,6 +333,7 @@ type LiveDeploySnapshot = Omit< completedOAuthProviders: OAuthProvider[]; cnameTargets?: readonly CnameTarget[]; dnsComplete: boolean; + unsupportedOAuthProviderCount: number; }; type DiscoveredOAuthProviders = { @@ -348,6 +343,20 @@ type DiscoveredOAuthProviders = { type DnsVerificationResult = "verified" | "pending"; +function warnUnsupportedOAuthProviders(count: number): void { + if (count === 0) return; + + const plural = count === 1 ? "" : "s"; + const verb = count === 1 ? "is" : "are"; + log.warn( + `${count} OAuth provider${plural} ${verb} enabled in development but not yet supported by automated \`clerk deploy\` setup.`, + ); + log.warn( + "These providers may not have working production credentials. Configure them from the Clerk Dashboard before going live, or disable them in development first.", + ); + log.blank(); +} + async function loadDevelopmentOAuthProviders( ctx: DeployContext, ): Promise { @@ -378,7 +387,8 @@ async function resolveLiveDeploySnapshot( const domain = await loadProductionDomain(ctx); if (!domain) return undefined; - const { descriptors: oauthProviderDescriptors } = await loadDevelopmentOAuthProviders(ctx); + const { descriptors: oauthProviderDescriptors, unsupported } = + await loadDevelopmentOAuthProviders(ctx); const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); const { productionConfig, deployStatus } = await loadProductionState( ctx, @@ -402,6 +412,7 @@ async function resolveLiveDeploySnapshot( oauthProviderDescriptors, completedOAuthProviders, cnameTargets: domain.cname_targets ?? [], + unsupportedOAuthProviderCount: unsupported.length, }; const dnsComplete = deployStatus.status === "complete"; From c2bbb44a34951114a19a1496c12c4000fdab01d5 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 12:03:37 -0600 Subject: [PATCH 35/52] fix(deploy): ignore optional oauth schema fields --- .../src/commands/deploy/providers.test.ts | 18 +++++++++++++++++- .../cli-core/src/commands/deploy/providers.ts | 6 +++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index a0232461..44478a60 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -77,11 +77,27 @@ describe("deploy OAuth provider descriptors", () => { test("Google descriptor exposes manual and JSON credential sources", () => { const result = buildOAuthProviderDescriptors( ["google"], - schemaResponse({ connection_oauth_google: basicOAuthSchema }), + schemaResponse({ + connection_oauth_google: oauthSchema({ + client_id: { type: "string", description: "Google OAuth client ID" }, + client_secret: { + type: "string", + description: "Google OAuth client secret", + "x-clerk-sensitive": true, + }, + show_account_selector_prompt: { + type: "boolean", + description: "Whether to show the account selector prompt during OAuth flow", + default: false, + }, + }), + }), ); const google = descriptorByProvider(result.supported, "google"); expect(google.credentialSources).toEqual(["manual", "google-json"]); + expect(google.fields.map((field) => field.key)).toEqual(["client_id", "client_secret"]); + expect(result.unsupported).toEqual([]); }); test("marks missing schema as unsupported", () => { diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 10274bb6..b8f5bc48 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -277,13 +277,17 @@ function buildOAuthProviderDescriptor( const override = PROVIDER_OVERRIDES[provider] ?? {}; const omittedFields = new Set(override.omittedFields ?? []); + const requiredFieldKeys = new Set(override.requiredCredentialKeys ?? DEFAULT_FIELD_ORDER); const fields: OAuthPromptField[] = []; for (const [key, property] of Object.entries(configSchema.properties)) { if (SYSTEM_FIELD_KEYS.has(key) || omittedFields.has(key) || property.readOnly) continue; const field = buildPromptField(key, property, override); - if (!field) return null; + if (!field) { + if (requiredFieldKeys.has(key)) return null; + continue; + } fields.push(field); } From 894eea07c52ad8f4185b122296b2b983af2d760d Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 12:06:37 -0600 Subject: [PATCH 36/52] fix(deploy): support public oauth providers --- .../src/commands/deploy/index.test.ts | 8 ++++++ .../cli-core/src/commands/deploy/providers.ts | 26 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index ad11312e..c12d8b2c 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1840,6 +1840,8 @@ describe("deploy", () => { mockHumanFlow(); mockFetchInstanceConfig.mockResolvedValueOnce({ connection_oauth_google: { enabled: true }, + connection_oauth_coinbase: { enabled: true }, + connection_oauth_twitter: { enabled: true }, connection_oauth_discord: { enabled: true }, connection_oauth_microsoft: { enabled: false }, unrelated_key: "ignored", @@ -1847,6 +1849,8 @@ describe("deploy", () => { mockFetchInstanceConfigSchema.mockResolvedValueOnce( schemaResponse({ connection_oauth_google: basicOAuthSchema, + connection_oauth_coinbase: basicOAuthSchema, + connection_oauth_twitter: basicOAuthSchema, connection_oauth_discord: basicOAuthSchema, }), ); @@ -1857,9 +1861,13 @@ describe("deploy", () => { expect(mockFetchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_dev_123"); expect(mockFetchInstanceConfigSchema).toHaveBeenCalledWith("app_xyz789", "ins_dev_123", [ "connection_oauth_google", + "connection_oauth_coinbase", + "connection_oauth_twitter", "connection_oauth_discord", ]); expect(err).toContain("Configure Google OAuth credentials"); + expect(err).toContain("Configure Coinbase OAuth credentials"); + expect(err).toContain("Configure Twitter OAuth credentials"); expect(err).toContain("Configure Discord OAuth credentials"); expect(err).not.toContain("Configure Microsoft OAuth credentials"); expect(err).not.toContain("not yet supported by automated `clerk deploy` setup"); diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index b8f5bc48..7e1fe8d2 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -8,11 +8,33 @@ const DEFAULT_DOCS_URL_PREFIX = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; const DEPLOY_OAUTH_PROVIDER_ALLOWLIST = [ "google", + "facebook", + "apple", "github", + "x", + "twitter", "microsoft", - "apple", - "linear", + "linkedin", + "linkedin_oidc", + "dropbox", "discord", + "twitch", + "tiktok", + "gitlab", + "huggingface", + "slack", + "linear", + "atlassian", + "bitbucket", + "instagram", + "hubspot", + "coinbase", + "spotify", + "notion", + "line", + "box", + "xero", + "vercel", ] as const; const COMPATIBLE_OAUTH_PROVIDERS = ["google", "github", "microsoft", "apple", "linear"] as const; From 65a08ff23a8ce4eb41e792470e68f9c3bf738de1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 14:57:26 -0600 Subject: [PATCH 37/52] fix(deploy): use verbose for debug output --- packages/cli-core/src/cli-program.test.ts | 4 ++-- packages/cli-core/src/cli-program.ts | 8 ++------ packages/cli-core/src/commands/deploy/README.md | 10 +++++----- packages/cli-core/src/commands/deploy/index.test.ts | 4 ++-- packages/cli-core/src/commands/deploy/index.ts | 12 +++--------- 5 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 4ed6e12a..bdca753a 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -45,12 +45,12 @@ test("users list exposes common filters and pagination options", () => { ); }); -test("deploy exposes the expected options", () => { +test("deploy relies on global 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"]); + expect(optionNames).toEqual([]); }); 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 a2e4ce26..92c5203e 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -120,7 +120,7 @@ Give AI agents better Clerk context: install the Clerk skills program.hook("preAction", async () => { // Reset log level at the start of each command invocation so a previous - // --verbose or --debug flag doesn't leak into subsequent runs. + // --verbose doesn't leak into subsequent runs. setLogLevel("info"); const opts = program.opts(); if (opts.verbose) { @@ -926,11 +926,7 @@ Tutorial — enable completions for your shell: ]) .action(update); - program - .command("deploy") - .description("Deploy a Clerk application to production") - .option("--debug", "Show detailed deployment debug output") - .action(deploy); + program.command("deploy").description("Deploy a Clerk application to production").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 25eebf5b..990d00c4 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -12,15 +12,15 @@ After all OAuth providers are configured, DNS verification runs a chain of three ```sh clerk deploy # Interactive, idempotent wizard (human mode) -clerk deploy --debug # With debug output +clerk deploy --verbose # With debug output clerk deploy --mode agent # Exit with human-mode-required guidance ``` -## Options +## Global Options -| Flag | Purpose | -| --------- | -------------------------------------------- | -| `--debug` | Show detailed deploy and PLAPI debug output. | +| Flag | Purpose | +| ----------- | -------------------------------------------- | +| `--verbose` | Show detailed deploy and PLAPI debug output. | ## Agent Mode diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index c12d8b2c..54fe9b7d 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -281,7 +281,7 @@ describe("deploy", () => { consoleSpy?.mockRestore(); }); - function runDeploy(options: Parameters[0]) { + function runDeploy(options: Parameters[0] = {}) { return deploy(options); } @@ -445,7 +445,7 @@ describe("deploy", () => { test("does not trigger interactive prompts", async () => { mockIsAgent.mockReturnValue(true); - await expect(runDeploy({ debug: true })).rejects.toBeInstanceOf(CliError); + await expect(runDeploy()).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 94238867..177d4a7f 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -74,24 +74,18 @@ import { type DeployOperationState, } from "./state.ts"; -type DeployOptions = { - debug?: boolean; -}; - const DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS = 3000; const DEPLOY_STATUS_MAX_RETRIES = 5; const DEPLOY_STATUS_BACKOFF_FACTOR = 2; -export async function deploy(options: DeployOptions = {}) { +type DeployOptions = Record; + +export async function deploy(_options: DeployOptions = {}) { if (isAgent()) { throwUsageError( "clerk deploy requires human mode because production configuration uses interactive prompts. Run `clerk deploy --mode human` from an interactive terminal.", ); } - if (options.debug) { - const { setLogLevel } = await import("../../lib/log.ts"); - setLogLevel("debug"); - } intro("clerk deploy"); try { From 0c7b159b23ebb2d463b31e7834e170ccc5b8b6b4 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 15:08:24 -0600 Subject: [PATCH 38/52] fix(deploy): remove oauth provider allowlist --- .../cli-core/src/commands/deploy/index.ts | 5 +- .../src/commands/deploy/providers.test.ts | 13 ++++-- .../cli-core/src/commands/deploy/providers.ts | 46 +------------------ 3 files changed, 10 insertions(+), 54 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 177d4a7f..c98a2f08 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -50,7 +50,6 @@ import { OAUTH_KEY_PREFIX, buildOAuthProviderDescriptors, hasProviderRequiredCredentials, - isDeployOAuthProviderSupported, providerLabel, providerSetupIntro, showOAuthWalkthrough, @@ -357,9 +356,7 @@ async function loadDevelopmentOAuthProviders( return withSpinner("Reading development configuration...", async () => { const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); const providerSlugs = discoverEnabledOAuthProviderSlugs(config); - const schemaKeys = providerSlugs - .filter((provider) => isDeployOAuthProviderSupported(provider)) - .map((provider) => `${OAUTH_KEY_PREFIX}${provider}`); + const schemaKeys = providerSlugs.map((provider) => `${OAUTH_KEY_PREFIX}${provider}`); const schema = schemaKeys.length > 0 ? await fetchInstanceConfigSchema(ctx.appId, ctx.developmentInstanceId, schemaKeys) diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index 44478a60..0627647a 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -61,17 +61,20 @@ describe("deploy OAuth provider descriptors", () => { expect(discord.requiredCredentialKeys).toEqual(["client_id", "client_secret"]); }); - test("marks providers outside the public deploy allowlist as unsupported", () => { + test("supports schema-compatible providers without a static deploy allowlist", () => { const result = buildOAuthProviderDescriptors( - ["google", "example_private_provider"], + ["google", "example_schema_provider"], schemaResponse({ connection_oauth_google: basicOAuthSchema, - connection_oauth_example_private_provider: basicOAuthSchema, + connection_oauth_example_schema_provider: basicOAuthSchema, }), ); - expect(result.supported.map((item) => item.provider)).toEqual(["google"]); - expect(result.unsupported).toEqual(["example_private_provider"]); + expect(result.supported.map((item) => item.provider)).toEqual([ + "google", + "example_schema_provider", + ]); + expect(result.unsupported).toEqual([]); }); test("Google descriptor exposes manual and JSON credential sources", () => { diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 7e1fe8d2..22abf087 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -6,39 +6,8 @@ import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi const DEFAULT_DOCS_URL_PREFIX = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; -const DEPLOY_OAUTH_PROVIDER_ALLOWLIST = [ - "google", - "facebook", - "apple", - "github", - "x", - "twitter", - "microsoft", - "linkedin", - "linkedin_oidc", - "dropbox", - "discord", - "twitch", - "tiktok", - "gitlab", - "huggingface", - "slack", - "linear", - "atlassian", - "bitbucket", - "instagram", - "hubspot", - "coinbase", - "spotify", - "notion", - "line", - "box", - "xero", - "vercel", -] as const; const COMPATIBLE_OAUTH_PROVIDERS = ["google", "github", "microsoft", "apple", "linear"] as const; -type DeployOAuthProvider = (typeof DEPLOY_OAUTH_PROVIDER_ALLOWLIST)[number]; type CompatibleOAuthProvider = (typeof COMPATIBLE_OAUTH_PROVIDERS)[number]; /** @@ -108,8 +77,7 @@ type ProviderOverride = { requiredCredentialKeys?: string[]; }; -const PROVIDER_OVERRIDES: Partial> & - Record = { +const PROVIDER_OVERRIDES: Record = { google: { credentialLabel: "I already have my Client ID and Client Secret", redirectLabel: "Authorized Redirect URI", @@ -236,13 +204,6 @@ export const PROVIDER_GOTCHAS: Record = COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.gotcha]), ) as Record; -/** - * Determine whether automated deploy can configure this public OAuth provider. - */ -export function isDeployOAuthProviderSupported(provider: string): boolean { - return (DEPLOY_OAUTH_PROVIDER_ALLOWLIST as readonly string[]).includes(provider); -} - /** * Build deploy OAuth provider descriptors from the instance config schema. */ @@ -254,11 +215,6 @@ export function buildOAuthProviderDescriptors( const unsupported: string[] = []; for (const provider of providers) { - if (!isDeployOAuthProviderSupported(provider)) { - unsupported.push(provider); - continue; - } - const descriptor = buildOAuthProviderDescriptor(provider, schema); if (!descriptor) { unsupported.push(provider); From 6c00613e6d1b2db61900b11ed42a823aba145d4b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 15:11:23 -0600 Subject: [PATCH 39/52] refactor(deploy): derive oauth compatibility metadata --- .../src/commands/deploy/providers.test.ts | 4 ++ .../cli-core/src/commands/deploy/providers.ts | 72 ++++++++++--------- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index 0627647a..c527fe0f 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -205,6 +205,10 @@ describe("deploy OAuth provider descriptors", () => { "Apple Key ID", "Apple Private Key - path to .p8 file", ]); + expect(providerFields("linear").map((field) => field.label)).toEqual([ + "Client ID", + "Client Secret", + ]); }); test("providerLabel falls back to title-cased unknown slugs", () => { diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 22abf087..5abe9956 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -6,9 +6,6 @@ import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi const DEFAULT_DOCS_URL_PREFIX = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; -const COMPATIBLE_OAUTH_PROVIDERS = ["google", "github", "microsoft", "apple", "linear"] as const; - -type CompatibleOAuthProvider = (typeof COMPATIBLE_OAUTH_PROVIDERS)[number]; /** * OAuth provider slug used by deploy. @@ -77,7 +74,7 @@ type ProviderOverride = { requiredCredentialKeys?: string[]; }; -const PROVIDER_OVERRIDES: Record = { +const PROVIDER_OVERRIDES = { google: { credentialLabel: "I already have my Client ID and Client Secret", redirectLabel: "Authorized Redirect URI", @@ -125,7 +122,9 @@ const PROVIDER_OVERRIDES: Record = { setupCopy: "Production Linear sign-in requires a Linear OAuth app and custom credentials.", gotcha: `${yellow("IMPORTANT")} You must be a workspace admin in Linear to create OAuth apps.`, }, -}; +} satisfies Record; + +type ProviderWithOverride = keyof typeof PROVIDER_OVERRIDES; const SYSTEM_FIELD_KEYS = new Set(["enabled", "authenticatable", "block_email_subaddresses"]); const DEFAULT_FIELD_ORDER = ["client_id", "client_secret"]; @@ -140,14 +139,14 @@ const COMPATIBLE_PROVIDER_DESCRIPTORS = buildCompatibilityDescriptors(); /** * Compatibility labels for the deploy flow that still consumes provider maps. */ -export const PROVIDER_LABELS: Record = Object.fromEntries( +export const PROVIDER_LABELS: Record = Object.fromEntries( COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.label]), -) as Record; +) as Record; /** * Compatibility fields for the deploy flow that still consumes provider maps. */ -export const PROVIDER_FIELDS: Record = Object.fromEntries( +export const PROVIDER_FIELDS: Record = Object.fromEntries( COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ descriptor.provider, descriptor.fields.map((field) => ({ @@ -157,52 +156,51 @@ export const PROVIDER_FIELDS: Record = Ob filePath: field.filePath || undefined, })), ]), -) as Record; +) as Record; /** * Compatibility credential action labels for the current prompt flow. */ -export const PROVIDER_CREDENTIAL_LABELS: Record = - Object.fromEntries( - COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ - descriptor.provider, - descriptor.credentialLabel, - ]), - ) as Record; +export const PROVIDER_CREDENTIAL_LABELS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ + descriptor.provider, + descriptor.credentialLabel, + ]), +) as Record; /** * Compatibility redirect labels for the current walkthrough flow. */ -export const PROVIDER_REDIRECT_LABELS: Record = Object.fromEntries( +export const PROVIDER_REDIRECT_LABELS: Record = Object.fromEntries( COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ descriptor.provider, descriptor.redirectLabel, ]), -) as Record; +) as Record; /** * Compatibility docs URLs for the current walkthrough flow. */ -export const PROVIDER_DOC_URLS: Record = Object.fromEntries( - COMPATIBLE_OAUTH_PROVIDERS.map((provider) => [ +export const PROVIDER_DOC_URLS: Record = Object.fromEntries( + Object.keys(PROVIDER_OVERRIDES).map((provider) => [ provider, `${DEFAULT_DOCS_URL_PREFIX}/${provider}`, ]), -) as Record; +) as Record; /** * Compatibility setup copy for the current walkthrough flow. */ -export const PROVIDER_SETUP_COPY: Record = Object.fromEntries( +export const PROVIDER_SETUP_COPY: Record = Object.fromEntries( COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.setupCopy]), -) as Record; +) as Record; /** * Compatibility gotchas for the current walkthrough flow. */ -export const PROVIDER_GOTCHAS: Record = Object.fromEntries( +export const PROVIDER_GOTCHAS: Record = Object.fromEntries( COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.gotcha]), -) as Record; +) as Record; /** * Build deploy OAuth provider descriptors from the instance config schema. @@ -253,7 +251,7 @@ function buildOAuthProviderDescriptor( const configSchema = schema.properties?.[configKey]; if (configSchema?.type !== "object" || !configSchema.properties) return null; - const override = PROVIDER_OVERRIDES[provider] ?? {}; + const override = providerOverride(provider) ?? {}; const omittedFields = new Set(override.omittedFields ?? []); const requiredFieldKeys = new Set(override.requiredCredentialKeys ?? DEFAULT_FIELD_ORDER); const fields: OAuthPromptField[] = []; @@ -320,6 +318,10 @@ function buildPromptField( }; } +function providerOverride(provider: string): ProviderOverride | undefined { + return (PROVIDER_OVERRIDES as Record)[provider]; +} + function compareFields(a: string, b: string, overrideOrder: string[] | undefined): number { const order = overrideOrder ?? DEFAULT_FIELD_ORDER; const aIndex = order.indexOf(a); @@ -376,7 +378,7 @@ function buildCompatibilityDescriptors(): OAuthProviderDescriptor[] { } function compatibilityProviderProperties(provider: string): Record { - const override = PROVIDER_OVERRIDES[provider]; + const override = providerOverride(provider); const keys = override?.requiredCredentialKeys ?? DEFAULT_FIELD_ORDER; return Object.fromEntries( keys.map((key) => [ @@ -400,7 +402,7 @@ export function providerLabel(provider: string): string { * Prompt fields for existing deploy callers, falling back to standard OAuth credentials. */ export function providerFields(provider: OAuthProvider): OAuthField[] { - if (isCompatibleOAuthProvider(provider)) return PROVIDER_FIELDS[provider]; + if (hasProviderOverride(provider)) return PROVIDER_FIELDS[provider]; return [ { key: "client_id", label: "Client ID" }, { key: "client_secret", label: "Client Secret", secret: true }, @@ -411,32 +413,32 @@ export function providerFields(provider: OAuthProvider): OAuthField[] { * Credential action label for existing deploy callers. */ export function providerCredentialLabel(provider: OAuthProvider): string { - if (isCompatibleOAuthProvider(provider)) return PROVIDER_CREDENTIAL_LABELS[provider]; + if (hasProviderOverride(provider)) return PROVIDER_CREDENTIAL_LABELS[provider]; return "I already have my Client ID and Client Secret"; } function providerRedirectLabel(provider: OAuthProvider): string { - if (isCompatibleOAuthProvider(provider)) return PROVIDER_REDIRECT_LABELS[provider]; + if (hasProviderOverride(provider)) return PROVIDER_REDIRECT_LABELS[provider]; return "Redirect URI"; } function providerLegacyDocsUrl(provider: OAuthProvider): string { - if (isCompatibleOAuthProvider(provider)) return PROVIDER_DOC_URLS[provider]; + if (hasProviderOverride(provider)) return PROVIDER_DOC_URLS[provider]; return providerDocsUrl(provider); } function providerSetupCopy(provider: OAuthProvider): string { - if (isCompatibleOAuthProvider(provider)) return PROVIDER_SETUP_COPY[provider]; + if (hasProviderOverride(provider)) return PROVIDER_SETUP_COPY[provider]; return `Production ${providerLabel(provider)} sign-in requires custom OAuth credentials.`; } function providerGotcha(provider: OAuthProvider): string | null { - if (isCompatibleOAuthProvider(provider)) return PROVIDER_GOTCHAS[provider]; + if (hasProviderOverride(provider)) return PROVIDER_GOTCHAS[provider]; return null; } -function isCompatibleOAuthProvider(provider: string): provider is CompatibleOAuthProvider { - return (COMPATIBLE_OAUTH_PROVIDERS as readonly string[]).includes(provider); +function hasProviderOverride(provider: string): provider is ProviderWithOverride { + return provider in PROVIDER_OVERRIDES; } function providerDescriptorFromInput( From 97b25851138d094f35c0aa866f8114b3e50e3549 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 26 May 2026 15:22:55 -0600 Subject: [PATCH 40/52] refactor(deploy): simplify oauth provider overrides --- .../cli-core/src/commands/deploy/README.md | 10 ++--- .../src/commands/deploy/index.test.ts | 16 +++---- .../src/commands/deploy/providers.test.ts | 4 +- .../cli-core/src/commands/deploy/providers.ts | 43 ++----------------- 4 files changed, 17 insertions(+), 56 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 990d00c4..addae9cb 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -137,12 +137,10 @@ Most providers ask for `client_id` and `client_secret`. Provider-specific schema The CLI keeps small local overrides for provider setup details that schema does not fully describe: -| Provider | Override | -| --------- | ------------------------------------------------------------------------------------------ | -| Google | Optional Google Cloud Console JSON import and OAuth consent screen warning | -| Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | -| Microsoft | Client secret expiry warning | -| Linear | Workspace admin warning | +| Provider | Override | +| -------- | ------------------------------------------------------------------------------------------ | +| Google | Optional Google Cloud Console JSON import and OAuth consent screen warning | +| Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | For Google, the wizard can load `client_id` and `client_secret` from the top-level `web` object in a Google Cloud Console OAuth client JSON file, or from `installed` for desktop-style client downloads. The file contents are used in memory and are not written to CLI config. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 54fe9b7d..b7aca2a9 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -404,27 +404,27 @@ describe("deploy", () => { 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", + "Reference: https://clerk.com/docs/authentication/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", + "Production GitHub sign-in requires custom OAuth credentials.", + "Reference: https://clerk.com/docs/authentication/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", + "Production Microsoft sign-in requires custom OAuth credentials.", + "Reference: https://clerk.com/docs/authentication/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", + "Reference: https://clerk.com/docs/authentication/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", + "Production Linear sign-in requires custom OAuth credentials.", + "Reference: https://clerk.com/docs/authentication/social-connections/linear", ]); }); diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index c527fe0f..b756b386 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -190,13 +190,13 @@ describe("deploy OAuth provider descriptors", () => { ]); }); - test("preserves existing compatibility prompt labels", () => { + test("keeps compatibility prompt labels only for behavioral overrides", () => { expect(providerFields("google").map((field) => field.label)).toEqual([ "Client ID", "Client Secret", ]); expect(providerFields("microsoft").map((field) => field.label)).toEqual([ - "Application (Client) ID", + "Client ID", "Client Secret", ]); expect(providerFields("apple").map((field) => field.label)).toEqual([ diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 5abe9956..5b7fde80 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -1,5 +1,5 @@ import { OAUTH_PROVIDERS } from "@clerk/shared/oauth"; -import { bold, cyan, dim, yellow, red } from "../../lib/color.ts"; +import { bold, cyan, dim, yellow } from "../../lib/color.ts"; import { log } from "../../lib/log.ts"; import { openBrowser } from "../../lib/open.ts"; import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi.ts"; @@ -83,22 +83,6 @@ const PROVIDER_OVERRIDES = { gotcha: `${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.`, credentialSources: ["manual", "google-json"], }, - github: { - credentialLabel: "I already have my Client ID and Client Secret", - redirectLabel: "Authorization Callback URL", - setupCopy: "Production GitHub sign-in requires a GitHub OAuth app and custom credentials.", - gotcha: null, - }, - microsoft: { - credentialLabel: "I already have my Application (Client) ID and Client Secret", - redirectLabel: "Redirect URI", - setupCopy: - "Production Microsoft sign-in requires a Microsoft Entra ID app and custom credentials.", - gotcha: `${red("WARNING")} Microsoft client secrets expire (default 6 months, max 24). Set a calendar reminder to rotate before expiration or sign-in will break.`, - fieldLabels: { - client_id: "Application (Client) ID", - }, - }, apple: { credentialLabel: "I already have my Services ID, Team ID, Key ID, and .p8 file", redirectLabel: "Return URL", @@ -116,12 +100,6 @@ const PROVIDER_OVERRIDES = { omittedFields: ["bundle_id"], requiredCredentialKeys: ["client_id", "team_id", "key_id", "client_secret"], }, - linear: { - credentialLabel: "I already have my Client ID and Client Secret", - redirectLabel: "Callback URL", - setupCopy: "Production Linear sign-in requires a Linear OAuth app and custom credentials.", - gotcha: `${yellow("IMPORTANT")} You must be a workspace admin in Linear to create OAuth apps.`, - }, } satisfies Record; type ProviderWithOverride = keyof typeof PROVIDER_OVERRIDES; @@ -178,16 +156,6 @@ export const PROVIDER_REDIRECT_LABELS: Record = Ob ]), ) as Record; -/** - * Compatibility docs URLs for the current walkthrough flow. - */ -export const PROVIDER_DOC_URLS: Record = Object.fromEntries( - Object.keys(PROVIDER_OVERRIDES).map((provider) => [ - provider, - `${DEFAULT_DOCS_URL_PREFIX}/${provider}`, - ]), -) as Record; - /** * Compatibility setup copy for the current walkthrough flow. */ @@ -422,11 +390,6 @@ function providerRedirectLabel(provider: OAuthProvider): string { return "Redirect URI"; } -function providerLegacyDocsUrl(provider: OAuthProvider): string { - if (hasProviderOverride(provider)) return PROVIDER_DOC_URLS[provider]; - return providerDocsUrl(provider); -} - function providerSetupCopy(provider: OAuthProvider): string { if (hasProviderOverride(provider)) return PROVIDER_SETUP_COPY[provider]; return `Production ${providerLabel(provider)} sign-in requires custom OAuth credentials.`; @@ -455,7 +418,7 @@ export function providerSetupIntro(provider: OAuthProvider | OAuthProviderDescri const slug = descriptor?.provider ?? (provider as OAuthProvider); const label = descriptor?.label ?? providerLabel(slug); const setupCopy = descriptor?.setupCopy ?? providerSetupCopy(slug); - const docsUrl = descriptor?.docsUrl ?? providerLegacyDocsUrl(slug); + const docsUrl = descriptor?.docsUrl ?? providerDocsUrl(slug); return [bold(`Configure ${label} OAuth for production`), setupCopy, dim(`Reference: ${docsUrl}`)]; } @@ -469,7 +432,7 @@ export async function showOAuthWalkthrough( const descriptor = providerDescriptorFromInput(provider); const slug = descriptor?.provider ?? (provider as OAuthProvider); const label = descriptor?.label ?? providerLabel(slug); - const docsUrl = descriptor?.docsUrl ?? providerLegacyDocsUrl(slug); + const docsUrl = descriptor?.docsUrl ?? providerDocsUrl(slug); log.info(`\nConfigure your ${bold(label)} OAuth app with these values:\n`); log.info(` ${dim("Authorized JavaScript origins")}`); From c735a0b4793298bc42264a5c8f3f2d68ba0481e3 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 27 May 2026 09:33:42 -0600 Subject: [PATCH 41/52] fix(ci): retry transient cleanup API failures --- scripts/cleanup-test-users.test.ts | 28 ++++++++++++ scripts/cleanup-test-users.ts | 68 +++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 scripts/cleanup-test-users.test.ts diff --git a/scripts/cleanup-test-users.test.ts b/scripts/cleanup-test-users.test.ts new file mode 100644 index 00000000..6ea8bb33 --- /dev/null +++ b/scripts/cleanup-test-users.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { plapiGet } from "./cleanup-test-users.ts"; + +const originalFetch = globalThis.fetch; + +describe("cleanup-test-users", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("retries transient Platform API failures before returning JSON", async () => { + globalThis.fetch = mock(async () => { + const attempts = (globalThis.fetch as unknown as ReturnType).mock.calls.length; + if (attempts === 1) { + return new Response("temporary failure", { status: 500 }); + } + + return Response.json({ instances: [] }); + }) as unknown as typeof fetch; + + const result = await plapiGet("/v1/platform/applications/app_123", "ak_test", { + delayMs: 0, + }); + + expect(result).toEqual({ instances: [] }); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/scripts/cleanup-test-users.ts b/scripts/cleanup-test-users.ts index ed06d7c9..b0aa6ed8 100644 --- a/scripts/cleanup-test-users.ts +++ b/scripts/cleanup-test-users.ts @@ -13,11 +13,63 @@ const PLAPI_BASE = process.env.CLERK_PLATFORM_API_URL ?? "https://api.clerk.com"; const BAPI_BASE = process.env.CLERK_BACKEND_API_URL ?? "https://api.clerk.com"; const TEST_EMAIL_SUFFIX = "+clerk_test@clerkcookie.com"; +const DEFAULT_RETRY_OPTIONS = { + attempts: 3, + delayMs: 500, +}; + +type RetryOptions = { + attempts?: number; + delayMs?: number; +}; + +async function sleep(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => setTimeout(resolve, ms)); +} -async function plapiGet(path: string, apiKey: string): Promise { - const res = await fetch(`${PLAPI_BASE}${path}`, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); +function isRetriableStatus(status: number): boolean { + return status === 429 || (status >= 500 && status <= 599); +} + +async function fetchWithRetry( + input: string, + init: RequestInit, + options: RetryOptions = {}, +): Promise { + const attempts = options.attempts ?? DEFAULT_RETRY_OPTIONS.attempts; + const delayMs = options.delayMs ?? DEFAULT_RETRY_OPTIONS.delayMs; + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const res = await fetch(input, init); + if (!isRetriableStatus(res.status) || attempt === attempts) { + return res; + } + } catch (err) { + lastError = err; + if (attempt === attempts) throw err; + } + + await sleep(delayMs * attempt); + } + + throw lastError instanceof Error ? lastError : new Error("Request failed"); +} + +export async function plapiGet( + path: string, + apiKey: string, + options?: RetryOptions, +): Promise { + const res = await fetchWithRetry( + `${PLAPI_BASE}${path}`, + { + headers: { Authorization: `Bearer ${apiKey}` }, + }, + options, + ); if (!res.ok) { const body = await res.text(); throw new Error(`Platform API ${path} failed (${res.status}): ${body}`); @@ -26,7 +78,7 @@ async function plapiGet(path: string, apiKey: string): Promise { } async function bapiGet(path: string, secretKey: string): Promise { - const res = await fetch(`${BAPI_BASE}${path}`, { + const res = await fetchWithRetry(`${BAPI_BASE}${path}`, { headers: { Authorization: `Bearer ${secretKey}` }, }); if (!res.ok) { @@ -37,7 +89,7 @@ async function bapiGet(path: string, secretKey: string): Promise { } async function bapiDelete(path: string, secretKey: string): Promise { - const res = await fetch(`${BAPI_BASE}${path}`, { + const res = await fetchWithRetry(`${BAPI_BASE}${path}`, { method: "DELETE", headers: { Authorization: `Bearer ${secretKey}` }, }); @@ -110,4 +162,6 @@ async function main() { console.log(`Deleted ${deleted}/${testUsers.length} test users.`); } -main(); +if (import.meta.main) { + main(); +} From 0012b737a9843b2960cc20241a505a0854fbc791 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 27 May 2026 12:15:41 -0600 Subject: [PATCH 42/52] fix(deploy): adapt to platform instance response --- .../cli-core/src/commands/deploy/README.md | 8 +-- .../src/commands/deploy/index.test.ts | 64 ++++++++++++------- .../cli-core/src/commands/deploy/index.ts | 15 ++--- packages/cli-core/src/lib/plapi.test.ts | 29 +++++++-- packages/cli-core/src/lib/plapi.ts | 11 ++-- 5 files changed, 80 insertions(+), 47 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index addae9cb..1457def8 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -40,7 +40,7 @@ Human mode reads and writes deploy state through the Platform API on every run. | Step | Endpoint | Behavior | | -------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Create production instance | `POST /v1/platform/applications/{appID}/instances` | Returns `instance_id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and `cname_targets[]`. | +| Create production instance | `POST /v1/platform/applications/{appID}/instances` | Returns `id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and timestamps. DNS records are read from `active_domain.cname_targets[]`. | | | | Performs clone feature compatibility validation before creating production resources. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | | Poll domain status | `GET /v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | Returns aggregate `status` plus nested DNS, SSL, mail, and proxy component status. The CLI drives three sequential per-component spinners (mail, DNS, SSL) that all share the same 10-second cap; each spinner emits a success line as its component flips complete. The aggregate `status` guards proxy and other server-side readiness gates. It performs one immediate status read, then polls every 3s. | | Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Returns the updated config snapshot. Used to persist production `connection_oauth_*` credentials. | @@ -77,8 +77,8 @@ sequenceDiagram User->>CLI: example.com %% Create production instance + domain in one round-trip, including clone validation - CLI->>API: POST /v1/platform/applications/{appID}/instances { home_url, clone_instance_id } - API-->>CLI: { instance_id, active_domain, publishable_key, secret_key, cname_targets } + CLI->>API: POST /v1/platform/applications/{appID}/instances { domain, environment_type, clone_instance_id } + API-->>CLI: { id, active_domain: { id, name, cname_targets }, publishable_key, secret_key } CLI->>User: Add these CNAME records to your DNS provider @@ -110,7 +110,7 @@ All endpoints are on the **Platform API** (`/v1/platform/...`) and are live HTTP | 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. | -| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and `cname_targets[]`. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and DNS records nested under `active_domain.cname_targets[]`. | | Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the mail, DNS, and SSL sequential spinners in `pollDeployStatus`; each spinner resolves independently within the shared 10-second cap. | ## OAuth Provider Config Format diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index b7aca2a9..576fb3d9 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -225,31 +225,44 @@ describe("deploy", () => { domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); mockCreateProductionInstance.mockImplementation( - (_appId: string, params: { home_url: string }) => { - const hostname = params.home_url.replace(/^https?:\/\//, ""); + (_appId: string, params: { domain: string }) => { + const hostname = params.domain; return { - instance_id: "ins_prod_mock", + object: "instance", + id: "ins_prod_mock", environment_type: "production" as const, - active_domain: { id: "dmn_prod_mock", name: hostname }, + active_domain: { + object: "domain", + id: "dmn_prod_mock", + name: hostname, + is_satellite: false, + is_provider_domain: false, + frontend_api_url: `https://clerk.${hostname}`, + development_origin: "", + 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, + }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }, 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, - }, - ], + created_at: 1770000000000, + updated_at: 1770000000000, }; }, ); @@ -499,7 +512,8 @@ describe("deploy", () => { expect(mockCreateProductionInstance).toHaveBeenCalledWith("app_xyz789", { clone_instance_id: "ins_dev_123", - home_url: "https://example.com", + domain: "example.com", + environment_type: "production", }); expect(stripAnsi(captured.err)).not.toContain("Validating subscription compatibility"); }); @@ -919,12 +933,14 @@ describe("deploy", () => { mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mockInput.mockResolvedValueOnce("example.com"); mockCreateProductionInstance.mockResolvedValueOnce({ - instance_id: "ins_prod_mock", + object: "instance", + id: "ins_prod_mock", environment_type: "production" as const, active_domain: null, publishable_key: "pk_live_test", secret_key: "sk_live_test", - cname_targets: [], + created_at: 1770000000000, + updated_at: 1770000000000, }); let thrown: unknown; diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index c98a2f08..24a5871d 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -210,7 +210,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { return; } const production = productionOrExists; - await persistProductionInstance(ctx, production.instance_id); + await persistProductionInstance(ctx, production.id); if (!production.active_domain) { throw new CliError( @@ -222,23 +222,21 @@ async function startNewDeploy(ctx: DeployContext): Promise { log.blank(); const productionDomain = production.active_domain.name; + const cnameTargets = production.active_domain.cname_targets ?? []; let completedOAuthProviders: OAuthProvider[] = []; const operationState: DeployOperationState = { appId: ctx.appId, developmentInstanceId: ctx.developmentInstanceId, - productionInstanceId: production.instance_id, + productionInstanceId: production.id, productionDomainId: production.active_domain.id, domain: productionDomain, pending: { type: "oauth", provider: oauthProviders[0]?.provider ?? "google" }, oauthProviders: oauthProviders.map((descriptor) => descriptor.provider), completedOAuthProviders, - cnameTargets: production.cname_targets, + cnameTargets, }; - await runDnsRecordHandoff( - { ...operationState, pending: { type: "dns" } }, - production.cname_targets, - ); + await runDnsRecordHandoff({ ...operationState, pending: { type: "dns" } }, cnameTargets); bar(); completedOAuthProviders = await runOAuthSetup(ctx, operationState, oauthProviders); @@ -510,7 +508,8 @@ async function createProductionInstance( return withSpinner("Creating production instance...", async () => { return mapDeployError( apiCreateProductionInstance(ctx.appId, { - home_url: `https://${domain}`, + domain, + environment_type: "production", clone_instance_id: ctx.developmentInstanceId, }), { onProductionInstanceExists: async () => "exists" }, diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 11156018..4c3c87b8 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -425,14 +425,27 @@ describe("plapi", () => { let capturedUrl = ""; let capturedBody = ""; const responseBody = { - instance_id: "ins_prod_123", + object: "instance" as const, + id: "ins_prod_123", environment_type: "production" as const, - active_domain: { id: "dmn_123", name: "example.com" }, + active_domain: { + object: "domain" as const, + id: "dmn_123", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.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", + }, publishable_key: "pk_live_123", secret_key: "sk_live_123", - cname_targets: [ - { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, - ], + created_at: 1770000000000, + updated_at: 1770000000000, }; stubFetch(async (input, init) => { capturedMethod = init?.method ?? "GET"; @@ -442,14 +455,16 @@ describe("plapi", () => { }); const result = await createProductionInstance("app_abc", { - home_url: "example.com", + domain: "example.com", + environment_type: "production", clone_instance_id: "ins_dev_123", }); expect(capturedMethod).toBe("POST"); expect(capturedUrl).toBe("https://api.clerk.com/v1/platform/applications/app_abc/instances"); expect(JSON.parse(capturedBody)).toEqual({ - home_url: "example.com", + domain: "example.com", + environment_type: "production", clone_instance_id: "ins_dev_123", }); expect(result).toEqual(responseBody); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 1c5ade15..8763515b 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -193,16 +193,19 @@ export type ListApplicationDomainsResponse = { }; export type ProductionInstanceResponse = { - instance_id: string; + id: string; + object: "instance"; environment_type: "production"; - active_domain: DomainSummary | null; + active_domain: ApplicationDomain | null; secret_key?: string; publishable_key: string; - cname_targets: CnameTarget[]; + created_at: number; + updated_at: number; }; export type CreateProductionInstanceParams = { - home_url: string; + domain: string; + environment_type: "production"; clone_instance_id?: string; }; From d1f7bf9e4690c270626d565eba5c93070fa8a894 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 27 May 2026 12:28:57 -0600 Subject: [PATCH 43/52] fix(deploy): update retry status copy --- packages/cli-core/src/commands/deploy/copy.test.ts | 2 +- packages/cli-core/src/commands/deploy/copy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index cd423289..1278c0f6 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -84,7 +84,7 @@ describe("deployComponentLabels", () => { describe("deployStatusRetryMessage", () => { test("includes current retry count and countdown", () => { expect(deployStatusRetryMessage("Verifying mail sender for example.com...", 2, 5, 30)).toBe( - "Verifying mail sender for example.com... (2/5 Retrying in 30s)", + "Verifying mail sender for example.com... 2/5 attempts, retrying in 30s", ); }); }); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index dbaf23f8..fd40c49d 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -175,7 +175,7 @@ export function deployStatusRetryMessage( totalRetries: number, seconds: number, ): string { - return `${message} (${currentRetry}/${totalRetries} Retrying in ${seconds}s)`; + return `${message} ${currentRetry}/${totalRetries} attempts, retrying in ${seconds}s`; } /** From fa5821c6e3cd334ed1b3e6eead74279a3e28b0c9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 27 May 2026 15:18:06 -0600 Subject: [PATCH 44/52] Update packages/cli-core/src/commands/deploy/copy.ts Co-authored-by: Rafael Thayto --- packages/cli-core/src/commands/deploy/copy.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index fd40c49d..258634e6 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -114,6 +114,8 @@ export function dnsDashboardHandoff(domain: string): string[] { return [ `Check the Domains section in the Clerk Dashboard for ${domain} to monitor DNS propagation and SSL issuance.`, "After OAuth setup, you can verify DNS or skip and finish. DNS propagation can take time.", + "After OAuth setup, you can verify DNS or skip and finish. DNS propagation can take time.", + "https://dashboard.clerk.com/last-active?path=domains" ]; } From a14bd0950de80dfe7fce78213c0283158fafd9c7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 28 May 2026 08:46:22 -0600 Subject: [PATCH 45/52] Revert "Update packages/cli-core/src/commands/deploy/copy.ts" This reverts commit fa5821c6e3cd334ed1b3e6eead74279a3e28b0c9. --- packages/cli-core/src/commands/deploy/copy.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 258634e6..fd40c49d 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -114,8 +114,6 @@ export function dnsDashboardHandoff(domain: string): string[] { return [ `Check the Domains section in the Clerk Dashboard for ${domain} to monitor DNS propagation and SSL issuance.`, "After OAuth setup, you can verify DNS or skip and finish. DNS propagation can take time.", - "After OAuth setup, you can verify DNS or skip and finish. DNS propagation can take time.", - "https://dashboard.clerk.com/last-active?path=domains" ]; } From 33e1b78451caf23d00ca75f594dc735a2827b14a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 28 May 2026 08:50:58 -0600 Subject: [PATCH 46/52] fix(review): address deploy PR feedback - give each deploy status component its own retry budget - normalize absent domain status components as pending - load resume domain and OAuth provider state concurrently - collapse repeated OAuth provider override lookups --- .../src/commands/deploy/index.test.ts | 87 +++++++++++++++++-- .../cli-core/src/commands/deploy/index.ts | 23 ++--- .../cli-core/src/commands/deploy/providers.ts | 33 ++++--- packages/cli-core/src/lib/plapi.ts | 6 +- 4 files changed, 118 insertions(+), 31 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 576fb3d9..8b87e391 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -90,15 +90,19 @@ function domainStatus({ mail, }: { status: "complete" | "incomplete"; - dns: boolean; - ssl: boolean; - mail: boolean; + dns?: boolean; + ssl?: boolean; + mail?: boolean; }) { return { status, - dns: { status: dns ? "complete" : "not_started", cnames: {} }, - ssl: { status: ssl ? "complete" : "not_started", required: true, failure_hints: [] }, - mail: { status: mail ? "complete" : "not_started", required: true }, + ...(dns === undefined ? {} : { dns: { status: dns ? "complete" : "not_started", cnames: {} } }), + ...(ssl === undefined + ? {} + : { ssl: { status: ssl ? "complete" : "not_started", required: true, failure_hints: [] } }), + ...(mail === undefined + ? {} + : { mail: { status: mail ? "complete" : "not_started", required: true } }), }; } @@ -747,6 +751,54 @@ describe("deploy", () => { expect(dnsIdx).toBeLessThan(sslIdx); }); + test("DNS verification gives each component its own retry budget", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true) // Create production instance? + .mockResolvedValueOnce(false); // Export BIND zone file? + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: true }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("Mail sender verified"); + expect(err).toContain("DNS verified for example.com"); + expect(err).toContain("SSL certificate issued for example.com"); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(8); + }); + test("DNS verification pauses when status stays incomplete despite all exposed booleans true (proxy_ok case)", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, @@ -1435,6 +1487,29 @@ describe("deploy", () => { expect(err).not.toContain("DNS, SSL, mail still pending"); }); + test("DNS verification treats absent components as pending", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", ssl: true, mail: true }), + ); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("DNS: pending SSL: ✓ Mail: ✓"); + expect(err).toContain("DNS still pending for example.com"); + expect(err).not.toContain("Domain Verified"); + }); + test("DNS verification timeout does not reprint DNS records when only SSL remains pending", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 24a5871d..d97a7037 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -373,11 +373,13 @@ async function resolveLiveDeploySnapshot( const productionInstanceId = ctx.productionInstanceId; if (!productionInstanceId) return undefined; - const domain = await loadProductionDomain(ctx); + const [domain, oauth] = await Promise.all([ + loadProductionDomain(ctx), + loadDevelopmentOAuthProviders(ctx), + ]); if (!domain) return undefined; - const { descriptors: oauthProviderDescriptors, unsupported } = - await loadDevelopmentOAuthProviders(ctx); + const { descriptors: oauthProviderDescriptors, unsupported } = oauth; const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); const { productionConfig, deployStatus } = await loadProductionState( ctx, @@ -649,10 +651,9 @@ async function pollDeployStatus( await triggerDeployStatusCheck(appId, domainIdOrName); let response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); let status = deployComponentStatusFromDomainStatus(response); - let retriesRemaining = DEPLOY_STATUS_MAX_RETRIES; - let nextRetryDelay = DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS; - for (const component of DEPLOY_COMPONENT_ORDER) { + let retriesRemaining = DEPLOY_STATUS_MAX_RETRIES; + let nextRetryDelay = DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS; const labels = deployComponentLabels(component, domain); const flipped = await withSpinner(labels.progress, async (spinner) => { if (status[component]) return true; @@ -716,14 +717,16 @@ function deployComponentStatusFromDomainStatus( response: DomainStatusResponse, ): DeployComponentStatus { return { - dns: response.dns?.status === "complete", - ssl: response.ssl ? checkStatusComplete(response.ssl) : false, + dns: checkStatusComplete(response.dns), + ssl: checkStatusComplete(response.ssl), mail: checkStatusComplete(response.mail), }; } -function checkStatusComplete(check: { status: string; required: boolean } | undefined): boolean { - return !check?.required || check.status === "complete"; +function checkStatusComplete(check: { status: string; required?: boolean } | undefined): boolean { + if (!check) return false; + if (check.required === false) return true; + return check.status === "complete"; } async function offerBindZoneExport( diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 5b7fde80..ecf55888 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -370,34 +370,45 @@ export function providerLabel(provider: string): string { * Prompt fields for existing deploy callers, falling back to standard OAuth credentials. */ export function providerFields(provider: OAuthProvider): OAuthField[] { - if (hasProviderOverride(provider)) return PROVIDER_FIELDS[provider]; - return [ + return fromOverride(provider, PROVIDER_FIELDS, [ { key: "client_id", label: "Client ID" }, { key: "client_secret", label: "Client Secret", secret: true }, - ]; + ]); } /** * Credential action label for existing deploy callers. */ export function providerCredentialLabel(provider: OAuthProvider): string { - if (hasProviderOverride(provider)) return PROVIDER_CREDENTIAL_LABELS[provider]; - return "I already have my Client ID and Client Secret"; + return fromOverride( + provider, + PROVIDER_CREDENTIAL_LABELS, + "I already have my Client ID and Client Secret", + ); } function providerRedirectLabel(provider: OAuthProvider): string { - if (hasProviderOverride(provider)) return PROVIDER_REDIRECT_LABELS[provider]; - return "Redirect URI"; + return fromOverride(provider, PROVIDER_REDIRECT_LABELS, "Redirect URI"); } function providerSetupCopy(provider: OAuthProvider): string { - if (hasProviderOverride(provider)) return PROVIDER_SETUP_COPY[provider]; - return `Production ${providerLabel(provider)} sign-in requires custom OAuth credentials.`; + return fromOverride( + provider, + PROVIDER_SETUP_COPY, + `Production ${providerLabel(provider)} sign-in requires custom OAuth credentials.`, + ); } function providerGotcha(provider: OAuthProvider): string | null { - if (hasProviderOverride(provider)) return PROVIDER_GOTCHAS[provider]; - return null; + return fromOverride(provider, PROVIDER_GOTCHAS, null); +} + +function fromOverride( + provider: OAuthProvider, + map: Record, + fallback: T, +): T { + return hasProviderOverride(provider) ? map[provider] : fallback; } function hasProviderOverride(provider: string): provider is ProviderWithOverride { diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 8763515b..53f78ba2 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -213,14 +213,12 @@ export type DeployStatus = "complete" | "incomplete"; type DomainCheckStatus = { status: string; - required: boolean; + required?: boolean; }; export type DomainStatusResponse = { status: DeployStatus; - dns?: { - status: string; - }; + dns?: DomainCheckStatus; ssl?: DomainCheckStatus; mail?: DomainCheckStatus; proxy?: DomainCheckStatus; From c6478937908dedff692db8be1db31d94400f345a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 28 May 2026 09:40:19 -0600 Subject: [PATCH 47/52] fix(deploy): link domain settings in next steps Add a direct Clerk Dashboard domains URL to deploy completion output so users can manage their production domain configuration. --- packages/cli-core/src/commands/deploy/copy.test.ts | 10 ++++++++++ packages/cli-core/src/commands/deploy/copy.ts | 7 ++++++- packages/cli-core/src/commands/deploy/index.test.ts | 7 +++++-- packages/cli-core/src/commands/deploy/index.ts | 10 ++++++++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 1278c0f6..b669f1a5 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -3,6 +3,7 @@ import { bindZoneFile, deployComponentLabels, deployStatusRetryMessage, + nextStepsBlock, pendingDnsRecords, } from "./copy.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; @@ -89,6 +90,15 @@ describe("deployStatusRetryMessage", () => { }); }); +describe("nextStepsBlock", () => { + test("links directly to the production instance domain settings", () => { + const output = nextStepsBlock("app_123", "ins_456"); + + expect(output).toContain("View and manage domain configuration in the Clerk Dashboard"); + expect(output).toContain("https://dashboard.clerk.com/apps/app_123/instances/ins_456/domains"); + }); +}); + describe("pendingDnsRecords", () => { const targets: CnameTarget[] = [ { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index fd40c49d..476f2dcb 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -222,7 +222,8 @@ export function productionSummary( ]; } -export const NEXT_STEPS_BLOCK = `${bold("Next steps")} +export function nextStepsBlock(appId: string, productionInstanceId: string): string { + return `${bold("Next steps")} 1. Pull production keys into your environment clerk env pull --instance prod @@ -242,10 +243,14 @@ export const NEXT_STEPS_BLOCK = `${bold("Next steps")} 5. (If applicable) Update your Content Security Policy ${dim("https://clerk.com/docs/guides/secure/best-practices/csp-headers")} + 6. View and manage domain configuration in the Clerk Dashboard + ${dim(`https://dashboard.clerk.com/apps/${appId}/instances/${productionInstanceId}/domains`)} + ${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} diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 8b87e391..a2257918 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -915,6 +915,9 @@ describe("deploy", () => { 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( + "https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", + ); expect(err).toContain("Production keys only work on your production domain"); }); @@ -1218,10 +1221,10 @@ describe("deploy", () => { test("existing production warns generically when an enabled provider schema is not usable", async () => { await linkedProject({ - instances: { development: "ins_dev_123", production: "ins_prod_discord" }, + instances: { development: "ins_dev_123", production: "ins_prod_unsupported" }, }); mockLiveProduction({ - instanceId: "ins_prod_discord", + instanceId: "ins_prod_unsupported", developmentConfig: { connection_oauth_discord: { enabled: true }, }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index d97a7037..5cf2056a 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -27,7 +27,6 @@ import { } from "../../lib/plapi.ts"; import { INTRO_PREAMBLE, - NEXT_STEPS_BLOCK, OAUTH_SECTION_INTRO, type DeployComponentStatus, type DeployPlanStep, @@ -41,6 +40,7 @@ import { dnsDashboardHandoff, dnsIntro, dnsRecords, + nextStepsBlock, pendingDnsRecords, printPlan, productionSummary, @@ -863,6 +863,12 @@ async function finishDeploy( log.info(line); } log.blank(); - log.info(NEXT_STEPS_BLOCK); + const productionInstanceId = ctx.productionInstanceId ?? ctx.profile.instances.production; + if (!productionInstanceId) { + throwUsageError( + "Cannot print deploy next steps because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.", + ); + } + log.info(nextStepsBlock(ctx.appId, productionInstanceId)); outro("Success"); } From 688f965b81b03ea82397cc9cac105e30e8e816d9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 29 May 2026 07:35:45 -0600 Subject: [PATCH 48/52] feat(deploy): add read-only deploy verification (#312) * refactor(deploy): extract poll core into status.ts * refactor(deploy): move state resolution into status.ts, rename dnsComplete * feat(deploy): add resolveDeployState discriminator * feat(deploy): add buildDeployStatusReport payload builder * feat(deploy): add clerk deploy check command * feat(deploy): tailor agent-mode deploy into a read-only handoff * feat(deploy): register clerk deploy check subcommand * docs(deploy): document deploy check and agent handoff * fix(deploy): surface agent status read failures * docs(clerk-cli): document deploy agent workflow * fix(deploy): avoid backoff in agent check * fix(deploy): check domain status as one DNS verification * fix(deploy): include domains URL in agent next action * fix(deploy): prompt agents to open domains URL * docs(clerk-cli): warn deploy wizard needs a terminal * fix(cli): address deploy review follow-ups * fix(deploy): persist live production instance metadata * chore: added link for codex * docs(clerk-cli): clarify deploy check agent workflow * refactor(deploy): remove unused status check helper * test(deploy): avoid leaking deploy check mocks * docs(testing): document bun test isolation * docs: remove hidden bird command from readme * feat(deploy): rename status check command * fix(deploy): humanize status dashboard guidance * refactor(deploy): tighten status resolution --- .agents/skills/audit-clerk-skill | 1 + .agents/skills/changesets | 1 + .changeset/deploy-status.md | 5 + .claude/rules/testing.md | 2 + AGENTS.md | 1 + CLAUDE.md | 2 + README.md | 2 +- packages/cli-core/src/cli-program.test.ts | 9 + packages/cli-core/src/cli-program.ts | 11 +- .../cli-core/src/commands/auth/login.test.ts | 41 ++ packages/cli-core/src/commands/auth/login.ts | 1 - .../cli-core/src/commands/deploy/README.md | 79 ++- .../cli-core/src/commands/deploy/copy.test.ts | 25 +- packages/cli-core/src/commands/deploy/copy.ts | 33 +- .../src/commands/deploy/index.test.ts | 219 ++++--- .../cli-core/src/commands/deploy/index.ts | 336 ++--------- .../cli-core/src/commands/deploy/prompts.ts | 3 +- .../commands/deploy/status-command.test.ts | 309 ++++++++++ .../src/commands/deploy/status-command.ts | 125 ++++ .../src/commands/deploy/status.test.ts | 325 +++++++++++ .../cli-core/src/commands/deploy/status.ts | 540 ++++++++++++++++++ .../src/test/integration/completion.test.ts | 4 + skills/clerk-cli/SKILL.md | 14 +- skills/clerk-cli/references/agent-mode.md | 98 +++- 24 files changed, 1740 insertions(+), 446 deletions(-) create mode 120000 .agents/skills/audit-clerk-skill create mode 120000 .agents/skills/changesets create mode 100644 .changeset/deploy-status.md create mode 120000 AGENTS.md create mode 100644 packages/cli-core/src/commands/deploy/status-command.test.ts create mode 100644 packages/cli-core/src/commands/deploy/status-command.ts create mode 100644 packages/cli-core/src/commands/deploy/status.test.ts create mode 100644 packages/cli-core/src/commands/deploy/status.ts diff --git a/.agents/skills/audit-clerk-skill b/.agents/skills/audit-clerk-skill new file mode 120000 index 00000000..5a0f5935 --- /dev/null +++ b/.agents/skills/audit-clerk-skill @@ -0,0 +1 @@ +../../.claude/skills/audit-clerk-skill \ No newline at end of file diff --git a/.agents/skills/changesets b/.agents/skills/changesets new file mode 120000 index 00000000..5582b9f2 --- /dev/null +++ b/.agents/skills/changesets @@ -0,0 +1 @@ +../../.claude/skills/changesets \ No newline at end of file diff --git a/.changeset/deploy-status.md b/.changeset/deploy-status.md new file mode 100644 index 00000000..59abf6da --- /dev/null +++ b/.changeset/deploy-status.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk deploy status`, a read-only command that verifies a production deploy, including DNS, SSL, email DNS, and OAuth credential completeness. Agent-mode `clerk deploy` now emits a tailored read-only handoff instead of a hard usage error. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 0817b834..60bd3ef2 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -24,6 +24,8 @@ bun run test This runs each unit and integration test file as a separate `bun test` subprocess via `scripts/run-tests.ts`, isolating module state between files. E2E fixtures are excluded and require separate setup (see `rules/e2e.md`). +When running multiple test files directly with `bun test`, always pass `--isolate` or `--parallel`. `--parallel` implies `--isolate`. Without isolation, Bun can share module mocks across files and produce order-dependent failures. + Prefer `spyOn()` for mocking, and always restore spies in `afterAll` with `mockRestore()`. Never use `for` or `forEach` loops inside a single test to verify multiple inputs or cases — use `test.each` (or `it.each` / `describe.each`) so each case is its own reported test case with its own name, setup/teardown, and pinpointed failure output. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a4eef392..de46450c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,8 @@ Locally, prefer `bun run test:e2e:op` so secrets are injected from 1Password in- CI runs `bun run format:check` (fails if unformatted), `bun run lint`, `bun test`, and `bun run test:e2e` on every PR to `main`. E2E tests only run for PRs from the same repository (not external forks) and target the production Clerk API with a dedicated test application. +When running multiple test files directly with `bun test`, always pass `--isolate` or `--parallel`. `--parallel` implies `--isolate`. Without isolation, Bun can share module mocks across files and produce order-dependent failures. Prefer `bun run test` for the full suite because it already isolates test files through `scripts/run-tests.ts`. + ## Versioning The `CLI_VERSION` global is injected at compile time via `bun build --compile --define "CLI_VERSION=..."`. Local `build:compile` omits it, so the binary reports `0.0.0-dev`. The CI release workflow injects the real version. diff --git a/README.md b/README.md index 564efe72..2ee89064 100644 --- a/README.md +++ b/README.md @@ -50,7 +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 + deploy 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 bdca753a..540fbed9 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -53,6 +53,15 @@ test("deploy relies on global options", () => { expect(optionNames).toEqual([]); }); +test("deploy status exposes wait option", () => { + const program = createProgram(); + const deploy = program.commands.find((command) => command.name() === "deploy")!; + const status = deploy.commands.find((command) => command.name() === "status")!; + const optionNames = status.options.map((option) => option.long); + + expect(optionNames).toContain("--wait"); +}); + 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 92c5203e..c4996d1e 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -46,6 +46,7 @@ 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 { deployStatus } from "./commands/deploy/status-command.ts"; import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; import { billingEnable, billingDisable } from "./commands/billing/index.ts"; @@ -926,7 +927,15 @@ Tutorial — enable completions for your shell: ]) .action(update); - program.command("deploy").description("Deploy a Clerk application to production").action(deploy); + const deployCmd = program + .command("deploy") + .description("Deploy a Clerk application to production"); + deployCmd.command("run", { isDefault: true, hidden: true }).action(deploy); + deployCmd + .command("status") + .description("Show production deploy status (read-only)") + .option("--wait", "Wait for DNS, SSL, and email DNS verification with retries") + .action(deployStatus); registerExtras(program); diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 65c4c73f..89c368e2 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -89,6 +89,7 @@ mock.module("../../lib/autoclaim.ts", () => ({ attemptAutoclaim: async () => ({ status: "not_keyless" }), })); +const { setLogLevel } = await import("../../lib/log.ts"); const { login } = await import("./login.ts"); describe("login", () => { @@ -114,6 +115,7 @@ describe("login", () => { mockEnsureFirstApplication.mockResolvedValue(undefined); mockIsHuman.mockReturnValue(false); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); + setLogLevel("info"); consoleSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); try { @@ -592,6 +594,45 @@ describe("login", () => { expect(parsed.searchParams.get("clerk_client")).toBe("cli"); }); + test("does not emit the OAuth authorize URL through debug logging", async () => { + setLogLevel("debug"); + mockGetValidToken.mockResolvedValue(null); + mockBunSpawn(); + + const mockServer = { + port: 54321, + waitForCallback: mock().mockResolvedValue({ code: "fresh-auth-code" }), + stop: mock(), + }; + mockStartAuthServer.mockReturnValue(mockServer); + + mockExchangeCodeForToken.mockResolvedValue({ + access_token: "new-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "new-refresh-token", + }); + mockCreateOAuthSession.mockReturnValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: 123, + tokenType: "Bearer", + }); + mockStoreToken.mockResolvedValue(undefined); + mockFetchUserInfo.mockResolvedValue({ + userId: "user_new", + email: "new@example.com", + }); + mockSetAuth.mockResolvedValue(undefined); + + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + await runLogin({ showNextSteps: false }); + + expect(captured.err).not.toContain("https://test.example.com/oauth/authorize"); + expect(captured.err).not.toContain("test-state-value"); + expect(captured.err).not.toContain("test-code-challenge"); + }); + test("calls ensureFirstApplication after a successful OAuth flow", async () => { mockGetValidToken.mockResolvedValue(null); mockBunSpawn(); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 92e6673b..c2c3eab6 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -57,7 +57,6 @@ async function performOAuthFlow(): Promise { // Critical fallback: the OAuth callback can't complete unless the user // reaches the authorize URL somehow. const urlString = authorizeUrl.toString(); - log.debug(`Opening browser to URL: ${urlString}`); const result = await openBrowser(urlString); if (!result.ok) { log.warn( diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 1457def8..bcaf8baf 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -6,14 +6,16 @@ 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 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. On new deploys, this handoff happens before OAuth setup so DNS propagation can start while the user configures providers. -After all OAuth providers are configured, DNS verification 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 10-second cap. If polling times out, the retry handoff lists only DNS records tied to still-pending DNS-backed checks. For example, when DNS and mail are verified but SSL is still pending, the CLI shows green checks for the verified components and does not reprint already verified records. +After all OAuth providers are configured, DNS verification checks DNS, SSL, and email DNS from the same domain status response. The wizard uses one shared exponential-backoff loop instead of waiting for each component separately. If polling times out, the retry handoff lists only DNS records tied to still-pending DNS-backed checks. For example, when DNS and email DNS are verified but SSL is still pending, the CLI shows green checks for the verified components and does not reprint already verified records. ## Usage ```sh clerk deploy # Interactive, idempotent wizard (human mode) clerk deploy --verbose # With debug output -clerk deploy --mode agent # Exit with human-mode-required guidance +clerk deploy --mode agent # Emit a read-only handoff for agents +clerk deploy status # Verify deploy completion without prompts +clerk deploy status --mode agent --wait # Agent verification with retrying wait ``` ## Global Options @@ -24,7 +26,40 @@ clerk deploy --mode agent # Exit with human-mode-required guidance ## Agent Mode -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. +When running `clerk deploy` in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), the command emits a structured JSON handoff via `log.data` on stdout. The handoff is read-only: it resolves the linked app, production instance, domain, domain status snapshot, and OAuth completeness, but it does not prompt, mutate config, trigger a DNS check, or poll. + +The handoff state is one of: + +| State | Meaning | +| --------------------- | ----------------------------------------------------------------------------------------------- | +| `not_started` | No production instance exists. Ask the human to run `clerk deploy`, then verify afterward. | +| `domain_provisioning` | A production instance exists, but PLAPI has not returned a production domain yet. | +| `domain_pending` | A production domain exists, but DNS, SSL, email DNS, or final server-side readiness is pending. | +| `oauth_pending` | The domain is verified, but supported OAuth providers still need production credentials. | +| `complete` | Production is deployed and verified. | + +Agent-mode `clerk deploy` exits successfully for linked projects because it is informational. The pass/fail gate is `clerk deploy status`. + +### `clerk deploy status` + +`clerk deploy status` is the read-only verification command for agents and automation. It resolves the same live deploy state, triggers a DNS check for active production domains, and reports DNS, SSL, email DNS, and OAuth completeness. Human mode waits with the shared exponential-backoff poll loop; agent mode performs one quick DNS check and returns the resulting status immediately by default. Pass `--wait` in agent mode to wait with the same poll loop: one immediate status read, then up to 5 retries with exponential backoff. + +In agent mode, `clerk deploy status` emits JSON on stdout with: + +- `complete`: `true` only when the domain is verified and all supported OAuth providers enabled in development have production credentials. +- `state`: `complete`, `domain_pending`, `oauth_pending`, `domain_provisioning`, or `not_started`. +- `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. +- `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks. +- `oauth`: configured, pending, and unsupported provider slugs. +- `nextAction`: the next step an agent should present to the user, including the Clerk Dashboard domains URL when a production instance exists. Agents should ask whether to open that URL for the user. + +Exit codes: + +| Exit | Meaning | +| ---- | --------------------------------------------------------------------------------------------- | +| `0` | Deploy is complete and verified. | +| `1` | The check ran successfully, but deploy is incomplete. Inspect `state` and `nextAction`. | +| else | A real CLI error occurred, such as not linked or an API failure, via the standard error path. | Agent mode is detected via the mode system (`src/mode.ts`), which checks in priority order: @@ -32,22 +67,23 @@ Agent mode is detected via the mode system (`src/mode.ts`), which checks in prio 2. `CLERK_MODE` environment variable 3. TTY detection (`process.stdout.isTTY`) -Agent mode does not call PLAPI and exits before the human-mode wizard starts. +The human-mode wizard still starts only in human mode. ## PLAPI Lifecycle Human mode reads and writes deploy state through the Platform API on every run. The CLI does not persist deploy progress locally; the only profile write is the ordinary `instances.production` value once the production instance has been created. -| Step | Endpoint | Behavior | -| -------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Create production instance | `POST /v1/platform/applications/{appID}/instances` | Returns `id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and timestamps. DNS records are read from `active_domain.cname_targets[]`. | -| | | Performs clone feature compatibility validation before creating production resources. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | -| Poll domain status | `GET /v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | Returns aggregate `status` plus nested DNS, SSL, mail, and proxy component status. The CLI drives three sequential per-component spinners (mail, DNS, SSL) that all share the same 10-second cap; each spinner emits a success line as its component flips complete. The aggregate `status` guards proxy and other server-side readiness gates. It performs one immediate status read, then polls every 3s. | -| 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 | +| -------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create production instance | `POST /v1/platform/applications/{appID}/instances` | Returns `id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and timestamps. DNS records are read from `active_domain.cname_targets[]`. | +| | | Performs clone feature compatibility validation before creating production resources. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | +| Trigger domain DNS check | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | Starts a server-side DNS check. `clerk deploy status` triggers this before resolving the live state. Agent-mode `clerk deploy status` returns after the post-trigger status snapshot by default; `--wait` polls active production domains with the shared retry loop. A 409 conflict means a check is already running. | +| Poll domain status | `GET /v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | Returns aggregate `status` plus nested DNS, SSL, email DNS, and proxy component status. The CLI drives one shared DNS verification loop over the full status response. The aggregate `status` guards proxy and other server-side readiness gates. It performs one immediate status read, then polls every 3s. | +| 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 domain status polling times out with SSL or mail still incomplete, the CLI surfaces the component status and instructs the user to rerun `clerk deploy` once DNS propagates. +PLAPI errors are translated to typed `CliError`s by `commands/deploy/errors.ts`. The CLI does not auto-retry SSL issuance or email DNS verification beyond the shared DNS verification loop. When domain status polling times out with SSL or email DNS still incomplete, 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. @@ -102,16 +138,17 @@ 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. | -| Read instance config schema | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config/schema` | `fetchInstanceConfigSchema`. Reads schemas for supported OAuth config keys so deploy can derive credential prompts. | -| 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. | -| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and DNS records nested under `active_domain.cname_targets[]`. | -| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the mail, DNS, and SSL sequential spinners in `pollDeployStatus`; each spinner resolves independently within the shared 10-second cap. | +| 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. | +| Read instance config schema | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config/schema` | `fetchInstanceConfigSchema`. Reads schemas for supported OAuth config keys so deploy can derive credential prompts. | +| 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. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and DNS records nested under `active_domain.cname_targets[]`. | +| Trigger domain DNS check | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | `triggerApplicationDomainDNSCheck`. Called by the wizard and by `clerk deploy status`; agent mode waits briefly, then reads one status snapshot. | +| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the wizard spinner and the human-mode `clerk deploy status` wait loop over the full domain status response. | ## OAuth Provider Config Format diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index b669f1a5..64d061f0 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -3,6 +3,7 @@ import { bindZoneFile, deployComponentLabels, deployStatusRetryMessage, + dnsRecords, nextStepsBlock, pendingDnsRecords, } from "./copy.ts"; @@ -60,10 +61,10 @@ describe("bindZoneFile", () => { }); describe("deployComponentLabels", () => { - test("returns mail in-progress and done labels", () => { + test("returns email DNS in-progress and done labels", () => { expect(deployComponentLabels("mail", "example.com")).toEqual({ - progress: "Verifying mail sender for example.com...", - done: "Mail sender verified", + progress: "Verifying email DNS records for example.com...", + done: "Email DNS records verified", }); }); @@ -84,8 +85,8 @@ describe("deployComponentLabels", () => { describe("deployStatusRetryMessage", () => { test("includes current retry count and countdown", () => { - expect(deployStatusRetryMessage("Verifying mail sender for example.com...", 2, 5, 30)).toBe( - "Verifying mail sender for example.com... 2/5 attempts, retrying in 30s", + expect(deployStatusRetryMessage("Verifying DNS records for example.com...", 2, 5, 30)).toBe( + "Verifying DNS records for example.com... 2/5 attempts, retrying in 30s", ); }); }); @@ -114,7 +115,7 @@ describe("pendingDnsRecords", () => { expect(pendingDnsRecords(targets, { dns: true, ssl: false, mail: true })).toEqual([]); }); - test("returns only email records when mail remains pending", () => { + test("returns only email records when email DNS remains pending", () => { const output = pendingDnsRecords(targets, { dns: true, ssl: true, mail: false }).join("\n"); expect(output).toContain("clkmail.example.com"); @@ -130,3 +131,15 @@ describe("pendingDnsRecords", () => { expect(output).not.toContain("clkmail.example.com"); }); }); + +describe("dnsRecords", () => { + test("labels DKIM CNAME records as email DNS records", () => { + const output = dnsRecords([ + { host: "clk._domainkey.example.com", value: "dkim1.clerk.services", required: true }, + { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: true }, + ]).join("\n"); + + expect(output).toContain("Email (Clerk handles SPF/DKIM automatically)"); + expect(output).not.toContain("\n CNAME\n Type:"); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 476f2dcb..3c77a474 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -84,7 +84,7 @@ export function pendingDnsRecords( return dnsRecords(pendingTargets); } -function cnameTargetPending(target: CnameTarget, status: DeployComponentStatus): boolean { +export function cnameTargetPending(target: CnameTarget, status: DeployComponentStatus): boolean { if (isMailCnameTarget(target)) return !status.mail; return !status.dns; } @@ -102,8 +102,8 @@ function cnameTargetLabel(host: string): string { case "accounts": return "Account portal"; case "clkmail": - case "clk._domainkey": - case "clk2._domainkey": + case "clk": + case "clk2": return "Email (Clerk handles SPF/DKIM automatically)"; default: return "CNAME"; @@ -129,12 +129,6 @@ export type DeployComponentStatus = { 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, @@ -142,8 +136,8 @@ export function deployComponentLabels( switch (component) { case "mail": return { - progress: `Verifying mail sender for ${domain}...`, - done: "Mail sender verified", + progress: `Verifying email DNS records for ${domain}...`, + done: "Email DNS records verified", }; case "dns": return { @@ -159,14 +153,13 @@ export function deployComponentLabels( } /** - * Status line for the three independent components Clerk verifies after - * the 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. + * Status line for the domain checks Clerk verifies after the production + * instance is created: DNS propagation, SSL issuance via Let's Encrypt, and + * email DNS records. Each value comes from the same domain status response. */ 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)}`; + return `DNS: ${mark(status.dns)} SSL: ${mark(status.ssl)} Email DNS: ${mark(status.mail)}`; } export function deployStatusRetryMessage( @@ -187,7 +180,7 @@ export function deployStatusPendingFooter(domain: string, status: DeployComponen const pending: string[] = []; if (!status.dns) pending.push("DNS"); if (!status.ssl) pending.push("SSL"); - if (!status.mail) pending.push("mail"); + if (!status.mail) pending.push("email DNS"); const lead = pending.length === 0 @@ -244,7 +237,7 @@ export function nextStepsBlock(appId: string, productionInstanceId: string): str ${dim("https://clerk.com/docs/guides/secure/best-practices/csp-headers")} 6. View and manage domain configuration in the Clerk Dashboard - ${dim(`https://dashboard.clerk.com/apps/${appId}/instances/${productionInstanceId}/domains`)} + ${dim(domainsDashboardUrl(appId, productionInstanceId))} ${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. @@ -252,6 +245,10 @@ ${yellow("NOTE")} Production keys only work on your production domain. They wil ${dim("Reference: https://clerk.com/docs/guides/development/deployment/production#api-keys-and-environment-variables")}`; } +export function domainsDashboardUrl(appId: string, productionInstanceId: string): string { + return `https://dashboard.clerk.com/apps/${appId}/instances/${productionInstanceId}/domains`; +} + export function pausedMessage(stepDescription: string): string { return `Deploy paused at: ${stepDescription} diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index a2257918..3f97738a 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -72,6 +72,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"); +const { collectCustomDomain } = await import("./prompts.ts"); function stripAnsi(value: string): string { return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); @@ -176,6 +177,7 @@ function schemaForEnabledOAuth(config: Record) { describe("deploy", () => { let consoleSpy: ReturnType; + let writeSpy: ReturnType; const captured = useCaptureLog(); let tempDir: string; @@ -273,6 +275,15 @@ describe("deploy", () => { mockTriggerApplicationDomainDNSCheck.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); + // Guard the real filesystem. When the BIND export prompt is accepted the + // deploy flow writes `clerk-.zone` to the cwd, which would otherwise + // leak an artifact into the repo on every run. Intercept only `.zone` writes + // so config writes (setProfile) still hit disk in the temp dir. + const realBunWrite = Bun.write.bind(Bun) as (...args: unknown[]) => Promise; + writeSpy = spyOn(Bun, "write").mockImplementation(((destination: unknown, ...rest: unknown[]) => + String(destination).endsWith(".zone") + ? Promise.resolve(0) + : realBunWrite(destination, ...rest)) as typeof Bun.write); }); afterEach(async () => { @@ -296,6 +307,7 @@ describe("deploy", () => { mockTriggerApplicationDomainDNSCheck.mockReset(); mockSleep.mockReset(); consoleSpy?.mockRestore(); + writeSpy?.mockRestore(); }); function runDeploy(options: Parameters[0] = {}) { @@ -446,24 +458,76 @@ describe("deploy", () => { }); describe("agent mode", () => { - test("exits with human mode guidance", async () => { + test("not_started emits JSON handoff telling human to run wizard", async () => { mockIsAgent.mockReturnValue(true); + await linkedProject(); - 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.", - }); + await runDeploy({}); - expect(captured.out).toBe(""); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("not_started"); + expect(payload.nextAction).toContain("clerk deploy"); + expect(payload.nextAction).toContain("clerk deploy status"); }); test("does not trigger interactive prompts", async () => { mockIsAgent.mockReturnValue(true); + await linkedProject(); + + await runDeploy(); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + }); + + test("complete deploy emits no-action handoff", async () => { + mockIsAgent.mockReturnValue(true); + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_mock" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_mock", + domain: "example.com", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); + + await runDeploy({}); + + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("complete"); + expect(payload.complete).toBe(true); + expect(captured.err).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); + expect(mockSleep).not.toHaveBeenCalled(); + expect(mockCreateProductionInstance).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("domain status read failures surface as errors", async () => { + mockIsAgent.mockReturnValue(true); + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_mock" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_mock", + domain: "example.com", + }); + mockGetApplicationDomainStatus.mockRejectedValue( + new PlapiError(500, JSON.stringify({ errors: [{ code: "server_error" }] }), "https://x"), + ); - await expect(runDeploy()).rejects.toBeInstanceOf(CliError); + await expect(runDeploy({})).rejects.toBeInstanceOf(PlapiError); + expect(captured.out).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); expect(mockSelect).not.toHaveBeenCalled(); expect(mockInput).not.toHaveBeenCalled(); expect(mockConfirm).not.toHaveBeenCalled(); @@ -711,7 +775,7 @@ describe("deploy", () => { expect(terminalOutput).not.toContain("Done"); }); - test("DNS verification emits per-component spinner labels in mail/dns/ssl order", async () => { + test("DNS verification checks all domain status components together", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm @@ -727,12 +791,6 @@ describe("deploy", () => { .mockResolvedValueOnce( domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), ) - .mockResolvedValueOnce( - domainStatus({ status: "incomplete", dns: false, ssl: false, mail: true }), - ) - .mockResolvedValueOnce( - domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), - ) .mockResolvedValueOnce( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); @@ -741,17 +799,12 @@ describe("deploy", () => { 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); + expect(err).toContain("DNS verified for example.com"); + expect(err).not.toContain("Mail sender verified"); + expect(err).not.toContain("SSL certificate issued for example.com"); }); - test("DNS verification gives each component its own retry budget", async () => { + test("DNS verification uses one shared retry budget for all domain status components", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm @@ -780,12 +833,6 @@ describe("deploy", () => { .mockResolvedValueOnce( domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), ) - .mockResolvedValueOnce( - domainStatus({ status: "incomplete", dns: false, ssl: false, mail: true }), - ) - .mockResolvedValueOnce( - domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), - ) .mockResolvedValueOnce( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); @@ -793,10 +840,8 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("Mail sender verified"); expect(err).toContain("DNS verified for example.com"); - expect(err).toContain("SSL certificate issued for example.com"); - expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(8); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(6); }); test("DNS verification pauses when status stays incomplete despite all exposed booleans true (proxy_ok case)", async () => { @@ -875,6 +920,12 @@ describe("deploy", () => { ); }); + test("trims the collected production domain before returning it", async () => { + mockInput.mockResolvedValueOnce(" example.com "); + + await expect(collectCustomDomain()).resolves.toBe("example.com"); + }); + test("Ctrl-C before changes are made reports cancelled instead of done", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -1380,24 +1431,21 @@ describe("deploy", () => { 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(); - } + await runDeploy({}); + const err = stripAnsi(captured.err); + + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + 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"); }); test("BIND export prompt writes no file when the user declines", async () => { @@ -1419,17 +1467,14 @@ describe("deploy", () => { mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - const writeSpy = spyOn(Bun, "write").mockResolvedValue(0); - try { - await runDeploy({}); - const err = stripAnsi(captured.err); + 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(); - } + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeUndefined(); + expect(err).not.toContain("Wrote "); }); test("BIND export prompt is skipped when cnameTargets is empty", async () => { @@ -1451,21 +1496,18 @@ describe("deploy", () => { mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - const writeSpy = spyOn(Bun, "write").mockResolvedValue(0); - try { - await runDeploy({}); + 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(); - } + // 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: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeUndefined(); }); test("DNS verification timeout names the specific pending components from domain status", async () => { @@ -1486,8 +1528,8 @@ describe("deploy", () => { 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"); + expect(err).toContain("SSL, email DNS still pending for example.com"); + expect(err).not.toContain("DNS, SSL, email DNS still pending"); }); test("DNS verification treats absent components as pending", async () => { @@ -1508,7 +1550,7 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("DNS: pending SSL: ✓ Mail: ✓"); + expect(err).toContain("DNS: pending SSL: ✓ Email DNS: ✓"); expect(err).toContain("DNS still pending for example.com"); expect(err).not.toContain("Domain Verified"); }); @@ -1535,7 +1577,7 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("DNS: ✓ SSL: pending Mail: ✓"); + expect(err).toContain("DNS: ✓ SSL: pending Email DNS: ✓"); expect(err).toContain("SSL still pending for example.com"); expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(1); }); @@ -1683,6 +1725,21 @@ describe("deploy", () => { expect(mockInput).not.toHaveBeenCalled(); }); + test("plain deploy persists production instance discovered from live API", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_live", + developmentConfig: {}, + productionConfig: {}, + }); + + await runDeploy({}); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_live"); + }); + test("custom-domain DNS setup can skip verification and later resume", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -1906,7 +1963,7 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); expect(err).toContain("DNS propagation can take several hours"); - expect(err).toContain("DNS, SSL, mail still pending for example.com"); + expect(err).toContain("DNS, SSL, email DNS 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"); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 5cf2056a..ba1a4332 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,39 +1,26 @@ import { isAgent } from "../../mode.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; -import { sleep } from "../../lib/sleep.ts"; -import { bar, intro, outro, withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; +import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; import { CliError, ERROR_CODE, - PlapiError, UserAbortError, isPromptExitError, throwUsageError, } from "../../lib/errors.ts"; -import { resolveProfile, setProfile } from "../../lib/config.ts"; +import { setProfile } from "../../lib/config.ts"; import { createProductionInstance as apiCreateProductionInstance, - fetchApplication, - fetchInstanceConfig, - fetchInstanceConfigSchema, - getApplicationDomainStatus, - listApplicationDomains, patchInstanceConfig, - triggerApplicationDomainDNSCheck, - type ApplicationDomain, type CnameTarget, - type DomainStatusResponse, type ProductionInstanceResponse, } from "../../lib/plapi.ts"; import { INTRO_PREAMBLE, OAUTH_SECTION_INTRO, - type DeployComponentStatus, type DeployPlanStep, - DEPLOY_COMPONENT_ORDER, deployComponentLabels, deployComponentStatus, - deployStatusRetryMessage, deployStatusPendingFooter, domainAssociationSummary, bindZoneFile, @@ -47,9 +34,6 @@ import { } from "./copy.ts"; import { mapDeployError } from "./errors.ts"; import { - OAUTH_KEY_PREFIX, - buildOAuthProviderDescriptors, - hasProviderRequiredCredentials, providerLabel, providerSetupIntro, showOAuthWalkthrough, @@ -72,18 +56,25 @@ import { type DeployContext, type DeployOperationState, } from "./state.ts"; - -const DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS = 3000; -const DEPLOY_STATUS_MAX_RETRIES = 5; -const DEPLOY_STATUS_BACKOFF_FACTOR = 2; +import { + buildDeployStatusReport, + loadDevelopmentOAuthProviders, + resolveDeployContext, + resolveDeployState, + resolveLiveApplicationContext, + resolveLiveDeploySnapshot, + waitForDeployStatus, + type DeployStatusOutcome, + type DiscoveredOAuthProviders, + type LiveDeploySnapshot, +} from "./status.ts"; type DeployOptions = Record; export async function deploy(_options: DeployOptions = {}) { if (isAgent()) { - throwUsageError( - "clerk deploy requires human mode because production configuration uses interactive prompts. Run `clerk deploy --mode human` from an interactive terminal.", - ); + await emitAgentDeployHandoff(); + return; } intro("clerk deploy"); @@ -108,48 +99,18 @@ export async function deploy(_options: DeployOptions = {}) { } } -async function resolveDeployContext(): Promise { - const resolved = await withSpinner("Resolving linked Clerk application...", () => - resolveProfile(process.cwd()), - ); - if (!resolved) { - return { - profileKey: process.cwd(), - profile: { - workspaceId: "", - appId: "", - instances: { development: "" }, - }, - appId: "", - appLabel: "", - developmentInstanceId: "", - }; +async function emitAgentDeployHandoff(): Promise { + const ctx = await resolveDeployContext(); + if (!ctx.appId || !ctx.developmentInstanceId) { + throw new CliError( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", + { code: ERROR_CODE.NOT_LINKED }, + ); } - return { - profileKey: resolved.path, - profile: resolved.profile, - ...(await withSpinner("Checking for production instance...", () => - resolveLiveApplicationContext(resolved.profile), - )), - }; -} - -async function resolveLiveApplicationContext(profile: DeployContext["profile"]): Promise<{ - appId: string; - appLabel: string; - developmentInstanceId: string; - productionInstanceId?: string; -}> { - const app = await fetchApplication(profile.appId); - const development = app.instances.find((entry) => entry.environment_type === "development"); - const production = app.instances.find((entry) => entry.environment_type === "production"); - return { - appId: app.application_id, - appLabel: app.name || profile.appName || app.application_id, - developmentInstanceId: development?.instance_id ?? profile.instances.development, - productionInstanceId: production?.instance_id, - }; + const state = await resolveDeployState(ctx); + const report = buildDeployStatusReport(state, null); + log.data(JSON.stringify(report, null, 2)); } async function runDeploy(ctx: DeployContext): Promise { @@ -169,7 +130,8 @@ async function runDeploy(ctx: DeployContext): Promise { } async function startNewDeploy(ctx: DeployContext): Promise { - const { descriptors: oauthProviders, unsupported } = await loadDevelopmentOAuthProviders(ctx); + const { descriptors: oauthProviders, unsupported }: DiscoveredOAuthProviders = + await loadDevelopmentOAuthProviders(ctx); log.blank(); log.info(INTRO_PREAMBLE); @@ -253,6 +215,10 @@ async function startNewDeploy(ctx: DeployContext): Promise { } async function reconcileExistingDeploy(ctx: DeployContext): Promise { + if (ctx.productionInstanceId && ctx.profile.instances.production !== ctx.productionInstanceId) { + await persistProductionInstance(ctx, ctx.productionInstanceId); + } + const snapshot = await resolveLiveDeploySnapshot(ctx); if (!snapshot) { log.blank(); @@ -276,7 +242,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { return; } - let dnsStatus: DnsVerificationResult = snapshot.dnsComplete ? "verified" : "pending"; + let dnsStatus: DnsVerificationResult = snapshot.domainComplete ? "verified" : "pending"; if ( snapshot.pending.type === "oauth" || @@ -303,35 +269,16 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { snapshot.completedOAuthProviders = completed; } - if (!snapshot.dnsComplete) { - const nextDnsStatus = await runExistingDomainDnsVerification(ctx, { + if (!snapshot.domainComplete) { + dnsStatus = await runExistingDomainDnsVerification(ctx, { ...snapshot, pending: { type: "dns" }, }); - dnsStatus = nextDnsStatus; } await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, dnsStatus); } -type LiveDeploySnapshot = Omit< - DeployOperationState, - "pending" | "oauthProviders" | "completedOAuthProviders" -> & { - pending?: DeployOperationState["pending"]; - oauthProviders: OAuthProvider[]; - oauthProviderDescriptors: OAuthProviderDescriptor[]; - completedOAuthProviders: OAuthProvider[]; - cnameTargets?: readonly CnameTarget[]; - dnsComplete: boolean; - unsupportedOAuthProviderCount: number; -}; - -type DiscoveredOAuthProviders = { - descriptors: OAuthProviderDescriptor[]; - unsupported: string[]; -}; - type DnsVerificationResult = "verified" | "pending"; function warnUnsupportedOAuthProviders(count: number): void { @@ -348,119 +295,6 @@ function warnUnsupportedOAuthProviders(count: number): void { log.blank(); } -async function loadDevelopmentOAuthProviders( - ctx: DeployContext, -): Promise { - return withSpinner("Reading development configuration...", async () => { - const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); - const providerSlugs = discoverEnabledOAuthProviderSlugs(config); - const schemaKeys = providerSlugs.map((provider) => `${OAUTH_KEY_PREFIX}${provider}`); - const schema = - schemaKeys.length > 0 - ? await fetchInstanceConfigSchema(ctx.appId, ctx.developmentInstanceId, schemaKeys) - : { properties: {} }; - const result = buildOAuthProviderDescriptors(providerSlugs, schema); - return { - descriptors: result.supported, - unsupported: result.unsupported, - }; - }); -} - -async function resolveLiveDeploySnapshot( - ctx: DeployContext, -): Promise { - const productionInstanceId = ctx.productionInstanceId; - if (!productionInstanceId) return undefined; - - const [domain, oauth] = await Promise.all([ - loadProductionDomain(ctx), - loadDevelopmentOAuthProviders(ctx), - ]); - if (!domain) return undefined; - - const { descriptors: oauthProviderDescriptors, unsupported } = oauth; - const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); - const { productionConfig, deployStatus } = await loadProductionState( - ctx, - productionInstanceId, - domain.id, - ); - const completedOAuthProviders = oauthProviderDescriptors - .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) - .map((descriptor) => descriptor.provider); - const pendingOAuthDescriptor = oauthProviderDescriptors.find( - (descriptor) => !completedOAuthProviders.includes(descriptor.provider), - ); - - const baseState = { - appId: ctx.appId, - developmentInstanceId: ctx.developmentInstanceId, - productionInstanceId, - productionDomainId: domain.id, - domain: domain.name, - oauthProviders, - oauthProviderDescriptors, - completedOAuthProviders, - cnameTargets: domain.cname_targets ?? [], - unsupportedOAuthProviderCount: unsupported.length, - }; - - const dnsComplete = deployStatus.status === "complete"; - const pending = pendingOAuthDescriptor - ? ({ type: "oauth", provider: pendingOAuthDescriptor.provider } as const) - : !dnsComplete - ? ({ type: "dns" } as const) - : undefined; - - return { ...baseState, dnsComplete, pending }; -} - -async function loadInitialDeployStatus( - appId: string, - domainIdOrName: string, -): Promise { - try { - return await getApplicationDomainStatus(appId, domainIdOrName); - } catch (error) { - log.debug( - `deploy: snapshot domain-status read failed, treating DNS as pending: ${error instanceof Error ? error.message : String(error)}`, - ); - return pendingDomainStatus(); - } -} - -async function loadProductionState( - ctx: DeployContext, - productionInstanceId: string, - domainIdOrName: string, -): Promise<{ - productionConfig: Record; - deployStatus: DomainStatusResponse; -}> { - return withSpinner("Reading production configuration...", async () => { - const [productionConfig, deployStatus] = await Promise.all([ - fetchInstanceConfig(ctx.appId, productionInstanceId), - loadInitialDeployStatus(ctx.appId, domainIdOrName), - ]); - return { productionConfig, deployStatus }; - }); -} - -function pendingDomainStatus(): DomainStatusResponse { - return { - status: "incomplete", - dns: { status: "not_started" }, - ssl: { status: "not_started", required: true }, - mail: { status: "not_started", required: true }, - }; -} - -async function loadProductionDomain(ctx: DeployContext): Promise { - const domains = await listApplicationDomains(ctx.appId); - return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; -} - function buildNewDeployPlan(oauthProviders: readonly OAuthProviderDescriptor[]): DeployPlanStep[] { return [ { label: "Create production instance", status: "pending" }, @@ -488,21 +322,10 @@ function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { status, }; }), - { label: "Verify DNS records", status: snapshot.dnsComplete ? "done" : "pending" }, + { label: "Verify DNS records", status: snapshot.domainComplete ? "done" : "pending" }, ]; } -function discoverEnabledOAuthProviderSlugs(config: Record): string[] { - const providers: 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; - providers.push(key.slice(OAUTH_KEY_PREFIX.length)); - } - return providers; -} - async function createProductionInstance( ctx: DeployContext, domain: string, @@ -639,94 +462,15 @@ async function runDnsVerification( } } -type DeployStatusOutcome = - | { verified: true; status: DeployComponentStatus } - | { verified: false; status: DeployComponentStatus }; - async function pollDeployStatus( appId: string, domainIdOrName: string, domain: string, ): Promise { - await triggerDeployStatusCheck(appId, domainIdOrName); - let response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); - let status = deployComponentStatusFromDomainStatus(response); - for (const component of DEPLOY_COMPONENT_ORDER) { - let retriesRemaining = DEPLOY_STATUS_MAX_RETRIES; - let nextRetryDelay = DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS; - const labels = deployComponentLabels(component, domain); - const flipped = await withSpinner(labels.progress, async (spinner) => { - if (status[component]) return true; - while (retriesRemaining > 0) { - await sleepWithRetryCountdown( - labels.progress, - DEPLOY_STATUS_MAX_RETRIES - retriesRemaining + 1, - DEPLOY_STATUS_MAX_RETRIES, - nextRetryDelay, - spinner, - ); - retriesRemaining--; - nextRetryDelay *= DEPLOY_STATUS_BACKOFF_FACTOR; - response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); - status = deployComponentStatusFromDomainStatus(response); - 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 sleepWithRetryCountdown( - message: string, - currentRetry: number, - totalRetries: number, - delayMs: number, - spinner: SpinnerControls, -): Promise { - let remainingMs = delayMs; - while (remainingMs > 0) { - const tickMs = Math.min(1000, remainingMs); - spinner.update( - deployStatusRetryMessage(message, currentRetry, totalRetries, Math.ceil(remainingMs / 1000)), - ); - await sleep(tickMs); - remainingMs -= tickMs; - } -} - -async function triggerDeployStatusCheck(appId: string, domainIdOrName: string): Promise { - try { - await mapDeployError(triggerApplicationDomainDNSCheck(appId, domainIdOrName)); - } catch (error) { - if (error instanceof PlapiError && error.status === 409 && error.code === "conflict") { - log.debug("DNS check is already in flight; continuing to poll domain status."); - return; - } - throw error; - } -} - -function deployComponentStatusFromDomainStatus( - response: DomainStatusResponse, -): DeployComponentStatus { - return { - dns: checkStatusComplete(response.dns), - ssl: checkStatusComplete(response.ssl), - mail: checkStatusComplete(response.mail), - }; -} - -function checkStatusComplete(check: { status: string; required?: boolean } | undefined): boolean { - if (!check) return false; - if (check.required === false) return true; - return check.status === "complete"; + return waitForDeployStatus(appId, domainIdOrName, domain, { + runVerification: (progressLabel, work) => withSpinner(progressLabel, work), + onVerified: () => log.success(deployComponentLabels("dns", domain).done), + }); } async function offerBindZoneExport( diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index 0a69bc7b..3fee78f1 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -23,10 +23,11 @@ export async function confirmProceed(): Promise { } export async function collectCustomDomain(): Promise { - return input({ + const domain = await input({ message: "Production domain (e.g. example.com)", validate: (value) => validateDomain(value), }); + return domain.trim(); } export function validateDomain(value: string): true | string { diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts new file mode 100644 index 00000000..91f8df0b --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EXIT_CODE, PlapiError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockFetchApplication = mock(); +const mockListApplicationDomains = mock(); +const mockFetchInstanceConfig = mock(); +const mockFetchInstanceConfigSchema = mock(); +const mockGetApplicationDomainStatus = mock(); +const mockTriggerApplicationDomainDNSCheck = mock(); +const mockSleep = mock(); + +mock.module("../../lib/sleep.ts", () => ({ + sleep: (ms: number) => { + mockSleep(ms); + return Promise.resolve(); + }, +})); + +const { _setConfigDir, setProfile } = await import("../../lib/config.ts"); +const { setMode } = await import("../../mode.ts"); +const { deployStatus } = await import("./status-command.ts"); + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); +} + +function appWith(production: boolean) { + const instances = [{ instance_id: "ins_dev", environment_type: "development" }]; + if (production) instances.push({ instance_id: "ins_prod", environment_type: "production" }); + return { application_id: "app_1", name: "app", instances }; +} + +function completeDomainStatus() { + return { + status: "complete", + dns: { status: "complete" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function pendingDnsDomainStatus() { + return { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function pendingSslDomainStatus() { + return { + status: "incomplete", + dns: { status: "complete" }, + ssl: { status: "pending", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function mockDomain() { + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + 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, + }, + ], + }, + ], + total_count: 1, + }); +} + +function mockOAuthComplete() { + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" || instanceId === "production" + ? { connection_oauth_google: { enabled: true, client_id: "x", client_secret: "y" } } + : { connection_oauth_google: { enabled: true } }, + ); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { + connection_oauth_google: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + }, + }, + }, + }); +} + +describe("deploy status", () => { + const captured = useCaptureLog(); + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + let tempDir = ""; + let exitCodeBefore: typeof process.exitCode; + + beforeEach(async () => { + captured.clear(); + setMode("agent"); + exitCodeBefore = process.exitCode; + process.exitCode = undefined; + process.env.CLERK_PLATFORM_API_KEY = "ak_test"; + stubFetch((...args) => routePlapiFetch(...args)); + tempDir = await mkdtemp(join(tmpdir(), "clerk-status-test-")); + _setConfigDir(tempDir); + await setProfile(process.cwd(), { + workspaceId: "", + appId: "app_1", + appName: "app", + instances: { development: "ins_dev" }, + } as never); + }); + + afterEach(async () => { + _setConfigDir(undefined); + if (tempDir) await rm(tempDir, { recursive: true, force: true }); + process.exitCode = exitCodeBefore ?? EXIT_CODE.SUCCESS; + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + setMode("human"); + tempDir = ""; + mockFetchApplication.mockReset(); + mockListApplicationDomains.mockReset(); + mockFetchInstanceConfig.mockReset(); + mockFetchInstanceConfigSchema.mockReset(); + mockGetApplicationDomainStatus.mockReset(); + mockTriggerApplicationDomainDNSCheck.mockReset(); + mockSleep.mockReset(); + }); + + test("agent mode not_started emits JSON with state not_started and exit 1", async () => { + mockFetchApplication.mockResolvedValue(appWith(false)); + + await deployStatus(); + + expect(process.exitCode).toBe(EXIT_CODE.GENERAL); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("not_started"); + expect(payload.complete).toBe(false); + expect(captured.out).not.toContain("error"); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); + }); + + test("agent mode complete triggers DNS check and emits complete state", async () => { + process.exitCode = EXIT_CODE.GENERAL; + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(completeDomainStatus()); + + await deployStatus(); + + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(EXIT_CODE.SUCCESS); + const payload = JSON.parse(captured.out); + expect(payload).toMatchObject({ + complete: true, + state: "complete", + domain: "example.com", + }); + expect(payload.domainStatus).toEqual({ dns: "complete", ssl: "complete", mail: "complete" }); + }); + + test("human mode not_started prints a readable status block and no JSON stdout", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(false)); + + await deployStatus(); + + expect(captured.out).toBe(""); + expect(stripAnsi(captured.err)).toContain("clerk deploy"); + }); + + test("agent mode domain pending reports pending DNS records and exit 1", async () => { + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingDnsDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingDnsDomainStatus()); + + await deployStatus(); + + expect(process.exitCode).toBe(EXIT_CODE.GENERAL); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(1); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledTimes(1); + expect(mockTriggerApplicationDomainDNSCheck.mock.invocationCallOrder[0]).toBeLessThan( + mockGetApplicationDomainStatus.mock.invocationCallOrder[0]!, + ); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("domain_pending"); + expect(payload.complete).toBe(false); + expect(payload.domainStatus).toEqual({ dns: "pending", ssl: "complete", mail: "complete" }); + expect(payload.pendingDnsRecords).toContainEqual({ + type: "CNAME", + host: "clerk.example.com", + value: "frontend-api.clerk.services", + }); + }); + + test("agent mode status snapshot failures surface as errors", async () => { + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockGetApplicationDomainStatus.mockRejectedValue( + new PlapiError(500, JSON.stringify({ errors: [{ code: "server_error" }] }), "https://x"), + ); + + await expect(deployStatus()).rejects.toBeInstanceOf(PlapiError); + + expect(captured.out).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + }); + + test("human mode shows a spinner while waiting for the DNS check to process", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(completeDomainStatus()); + + await deployStatus(); + + expect(stripAnsi(captured.err)).toContain("Waiting for Clerk DNS check to process"); + expect(mockSleep).toHaveBeenCalledWith(2000); + }); + + test("human mode shows dashboard monitoring guidance without agent handoff copy", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingSslDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingSslDomainStatus()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain( + "SSL still provisioning for example.com. Re-run `clerk deploy status` in a few minutes, DNS propagation can take time. Visit the Clerk Dashboard domains page to monitor its status there: https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(output).not.toContain("Ask the user to visit"); + expect(output).not.toContain("offer to open it"); + }); +}); + +async function routePlapiFetch( + input: string | URL | Request, + init?: RequestInit, +): Promise { + const url = new URL(input.toString()); + const method = init?.method ?? "GET"; + const path = url.pathname; + const json = async (value: unknown) => { + const body = await value; + return new Response(JSON.stringify(body ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + if (method === "GET" && path === "/v1/platform/applications/app_1") { + return json(mockFetchApplication("app_1")); + } + if (method === "GET" && path === "/v1/platform/applications/app_1/domains") { + return json(mockListApplicationDomains("app_1")); + } + if (method === "GET" && path.endsWith("/config/schema")) { + const instanceId = path.split("/").at(-3)!; + return json( + mockFetchInstanceConfigSchema("app_1", instanceId, url.searchParams.getAll("keys")), + ); + } + if (method === "GET" && path.endsWith("/config")) { + const instanceId = path.split("/").at(-2)!; + return json(mockFetchInstanceConfig("app_1", instanceId)); + } + if (method === "POST" && path.endsWith("/dns_check")) { + const domainIdOrName = path.split("/").at(-2)!; + return json(mockTriggerApplicationDomainDNSCheck("app_1", domainIdOrName)); + } + if (method === "GET" && path.endsWith("/status")) { + const domainIdOrName = path.split("/").at(-2)!; + return json(mockGetApplicationDomainStatus("app_1", domainIdOrName)); + } + + return new Response("Not Found", { status: 404 }); +} diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts new file mode 100644 index 00000000..4c975634 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -0,0 +1,125 @@ +import { isAgent } from "../../mode.ts"; +import { CliError, ERROR_CODE, EXIT_CODE } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { sleep } from "../../lib/sleep.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { deployComponentLabels } from "./copy.ts"; +import { + buildDeployStatusReport, + loadProductionDomain, + resolveDeployContext, + resolveDeployState, + triggerDeployStatusCheck, + waitForDeployStatus, + type DeployState, + type DeployStatusOutcome, + type DeployStatusReport, +} from "./status.ts"; +import type { DeployContext } from "./state.ts"; + +type DeployStatusOptions = { + wait?: boolean; +}; + +const DEPLOY_STATUS_PREFLIGHT_DELAY_MS = 2000; + +export async function deployStatus(options: DeployStatusOptions = {}): Promise { + const ctx = await resolveDeployContext(); + if (!ctx.appId || !ctx.developmentInstanceId) { + throw new CliError( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy status`.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + const preflightTriggered = await runPreflightDeployStatusCheck(ctx); + const state = await resolveDeployState(ctx); + const shouldWait = options.wait === true || !isAgent(); + const outcome = + state.kind === "active" && shouldWait + ? await runWait(state, { triggerCheck: !preflightTriggered }) + : null; + const report = buildDeployStatusReport(state, outcome); + + emitReport(report); + process.exitCode = report.complete ? EXIT_CODE.SUCCESS : EXIT_CODE.GENERAL; +} + +async function runPreflightDeployStatusCheck(ctx: DeployContext): Promise { + if (!ctx.productionInstanceId) return false; + + const domain = await loadProductionDomain(ctx); + if (!domain) return false; + + const domainIdOrName = domain.id ?? domain.name; + await triggerDeployStatusCheck(ctx.appId, domainIdOrName); + await withSpinner("Waiting for Clerk DNS check to process...", () => + sleep(DEPLOY_STATUS_PREFLIGHT_DELAY_MS), + ); + return true; +} + +function runWait( + state: Extract, + options: { triggerCheck?: boolean } = {}, +): Promise { + const { snapshot } = state; + const domainIdOrName = snapshot.productionDomainId ?? snapshot.domain; + return waitForDeployStatus( + snapshot.appId, + domainIdOrName, + snapshot.domain, + { + runVerification: (progressLabel, work) => withSpinner(progressLabel, work), + onVerified: () => { + if (!isAgent()) log.success(deployComponentLabels("dns", snapshot.domain).done); + }, + }, + options, + ); +} + +function emitReport(report: DeployStatusReport): void { + if (isAgent()) { + log.data(JSON.stringify(report, null, 2)); + return; + } + renderHuman(report); +} + +function renderHuman(report: DeployStatusReport): void { + log.blank(); + if (report.domain) { + log.info(`Deploy status for \`${report.domain}\``); + } else { + log.info("Deploy status"); + } + + if (report.domainStatus) { + log.info( + ` Domain DNS: ${report.domainStatus.dns} SSL: ${report.domainStatus.ssl} Email DNS: ${report.domainStatus.mail}`, + ); + } + + const oauthStatus = report.oauth.complete + ? "complete" + : `pending: ${report.oauth.pending.join(", ") || "none"}`; + log.info(` OAuth ${oauthStatus}`); + + if (report.oauth.unsupported.length > 0) { + log.warn( + ` ${report.oauth.unsupported.length} OAuth provider(s) enabled in dev are not supported by automated deploy: ${report.oauth.unsupported.join(", ")}. Configure them from the Clerk Dashboard.`, + ); + } + + log.blank(); + log.info(formatHumanNextAction(report.nextAction)); + log.blank(); +} + +function formatHumanNextAction(nextAction: string): string { + return nextAction.replace( + /Ask the user to visit the Clerk Dashboard domains page, or offer to open it: (https:\/\/\S+)/, + "Visit the Clerk Dashboard domains page to monitor its status there: $1", + ); +} diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts new file mode 100644 index 00000000..62435c39 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -0,0 +1,325 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { PlapiError } from "../../lib/errors.ts"; +import type { LiveDeploySnapshot } from "./status.ts"; + +const mockFetchApplication = mock(); +const mockListApplicationDomains = mock(); +const mockFetchInstanceConfig = mock(); +const mockFetchInstanceConfigSchema = mock(); +const mockGetApplicationDomainStatus = mock(); +const mockTriggerApplicationDomainDNSCheck = mock(); + +mock.module("../../lib/plapi.ts", () => ({ + fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), + fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), + fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), + getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), + triggerApplicationDomainDNSCheck: (...args: unknown[]) => + mockTriggerApplicationDomainDNSCheck(...args), +})); + +const { buildDeployStatusReport, resolveDeployState, waitForDeployStatus } = + await import("./status.ts"); + +const ctx = { + profileKey: "/tmp/x", + profile: { + workspaceId: "", + appId: "app_1", + instances: { development: "ins_dev" }, + }, + appId: "app_1", + appLabel: "app_1", + developmentInstanceId: "ins_dev", +} as const; + +const completeStatus = { + status: "complete", + dns: { status: "complete" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, +}; + +const passthroughHandlers = { + runVerification: (_label: string, work: (controls: { update: () => void }) => Promise) => + work({ update: () => {} }), +}; + +beforeEach(() => { + mockFetchInstanceConfig.mockResolvedValue({}); + mockFetchInstanceConfigSchema.mockResolvedValue({ properties: {} }); +}); + +afterEach(() => { + mockFetchApplication.mockReset(); + mockListApplicationDomains.mockReset(); + mockFetchInstanceConfig.mockReset(); + mockFetchInstanceConfigSchema.mockReset(); + mockGetApplicationDomainStatus.mockReset(); + mockTriggerApplicationDomainDNSCheck.mockReset(); +}); + +describe("resolveDeployState", () => { + test("returns not_started when the application has no production instance", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [{ instance_id: "ins_dev", environment_type: "development" }], + }); + + const state = await resolveDeployState({ ...ctx }); + + expect(state.kind).toBe("not_started"); + }); + + test("returns domain_provisioning when production instance exists but has no domain", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ data: [], total_count: 0 }); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state).toEqual({ + kind: "domain_provisioning", + appId: "app_1", + productionInstanceId: "ins_prod", + }); + }); + + test("returns active with a snapshot when instance and domain exist", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + 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, + }, + ], + }, + ], + total_count: 1, + }); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { connection_oauth_google: { enabled: true, client_id: "id", client_secret: "secret" } } + : { connection_oauth_google: { enabled: true } }, + ); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { + connection_oauth_google: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + }, + }, + }, + }); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.domain).toBe("example.com"); + expect(state.snapshot.domainComplete).toBe(true); + expect(state.snapshot.oauthProviders).toEqual(["google"]); + expect(state.snapshot.completedOAuthProviders).toEqual(["google"]); + } + }); +}); + +describe("waitForDeployStatus", () => { + test("triggers a DNS check before polling and returns verified when complete", async () => { + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeStatus); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const outcome = await waitForDeployStatus("app_1", "dmn_1", "example.com", passthroughHandlers); + + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + expect(mockTriggerApplicationDomainDNSCheck.mock.invocationCallOrder[0]).toBeLessThan( + mockGetApplicationDomainStatus.mock.invocationCallOrder[0]!, + ); + expect(outcome).toEqual({ + verified: true, + status: { dns: true, ssl: true, mail: true }, + }); + }); + + test("continues polling when the DNS check is already in flight", async () => { + mockTriggerApplicationDomainDNSCheck.mockRejectedValue( + new PlapiError(409, JSON.stringify({ errors: [{ code: "conflict" }] }), "https://x"), + ); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const outcome = await waitForDeployStatus("app_1", "dmn_1", "example.com", passthroughHandlers); + + expect(outcome).toEqual({ + verified: true, + status: { dns: true, ssl: true, mail: true }, + }); + }); +}); + +describe("buildDeployStatusReport", () => { + const activeSnapshot = { + appId: "app_1", + developmentInstanceId: "ins_dev", + productionInstanceId: "ins_prod", + productionDomainId: "dmn_1", + domain: "example.com", + oauthProviders: ["google", "github"], + oauthProviderDescriptors: [], + completedOAuthProviders: ["google"], + cnameTargets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "clkmail.example.com", value: "mail.clerk.services", required: true }, + ], + domainComplete: false, + componentStatus: { dns: false, ssl: false, mail: false }, + unsupportedOAuthProviderCount: 0, + unsupportedOAuthProviders: [], + pending: { type: "oauth" as const, provider: "github" }, + } satisfies LiveDeploySnapshot; + + test("not_started reports incomplete with deploy next action", () => { + const report = buildDeployStatusReport({ kind: "not_started" }, null); + + expect(report.complete).toBe(false); + expect(report.state).toBe("not_started"); + expect(report.domain).toBeNull(); + expect(report.productionInstanceId).toBeNull(); + expect(report.domainStatus).toBeNull(); + expect(report.nextAction).toContain("clerk deploy"); + }); + + test("domain_provisioning reports production instance", () => { + const report = buildDeployStatusReport( + { kind: "domain_provisioning", appId: "app_1", productionInstanceId: "ins_prod" }, + null, + ); + + expect(report.state).toBe("domain_provisioning"); + expect(report.complete).toBe(false); + expect(report.productionInstanceId).toBe("ins_prod"); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + }); + + test("active with pending domain gives domain precedence over OAuth", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: false, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("domain_pending"); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(report.nextAction).toContain("Ask the user to visit"); + expect(report.nextAction).toContain("offer to open it"); + expect(report.domainStatus).toEqual({ dns: "pending", ssl: "complete", mail: "complete" }); + expect(report.pendingDnsRecords).toContainEqual({ + type: "CNAME", + host: "clerk.example.com", + value: "frontend-api.clerk.services", + }); + expect(report.oauth.pending).toEqual(["github"]); + }); + + test("active with pending email DNS reports only email CNAME records", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: true, ssl: true, mail: false } }, + ); + + expect(report.pendingDnsRecords).toEqual([ + { + type: "CNAME", + host: "clkmail.example.com", + value: "mail.clerk.services", + }, + ]); + }); + + test("active with complete domain but pending OAuth reports oauth_pending", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("oauth_pending"); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(report.oauth).toMatchObject({ + complete: false, + configured: ["google"], + pending: ["github"], + }); + }); + + test("active with complete domain and OAuth reports complete", () => { + const allDone = { + ...activeSnapshot, + completedOAuthProviders: ["google", "github"], + } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: allDone }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("complete"); + expect(report.complete).toBe(true); + expect(report.domainStatus).toEqual({ dns: "complete", ssl: "complete", mail: "complete" }); + expect(report.nextAction).toContain("https://example.com"); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + }); + + test("unsupported OAuth providers surface without blocking completion", () => { + const withUnsupported = { + ...activeSnapshot, + completedOAuthProviders: ["google", "github"], + unsupportedOAuthProviders: ["discord"], + unsupportedOAuthProviderCount: 1, + } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: withUnsupported }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.complete).toBe(true); + expect(report.oauth.unsupported).toEqual(["discord"]); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts new file mode 100644 index 00000000..0d2836df --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -0,0 +1,540 @@ +import { resolveProfile } from "../../lib/config.ts"; +import { PlapiError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { + fetchApplication, + fetchInstanceConfig, + fetchInstanceConfigSchema, + getApplicationDomainStatus, + listApplicationDomains, + triggerApplicationDomainDNSCheck, + type ApplicationDomain, + type DomainStatusResponse, +} from "../../lib/plapi.ts"; +import { sleep } from "../../lib/sleep.ts"; +import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; +import { + cnameTargetPending, + deployComponentLabels, + deployStatusRetryMessage, + domainsDashboardUrl, + type DeployComponentStatus, +} from "./copy.ts"; +import { mapDeployError } from "./errors.ts"; +import { + OAUTH_KEY_PREFIX, + buildOAuthProviderDescriptors, + hasProviderRequiredCredentials, + type OAuthProvider, + type OAuthProviderDescriptor, +} from "./providers.ts"; +import type { DeployContext, DeployOperationState } from "./state.ts"; + +const DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS = 3000; +const DEPLOY_STATUS_MAX_RETRIES = 5; +const DEPLOY_STATUS_BACKOFF_FACTOR = 2; + +export interface DeployProgressHandlers { + runVerification( + progressLabel: string, + work: (controls: SpinnerControls) => Promise, + ): Promise; + onVerified?(): void; +} + +export type DeployStatusOutcome = { verified: boolean; status: DeployComponentStatus }; + +export type DeployStatusState = + | "complete" + | "domain_pending" + | "oauth_pending" + | "domain_provisioning" + | "not_started"; + +export interface DeployStatusReport { + complete: boolean; + state: DeployStatusState; + domain: string | null; + productionInstanceId: string | null; + domainStatus: { dns: string; ssl: string; mail: string } | null; + pendingDnsRecords: { type: "CNAME"; host: string; value: string }[]; + oauth: { complete: boolean; configured: string[]; pending: string[]; unsupported: string[] }; + nextAction: string; +} + +export type LiveDeploySnapshot = Omit< + DeployOperationState, + "pending" | "oauthProviders" | "completedOAuthProviders" +> & { + pending: DeployOperationState["pending"] | undefined; + oauthProviders: OAuthProvider[]; + oauthProviderDescriptors: OAuthProviderDescriptor[]; + completedOAuthProviders: OAuthProvider[]; + domainComplete: boolean; + componentStatus: DeployComponentStatus; + unsupportedOAuthProviderCount: number; + unsupportedOAuthProviders: string[]; +}; + +export type DeployState = + | { kind: "not_started" } + | { kind: "domain_provisioning"; appId: string; productionInstanceId: string } + | { kind: "active"; snapshot: LiveDeploySnapshot }; + +type SnapshotOptions = { + /** + * When true, a failed domain-status read throws instead of being treated as + * pending. The read-only status path enables this so transient API errors are + * surfaced rather than reported as legitimate progress. The interactive deploy + * flow leaves it off, letting the user retry from the on-screen status. + */ + throwOnStatusError?: boolean; +}; + +export type DiscoveredOAuthProviders = { + descriptors: OAuthProviderDescriptor[]; + unsupported: string[]; +}; + +export async function resolveDeployContext(): Promise { + const resolved = await withSpinner("Resolving linked Clerk application...", () => + resolveProfile(process.cwd()), + ); + if (!resolved) { + return { + profileKey: process.cwd(), + profile: { + workspaceId: "", + appId: "", + instances: { development: "" }, + }, + appId: "", + appLabel: "", + developmentInstanceId: "", + }; + } + + return { + profileKey: resolved.path, + profile: resolved.profile, + ...(await withSpinner("Checking for production instance...", () => + resolveLiveApplicationContext(resolved.profile), + )), + }; +} + +export async function resolveLiveApplicationContext(profile: DeployContext["profile"]): Promise<{ + appId: string; + appLabel: string; + developmentInstanceId: string; + productionInstanceId?: string; +}> { + const app = await fetchApplication(profile.appId); + const development = app.instances.find((entry) => entry.environment_type === "development"); + const production = app.instances.find((entry) => entry.environment_type === "production"); + return { + appId: app.application_id, + appLabel: app.name || profile.appName || app.application_id, + developmentInstanceId: development?.instance_id ?? profile.instances.development, + productionInstanceId: production?.instance_id, + }; +} + +export async function resolveDeployState(ctx: DeployContext): Promise { + const live = await resolveLiveApplicationContext(ctx.profile); + if (!live.productionInstanceId) return { kind: "not_started" }; + + // The read-only status path surfaces domain-status read failures instead of + // masking them as pending, so a transient API error is not reported as + // legitimate progress. + const snapshot = await resolveLiveDeploySnapshot( + { + ...ctx, + productionInstanceId: live.productionInstanceId, + }, + { throwOnStatusError: true }, + ); + if (!snapshot) { + return { + kind: "domain_provisioning", + appId: live.appId, + productionInstanceId: live.productionInstanceId, + }; + } + return { kind: "active", snapshot }; +} + +export async function loadDevelopmentOAuthProviders( + ctx: DeployContext, +): Promise { + return withSpinner("Reading development configuration...", async () => { + const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); + const providerSlugs = discoverEnabledOAuthProviderSlugs(config); + const schemaKeys = providerSlugs.map((provider) => `${OAUTH_KEY_PREFIX}${provider}`); + const schema = + schemaKeys.length > 0 + ? await fetchInstanceConfigSchema(ctx.appId, ctx.developmentInstanceId, schemaKeys) + : { properties: {} }; + const result = buildOAuthProviderDescriptors(providerSlugs, schema); + return { + descriptors: result.supported, + unsupported: result.unsupported, + }; + }); +} + +export async function resolveLiveDeploySnapshot( + ctx: DeployContext, + options: SnapshotOptions = {}, +): Promise { + const productionInstanceId = ctx.productionInstanceId; + if (!productionInstanceId) return undefined; + + const [domain, oauth] = await Promise.all([ + loadProductionDomain(ctx), + loadDevelopmentOAuthProviders(ctx), + ]); + if (!domain) return undefined; + + const { descriptors: oauthProviderDescriptors, unsupported } = oauth; + const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); + const { productionConfig, deployStatus } = await loadProductionState( + ctx, + productionInstanceId, + domain.id, + options, + ); + const completedOAuthProviders = oauthProviderDescriptors + .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) + .map((descriptor) => descriptor.provider); + const pendingOAuthDescriptor = oauthProviderDescriptors.find( + (descriptor) => !completedOAuthProviders.includes(descriptor.provider), + ); + + const baseState = { + appId: ctx.appId, + developmentInstanceId: ctx.developmentInstanceId, + productionInstanceId, + productionDomainId: domain.id, + domain: domain.name, + oauthProviders, + oauthProviderDescriptors, + completedOAuthProviders, + cnameTargets: domain.cname_targets ?? [], + componentStatus: deployComponentStatusFromDomainStatus(deployStatus), + unsupportedOAuthProviderCount: unsupported.length, + unsupportedOAuthProviders: unsupported, + }; + + const domainComplete = deployStatus.status === "complete"; + return { + ...baseState, + domainComplete, + pending: resolvePendingStep(pendingOAuthDescriptor, domainComplete), + }; +} + +function resolvePendingStep( + pendingOAuthDescriptor: OAuthProviderDescriptor | undefined, + domainComplete: boolean, +): DeployOperationState["pending"] | undefined { + if (pendingOAuthDescriptor) { + return { type: "oauth", provider: pendingOAuthDescriptor.provider }; + } + if (!domainComplete) { + return { type: "dns" }; + } + return undefined; +} + +export async function loadInitialDeployStatus( + appId: string, + domainIdOrName: string, + options: SnapshotOptions = {}, +): Promise { + const status = mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + if (options.throwOnStatusError) return status; + + try { + return await status; + } catch (error) { + log.debug( + `deploy: snapshot domain-status read failed, treating DNS as pending: ${error instanceof Error ? error.message : String(error)}`, + ); + return pendingDomainStatus(); + } +} + +export async function loadProductionState( + ctx: DeployContext, + productionInstanceId: string, + domainIdOrName: string, + options: SnapshotOptions = {}, +): Promise<{ + productionConfig: Record; + deployStatus: DomainStatusResponse; +}> { + return withSpinner("Reading production configuration...", async () => { + const [productionConfig, deployStatus] = await Promise.all([ + fetchInstanceConfig(ctx.appId, productionInstanceId), + loadInitialDeployStatus(ctx.appId, domainIdOrName, options), + ]); + return { productionConfig, deployStatus }; + }); +} + +export function pendingDomainStatus(): DomainStatusResponse { + return { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "not_started", required: true }, + mail: { status: "not_started", required: true }, + }; +} + +function domainComponentState(value: boolean): "complete" | "pending" { + return value ? "complete" : "pending"; +} + +export function buildDeployStatusReport( + state: DeployState, + outcome: DeployStatusOutcome | null, +): DeployStatusReport { + if (state.kind === "not_started") { + return { + complete: false, + state: "not_started", + domain: null, + productionInstanceId: null, + domainStatus: null, + pendingDnsRecords: [], + oauth: { complete: false, configured: [], pending: [], unsupported: [] }, + nextAction: + "No production instance yet. `clerk deploy` configures production interactively and " + + "needs a human terminal, ask the user to run `clerk deploy`, then run `clerk deploy status` to verify.", + }; + } + + if (state.kind === "domain_provisioning") { + const domainsAction = domainSettingsNextAction( + domainsDashboardUrl(state.appId, state.productionInstanceId), + ); + return { + complete: false, + state: "domain_provisioning", + domain: null, + productionInstanceId: state.productionInstanceId, + domainStatus: null, + pendingDnsRecords: [], + oauth: { complete: false, configured: [], pending: [], unsupported: [] }, + nextAction: + "A production instance exists but its domain is still provisioning. " + + "Run `clerk deploy status` again shortly, or ask the user to finish `clerk deploy`. " + + domainsAction, + }; + } + + const { snapshot } = state; + const componentStatus = outcome?.status ?? snapshot.componentStatus; + const domainComplete = outcome ? outcome.verified : snapshot.domainComplete; + const oauthPending = snapshot.oauthProviders.filter( + (provider) => !snapshot.completedOAuthProviders.includes(provider), + ); + const oauthComplete = oauthPending.length === 0; + const complete = domainComplete && oauthComplete; + const reportState = resolveActiveReportState(domainComplete, complete); + + const pendingDnsRecords: DeployStatusReport["pendingDnsRecords"] = !domainComplete + ? (snapshot.cnameTargets ?? []) + .filter((target) => cnameTargetPending(target, componentStatus)) + .map((target) => ({ type: "CNAME" as const, host: target.host, value: target.value })) + : []; + + return { + complete, + state: reportState, + domain: snapshot.domain, + productionInstanceId: snapshot.productionInstanceId ?? null, + domainStatus: { + dns: domainComponentState(componentStatus.dns), + ssl: domainComponentState(componentStatus.ssl), + mail: domainComponentState(componentStatus.mail), + }, + pendingDnsRecords, + oauth: { + complete: oauthComplete, + configured: [...snapshot.completedOAuthProviders], + pending: oauthPending, + unsupported: [...snapshot.unsupportedOAuthProviders], + }, + nextAction: deployNextAction( + reportState, + snapshot.domain, + componentStatus, + oauthPending, + snapshot.productionInstanceId + ? domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId) + : null, + ), + }; +} + +function resolveActiveReportState(domainComplete: boolean, complete: boolean): DeployStatusState { + if (complete) return "complete"; + if (!domainComplete) return "domain_pending"; + return "oauth_pending"; +} + +function deployNextAction( + state: DeployStatusState, + domain: string, + componentStatus: DeployComponentStatus, + oauthPending: string[], + domainsUrl: string | null, +): string { + const domainsAction = domainsUrl ? ` ${domainSettingsNextAction(domainsUrl)}` : ""; + + if (state === "complete") { + return `Production is deployed and verified at https://${domain}. No action needed.${domainsAction}`; + } + if (state === "oauth_pending") { + return ( + `Domain verified, but these OAuth providers are missing production credentials: ` + + `${oauthPending.join(", ")}. Ask the user to finish \`clerk deploy\`, then run \`clerk deploy status\`.` + + domainsAction + ); + } + + const pendingComponents = [ + !componentStatus.dns ? "DNS" : null, + !componentStatus.ssl ? "SSL" : null, + !componentStatus.mail ? "email DNS" : null, + ].filter((value): value is string => value !== null); + + if (pendingComponents.length === 0) { + return ( + `Production setup for ${domain} is still finalizing on Clerk's side. ` + + `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` + ); + } + + return ( + `${pendingComponents.join(", ")} still provisioning for ${domain}. ` + + `Re-run \`clerk deploy status\` in a few minutes, DNS propagation can take time.` + + domainsAction + ); +} + +function domainSettingsNextAction(domainsUrl: string): string { + return `Ask the user to visit the Clerk Dashboard domains page, or offer to open it: ${domainsUrl}`; +} + +export async function loadProductionDomain( + ctx: DeployContext, +): Promise { + const domains = await listApplicationDomains(ctx.appId); + return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; +} + +export function discoverEnabledOAuthProviderSlugs(config: Record): string[] { + const providers: 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; + providers.push(key.slice(OAUTH_KEY_PREFIX.length)); + } + return providers; +} + +export async function waitForDeployStatus( + appId: string, + domainIdOrName: string, + domain: string, + handlers: DeployProgressHandlers, + options: { triggerCheck?: boolean } = {}, +): Promise { + if (options.triggerCheck !== false) { + await triggerDeployStatusCheck(appId, domainIdOrName); + } + let response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + let status = deployComponentStatusFromDomainStatus(response); + + const labels = deployComponentLabels("dns", domain); + const verified = await handlers.runVerification(labels.progress, async (spinner) => { + if (response.status === "complete") return true; + + let retriesRemaining = DEPLOY_STATUS_MAX_RETRIES; + let nextRetryDelay = DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS; + while (retriesRemaining > 0) { + await sleepWithRetryCountdown( + labels.progress, + DEPLOY_STATUS_MAX_RETRIES - retriesRemaining + 1, + DEPLOY_STATUS_MAX_RETRIES, + nextRetryDelay, + spinner, + ); + retriesRemaining--; + nextRetryDelay *= DEPLOY_STATUS_BACKOFF_FACTOR; + response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + status = deployComponentStatusFromDomainStatus(response); + if (response.status === "complete") return true; + } + return false; + }); + + if (!verified) { + return { verified: false, status }; + } + handlers.onVerified?.(); + return { verified: true, status }; +} + +async function sleepWithRetryCountdown( + message: string, + currentRetry: number, + totalRetries: number, + delayMs: number, + spinner: SpinnerControls, +): Promise { + let remainingMs = delayMs; + while (remainingMs > 0) { + const tickMs = Math.min(1000, remainingMs); + spinner.update( + deployStatusRetryMessage(message, currentRetry, totalRetries, Math.ceil(remainingMs / 1000)), + ); + await sleep(tickMs); + remainingMs -= tickMs; + } +} + +export async function triggerDeployStatusCheck( + appId: string, + domainIdOrName: string, +): Promise { + try { + await mapDeployError(triggerApplicationDomainDNSCheck(appId, domainIdOrName)); + } catch (error) { + if (error instanceof PlapiError && error.status === 409 && error.code === "conflict") { + log.debug("DNS check is already in flight; continuing to poll domain status."); + return; + } + throw error; + } +} + +export function deployComponentStatusFromDomainStatus( + response: DomainStatusResponse, +): DeployComponentStatus { + return { + dns: checkStatusComplete(response.dns), + ssl: checkStatusComplete(response.ssl), + mail: checkStatusComplete(response.mail), + }; +} + +function checkStatusComplete(check: { status: string; required?: boolean } | undefined): boolean { + if (!check) return false; + if (check.required === false) return true; + return check.status === "complete"; +} diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 1f6b56d6..edc8c506 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -73,6 +73,10 @@ describe("generateCompletions", () => { expect(names).toContain("patch"); expect(names).toContain("put"); }); + + test("completes deploy subcommands", () => { + expect(completionNames("deploy", "")).toContain("status"); + }); }); describe("alias completion", () => { diff --git a/skills/clerk-cli/SKILL.md b/skills/clerk-cli/SKILL.md index 7ff12b0f..6e04bf66 100644 --- a/skills/clerk-cli/SKILL.md +++ b/skills/clerk-cli/SKILL.md @@ -2,11 +2,12 @@ name: clerk-cli description: >- Operate the Clerk CLI (`clerk` binary) for authentication, user/org/session management, - instance config, env keys, and any Clerk Backend or Platform API call. Use when the user - mentions Clerk management tasks, "list clerk users", "create a clerk user", "update - organization", "pull clerk config", "clerk env pull", "clerk doctor", "clerk api", or - any ad-hoc Clerk API request. Prefer the CLI over raw HTTP: it handles auth, key - resolution, app/instance targeting, and formatting automatically. + deploy verification, instance config, env keys, and any Clerk Backend or Platform API + call. Use when the user mentions Clerk management tasks, "list clerk users", "create a + clerk user", "update organization", "pull clerk config", "clerk env pull", + "clerk doctor", "clerk deploy", "clerk deploy status", "clerk api", or any ad-hoc + Clerk API request. Prefer the CLI over raw HTTP: it handles auth, key resolution, + app/instance targeting, and formatting automatically. --- # Clerk CLI @@ -209,6 +210,8 @@ node -e 'const d=require("/tmp/users.json"); console.log(d.data.length, d.hasMor | `clerk users create` | Create a user from curated flags or a raw BAPI body. Confirmation prompt unless `--yes`. | `--email`, `--phone`, `--username`, `--password`, `--first-name`, `--last-name`, `--external-id`, `-d, --data`, `--file`, `--dry-run`, `--yes`, `--json` | | `clerk users open [user-id]` | Open a user's dashboard page. Agent mode requires `user-id` and prints a JSON descriptor instead of launching a browser. | (see `--help`) | | `clerk open [subpath]` | Open the linked app's dashboard in a browser. Agent mode: prints a JSON descriptor instead of opening. | (see `--help`) | +| `clerk deploy` | Human-mode production deploy wizard. Agent mode: emits a read-only JSON handoff and tells the agent whether to ask the human to run the wizard, wait for provisioning, finish OAuth, or do nothing. | `--mode agent`, `--mode human`, `--verbose` | +| `clerk deploy status` | Read-only deploy verification. Triggers a DNS check, reports aggregate domain and OAuth readiness, and exits `0` only when complete. Agent mode does one quick check by default; pass `--wait` to keep waiting. | `--mode agent`, `--wait`, `--verbose` | | `clerk doctor` | Health check (CLI version, login, link, env, config, completion; plus host-execution probe in agent mode). | `--json`, `--spotlight`, `--verbose`, `--fix` | | `clerk api [path]` | Authenticated HTTP to Backend/Platform API. | `-X`, `-d`, `--file`, `--dry-run`, `--yes`, `--include`, `--app`, `--secret-key`, `--instance`, `--platform` | | `clerk api ls [filter]` | Discover endpoints from the bundled OpenAPI catalog. | (see `--help`) | @@ -232,6 +235,7 @@ The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` - **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. - **`apps list` and `apps create` default to JSON** when piped. - **`users` defaults to JSON when piped, like `apps`.** `clerk users list` and `clerk users create` emit JSON in agent mode. Bare `clerk users` (no subcommand) is a usage error in agent mode — pass `list`, `create`, or `open` explicitly. `clerk users open` requires the `user-id` positional in agent mode and prints a JSON descriptor instead of launching a browser. +- **`deploy` has an agent handoff plus a verification gate.** In agent mode, bare `clerk deploy` is read-only and emits a JSON handoff. It never drives the interactive wizard. Do not tell Claude or another agent to run `! clerk deploy`, because the wizard needs interactive stdin prompts. Ask the human to run `clerk deploy` in a new terminal window when needed, then run `clerk deploy status --mode agent` to verify completion. See [references/agent-mode.md](references/agent-mode.md#deploy-handoff-and-verification). - **`--input-json `** expands JSON into flags on any command (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Piped stdin is also accepted: `echo '{"yes":true}' | clerk init`. Place `--input-json` after the leaf subcommand. Full rules in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). Full matrix and sandbox details in [references/agent-mode.md](references/agent-mode.md). diff --git a/skills/clerk-cli/references/agent-mode.md b/skills/clerk-cli/references/agent-mode.md index 271c62e4..3b1e4cf2 100644 --- a/skills/clerk-cli/references/agent-mode.md +++ b/skills/clerk-cli/references/agent-mode.md @@ -61,6 +61,8 @@ Force human mode with `--mode human` or `CLERK_MODE=human`. Typical AI-agent inv | `clerk users` (no subcommand) | Interactive action picker | Prints the action list and exits with a usage error (code `2`) — pass `list` / `create` / `open` | | `clerk users open [user-id]` | Picks a user interactively, opens browser | Requires `user-id`; prints `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` and does not open a browser | | `clerk open [subpath]` | Opens the browser to the URL | Does not open a browser. Prints a JSON descriptor (`{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}`) on stdout so the agent can surface it | +| `clerk deploy` | Interactive production deploy wizard | Read-only handoff. Emits deploy status JSON on stdout and exits `0` for linked projects. Does not prompt, mutate, trigger DNS checks, or poll. | +| `clerk deploy status` | Verify production deploy state | Read-only verification gate. Triggers one DNS check for active production domains, waits briefly, reads one live status snapshot, emits status JSON on stdout, exits `0` when complete and `1` when incomplete. It does not keep waiting or back off in agent mode unless `--wait` is passed. Use `--wait` when the user asks the agent to keep waiting for DNS, SSL, email DNS, or final Clerk-side readiness. | | `clerk auth login` when already authenticated | Prompt to re-auth | Silent no-op | | `clerk init` | Full interactive scaffold flow | Runs non-interactively. Explicit `--app` or a linked profile uses the real-app auth/link/env flow; without it, an authenticated agent on a keyless-capable framework creates a real app and links it. Pass `--keyless` to opt into auto-generated dev keys for new projects on keyless-capable frameworks. Without `--keyless`, an unauthenticated agent (or non-keyless framework with no app target) prints manual setup guidance. | | Color / spinners | Enabled | Disabled | @@ -130,21 +132,23 @@ Errors use the standard agent-mode format: bad JSON → `invalid_json`, missing ## Structured outputs you can rely on -| Command | Structured output | -| --------------------------------------------- | --------------------------------------------------------------------------------------- | -| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | -| `clerk apps list --json` | Array of application objects | -| `clerk apps create --json` | Single application object | -| `clerk users list` (agent mode or `--json`) | `{data: [...users], hasMore}` envelope (BAPI user shape inside `data`) | -| `clerk users create` (agent mode or `--json`) | Single user object (raw BAPI shape) | -| `clerk users open` (agent mode) | `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` | -| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | -| `clerk api --include` | Response headers on stderr, body on stdout | -| `clerk config pull` | Instance config JSON | -| `clerk config schema` | JSON Schema | -| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | -| `clerk open --print` | Plain dashboard URL on stdout | -| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | +| Command | Structured output | +| --------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | +| `clerk apps list --json` | Array of application objects | +| `clerk apps create --json` | Single application object | +| `clerk users list` (agent mode or `--json`) | `{data: [...users], hasMore}` envelope (BAPI user shape inside `data`) | +| `clerk users create` (agent mode or `--json`) | Single user object (raw BAPI shape) | +| `clerk users open` (agent mode) | `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` | +| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | +| `clerk api --include` | Response headers on stderr, body on stdout | +| `clerk config pull` | Instance config JSON | +| `clerk config schema` | JSON Schema | +| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | +| `clerk open --print` | Plain dashboard URL on stdout | +| `clerk deploy` (agent mode) | Deploy handoff report with `complete`, `state`, domain status, OAuth status, and `nextAction` | +| `clerk deploy status` (agent mode) | Deploy verification report with the same shape, plus exit `0` complete or `1` incomplete | +| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | For commands without an explicit `--json` flag, `clerk api` is your escape hatch: hit the underlying endpoint directly. @@ -181,6 +185,70 @@ clerk api /users --app app_abc123 --instance prod The same advice applies to linking in agent mode: `clerk link --app app_abc123` is deterministic and works non-interactively. If you omit `--app`, the command only succeeds when silent autolink can prove the target app from existing publishable keys. +### Deploy handoff and verification + +Do not try to drive the interactive deploy wizard from an agent. Use the handoff and check commands instead. + +```sh +# 1. Inspect current production deploy state without mutating anything. +clerk deploy --mode agent + +# 2. If the handoff says a human action is needed, ask the user to run this +# in a new terminal window, not through `! clerk deploy`: +clerk deploy --mode human + +# 3. After the user finishes or DNS has had time to propagate, verify: +clerk deploy status --mode agent + +# 4. If the user asks you to keep waiting, use the retrying wait loop: +clerk deploy status --mode agent --wait +``` + +`clerk deploy --mode agent` is read-only. It resolves the linked app and current production deploy snapshot, then emits JSON on stdout. It does **not** trigger DNS checks, poll, create production instances, patch OAuth config, or prompt. Linked projects exit `0` because this is an informational handoff. Not-linked and API failures still use the normal agent error envelope on stderr. + +Never try to run the human wizard through Claude's `! clerk deploy` shell escape or any non-interactive agent shell. The deploy wizard asks for domain, DNS export, OAuth, and verification inputs over stdin, so it needs a real human terminal. Tell the user to open a new terminal window in the project directory and run `clerk deploy` or `clerk deploy --mode human` there. After they finish, return to agent mode and run `clerk deploy status --mode agent`. + +`clerk deploy status --mode agent` is the gate. It is also read-only with respect to deploy configuration, but for an active production domain it triggers one Clerk DNS check, waits briefly, reads a live status/config snapshot, then reports DNS, SSL, email DNS, aggregate domain readiness, and OAuth completeness. By default it does not keep waiting or exponentially back off in agent mode. If the check is incomplete and the user asks the agent to continue waiting, run `clerk deploy status --mode agent --wait` instead of manually sleeping and retrying. `--wait` uses the shared poll loop: one immediate status read, then up to 5 exponential-backoff retries until aggregate domain status is complete. It emits the same status JSON. It exits: + +| Exit | Meaning | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | Deploy is complete and verified. | +| `1` | The check ran successfully, but deploy is incomplete. Read `state` and `nextAction`. | +| else | A real CLI error occurred. Read the standard agent error envelope on stderr. | + +Deploy-specific agent errors still use the standard envelope and may include typed codes such as `plan_insufficient`, `provider_domain_not_allowed`, `home_url_taken`, or `form_param_invalid`. + +When a production instance exists, `nextAction` includes the full Clerk Dashboard domains URL so agents can send the user directly to the same page the human CLI prints in its next steps. Always show that URL to the user. Ask whether they want you to open it for them instead of omitting or paraphrasing it away. + +The deploy report has this shape: + +```json +{ + "complete": false, + "state": "domain_pending", + "domain": "example.com", + "productionInstanceId": "ins_...", + "domainStatus": { "dns": "complete", "ssl": "pending", "mail": "complete" }, + "pendingDnsRecords": [{ "type": "CNAME", "host": "clerk.example.com", "value": "..." }], + "oauth": { "complete": true, "configured": ["google"], "pending": [], "unsupported": [] }, + "nextAction": "SSL still provisioning for example.com. Re-run `clerk deploy status` in a few minutes, DNS propagation can take time. Ask the user to visit the Clerk Dashboard domains page, or offer to open it: https://dashboard.clerk.com/apps/app_.../instances/ins_.../domains" +} +``` + +`complete` is `true` only when the aggregate domain status is complete and all supported OAuth providers enabled in development have production credentials. The `domainStatus` object is a component summary; DNS, SSL, and email DNS can all read `complete` while `state` remains `domain_pending` if Clerk-side finalization is still pending. + +State precedence: + +| State | What to do | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `not_started` | Ask the human to run `clerk deploy --mode human`, then run `clerk deploy status --mode agent`. | +| `domain_provisioning` | Wait briefly or ask the human to finish `clerk deploy`, then run `clerk deploy status --mode agent`. | +| `domain_pending` | Surface `pendingDnsRecords` when present. Re-run `clerk deploy status --mode agent` after DNS, SSL, or email DNS propagation. | +| `oauth_pending` | Ask the human to finish the OAuth credential steps in `clerk deploy --mode human`, then verify with `deploy status`. | +| `complete` | No action needed. | + +Unsupported OAuth providers do not block `complete`, because the wizard cannot configure them automatically. They are still surfaced in `oauth.unsupported` so you can warn the user to review them in the Clerk Dashboard. + ### Use the catalog, not hard-coded paths ```sh From 64a18bd9c33f17ffe2c0efbfd784bff0b4c1f9d5 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 29 May 2026 07:12:00 -0600 Subject: [PATCH 49/52] fix(deploy): label DKIM records as email in DNS handoff cnameTargetLabel keyed off the full `clk._domainkey`/`clk2._domainkey` labels, but host.split(".", 1)[0] only ever yields the first label ("clk"/"clk2"), so those cases were dead and DKIM records rendered as a generic "CNAME". Match the actual prefixes so DKIM records show the email label. --- .../cli-core/src/commands/deploy/copy.test.ts | 19 +++++++++++++++++++ packages/cli-core/src/commands/deploy/copy.ts | 2 ++ 2 files changed, 21 insertions(+) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 64d061f0..0d60a6c9 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -60,6 +60,25 @@ describe("bindZoneFile", () => { }); }); +describe("dnsRecords", () => { + test("labels DKIM (_domainkey) records as email records, not bare CNAME", () => { + const targets: CnameTarget[] = [ + { host: "clk._domainkey.example.com", value: "dkim1.clerk.services", required: false }, + { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: false }, + ]; + const output = dnsRecords(targets); + + expect(output).toContain(" Host: clk._domainkey.example.com"); + expect(output).toContain(" Host: clk2._domainkey.example.com"); + // Both DKIM hosts must carry the email label, not fall through to the + // generic "CNAME" default (the host's first label is "clk"/"clk2"). + expect( + output.filter((line) => line.includes("Email (Clerk handles SPF/DKIM automatically)")), + ).toHaveLength(2); + expect(output.some((line) => line.trimStart().startsWith("CNAME"))).toBe(false); + }); +}); + describe("deployComponentLabels", () => { test("returns email DNS in-progress and done labels", () => { expect(deployComponentLabels("mail", "example.com")).toEqual({ diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 3c77a474..a4260fa4 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -101,6 +101,8 @@ function cnameTargetLabel(host: string): string { return "Frontend API"; case "accounts": return "Account portal"; + // `host.split(".", 1)[0]` yields only the first label, so DKIM records + // (clk._domainkey, clk2._domainkey) arrive here as "clk"/"clk2". case "clkmail": case "clk": case "clk2": From 63478a92f2de96fc80c8d4c74f9b55e8c51d93a8 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 29 May 2026 07:12:08 -0600 Subject: [PATCH 50/52] refactor(deploy): drop unreachable OAuth completion guards runOAuthSetup pauses by throwing DeployPausedError on skip or interrupt, so it never returns a partial list. The `completed.length < providers.length` guards after it were dead code implying a contract that does not exist. Remove them and document the pause-by-throw contract on runOAuthSetup instead. --- packages/cli-core/src/commands/deploy/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index ba1a4332..37da4c32 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -202,7 +202,6 @@ async function startNewDeploy(ctx: DeployContext): Promise { bar(); completedOAuthProviders = await runOAuthSetup(ctx, operationState, oauthProviders); - if (completedOAuthProviders.length < oauthProviders.length) return; bar(); const dnsStatus = await runDnsVerificationPrompt(ctx, { @@ -265,7 +264,6 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { }, snapshot.oauthProviderDescriptors, ); - if (completed.length < snapshot.oauthProviders.length) return; snapshot.completedOAuthProviders = completed; } @@ -486,6 +484,12 @@ async function offerBindZoneExport( log.success(`Wrote ${filePath}`); } +/** + * Configures every provider in `descriptors`, returning the full set of + * completed providers. If the user skips a provider or interrupts the prompt, + * this pauses by throwing `DeployPausedError` rather than returning a partial + * list, so a successful return always means OAuth is fully complete. + */ async function runOAuthSetup( ctx: DeployContext, state: DeployOperationState, From c28a25fbfb8fed68c99462f5095f9bda53dd05f5 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 29 May 2026 07:54:27 -0600 Subject: [PATCH 51/52] fix(deploy): re-prompt Google OAuth JSON after docs --- .changeset/deploy-wizard.md | 1 + .../src/commands/deploy/index.test.ts | 56 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 8 ++- .../cli-core/src/commands/deploy/prompts.ts | 5 +- 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/.changeset/deploy-wizard.md b/.changeset/deploy-wizard.md index bd457622..87f3f0c7 100644 --- a/.changeset/deploy-wizard.md +++ b/.changeset/deploy-wizard.md @@ -9,3 +9,4 @@ Add `clerk deploy`, an interactive wizard that promotes a Clerk application from - 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. - Uses provider schemas to collect production OAuth credentials for broader built-in provider support. +- Lets Google OAuth setup load the downloaded credentials JSON after opening the provider docs. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 3f97738a..c88382c3 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -32,6 +32,7 @@ const mockCreateProductionInstance = mock(); const mockGetApplicationDomainStatus = mock(); const mockTriggerApplicationDomainDNSCheck = mock(); const mockSleep = mock(); +const mockOpenBrowser = mock(); mock.module("@inquirer/prompts", () => ({ ...promptsStubs, @@ -69,6 +70,10 @@ mock.module("../../lib/sleep.ts", () => ({ }, })); +mock.module("../../lib/open.ts", () => ({ + openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), +})); + const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts"); const { deploy } = await import("./index.ts"); const { providerSetupIntro } = await import("./providers.ts"); @@ -306,6 +311,7 @@ describe("deploy", () => { mockGetApplicationDomainStatus.mockReset(); mockTriggerApplicationDomainDNSCheck.mockReset(); mockSleep.mockReset(); + mockOpenBrowser.mockReset(); consoleSpy?.mockRestore(); writeSpy?.mockRestore(); }); @@ -1112,6 +1118,56 @@ describe("deploy", () => { expect(captured.err).toContain("Saved Google OAuth credentials"); }); + test("Google OAuth walkthrough re-prompts with JSON import instead of another walkthrough", 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(); + mockOpenBrowser.mockResolvedValueOnce({ ok: true, launcher: "test" }); + mockSelect.mockResolvedValueOnce("walkthrough").mockResolvedValueOnce("google-json"); + mockInput.mockResolvedValueOnce(googleJsonPath); + mockPassword.mockResolvedValueOnce("manual-secret"); + + await runDeploy({}); + + const oauthSelects = mockSelect.mock.calls + .map( + (call) => + call[0] as { message?: string; choices?: Array<{ name: string; value: string }> }, + ) + .filter((call) => String(call.message).includes("Google OAuth")); + + expect(mockOpenBrowser).toHaveBeenCalledWith( + "https://clerk.com/docs/authentication/social-connections/google", + ); + expect(oauthSelects).toHaveLength(2); + expect(oauthSelects[1]?.choices).not.toContainEqual({ + name: "Walk me through creating them", + value: "walkthrough", + }); + expect(oauthSelects[1]?.choices).toContainEqual({ + name: "Load credentials from a Google Cloud Console JSON file", + value: "google-json", + }); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { + connection_oauth_google: { + enabled: true, + client_id: "google-json-client.apps.googleusercontent.com", + client_secret: "fake-json-secret", + }, + }); + }); + test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_apple" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 37da4c32..565773a4 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -557,7 +557,7 @@ async function collectAndSaveOAuthCredentials( for (const line of providerSetupIntro(descriptor)) log.info(line); log.blank(); - const choice = await chooseOAuthCredentialAction(descriptor); + let choice = await chooseOAuthCredentialAction(descriptor); if (choice === "skip") { return false; @@ -565,6 +565,12 @@ async function collectAndSaveOAuthCredentials( if (choice === "walkthrough") { await showOAuthWalkthrough(descriptor, domain); + if (descriptor.provider === "google") { + choice = await chooseOAuthCredentialAction(descriptor, { includeWalkthrough: false }); + if (choice === "skip") { + return false; + } + } } const credentials = await collectOAuthCredentials( diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index 3fee78f1..891644a1 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -81,11 +81,14 @@ export async function confirmExportBindZone(): Promise { export async function chooseOAuthCredentialAction( descriptor: OAuthProviderDescriptor, + options: { includeWalkthrough?: boolean } = {}, ): Promise { const choices: Array<{ name: string; value: OAuthCredentialAction }> = [ { name: descriptor.credentialLabel, value: "have-credentials" }, - { name: "Walk me through creating them", value: "walkthrough" }, ]; + if (options.includeWalkthrough !== false) { + choices.push({ name: "Walk me through creating them", value: "walkthrough" }); + } if (descriptor.credentialSources.includes("google-json")) { choices.push({ name: "Load credentials from a Google Cloud Console JSON file", From 878b726141cb5a358ae4f26126a44424e3535432 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 29 May 2026 08:01:05 -0600 Subject: [PATCH 52/52] fix(deploy): re-prompt oauth credentials after walkthrough --- .changeset/deploy-wizard.md | 2 +- .../src/commands/deploy/index.test.ts | 46 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 8 ++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/.changeset/deploy-wizard.md b/.changeset/deploy-wizard.md index 87f3f0c7..dc657263 100644 --- a/.changeset/deploy-wizard.md +++ b/.changeset/deploy-wizard.md @@ -9,4 +9,4 @@ Add `clerk deploy`, an interactive wizard that promotes a Clerk application from - 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. - Uses provider schemas to collect production OAuth credentials for broader built-in provider support. -- Lets Google OAuth setup load the downloaded credentials JSON after opening the provider docs. +- Returns users to credential choices after opening provider docs, including Google JSON import when supported. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index c88382c3..f0cfc991 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1168,6 +1168,52 @@ describe("deploy", () => { }); }); + test("OAuth walkthrough re-prompts without another walkthrough for non-Google providers", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_github" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_github", + developmentConfig: { + connection_oauth_github: { enabled: true }, + }, + productionConfig: { + connection_oauth_github: { enabled: true, client_id: "", client_secret: "" }, + }, + }); + mockOpenBrowser.mockResolvedValueOnce({ ok: true, launcher: "test" }); + mockSelect.mockResolvedValueOnce("walkthrough").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("github-client-id"); + mockPassword.mockResolvedValueOnce("github-secret"); + + await runDeploy({}); + + const oauthSelects = mockSelect.mock.calls + .map( + (call) => + call[0] as { message?: string; choices?: Array<{ name: string; value: string }> }, + ) + .filter((call) => String(call.message).includes("GitHub OAuth")); + + expect(oauthSelects).toHaveLength(2); + expect(oauthSelects[1]?.choices).not.toContainEqual({ + name: "Walk me through creating them", + value: "walkthrough", + }); + expect(oauthSelects[1]?.choices).not.toContainEqual({ + name: "Load credentials from a Google Cloud Console JSON file", + value: "google-json", + }); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_github", { + connection_oauth_github: { + enabled: true, + client_id: "github-client-id", + client_secret: "github-secret", + }, + }); + }); + test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_apple" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 565773a4..0dcc12c1 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -565,11 +565,9 @@ async function collectAndSaveOAuthCredentials( if (choice === "walkthrough") { await showOAuthWalkthrough(descriptor, domain); - if (descriptor.provider === "google") { - choice = await chooseOAuthCredentialAction(descriptor, { includeWalkthrough: false }); - if (choice === "skip") { - return false; - } + choice = await chooseOAuthCredentialAction(descriptor, { includeWalkthrough: false }); + if (choice === "skip") { + return false; } }