From e0e6b8ff234566c927f0330e3c1924c6b4a73a41 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 5 May 2026 18:14:49 -0600 Subject: [PATCH 1/6] 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 a6d736fb..f05bee41 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -37,14 +37,15 @@ import { PlapiError, FapiError, EXIT_CODE, + isPromptExitError, throwUsageError, } from "./lib/errors.ts"; import { clerkHelpConfig } from "./lib/help.ts"; -import { ExitPromptError } from "@inquirer/core"; import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { update } from "./commands/update/index.ts"; +import { deploy } from "./commands/deploy/index.ts"; import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; import { billingEnable, billingDisable } from "./commands/billing/index.ts"; @@ -901,6 +902,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; @@ -1006,7 +1015,7 @@ export async function runProgram( } catch (error) { const verbose = program.opts().verbose ?? false; - if (error instanceof UserAbortError || error instanceof ExitPromptError) { + if (error instanceof UserAbortError || isPromptExitError(error)) { process.exit(EXIT_CODE.SUCCESS); } diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index ecddd4aa..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 289ce5ab..519addca 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[]; } interface ClerkConfig { @@ -358,4 +370,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 ef577503..3d9edbcc 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -1,4 +1,5 @@ import { isAgent } from "../mode.ts"; +import { ExitPromptError } from "@inquirer/core"; /** Standard process exit codes used by the CLI. */ export const EXIT_CODE = { @@ -151,6 +152,15 @@ export class UserAbortError extends Error { } } +export function isPromptExitError(error: unknown): boolean { + return ( + error instanceof ExitPromptError || + (error instanceof Error && + error.name === "ExitPromptError" && + error.message.includes("User force closed the prompt")) + ); +} + /** * Base class for HTTP API errors. * diff --git a/packages/cli-core/src/lib/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 e3a37f773a30af3b2e6f47cfc15cbd658e11cde9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 08:13:00 -0600 Subject: [PATCH 2/6] 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 346d8eb8043478eac5b180dbd0989774a7a6beac Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 09:34:43 -0600 Subject: [PATCH 3/6] 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 3835570e..172852f1 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -141,6 +141,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"); @@ -148,6 +184,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 71c3840531fcfe123707d3cd529ba101af8db132 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 13:21:25 -0600 Subject: [PATCH 4/6] 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 3dc13520..715f57b6 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -44,6 +44,23 @@ test("users list exposes common filters and pagination options", () => { ); }); +test("deploy exposes the expected options", () => { + const program = createProgram(); + const deploy = program.commands.find((command) => command.name() === "deploy")!; + const optionNames = deploy.options.map((option) => option.long); + + expect(optionNames).toEqual([ + "--debug", + "--test-force-production-instance", + "--test-fail-production-instance-check", + "--test-fail-domain-lookup", + "--test-fail-validate-cloning", + "--test-fail-create-production-instance", + "--test-fail-dns-verification", + "--test-fail-oauth-save", + ]); +}); + describe("parseIntegerOption (via users list --limit / --offset)", () => { function parseUsersList(args: readonly string[]) { return createProgram().parseAsync(["users", "list", ...args], { from: "user" }); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index f05bee41..5ee648ab 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -906,8 +906,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 519addca..289ce5ab 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[]; } interface ClerkConfig { @@ -370,4 +358,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 172852f1..0f4a2208 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -152,6 +152,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"; @@ -184,6 +204,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 d0436907dd2ecfdba3d87107f9ed8cffb6e03b95 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 13:52:50 -0600 Subject: [PATCH 5/6] 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 5ee648ab..e200dda1 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -910,43 +910,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 6191e86d9b8143e8e819d539a17b86a8e77c6d92 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 May 2026 14:05:32 -0600 Subject: [PATCH 6/6] 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; } }