diff --git a/.agents/skills/audit-clerk-skill b/.agents/skills/audit-clerk-skill new file mode 120000 index 00000000..5a0f5935 --- /dev/null +++ b/.agents/skills/audit-clerk-skill @@ -0,0 +1 @@ +../../.claude/skills/audit-clerk-skill \ No newline at end of file diff --git a/.agents/skills/changesets b/.agents/skills/changesets new file mode 120000 index 00000000..5582b9f2 --- /dev/null +++ b/.agents/skills/changesets @@ -0,0 +1 @@ +../../.claude/skills/changesets \ No newline at end of file diff --git a/.changeset/deploy-status.md b/.changeset/deploy-status.md new file mode 100644 index 00000000..59abf6da --- /dev/null +++ b/.changeset/deploy-status.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk deploy status`, a read-only command that verifies a production deploy, including DNS, SSL, email DNS, and OAuth credential completeness. Agent-mode `clerk deploy` now emits a tailored read-only handoff instead of a hard usage error. diff --git a/.changeset/deploy-wizard.md b/.changeset/deploy-wizard.md new file mode 100644 index 00000000..dc657263 --- /dev/null +++ b/.changeset/deploy-wizard.md @@ -0,0 +1,12 @@ +--- +"clerk": minor +--- + +Add `clerk deploy`, an interactive wizard that promotes a Clerk application from development to production. + +- Walks through cloning the development instance, creating the production instance, and configuring CNAME records. +- Verifies mail, DNS, and SSL one component at a time so each step's status is visible while polling. +- Optionally exports the DNS records as a BIND zone file at `./clerk-.zone` for import into providers like Cloudflare, Route 53, and Google Cloud DNS. +- Resumes from the next pending step on subsequent runs, including reshowing the CNAME records when DNS is not yet verified. +- Uses provider schemas to collect production OAuth credentials for broader built-in provider support. +- Returns users to credential choices after opening provider docs, including Google JSON import when supported. diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index d427ba76..f533e95b 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -15,17 +15,17 @@ Adjust the relative path to `lib/log.ts` based on the file's location under `pac ## Which method to use -| Method | Stream | When to use | -| --------------- | ---------- | ----------------------------------------------- | -| `log.data()` | **stdout** | Pipeable output (JSON, lists, machine-readable) | -| `log.info()` | stderr | Status messages | -| `log.success()` | stderr | Completion confirmations (green) | -| `log.warn()` | stderr | Warnings (yellow) | -| `log.error()` | stderr | Errors (red, auto-prefixed `error:`) | -| `log.debug()` | stderr | Diagnostic info, only with `--verbose` | -| `log.raw()` | stderr | Machine-readable JSON for agent mode | +| Method | Stream | When to use | +| --------------- | ---------- | ------------------------------------------------ | +| `log.data()` | **stdout** | Pipeable output (JSON, lists, machine-readable) | +| `log.info()` | stderr | Status messages | +| `log.success()` | stderr | Completion confirmations (green) | +| `log.warn()` | stderr | Warnings (yellow) | +| `log.error()` | stderr | Errors (red, auto-prefixed `error:`) | +| `log.debug()` | stderr | Diagnostic info, only with `--verbose` | +| `log.raw()` | stderr | Machine-readable JSON for agent mode | | `log.ui()` | stderr | Pre-formatted UI (spinner, intro/outro brackets) | -| `log.blank()` | stderr | Blank line | +| `log.blank()` | stderr | Blank line | `log.data()` writes to **stdout** — this is what gets piped (e.g., `clerk apps list | jq`). Everything else writes to **stderr** as UI for humans. Never mix these. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 0817b834..60bd3ef2 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -24,6 +24,8 @@ bun run test This runs each unit and integration test file as a separate `bun test` subprocess via `scripts/run-tests.ts`, isolating module state between files. E2E fixtures are excluded and require separate setup (see `rules/e2e.md`). +When running multiple test files directly with `bun test`, always pass `--isolate` or `--parallel`. `--parallel` implies `--isolate`. Without isolation, Bun can share module mocks across files and produce order-dependent failures. + Prefer `spyOn()` for mocking, and always restore spies in `afterAll` with `mockRestore()`. Never use `for` or `forEach` loops inside a single test to verify multiple inputs or cases — use `test.each` (or `it.each` / `describe.each`) so each case is its own reported test case with its own name, setup/teardown, and pinpointed failure output. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a4eef392..de46450c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,8 @@ Locally, prefer `bun run test:e2e:op` so secrets are injected from 1Password in- CI runs `bun run format:check` (fails if unformatted), `bun run lint`, `bun test`, and `bun run test:e2e` on every PR to `main`. E2E tests only run for PRs from the same repository (not external forks) and target the production Clerk API with a dedicated test application. +When running multiple test files directly with `bun test`, always pass `--isolate` or `--parallel`. `--parallel` implies `--isolate`. Without isolation, Bun can share module mocks across files and produce order-dependent failures. Prefer `bun run test` for the full suite because it already isolates test files through `scripts/run-tests.ts`. + ## Versioning The `CLI_VERSION` global is injected at compile time via `bun build --compile --define "CLI_VERSION=..."`. Local `build:compile` omits it, so the binary reports `0.0.0-dev`. The CI release workflow injects the real version. diff --git a/README.md b/README.md index 7e8be625..2ee89064 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Commands: completion [shell] Generate shell autocompletion script skill Manage the bundled Clerk CLI agent skill update [options] Update the Clerk CLI to the latest version + deploy Deploy a Clerk application to production help [command] Display help for command Give AI agents better Clerk context: install the Clerk skills diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 97039ca4..540fbed9 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -45,6 +45,23 @@ test("users list exposes common filters and pagination options", () => { ); }); +test("deploy relies on global options", () => { + const program = createProgram(); + const deploy = program.commands.find((command) => command.name() === "deploy")!; + const optionNames = deploy.options.map((option) => option.long); + + expect(optionNames).toEqual([]); +}); + +test("deploy status exposes wait option", () => { + const program = createProgram(); + const deploy = program.commands.find((command) => command.name() === "deploy")!; + const status = deploy.commands.find((command) => command.name() === "status")!; + const optionNames = status.options.map((option) => option.long); + + expect(optionNames).toContain("--wait"); +}); + describe("parseIntegerOption (via users list --limit / --offset)", () => { function parseUsersList(args: readonly string[]) { return createProgram().parseAsync(["users", "list", ...args], { from: "user" }); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a1069fd0..c4996d1e 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -37,14 +37,16 @@ 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 { deployStatus } from "./commands/deploy/status-command.ts"; import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; import { billingEnable, billingDisable } from "./commands/billing/index.ts"; @@ -119,7 +121,7 @@ Give AI agents better Clerk context: install the Clerk skills program.hook("preAction", async () => { // Reset log level at the start of each command invocation so a previous - // --verbose or --debug flag doesn't leak into subsequent runs. + // --verbose doesn't leak into subsequent runs. setLogLevel("info"); const opts = program.opts(); if (opts.verbose) { @@ -925,6 +927,16 @@ Tutorial — enable completions for your shell: ]) .action(update); + const deployCmd = program + .command("deploy") + .description("Deploy a Clerk application to production"); + deployCmd.command("run", { isDefault: true, hidden: true }).action(deploy); + deployCmd + .command("status") + .description("Show production deploy status (read-only)") + .option("--wait", "Wait for DNS, SSL, and email DNS verification with retries") + .action(deployStatus); + registerExtras(program); return program; @@ -1012,7 +1024,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/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 65c4c73f..89c368e2 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -89,6 +89,7 @@ mock.module("../../lib/autoclaim.ts", () => ({ attemptAutoclaim: async () => ({ status: "not_keyless" }), })); +const { setLogLevel } = await import("../../lib/log.ts"); const { login } = await import("./login.ts"); describe("login", () => { @@ -114,6 +115,7 @@ describe("login", () => { mockEnsureFirstApplication.mockResolvedValue(undefined); mockIsHuman.mockReturnValue(false); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); + setLogLevel("info"); consoleSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); try { @@ -592,6 +594,45 @@ describe("login", () => { expect(parsed.searchParams.get("clerk_client")).toBe("cli"); }); + test("does not emit the OAuth authorize URL through debug logging", async () => { + setLogLevel("debug"); + mockGetValidToken.mockResolvedValue(null); + mockBunSpawn(); + + const mockServer = { + port: 54321, + waitForCallback: mock().mockResolvedValue({ code: "fresh-auth-code" }), + stop: mock(), + }; + mockStartAuthServer.mockReturnValue(mockServer); + + mockExchangeCodeForToken.mockResolvedValue({ + access_token: "new-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "new-refresh-token", + }); + mockCreateOAuthSession.mockReturnValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: 123, + tokenType: "Bearer", + }); + mockStoreToken.mockResolvedValue(undefined); + mockFetchUserInfo.mockResolvedValue({ + userId: "user_new", + email: "new@example.com", + }); + mockSetAuth.mockResolvedValue(undefined); + + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + await runLogin({ showNextSteps: false }); + + expect(captured.err).not.toContain("https://test.example.com/oauth/authorize"); + expect(captured.err).not.toContain("test-state-value"); + expect(captured.err).not.toContain("test-code-challenge"); + }); + test("calls ensureFirstApplication after a successful OAuth flow", async () => { mockGetValidToken.mockResolvedValue(null); mockBunSpawn(); diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index ecddd4aa..bcaf8baf 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -1,28 +1,65 @@ # Deploy Command -> **Fully mocked.** This command uses hardcoded test data and is not yet wired to real APIs. The interactive prompts are real, but all API calls (application lookup, instance creation, DNS, OAuth credential storage) are simulated. +> **Live PLAPI lifecycle.** Human mode resolves the linked application, production domains, domain status, and instance config from the Platform API on each run. The production-instance lifecycle calls (`instances` and domain `status`) call the helpers in `lib/plapi.ts` directly. PLAPI error codes are translated to typed `CliError`s by `commands/deploy/errors.ts`. Guides a user through deploying their Clerk application to production. +When the CLI reaches the DNS configuration step, it displays the required CNAME records and then prompts the user to export those records as a BIND zone file (`./clerk-.zone`). The prompt defaults to "no" and the file is overwritten silently on subsequent runs. On new deploys, this handoff happens before OAuth setup so DNS propagation can start while the user configures providers. + +After all OAuth providers are configured, DNS verification checks DNS, SSL, and email DNS from the same domain status response. The wizard uses one shared exponential-backoff loop instead of waiting for each component separately. If polling times out, the retry handoff lists only DNS records tied to still-pending DNS-backed checks. For example, when DNS and email DNS are verified but SSL is still pending, the CLI shows green checks for the verified components and does not reprint already verified records. + ## Usage ```sh -clerk deploy # Interactive wizard (human mode) -clerk deploy --debug # With debug output -clerk deploy --mode agent # Output agent prompt instead of interactive flow +clerk deploy # Interactive, idempotent wizard (human mode) +clerk deploy --verbose # With debug output +clerk deploy --mode agent # Emit a read-only handoff for agents +clerk deploy status # Verify deploy completion without prompts +clerk deploy status --mode agent --wait # Agent verification with retrying wait ``` +## Global Options + +| Flag | Purpose | +| ----------- | -------------------------------------------- | +| `--verbose` | Show detailed deploy and PLAPI debug output. | + ## Agent Mode -> **TODO:** The `DEPLOY_PROMPT` string is hardcoded. It should probably fetch from the quickstart prompt in the Clerk docs instead. +When running `clerk deploy` in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), the command emits a structured JSON handoff via `log.data` on stdout. The handoff is read-only: it resolves the linked app, production instance, domain, domain status snapshot, and OAuth completeness, but it does not prompt, mutate config, trigger a DNS check, or poll. + +The handoff state is one of: + +| State | Meaning | +| --------------------- | ----------------------------------------------------------------------------------------------- | +| `not_started` | No production instance exists. Ask the human to run `clerk deploy`, then verify afterward. | +| `domain_provisioning` | A production instance exists, but PLAPI has not returned a production domain yet. | +| `domain_pending` | A production domain exists, but DNS, SSL, email DNS, or final server-side readiness is pending. | +| `oauth_pending` | The domain is verified, but supported OAuth providers still need production credentials. | +| `complete` | Production is deployed and verified. | -When running in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), this command outputs a structured prompt describing the full deployment flow instead of running the interactive wizard. The prompt includes: +Agent-mode `clerk deploy` exits successfully for linked projects because it is informational. The pass/fail gate is `clerk deploy status`. -- Prerequisites and pre-flight checks -- Domain selection options (custom vs. Clerk subdomain) -- Production instance creation steps -- OAuth credential collection for social providers -- All relevant Platform API endpoints +### `clerk deploy status` + +`clerk deploy status` is the read-only verification command for agents and automation. It resolves the same live deploy state, triggers a DNS check for active production domains, and reports DNS, SSL, email DNS, and OAuth completeness. Human mode waits with the shared exponential-backoff poll loop; agent mode performs one quick DNS check and returns the resulting status immediately by default. Pass `--wait` in agent mode to wait with the same poll loop: one immediate status read, then up to 5 retries with exponential backoff. + +In agent mode, `clerk deploy status` emits JSON on stdout with: + +- `complete`: `true` only when the domain is verified and all supported OAuth providers enabled in development have production credentials. +- `state`: `complete`, `domain_pending`, `oauth_pending`, `domain_provisioning`, or `not_started`. +- `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. +- `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks. +- `oauth`: configured, pending, and unsupported provider slugs. +- `nextAction`: the next step an agent should present to the user, including the Clerk Dashboard domains URL when a production instance exists. Agents should ask whether to open that URL for the user. + +Exit codes: + +| Exit | Meaning | +| ---- | --------------------------------------------------------------------------------------------- | +| `0` | Deploy is complete and verified. | +| `1` | The check ran successfully, but deploy is incomplete. Inspect `state` and `nextAction`. | +| else | A real CLI error occurred, such as not linked or an API failure, via the standard error path. | Agent mode is detected via the mode system (`src/mode.ts`), which checks in priority order: @@ -30,6 +67,26 @@ 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`) +The human-mode wizard still starts only in human mode. + +## PLAPI Lifecycle + +Human mode reads and writes deploy state through the Platform API on every run. The CLI does not persist deploy progress locally; the only profile write is the ordinary `instances.production` value once the production instance has been created. + +| Step | Endpoint | Behavior | +| -------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create production instance | `POST /v1/platform/applications/{appID}/instances` | Returns `id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and timestamps. DNS records are read from `active_domain.cname_targets[]`. | +| | | Performs clone feature compatibility validation before creating production resources. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | +| Trigger domain DNS check | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | Starts a server-side DNS check. `clerk deploy status` triggers this before resolving the live state. Agent-mode `clerk deploy status` returns after the post-trigger status snapshot by default; `--wait` polls active production domains with the shared retry loop. A 409 conflict means a check is already running. | +| Poll domain status | `GET /v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | Returns aggregate `status` plus nested DNS, SSL, email DNS, and proxy component status. The CLI drives one shared DNS verification loop over the full status response. The aggregate `status` guards proxy and other server-side readiness gates. It performs one immediate status read, then polls every 3s. | +| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Returns the updated config snapshot. Used to persist production `connection_oauth_*` credentials. | + +After displaying the DNS records block, when CNAME records are present the CLI prompts "Export DNS records as a BIND zone file? (y/N)". On yes, it writes `./clerk-.zone` in the current working directory. The file is a standard BIND fragment containing `$ORIGIN`, `$TTL 300`, and one fully-qualified CNAME per record. The default is "no" and the file is overwritten silently on subsequent runs. Most major DNS providers (Cloudflare, Route 53, Google Cloud DNS, and others) support importing BIND zone fragments. The same prompt appears on both new deploys and resumed deploys. + +PLAPI errors are translated to typed `CliError`s by `commands/deploy/errors.ts`. The CLI does not auto-retry SSL issuance or email DNS verification beyond the shared DNS verification loop. When domain status polling times out with SSL or email DNS still incomplete, the CLI surfaces the component status and instructs the user to rerun `clerk deploy` once DNS propagates. + +If the user presses Ctrl-C after the production instance has been created, the wizard tells them to run `clerk deploy` again and exits with SIGINT code 130. The next run derives the current DNS or OAuth step from API state and resumes without starting another production instance. + ## Sequence Diagram ```mermaid @@ -37,146 +94,61 @@ 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: {...}, ... } - - %% 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 - CLI->>User: Upgrade plan to continue - 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 - - CLI->>API: POST /v1/platform/applications/{appID}/domains/{domainID}/dns_check - API-->>CLI: { status } - end - %% Social Provider Credential Collection - Note over CLI: Dev config already fetched above —
check for enabled connection_oauth_* keys + %% Discover enabled OAuth providers in dev + CLI->>API: GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config?keys=connection_oauth_* + API-->>CLI: { connection_oauth_google: { enabled: true }, ... } + CLI->>API: GET /v1/platform/applications/{appID}/instances/{dev_instance_id}/config/schema?keys=connection_oauth_google,... + API-->>CLI: { properties: { connection_oauth_google: { properties: ... } } } - loop Each enabled social provider (e.g. google) - CLI->>User: Your app uses {Provider} OAuth. Have credentials? + %% Plan summary + domain + CLI->>User: Plan summary + CLI->>User: Production domain (e.g. example.com) + User->>CLI: example.com - 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" - end + %% Create production instance + domain in one round-trip, including clone validation + CLI->>API: POST /v1/platform/applications/{appID}/instances { domain, environment_type, clone_instance_id } + API-->>CLI: { id, active_domain: { id, name, cname_targets }, publishable_key, secret_key } - CLI->>User: Client ID: - User->>CLI: {client_id} - CLI->>User: Client Secret: - User->>CLI: {client_secret} + CLI->>User: Add these CNAME records to your DNS provider - 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 supported schema-backed OAuth provider + CLI->>User: Provider credentials + CLI->>API: PATCH /v1/platform/applications/{appID}/instances/{instance_id}/config { connection_oauth_{provider} } API-->>CLI: { before, after, config_version } end - %% Done + %% Poll domain status + loop every 3s until status == "complete" + CLI->>API: GET /v1/platform/applications/{appID}/domains/{domain_id_or_name}/status + API-->>CLI: { status, dns, ssl, mail, proxy } + end + CLI->>User: Production ready at https://{domain} - 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 } }` | +All endpoints are on the **Platform API** (`/v1/platform/...`) and are live HTTP calls. The deploy command calls the helpers in `lib/plapi.ts` directly. -## API Gaps - -### Creating a production instance for an existing app - -The current Platform API only creates instances during application creation via `POST /v1/platform/applications` with the `environment_types` parameter: - -```json -POST /v1/platform/applications -{ - "name": "my-app", - "environment_types": ["development", "production"], - "domain": "example.com" -} -``` - -There is **no endpoint** to add a production instance to an application that was originally created with only a development instance. This needs either: - -1. A new `POST /v1/platform/applications/{appID}/instances` endpoint -2. Or a different approach (e.g., re-creating the application) - -### Subscription feature comparison - -`GET /v1/platform/applications/{appID}/subscription` returns only basic metadata (`id`, `stripe_subscription_id`), not feature lists. Feature detection is done server-side in `pkg/pricing/pricing.go` by inspecting instance config. The CLI would need either: - -1. A new endpoint that returns the feature comparison result -2. Or access to plan feature lists to compare client-side +| Step | Method | Endpoint | Helper | +| --------------------------- | ------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Auth | n/a | Local config | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | +| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `fetchInstanceConfig` from `lib/plapi.ts`. Discovers enabled `connection_oauth_*` providers. | +| Read instance config schema | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config/schema` | `fetchInstanceConfigSchema`. Reads schemas for supported OAuth config keys so deploy can derive credential prompts. | +| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `patchInstanceConfig`. Writes production OAuth credentials. | +| Read application | `GET` | `/v1/platform/applications/{appID}` | `fetchApplication`. Resolves development and production instance IDs. | +| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | `listApplicationDomains`. Recovers production domain name and CNAME targets on each run. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and DNS records nested under `active_domain.cname_targets[]`. | +| Trigger domain DNS check | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | `triggerApplicationDomainDNSCheck`. Called by the wizard and by `clerk deploy status`; agent mode waits briefly, then reads one status snapshot. | +| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the wizard spinner and the human-mode `clerk deploy status` wait loop over the full domain status response. | ## OAuth Provider Config Format @@ -194,21 +166,24 @@ PATCH /v1/platform/applications/{appID}/instances/production/config } ``` -### Provider-specific required fields +### OAuth provider support -| Provider | Required Fields | -| --------- | ------------------------------------------------- | -| Google | `client_id`, `client_secret` | -| GitHub | `client_id`, `client_secret` | -| Microsoft | `client_id`, `client_secret` | -| Apple | `client_id`, `client_secret`, `key_id`, `team_id` | -| Linear | `client_id`, `client_secret` | +`clerk deploy` discovers enabled built-in OAuth providers from development config keys named `connection_oauth_{provider}`. For public built-in providers supported by the deploy flow, the command reads `GET /config/schema` and derives credential prompts from the provider schema. -Production instances return `422` if you try to enable a provider without credentials. +Most providers ask for `client_id` and `client_secret`. Provider-specific schema fields are prompted when they are safe for deploy setup, such as Linear `actor`. + +The CLI keeps small local overrides for provider setup details that schema does not fully describe: + +| Provider | Override | +| -------- | ------------------------------------------------------------------------------------------ | +| Google | Optional Google Cloud Console JSON import and OAuth consent screen warning | +| Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | -### Google OAuth `client_id` validation +For Google, the wizard can load `client_id` and `client_secret` from the top-level `web` object in a Google Cloud Console OAuth client JSON file, or from `installed` for desktop-style client downloads. The file contents are used in memory and are not written to CLI config. -Google enforces a pattern: `^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$` +Providers not currently supported by automated deploy setup are cloned to production without automated credential setup. Configure those providers from the Clerk Dashboard before going live. + +Production instances return `422` if you try to enable a provider without credentials. ## Helpful values for OAuth walkthrough diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts new file mode 100644 index 00000000..0d60a6c9 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -0,0 +1,164 @@ +import { test, expect, describe } from "bun:test"; +import { + bindZoneFile, + deployComponentLabels, + deployStatusRetryMessage, + dnsRecords, + nextStepsBlock, + pendingDnsRecords, +} from "./copy.ts"; +import type { CnameTarget } from "../../lib/plapi.ts"; + +describe("bindZoneFile", () => { + const fixedDate = new Date("2026-05-20T18:30:00.000Z"); + + test("renders a BIND fragment with $ORIGIN, $TTL, and one CNAME per target", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "accounts.example.com", value: "accounts.clerk.services", required: true }, + { + host: "clkmail.example.com", + value: "mail.example.com.nam1.clerk.services", + required: true, + }, + ]; + const output = bindZoneFile("example.com", targets, fixedDate); + + expect(output).toContain("; Generated by `clerk deploy` on 2026-05-20T18:30:00.000Z"); + expect(output).toContain( + "; Import into your existing zone for example.com to add Clerk's required DNS records.", + ); + expect(output).toContain("$ORIGIN example.com."); + expect(output).toContain("$TTL 300"); + expect(output).toContain("clerk.example.com.\tIN\tCNAME\tfrontend-api.clerk.services."); + expect(output).toContain("accounts.example.com.\tIN\tCNAME\taccounts.clerk.services."); + expect(output).toContain( + "clkmail.example.com.\tIN\tCNAME\tmail.example.com.nam1.clerk.services.", + ); + expect(output.endsWith("\n")).toBe(true); + }); + + test("includes optional records (required: false) without filtering", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: false }, + ]; + const output = bindZoneFile("example.com", targets, fixedDate); + + expect(output).toContain("clerk.example.com.\tIN\tCNAME\tfrontend-api.clerk.services."); + expect(output).toContain("clk2._domainkey.example.com.\tIN\tCNAME\tdkim2.clerk.services."); + }); + + test("does not double-append the trailing dot when the value already ends with one", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services.", required: true }, + ]; + const output = bindZoneFile("example.com", targets, fixedDate); + + expect(output).toContain("clerk.example.com.\tIN\tCNAME\tfrontend-api.clerk.services."); + expect(output).not.toContain("frontend-api.clerk.services.."); + }); +}); + +describe("dnsRecords", () => { + test("labels DKIM (_domainkey) records as email records, not bare CNAME", () => { + const targets: CnameTarget[] = [ + { host: "clk._domainkey.example.com", value: "dkim1.clerk.services", required: false }, + { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: false }, + ]; + const output = dnsRecords(targets); + + expect(output).toContain(" Host: clk._domainkey.example.com"); + expect(output).toContain(" Host: clk2._domainkey.example.com"); + // Both DKIM hosts must carry the email label, not fall through to the + // generic "CNAME" default (the host's first label is "clk"/"clk2"). + expect( + output.filter((line) => line.includes("Email (Clerk handles SPF/DKIM automatically)")), + ).toHaveLength(2); + expect(output.some((line) => line.trimStart().startsWith("CNAME"))).toBe(false); + }); +}); + +describe("deployComponentLabels", () => { + test("returns email DNS in-progress and done labels", () => { + expect(deployComponentLabels("mail", "example.com")).toEqual({ + progress: "Verifying email DNS records for example.com...", + done: "Email DNS records verified", + }); + }); + + test("returns dns labels matching the existing dnsVerified string", () => { + expect(deployComponentLabels("dns", "example.com")).toEqual({ + progress: "Verifying DNS records for example.com...", + done: "DNS verified for example.com.", + }); + }); + + test("returns ssl labels", () => { + expect(deployComponentLabels("ssl", "example.com")).toEqual({ + progress: "Issuing SSL certificate for example.com...", + done: "SSL certificate issued for example.com", + }); + }); +}); + +describe("deployStatusRetryMessage", () => { + test("includes current retry count and countdown", () => { + expect(deployStatusRetryMessage("Verifying DNS records for example.com...", 2, 5, 30)).toBe( + "Verifying DNS records for example.com... 2/5 attempts, retrying in 30s", + ); + }); +}); + +describe("nextStepsBlock", () => { + test("links directly to the production instance domain settings", () => { + const output = nextStepsBlock("app_123", "ins_456"); + + expect(output).toContain("View and manage domain configuration in the Clerk Dashboard"); + expect(output).toContain("https://dashboard.clerk.com/apps/app_123/instances/ins_456/domains"); + }); +}); + +describe("pendingDnsRecords", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "accounts.example.com", value: "accounts.clerk.services", required: true }, + { + host: "clkmail.example.com", + value: "mail.example.com.nam1.clerk.services", + required: true, + }, + ]; + + test("returns no records when only SSL remains pending", () => { + expect(pendingDnsRecords(targets, { dns: true, ssl: false, mail: true })).toEqual([]); + }); + + test("returns only email records when email DNS remains pending", () => { + const output = pendingDnsRecords(targets, { dns: true, ssl: true, mail: false }).join("\n"); + + expect(output).toContain("clkmail.example.com"); + expect(output).not.toContain("clerk.example.com"); + expect(output).not.toContain("accounts.example.com"); + }); + + test("returns non-email records when DNS remains pending", () => { + const output = pendingDnsRecords(targets, { dns: false, ssl: true, mail: true }).join("\n"); + + expect(output).toContain("clerk.example.com"); + expect(output).toContain("accounts.example.com"); + expect(output).not.toContain("clkmail.example.com"); + }); +}); + +describe("dnsRecords", () => { + test("labels DKIM CNAME records as email DNS records", () => { + const output = dnsRecords([ + { host: "clk._domainkey.example.com", value: "dkim1.clerk.services", required: true }, + { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: true }, + ]).join("\n"); + + expect(output).toContain("Email (Clerk handles SPF/DKIM automatically)"); + expect(output).not.toContain("\n CNAME\n Type:"); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts new file mode 100644 index 00000000..a4260fa4 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -0,0 +1,282 @@ +import { bold, cyan, dim, green, yellow } from "../../lib/color.ts"; +import type { CnameTarget } from "../../lib/plapi.ts"; + +export type DeployPlanStep = { + label: string; + status: "done" | "pending"; +}; + +export const INTRO_PREAMBLE = `This will prepare your linked Clerk app for production by cloning your +development instance into a new production instance and walking you through +the setup the dashboard would otherwise guide you through. + +Before you begin you will need: + - A domain you own (production cannot use a development subdomain). + - The ability to add DNS records on that domain. + - OAuth credentials for any social providers you have enabled in dev. + +${dim("Reference: https://clerk.com/docs/guides/development/deployment/production")}`; + +export function printPlan(appLabel: string, steps: readonly DeployPlanStep[]): string[] { + return [ + `clerk deploy will prepare ${cyan(appLabel)} for production:`, + "", + ...steps.map((step) => ` ${planStatus(step.status)} ${step.label}`), + ]; +} + +function planStatus(status: DeployPlanStep["status"]): string { + if (status === "done") return green("[x]"); + return yellow("[ ]"); +} + +export function dnsIntro(domain: string): string[] { + return [ + `Configure DNS for ${cyan(domain)}`, + "", + "Clerk uses DNS records to provide session management and emails", + "verified from your domain.", + "", + `${yellow("NOTE")} It can take up to 48 hours for DNS records to fully propagate.`, + `${dim(cyan("TIP"))} If you can't add a CNAME for the Frontend API, you can use a proxy:`, + dim(" https://clerk.com/docs/guides/dashboard/dns-domains/proxy-fapi"), + dim("Reference: https://clerk.com/docs/guides/development/deployment/production#dns-records"), + ]; +} + +export function domainAssociationSummary(domain: string): string[] { + const hosts = [`clerk.${domain}`, `accounts.${domain}`, `clkmail.${domain}`]; + return [ + `Clerk will associate these subdomains with ${cyan(domain)}:`, + "", + ...hosts.map((host) => ` ${cnameTargetLabel(host)} ${host}`), + "", + "This will create a Clerk production instance for your application.", + ]; +} + +export function dnsRecords(targets: readonly CnameTarget[]): string[] { + const lines = ["Add the following records at your DNS provider:"]; + for (const target of targets) { + const label = cnameTargetLabel(target.host); + const optional = target.required ? "" : ` ${dim("(optional)")}`; + lines.push( + "", + ` ${label}${optional}`, + ` Type: CNAME`, + ` Host: ${target.host}`, + ` Value: ${target.value}`, + ); + } + lines.push( + "", + `${yellow("NOTE")} If your DNS host proxies these records, set them to "DNS only" or verification will fail.`, + ); + return lines; +} + +export function pendingDnsRecords( + targets: readonly CnameTarget[], + status: DeployComponentStatus, +): string[] { + const pendingTargets = targets.filter((target) => cnameTargetPending(target, status)); + if (pendingTargets.length === 0) return []; + return dnsRecords(pendingTargets); +} + +export function cnameTargetPending(target: CnameTarget, status: DeployComponentStatus): boolean { + if (isMailCnameTarget(target)) return !status.mail; + return !status.dns; +} + +function isMailCnameTarget(target: CnameTarget): boolean { + const prefix = target.host.split(".", 1)[0]; + return prefix === "clkmail" || prefix === "clk" || prefix === "clk2"; +} + +function cnameTargetLabel(host: string): string { + const prefix = host.split(".", 1)[0]; + switch (prefix) { + case "clerk": + return "Frontend API"; + case "accounts": + return "Account portal"; + // `host.split(".", 1)[0]` yields only the first label, so DKIM records + // (clk._domainkey, clk2._domainkey) arrive here as "clk"/"clk2". + case "clkmail": + case "clk": + case "clk2": + 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.`, + "After OAuth setup, you can verify DNS or skip and finish. DNS propagation can take time.", + ]; +} + +export function dnsVerified(domain: string): string[] { + return [`DNS verified for ${domain}.`]; +} + +export type DeployComponentStatus = { + dns: boolean; + ssl: boolean; + mail: boolean; +}; + +export type DeployComponent = "mail" | "dns" | "ssl"; + +export function deployComponentLabels( + component: DeployComponent, + domain: string, +): { progress: string; done: string } { + switch (component) { + case "mail": + return { + progress: `Verifying email DNS records for ${domain}...`, + done: "Email DNS records verified", + }; + case "dns": + return { + progress: `Verifying DNS records for ${domain}...`, + done: `DNS verified for ${domain}.`, + }; + case "ssl": + return { + progress: `Issuing SSL certificate for ${domain}...`, + done: `SSL certificate issued for ${domain}`, + }; + } +} + +/** + * Status line for the domain checks Clerk verifies after the production + * instance is created: DNS propagation, SSL issuance via Let's Encrypt, and + * email DNS records. Each value comes from the same domain status response. + */ +export function deployComponentStatus(status: DeployComponentStatus): string { + const mark = (ok: boolean) => (ok ? green("✓") : yellow("pending")); + return `DNS: ${mark(status.dns)} SSL: ${mark(status.ssl)} Email DNS: ${mark(status.mail)}`; +} + +export function deployStatusRetryMessage( + message: string, + currentRetry: number, + totalRetries: number, + seconds: number, +): string { + return `${message} ${currentRetry}/${totalRetries} attempts, retrying in ${seconds}s`; +} + +/** + * Footer printed when domain status polling times out before all three + * components are complete. The user keeps the deploy state; rerunning + * `clerk deploy` resumes from whichever component is still pending. + */ +export function deployStatusPendingFooter(domain: string, status: DeployComponentStatus): string[] { + const pending: string[] = []; + if (!status.dns) pending.push("DNS"); + if (!status.ssl) pending.push("SSL"); + if (!status.mail) pending.push("email DNS"); + + const lead = + pending.length === 0 + ? `Production setup for ${domain} is still finalizing.` + : `${pending.join(", ")} still pending for ${domain}.`; + + return [ + lead, + "DNS propagation can take several hours depending on your provider.", + "Run `clerk deploy` again to resume. The production instance is already created.", + ]; +} + +export const OAUTH_SECTION_INTRO = `${bold("Configure OAuth credentials for production")} + +In development, Clerk provides shared OAuth credentials for most providers. +In production, those are not secure. You need your own credentials for +each enabled provider. + +${dim("Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/overview")}`; + +export function productionSummary( + domain: string, + completedOAuthProviderLabels: readonly string[], + domainStatus: "verified" | "pending" = "verified", +): string[] { + return [ + `Production ready at ${cyan(`https://${domain}`)}`, + "", + ` Domain ${domainStatus === "verified" ? "Verified" : "DNS pending"}`, + ` OAuth ${completedOAuthProviderLabels.length ? completedOAuthProviderLabels.join(", ") : "Not applicable"}`, + ]; +} + +export function nextStepsBlock(appId: string, productionInstanceId: string): string { + return `${bold("Next steps")} + + 1. Pull production keys into your environment + clerk env pull --instance prod + + This writes pk_live_... and sk_live_... to your .env. They replace your + pk_test_... and sk_test_... keys. + + 2. Update env vars on your hosting provider + Vercel, AWS, GCP, Heroku, Render, etc. all expose env vars in their UI. + Add the same pk_live_/sk_live_ values there. + + 3. Redeploy your app + + 4. (If applicable) Update webhook URLs and signing secrets + ${dim("https://clerk.com/docs/guides/development/webhooks/syncing#configure-your-production-instance")} + + 5. (If applicable) Update your Content Security Policy + ${dim("https://clerk.com/docs/guides/secure/best-practices/csp-headers")} + + 6. View and manage domain configuration in the Clerk Dashboard + ${dim(domainsDashboardUrl(appId, productionInstanceId))} + +${yellow("NOTE")} Production keys only work on your production domain. They will not work on localhost. + To run your dev environment, keep using your dev keys. + +${dim("Reference: https://clerk.com/docs/guides/development/deployment/production#api-keys-and-environment-variables")}`; +} + +export function domainsDashboardUrl(appId: string, productionInstanceId: string): string { + return `https://dashboard.clerk.com/apps/${appId}/instances/${productionInstanceId}/domains`; +} + +export function pausedMessage(stepDescription: string): string { + return `Deploy paused at: ${stepDescription} + +${pausedOperationNotice()}`; +} + +export function pausedOperationNotice(): string { + return `Deploy paused. + +Run \`clerk deploy\` again to continue from the current API state.`; +} + +function ensureTrailingDot(value: string): string { + return value.endsWith(".") ? value : `${value}.`; +} + +export function bindZoneFile(domain: string, targets: readonly CnameTarget[], now: Date): string { + const lines = [ + `; Generated by \`clerk deploy\` on ${now.toISOString()}`, + `; Import into your existing zone for ${domain} to add Clerk's required DNS records.`, + `$ORIGIN ${ensureTrailingDot(domain)}`, + `$TTL 300`, + ``, + ]; + for (const target of targets) { + lines.push(`${ensureTrailingDot(target.host)}\tIN\tCNAME\t${ensureTrailingDot(target.value)}`); + } + return `${lines.join("\n")}\n`; +} diff --git a/packages/cli-core/src/commands/deploy/errors.ts b/packages/cli-core/src/commands/deploy/errors.ts new file mode 100644 index 00000000..ae21ef01 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/errors.ts @@ -0,0 +1,132 @@ +/** + * PLAPI deploy error mapping. + * + * The handbook (`2026-05-15 clerk-plapi-deploy-endpoints-handbook.md`) maps + * HTTP status + `code` to a deterministic CLI action. This module is the + * single place that translation lives — callers wrap PLAPI calls with + * `mapDeployError(promise, { onProductionInstanceExists })` and either get + * the resolved value or a typed `CliError` they can branch on. + */ + +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; + +type ProductionInstanceExistsRecovery = () => Promise; + +export type MapDeployErrorOptions = { + /** + * When PLAPI returns 409 `production_instance_exists` for a creation call, + * the wizard should re-derive state via `fetchApplication` rather than + * surfacing the error. Pass a recovery callback here to opt into that path. + */ + onProductionInstanceExists?: ProductionInstanceExistsRecovery; +}; + +export async function mapDeployError( + promise: Promise, + options: MapDeployErrorOptions = {}, +): Promise { + try { + return await promise; + } catch (error) { + if (!(error instanceof PlapiError)) throw error; + const recovered = await maybeRecover(error, options); + if (recovered.recovered) return recovered.value; + throw translatePlapiError(error); + } +} + +type RecoveryOutcome = { recovered: true; value: T } | { recovered: false }; + +async function maybeRecover( + error: PlapiError, + options: MapDeployErrorOptions, +): Promise> { + if (error.status === 409 && error.code === "production_instance_exists") { + if (options.onProductionInstanceExists) { + return { recovered: true, value: await options.onProductionInstanceExists() }; + } + } + return { recovered: false }; +} + +function translatePlapiError(error: PlapiError): CliError | PlapiError { + const { status, code } = error; + + if (status === 402 && code === "unsupported_subscription_plan_features") { + return new CliError(planInsufficientMessage(error), { + code: ERROR_CODE.PLAN_INSUFFICIENT, + docsUrl: "https://clerk.com/pricing", + }); + } + + if (status === 400 && code === "provider_domain_operation_not_allowed_for_api") { + return new CliError( + "The home URL points to a provider domain (e.g. *.vercel.app, *.replit.app). " + + "Production instances require a domain you own — use a custom domain instead.", + { code: ERROR_CODE.PROVIDER_DOMAIN_NOT_ALLOWED }, + ); + } + + if (status === 400 && code === "home_url_taken") { + return new CliError( + "Another instance is already using that home URL. Pick a different domain and run `clerk deploy` again.", + { code: ERROR_CODE.HOME_URL_TAKEN }, + ); + } + + if (status === 409 && code === "production_instance_exists") { + // Reached only when the caller did NOT pass onProductionInstanceExists. + // Surface a typed error so any future call site can branch on it. + return new CliError( + "This application already has a production instance. Run `clerk deploy` to resume the existing flow.", + { code: ERROR_CODE.PRODUCTION_INSTANCE_EXISTS }, + ); + } + + if (status === 404 && code === "resource_not_found") { + return new CliError( + "Clerk couldn't find this application (it may have been deleted or your workspace no longer has access). " + + "Run `clerk link` to re-link this directory.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + if (status === 422 && code === "form_param_format_invalid") { + const paramName = readParamName(error.meta); + const baseMessage = error.message || "A request parameter was invalid."; + const message = paramName ? `${baseMessage} (parameter: ${paramName})` : baseMessage; + return new CliError(message, { code: ERROR_CODE.FORM_PARAM_INVALID }); + } + + // Pass everything else through unchanged — the global handler prints the + // PLAPI message with a "Platform API request failed" prefix. + return error; +} + +function planInsufficientMessage(error: PlapiError): string { + const features = readFeatures(error.meta); + if (features.length === 0) { + return ( + "Your subscription plan doesn't cover all the features enabled in your development instance. " + + "Upgrade your plan from the Clerk Dashboard before deploying." + ); + } + return ( + "Your subscription plan doesn't cover these features enabled in development:\n" + + features.map((f) => ` • ${f}`).join("\n") + + "\n\nUpgrade your plan from the Clerk Dashboard or disable these features in development before deploying." + ); +} + +function readFeatures(meta: Record | null): string[] { + if (!meta) return []; + const features = meta.features; + if (!Array.isArray(features)) return []; + return features.filter((f): f is string => typeof f === "string"); +} + +function readParamName(meta: Record | null): string | undefined { + if (!meta) return undefined; + const value = meta.param_name; + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index eb78e367..f0cfc991 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, afterEach, mock, spyOn } from "bun:test"; +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 { useCaptureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts"; +import { CliError, ERROR_CODE, EXIT_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -19,6 +23,16 @@ const mockSelect = mock(); const mockInput = mock(); const mockConfirm = mock(); const mockPassword = mock(); +const mockPatchInstanceConfig = mock(); +const mockFetchInstanceConfig = mock(); +const mockFetchInstanceConfigSchema = mock(); +const mockFetchApplication = mock(); +const mockListApplicationDomains = mock(); +const mockCreateProductionInstance = mock(); +const mockGetApplicationDomainStatus = mock(); +const mockTriggerApplicationDomainDNSCheck = mock(); +const mockSleep = mock(); +const mockOpenBrowser = mock(); mock.module("@inquirer/prompts", () => ({ ...promptsStubs, @@ -37,79 +51,489 @@ mock.module("../../lib/listage.ts", () => ({ select: (...args: unknown[]) => mockSelect(...args), })); +mock.module("../../lib/plapi.ts", () => ({ + fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), + fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), + fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), + createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), + getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), + triggerApplicationDomainDNSCheck: (...args: unknown[]) => + mockTriggerApplicationDomainDNSCheck(...args), + patchInstanceConfig: (...args: unknown[]) => mockPatchInstanceConfig(...args), +})); + +mock.module("../../lib/sleep.ts", () => ({ + sleep: (...args: unknown[]) => { + mockSleep(...args); + return Promise.resolve(); + }, +})); + +mock.module("../../lib/open.ts", () => ({ + openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), +})); + +const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts"); const { deploy } = await import("./index.ts"); +const { providerSetupIntro } = await import("./providers.ts"); +const { collectCustomDomain } = await import("./prompts.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; +} + +function domainStatus({ + status, + dns, + ssl, + mail, +}: { + status: "complete" | "incomplete"; + dns?: boolean; + ssl?: boolean; + mail?: boolean; +}) { + return { + status, + ...(dns === undefined ? {} : { dns: { status: dns ? "complete" : "not_started", cnames: {} } }), + ...(ssl === undefined + ? {} + : { ssl: { status: ssl ? "complete" : "not_started", required: true, failure_hints: [] } }), + ...(mail === undefined + ? {} + : { mail: { status: mail ? "complete" : "not_started", required: true } }), + }; +} + +const oauthSchema = (properties: Record) => ({ + type: "object", + description: "OAuth SSO connection configuration", + properties: { + enabled: { type: "boolean", default: false }, + authenticatable: { type: "boolean", default: true }, + block_email_subaddresses: { type: "boolean", default: false }, + ...properties, + }, +}); + +const basicOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "OAuth client ID" }, + client_secret: { + type: "string", + description: "OAuth client secret", + "x-clerk-sensitive": true, + }, +}); + +const appleOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "Apple Services ID" }, + client_secret: { + type: "string", + description: "Apple Private Key", + "x-clerk-sensitive": true, + }, + key_id: { type: "string", description: "Apple Key ID" }, + team_id: { type: "string", description: "Apple Team ID" }, + bundle_id: { + type: "string", + description: "iOS app Bundle ID for native Sign in with Apple", + }, +}); + +const linearOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "Linear OAuth client ID" }, + client_secret: { + type: "string", + description: "Linear OAuth client secret", + "x-clerk-sensitive": true, + }, + actor: { + type: "string", + description: "Linear OAuth actor", + enum: ["user", "application"], + default: "user", + }, +}); + +const schemaResponse = (properties: Record) => ({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://clerk.com/schemas/platform-config/2025-01-01", + type: "object", + properties, +}); + +function schemaForEnabledOAuth(config: Record) { + const properties: Record = {}; + for (const [key, value] of Object.entries(config)) { + if (!key.startsWith("connection_oauth_")) continue; + if (!value || typeof value !== "object") continue; + if ((value as Record).enabled !== true) continue; + properties[key] = key === "connection_oauth_apple" ? appleOAuthSchema : basicOAuthSchema; + } + return schemaResponse(properties); +} describe("deploy", () => { let consoleSpy: ReturnType; + let writeSpy: ReturnType; const captured = useCaptureLog(); + let tempDir: string; + + beforeEach(() => { + tempDir = ""; + // Sensible defaults so most tests need only override what they exercise. + mockFetchInstanceConfig.mockResolvedValue({ + connection_oauth_google: { enabled: true }, + }); + mockFetchInstanceConfigSchema.mockResolvedValue( + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + }), + ); + 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, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + mockCreateProductionInstance.mockImplementation( + (_appId: string, params: { domain: string }) => { + const hostname = params.domain; + return { + object: "instance", + id: "ins_prod_mock", + environment_type: "production" as const, + active_domain: { + object: "domain", + id: "dmn_prod_mock", + name: hostname, + is_satellite: false, + is_provider_domain: false, + frontend_api_url: `https://clerk.${hostname}`, + development_origin: "", + cname_targets: [ + { + host: `clerk.${hostname}`, + value: "frontend-api.clerk.services", + required: true, + }, + { + host: `accounts.${hostname}`, + value: "accounts.clerk.services", + required: true, + }, + { + host: `clkmail.${hostname}`, + value: `mail.${hostname}.nam1.clerk.services`, + required: true, + }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }, + publishable_key: "pk_live_test", + secret_key: "sk_live_test", + created_at: 1770000000000, + updated_at: 1770000000000, + }; + }, + ); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + // Guard the real filesystem. When the BIND export prompt is accepted the + // deploy flow writes `clerk-.zone` to the cwd, which would otherwise + // leak an artifact into the repo on every run. Intercept only `.zone` writes + // so config writes (setProfile) still hit disk in the temp dir. + const realBunWrite = Bun.write.bind(Bun) as (...args: unknown[]) => Promise; + writeSpy = spyOn(Bun, "write").mockImplementation(((destination: unknown, ...rest: unknown[]) => + String(destination).endsWith(".zone") + ? Promise.resolve(0) + : realBunWrite(destination, ...rest)) as typeof Bun.write); + }); - afterEach(() => { + afterEach(async () => { + _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(); + mockFetchInstanceConfigSchema.mockReset(); + mockFetchApplication.mockReset(); + mockListApplicationDomains.mockReset(); + mockCreateProductionInstance.mockReset(); + mockGetApplicationDomainStatus.mockReset(); + mockTriggerApplicationDomainDNSCheck.mockReset(); + mockSleep.mockReset(); + mockOpenBrowser.mockReset(); consoleSpy?.mockRestore(); + writeSpy?.mockRestore(); }); - function runDeploy(options: Parameters[0]) { + function runDeploy(options: Parameters[0] = {}) { return deploy(options); } - describe("agent mode", () => { - test("outputs deploy prompt and returns", async () => { - mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + async function runDeployUntilPause(options: Parameters[0] = {}) { + try { + await runDeploy(options); + } catch (error) { + if (!(error instanceof CliError) || !error.message.includes("Deploy paused")) { + throw error; + } + } + } - await runDeploy({}); + async function linkedProject(profile: Record = {}) { + tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-")); + _setConfigDir(tempDir); + const nextProfile = { + workspaceId: "workspace_123", + appId: "app_xyz789", + appName: "my-saas-app", + instances: { development: "ins_dev_123" }, + ...profile, + } as never; + await setProfile(process.cwd(), nextProfile); - expect(captured.out).toContain("deploying a Clerk application to production"); + 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; + cnameTargets?: readonly { host: string; value: string; required: boolean }[]; + } = {}, + ) { + const instanceId = options.instanceId ?? "ins_prod_mock"; + const domain = options.domain ?? "example.com"; + const domainId = options.domainId ?? "dmn_prod_mock"; + const developmentConfig = options.developmentConfig ?? { + connection_oauth_google: { enabled: true }, + }; + const productionConfig = options.productionConfig ?? { + connection_oauth_google: { enabled: false, client_id: "", client_secret: "" }, + }; + const cnameTargets = options.cnameTargets ?? [ + { host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true }, + ]; + + mockFetchApplication.mockResolvedValue({ + application_id: "app_xyz789", + name: "my-saas-app", + instances: [ + { + instance_id: "ins_dev_123", + environment_type: "development", + publishable_key: "pk_test_123", + }, + { + instance_id: instanceId, + environment_type: "production", + publishable_key: "pk_live_123", + }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: domainId, + name: domain, + is_satellite: false, + is_provider_domain: false, + frontend_api_url: `https://clerk.${domain}`, + accounts_portal_url: `https://accounts.${domain}`, + development_origin: "", + cname_targets: cnameTargets, + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }, + ], + total_count: 1, + }); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceIdOrEnv: string) => { + if (instanceIdOrEnv === instanceId || instanceIdOrEnv === "production") { + return productionConfig; + } + return developmentConfig; }); + mockFetchInstanceConfigSchema.mockResolvedValue(schemaForEnabledOAuth(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/authentication/social-connections/google", + ]); + expect(intros.github).toEqual([ + "Configure GitHub OAuth for production", + "Production GitHub sign-in requires custom OAuth credentials.", + "Reference: https://clerk.com/docs/authentication/social-connections/github", + ]); + expect(intros.microsoft).toEqual([ + "Configure Microsoft OAuth for production", + "Production Microsoft sign-in requires custom OAuth credentials.", + "Reference: https://clerk.com/docs/authentication/social-connections/microsoft", + ]); + expect(intros.apple).toEqual([ + "Configure Apple OAuth for production", + "Production Apple sign-in requires an Apple Services ID, Team ID, Key ID, and private key file.", + "Reference: https://clerk.com/docs/authentication/social-connections/apple", + ]); + expect(intros.linear).toEqual([ + "Configure Linear OAuth for production", + "Production Linear sign-in requires custom OAuth credentials.", + "Reference: https://clerk.com/docs/authentication/social-connections/linear", + ]); + }); - test("prompt includes all deployment steps", async () => { + describe("agent mode", () => { + test("not_started emits JSON handoff telling human to run wizard", async () => { mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + await linkedProject(); await runDeploy({}); - const output = captured.out; - expect(output).toContain("Prerequisites"); - expect(output).toContain("Verify Subscription Compatibility"); - expect(output).toContain("Choose a Production Domain"); - expect(output).toContain("Create the Production Instance"); - expect(output).toContain("Configure Social OAuth Providers"); - expect(output).toContain("Finalize"); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("not_started"); + expect(payload.nextAction).toContain("clerk deploy"); + expect(payload.nextAction).toContain("clerk deploy status"); }); - test("prompt includes API reference", async () => { + test("does not trigger interactive prompts", async () => { mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + await linkedProject(); - await runDeploy({}); + await runDeploy(); - const output = captured.out; - expect(output).toContain("/v1/platform/applications"); - expect(output).toContain("instances/production/config"); - expect(output).toContain("instances/development/config"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); }); - test("prompt includes OAuth redirect URI pattern", async () => { + test("complete deploy emits no-action handoff", async () => { mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_mock" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_mock", + domain: "example.com", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); await runDeploy({}); - const output = captured.out; - expect(output).toContain("accounts.{domain}/v1/oauth_callback"); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("complete"); + expect(payload.complete).toBe(true); + expect(captured.err).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); + expect(mockSleep).not.toHaveBeenCalled(); + expect(mockCreateProductionInstance).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); }); - test("does not trigger interactive prompts", async () => { + test("domain status read failures surface as errors", async () => { mockIsAgent.mockReturnValue(true); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_mock" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_mock", + domain: "example.com", + }); + mockGetApplicationDomainStatus.mockRejectedValue( + new PlapiError(500, JSON.stringify({ errors: [{ code: "server_error" }] }), "https://x"), + ); - await runDeploy({ debug: true }); + await expect(runDeploy({})).rejects.toBeInstanceOf(PlapiError); + expect(captured.out).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); expect(mockSelect).not.toHaveBeenCalled(); expect(mockInput).not.toHaveBeenCalled(); expect(mockConfirm).not.toHaveBeenCalled(); @@ -120,30 +544,1652 @@ describe("deploy", () => { describe("human mode", () => { function mockHumanFlow() { mockIsAgent.mockReturnValue(false); - // Domain selection → OAuth credential choice - mockSelect.mockResolvedValueOnce("clerk-subdomain").mockResolvedValueOnce("have-credentials"); + // Proceed → create instance → show DNS records → pause at OAuth. + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("example.com"); + } + + async function runDnsHandoff() { + mockHumanFlow(); + await runDeployUntilPause(); + mockLiveProduction(); + captured.clear(); + mockConfirm.mockReset(); + mockSelect.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + } + + function mockOAuthCompletion() { + mockSelect.mockResolvedValueOnce("have-credentials"); mockInput.mockResolvedValueOnce("fake-client-id-12345"); mockPassword.mockResolvedValueOnce("fake-secret"); } test("does not print deploy prompt", async () => { + await linkedProject(); mockHumanFlow(); consoleSpy = spyOn(console, "log").mockImplementation(() => {}); - await runDeploy({}); + await runDeployUntilPause(); const allOutput = captured.out; expect(allOutput).not.toContain("deploying a Clerk application to production"); }); - test("shows mock banner", async () => { + test("creates production instance without a separate clone-validation preflight", async () => { + await linkedProject(); mockHumanFlow(); - consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runDeployUntilPause(); + + expect(mockCreateProductionInstance).toHaveBeenCalledWith("app_xyz789", { + clone_instance_id: "ins_dev_123", + domain: "example.com", + environment_type: "production", + }); + expect(stripAnsi(captured.err)).not.toContain("Validating subscription compatibility"); + }); + + test("checks for an existing production instance before reading development config", async () => { + await linkedProject(); + mockHumanFlow(); + + await runDeployUntilPause(); + const err = stripAnsi(captured.err); + + const productionCheckIndex = err.indexOf("Checking for production instance..."); + const developmentConfigIndex = err.indexOf("Reading development configuration..."); + expect(productionCheckIndex).toBeGreaterThan(-1); + expect(developmentConfigIndex).toBeGreaterThan(-1); + expect(productionCheckIndex).toBeLessThan(developmentConfigIndex); + }); + + test("discovers enabled OAuth providers by iterating the dev config response", async () => { + await linkedProject(); + mockHumanFlow(); + mockFetchInstanceConfig.mockResolvedValueOnce({ + connection_oauth_google: { enabled: true }, + connection_oauth_github: { enabled: true }, + connection_oauth_microsoft: { enabled: false }, + connection_oauth_unknown: { enabled: true }, + unrelated_key: "ignored", + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + connection_oauth_github: basicOAuthSchema, + }), + ); + + await runDeployUntilPause(); + const err = stripAnsi(captured.err); + + expect(mockFetchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_dev_123"); + expect(err).toContain("Configure Google OAuth credentials"); + expect(err).toContain("Configure GitHub OAuth credentials"); + expect(err).not.toContain("Configure Microsoft OAuth credentials"); + expect(err).toContain( + "1 OAuth provider is enabled in development but not yet supported by automated `clerk deploy` setup.", + ); + expect(err).not.toContain("unknown"); + expect(err).toContain("Configure them from the Clerk Dashboard before going live"); + }); + + test("warns when enabled provider schema is not usable", async () => { + await linkedProject(); + mockHumanFlow(); + mockFetchInstanceConfig.mockResolvedValueOnce({ + connection_oauth_discord: { enabled: true }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ + connection_oauth_discord: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "number" }, + }, + }, + }), + ); + + await runDeployUntilPause(); + const err = stripAnsi(captured.err); + + expect(err).toContain( + "1 OAuth provider is enabled in development but not yet supported by automated `clerk deploy` setup.", + ); + expect(err).not.toContain("discord"); + expect(err).not.toContain("Discord"); + expect(err).not.toContain("Configure Discord OAuth credentials"); + }); + + test("DNS verification polls getApplicationDomainStatus until complete", async () => { + await linkedProject(); + // Proceed → create instance → check DNS now → complete OAuth. + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + mockPatchInstanceConfig.mockResolvedValueOnce({}); await runDeploy({}); + const err = stripAnsi(captured.err); - const allOutput = captured.out; - expect(allOutput).toContain("[mock]"); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledWith("app_xyz789", "dmn_prod_mock"); + expect(mockGetApplicationDomainStatus.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(err).toContain("DNS verified for example.com"); + expect(err).toContain("Production ready at https://example.com"); + }); + + test("DNS verification triggers a fresh DNS check before polling status", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockTriggerApplicationDomainDNSCheck.mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockGetApplicationDomainStatus.mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith( + "app_xyz789", + "dmn_prod_mock", + ); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledWith("app_xyz789", "dmn_prod_mock"); + expect(mockTriggerApplicationDomainDNSCheck.mock.invocationCallOrder[0]).toBeLessThan( + mockGetApplicationDomainStatus.mock.invocationCallOrder[0]!, + ); + }); + + test("DNS verification retries status polling five times with exponential backoff", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("check") + .mockResolvedValueOnce("skip"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(6); + expect(mockSleep).toHaveBeenCalledTimes(93); + expect(mockSleep.mock.calls.every(([delay]) => delay === 1000)).toBe(true); + }); + + test("Ctrl-C at the DNS retry prompt reports paused", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockSelect.mockResolvedValueOnce("check").mockRejectedValueOnce(promptExitError()); + + let error: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + error = caught as CliError; + } + + expect(error?.message).toContain("Deploy paused at: DNS verification"); + expect(error?.exitCode).toBe(EXIT_CODE.SIGINT); + const terminalOutput = stripAnsi(captured.err); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).not.toContain("Done"); + }); + + test("DNS verification checks all domain status components together", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true) // Create production instance? + .mockResolvedValueOnce(false); // Export BIND zone file? (wired in Task 5; harmless when not yet consumed) + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("DNS verified for example.com"); + expect(err).not.toContain("Mail sender verified"); + expect(err).not.toContain("SSL certificate issued for example.com"); + }); + + test("DNS verification uses one shared retry budget for all domain status components", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true) // Create production instance? + .mockResolvedValueOnce(false); // Export BIND zone file? + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("DNS verified for example.com"); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(6); + }); + + test("DNS verification pauses when status stays incomplete despite all exposed booleans true (proxy_ok case)", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + // Every poll returns dns/ssl/mail all true but status incomplete (proxy_ok = false on server). + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: true, mail: true }), + ); + mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: skip (wired in Task 5) + mockSelect.mockResolvedValueOnce("check"); + + let error: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + error = caught as CliError; + } + const err = stripAnsi(captured.err); + + expect(error?.message).toContain("Deploy paused at: DNS verification"); + expect(error?.exitCode).toBe(EXIT_CODE.GENERAL); + expect(err).toContain("Production setup for example.com is still finalizing."); + expect(err).toContain("Paused"); + expect(err).not.toContain("Production ready at"); + }); + + test("uses existing wizard framing and concise plan confirmation", async () => { + await linkedProject(); + mockHumanFlow(); + + await runDeployUntilPause(); + 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("[ ] Verify DNS records"); + expect(err).toContain("[ ] Configure Google OAuth credentials"); + expect(err).toContain("Check the Domains section in the Clerk Dashboard"); + }); + + test("asks directly for an owned production domain and accepts short domains", async () => { + await linkedProject(); + mockHumanFlow(); + + await runDeployUntilPause(); + + const firstInputArg = mockInput.mock.calls[0]?.[0] as { + message: string; + validate: (value: string) => true | string; + }; + expect(firstInputArg.message).toContain("Production domain"); + expect(firstInputArg.validate("x.io")).toBe(true); + expect(firstInputArg.validate("https://example.com")).toContain("without https://"); + expect(firstInputArg.validate("example..com")).toContain("Enter a valid domain"); + expect(firstInputArg.validate("example-.com")).toContain("Enter a valid domain"); + expect(firstInputArg.validate("-example.com")).toContain("Enter a valid domain"); + expect(firstInputArg.validate("demo.vercel.app")).toContain( + "Production needs a domain you own", + ); + expect(firstInputArg.validate("demo.clerk.app")).toContain( + "Production needs a domain you own", + ); + expect(mockSelect).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: "How would you like to set up your production domain?", + }), + ); + }); + + test("trims the collected production domain before returning it", async () => { + mockInput.mockResolvedValueOnce(" example.com "); + + await expect(collectCustomDomain()).resolves.toBe("example.com"); + }); + + test("Ctrl-C before changes are made reports cancelled instead of done", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockRejectedValueOnce(promptExitError()); + + await expect(runDeploy({})).rejects.toBeInstanceOf(UserAbortError); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined(); + const terminalOutput = stripAnsi(captured.err); + expect(terminalOutput).toContain("Cancelled"); + expect(terminalOutput).not.toContain("Done"); + }); + + test("Ctrl-C at domain collection reports cancelled instead of done", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true); + mockInput.mockRejectedValueOnce(promptExitError()); + + await expect(runDeploy({})).rejects.toBeInstanceOf(UserAbortError); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined(); + const terminalOutput = stripAnsi(captured.err); + expect(terminalOutput).toContain("Cancelled"); + expect(terminalOutput).not.toContain("Done"); + }); + + test("prints production next steps after successful deploy", async () => { + await linkedProject(); + await runDnsHandoff(); + mockOAuthCompletion(); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("Next steps"); + expect(err).toContain("clerk env pull --instance prod"); + expect(err).toContain("Update env vars on your hosting provider"); + expect(err).toContain( + "https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", + ); + expect(err).toContain("Production keys only work on your production domain"); + }); + + test("DNS setup prints dashboard handoff before OAuth without asking for verification", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true) // Create production instance? + .mockResolvedValueOnce(false); // Export DNS records as a BIND zone file? + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeployUntilPause(); + const err = stripAnsi(captured.err); + expect(err).toContain("Clerk will associate these subdomains with example.com"); + expect(err).toContain("clerk.example.com"); + expect(err).toContain("accounts.example.com"); + expect(err).toContain("clkmail.example.com"); + expect(err).toContain("This will create a Clerk production instance"); + expect(err).toContain("Add the following records at your DNS provider"); + expect(err).toContain("Check the Domains section in the Clerk Dashboard"); + expect(err).toContain("propagation and SSL issuance"); + expect(err).toContain("DNS propagation can take time"); + expect(mockConfirm).toHaveBeenCalledTimes(3); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Create production instance?", + default: true, + }); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Export DNS records as a BIND zone file?", + default: false, + }); + expect(mockConfirm).not.toHaveBeenCalledWith({ + message: "Continue to OAuth setup?", + default: true, + }); + expect(mockSelect).not.toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], + }); + }); + + test("declining production instance creation does not call the production instance API", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("Clerk will associate these subdomains with example.com"); + expect(err).toContain("No production instance was created."); + expect(mockCreateProductionInstance).not.toHaveBeenCalled(); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Create production instance?", + default: true, + }); + }); + + test("throws a CliError when createProductionInstance returns no active_domain", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("example.com"); + mockCreateProductionInstance.mockResolvedValueOnce({ + object: "instance", + id: "ins_prod_mock", + environment_type: "production" as const, + active_domain: null, + publishable_key: "pk_live_test", + secret_key: "sk_live_test", + created_at: 1770000000000, + updated_at: 1770000000000, + }); + + let thrown: unknown; + try { + await runDeploy({}); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(CliError); + expect((thrown as CliError).message).toContain("did not return a domain"); + }); + + test("Ctrl-C at the DNS handoff reports paused", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(promptExitError()); + mockInput.mockResolvedValueOnce("example.com"); + + let error: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + error = caught as CliError; + } + expect(error?.message).toContain("Deploy paused at: DNS verification"); + expect(error?.message).toContain("Run `clerk deploy` again"); + expect(error?.exitCode).toBe(EXIT_CODE.SIGINT); + const terminalOutput = stripAnsi(captured.err); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).not.toContain("Done"); + }); + + test("Google OAuth can load credentials from a downloaded JSON file", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + const googleJsonPath = join(tempDir, "client_secret_google.json"); + await Bun.write( + googleJsonPath, + JSON.stringify({ + web: { + client_id: "google-json-client.apps.googleusercontent.com", + client_secret: "fake-json-secret", + }, + }), + ); + await runDnsHandoff(); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("google-json"); + mockInput.mockResolvedValueOnce(googleJsonPath); + await runDeploy({}); + const oauthSelect = mockSelect.mock.calls.find((call) => + String((call[0] as { message?: string }).message).includes("Google OAuth"), + )?.[0] as { choices: Array<{ name: string; value: string }> }; + + expect(oauthSelect.choices).toContainEqual({ + name: "Load credentials from a Google Cloud Console JSON file", + value: "google-json", + }); + expect(mockPassword).not.toHaveBeenCalled(); + expect(captured.err).toContain("Saved Google OAuth credentials"); + }); + + test("Google OAuth walkthrough re-prompts with JSON import instead of another walkthrough", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + const googleJsonPath = join(tempDir, "client_secret_google.json"); + await Bun.write( + googleJsonPath, + JSON.stringify({ + web: { + client_id: "google-json-client.apps.googleusercontent.com", + client_secret: "fake-json-secret", + }, + }), + ); + await runDnsHandoff(); + mockOpenBrowser.mockResolvedValueOnce({ ok: true, launcher: "test" }); + mockSelect.mockResolvedValueOnce("walkthrough").mockResolvedValueOnce("google-json"); + mockInput.mockResolvedValueOnce(googleJsonPath); + mockPassword.mockResolvedValueOnce("manual-secret"); + + await runDeploy({}); + + const oauthSelects = mockSelect.mock.calls + .map( + (call) => + call[0] as { message?: string; choices?: Array<{ name: string; value: string }> }, + ) + .filter((call) => String(call.message).includes("Google OAuth")); + + expect(mockOpenBrowser).toHaveBeenCalledWith( + "https://clerk.com/docs/authentication/social-connections/google", + ); + expect(oauthSelects).toHaveLength(2); + expect(oauthSelects[1]?.choices).not.toContainEqual({ + name: "Walk me through creating them", + value: "walkthrough", + }); + expect(oauthSelects[1]?.choices).toContainEqual({ + name: "Load credentials from a Google Cloud Console JSON file", + value: "google-json", + }); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { + connection_oauth_google: { + enabled: true, + client_id: "google-json-client.apps.googleusercontent.com", + client_secret: "fake-json-secret", + }, + }); + }); + + test("OAuth walkthrough re-prompts without another walkthrough for non-Google providers", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_github" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_github", + developmentConfig: { + connection_oauth_github: { enabled: true }, + }, + productionConfig: { + connection_oauth_github: { enabled: true, client_id: "", client_secret: "" }, + }, + }); + mockOpenBrowser.mockResolvedValueOnce({ ok: true, launcher: "test" }); + mockSelect.mockResolvedValueOnce("walkthrough").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("github-client-id"); + mockPassword.mockResolvedValueOnce("github-secret"); + + await runDeploy({}); + + const oauthSelects = mockSelect.mock.calls + .map( + (call) => + call[0] as { message?: string; choices?: Array<{ name: string; value: string }> }, + ) + .filter((call) => String(call.message).includes("GitHub OAuth")); + + expect(oauthSelects).toHaveLength(2); + expect(oauthSelects[1]?.choices).not.toContainEqual({ + name: "Walk me through creating them", + value: "walkthrough", + }); + expect(oauthSelects[1]?.choices).not.toContainEqual({ + name: "Load credentials from a Google Cloud Console JSON file", + value: "google-json", + }); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_github", { + connection_oauth_github: { + enabled: true, + client_id: "github-client-id", + client_secret: "github-secret", + }, + }); + }); + + test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_apple" }, + }); + 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: "", + }, + }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + 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({}); + + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_apple", { + connection_oauth_apple: { + enabled: true, + client_id: "apple-services-id", + team_id: "apple-team-id", + key_id: "apple-key-id", + client_secret: + "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n", + }, + }); + const p8Input = mockInput.mock.calls.find((call) => + String((call[0] as { message?: string }).message).includes("Apple Private Key"), + )?.[0] as { validate: (value: string) => Promise }; + 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("Linear OAuth actor is collected from a select prompt", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + await runDnsHandoff(); + mockLiveProduction({ + developmentConfig: { + connection_oauth_linear: { enabled: true }, + }, + productionConfig: { + connection_oauth_linear: { enabled: true }, + }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ connection_oauth_linear: linearOAuthSchema }), + ); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("application"); + mockInput.mockResolvedValueOnce("linear-client-id"); + mockPassword.mockResolvedValueOnce("linear-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { + connection_oauth_linear: { + enabled: true, + client_id: "linear-client-id", + client_secret: "linear-secret", + actor: "application", + }, + }); + }); + + test("Google OAuth JSON file prompt validates path and shape before continuing", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + const invalidJsonPath = join(tempDir, "not-google.json"); + const googleJsonPath = join(tempDir, "client_secret_google.json"); + await Bun.write(invalidJsonPath, JSON.stringify({ nope: true })); + await Bun.write( + googleJsonPath, + JSON.stringify({ + web: { + client_id: "google-json-client.apps.googleusercontent.com", + client_secret: "fake-json-secret", + }, + }), + ); + await runDnsHandoff(); + mockConfirm.mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("google-json"); + mockInput.mockResolvedValueOnce(googleJsonPath); + await runDeploy({}); + + const jsonInput = mockInput.mock.calls.find((call) => + String((call[0] as { message?: string }).message).includes("Google OAuth JSON file path"), + )?.[0] as { validate: (value: string) => Promise }; + await expect(jsonInput.validate("df")).resolves.toContain("No file at df."); + await expect(jsonInput.validate(invalidJsonPath)).resolves.toContain( + `That JSON file doesn't look like a Google OAuth client download`, + ); + await expect(jsonInput.validate(googleJsonPath)).resolves.toBe(true); + const relativeJsonPath = relative(process.cwd(), googleJsonPath); + await expect(jsonInput.validate(relativeJsonPath)).resolves.toBe(true); + }); + + test("plain deploy is a no-op when the API reports deploy is already complete", async () => { + await linkedProject(); + mockLiveProduction({ + instanceId: "ins_prod_from_api", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); + mockIsAgent.mockReturnValue(false); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("clerk deploy will prepare my-saas-app for production"); + expect(err).toContain("[x] Create production instance"); + expect(err).toContain("[x] Verify 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("existing production warns generically when an enabled provider schema is not usable", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_unsupported" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_unsupported", + developmentConfig: { + connection_oauth_discord: { enabled: true }, + }, + productionConfig: { + connection_oauth_discord: { enabled: true }, + }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ + connection_oauth_discord: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "number" }, + }, + }, + }), + ); + mockIsAgent.mockReturnValue(false); + + await runDeploy({}); + const err = stripAnsi(captured.err); + const warningIndex = err.indexOf( + "1 OAuth provider is enabled in development but not yet supported by automated `clerk deploy` setup.", + ); + const noActionsIndex = err.indexOf("No deploy actions remain."); + const readyIndex = err.indexOf("Production ready at https://example.com"); + + expect(warningIndex).toBeGreaterThan(-1); + expect(noActionsIndex).toBeGreaterThan(-1); + expect(readyIndex).toBeGreaterThan(-1); + expect(warningIndex).toBeLessThan(noActionsIndex); + expect(warningIndex).toBeLessThan(readyIndex); + expect(err).not.toContain("discord"); + expect(err).not.toContain("Discord"); + expect(err).not.toContain("Configure Discord OAuth credentials"); + }); + + test("resume treats redacted sensitive OAuth credentials as present", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_discord" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_discord", + developmentConfig: { + connection_oauth_discord: { enabled: true }, + }, + productionConfig: { + connection_oauth_discord: { + enabled: true, + client_id: "discord-client-id", + client_secret: "••••••••", + }, + }, + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ connection_oauth_discord: basicOAuthSchema }), + ); + mockIsAgent.mockReturnValue(false); + mockSelect.mockResolvedValueOnce("next-steps"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("[x] Configure Discord OAuth credentials"); + expect(mockPassword).not.toHaveBeenCalled(); + }); + + test("plain deploy resumes DNS verification from live API state", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: {}, + }); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("[x] Create production instance"); + expect(err).toContain("[ ] Verify 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("resume DNS verification prints CNAME records before the verification prompt", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockConfirm.mockResolvedValueOnce(false); // BIND export prompt placeholder (wired in Task 5) + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + const recordsIdx = err.indexOf("Add the following records at your DNS provider"); + const promptIdx = err.indexOf("DNS verification"); + expect(recordsIdx).toBeGreaterThan(-1); + expect(promptIdx).toBeGreaterThan(-1); + expect(recordsIdx).toBeLessThan(promptIdx); + }); + + test("BIND export prompt writes the zone file when the user accepts", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockConfirm.mockResolvedValueOnce(true); // BIND export prompt: yes + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeDefined(); + const pathArg = zoneCall![0]; + const contentArg = zoneCall![1]; + expect(String(pathArg)).toMatch(/clerk-example\.com\.zone$/); + expect(String(contentArg)).toContain("$ORIGIN example.com."); + expect(String(contentArg)).toContain("$TTL 300"); + expect(String(contentArg)).toContain("IN\tCNAME"); + expect(err).toContain("Wrote "); + expect(err).toContain("clerk-example.com.zone"); + }); + + test("BIND export prompt writes no file when the user declines", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: no + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeUndefined(); + expect(err).not.toContain("Wrote "); + }); + + test("BIND export prompt is skipped when cnameTargets is empty", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + cnameTargets: [], // override: domain has no CNAME targets + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + + // confirm() was never called for the BIND prompt in this run. + const bindPromptCalls = mockConfirm.mock.calls.filter((call) => { + const arg = call[0] as { message?: string } | undefined; + return typeof arg?.message === "string" && arg.message.includes("BIND zone file"); + }); + expect(bindPromptCalls.length).toBe(0); + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeUndefined(); + }); + + test("DNS verification timeout names the specific pending components from domain status", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: false }), + ); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("SSL, email DNS still pending for example.com"); + expect(err).not.toContain("DNS, SSL, email DNS still pending"); + }); + + test("DNS verification treats absent components as pending", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", ssl: true, mail: true }), + ); + mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("DNS: pending SSL: ✓ Email DNS: ✓"); + expect(err).toContain("DNS still pending for example.com"); + expect(err).not.toContain("Domain Verified"); + }); + + test("DNS verification timeout does not reprint DNS records when only SSL remains pending", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + mockInput.mockResolvedValueOnce("example.com"); + mockSelect + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("check") + .mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), + ); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("DNS: ✓ SSL: pending Email DNS: ✓"); + expect(err).toContain("SSL still pending for example.com"); + expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(1); + }); + + test("plain deploy can skip DNS verification and continue configuring production", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("skip"); + mockConfirm.mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("Saved Google OAuth credentials"); + expect(err).toContain("Domain DNS pending"); + expect(err).not.toContain("Domain Verified"); + expect(mockSelect).toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], + }); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(1); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_123", { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "google-secret", + }, + }); + }); + + test("DNS handoff points users to the Clerk Dashboard for propagation status", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeployUntilPause(); + const err = stripAnsi(captured.err); + expect(err).toContain("Check the Domains section in the Clerk Dashboard"); + expect(err).toContain("DNS propagation can take time"); + expect(err).toContain("Configure Google OAuth for production"); + }); + + test("Ctrl-C during OAuth setup reports plain deploy continuation", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + await runDnsHandoff(); + mockSelect.mockRejectedValueOnce(promptExitError()); + + 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 = stripAnsi(captured.err); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).not.toContain("Done"); + }); + + test("saves OAuth credentials to the production instance from live deploy state", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_created_456" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_created_456", + productionConfig: {}, + }); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetApplicationDomainStatus.mockReset(); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + + await runDeploy({}); + + const err = stripAnsi(captured.err); + expect(captured.err).toContain("\x1b[1mConfigure OAuth credentials for production\x1b[0m"); + expect(err).toContain("Configure Google OAuth for production"); + expect(err).toContain( + "Production Google sign-in requires custom OAuth credentials from Google Cloud Console.", + ); + expect(err).toContain( + "Reference: https://clerk.com/docs/authentication/social-connections/google", + ); + expect(mockConfirm).not.toHaveBeenCalledWith({ + message: "Set up Google OAuth now?", + default: true, + }); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_created_456", { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "google-secret", + }, + }); + }); + + test("plain deploy resolves complete live API state without prompting", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("[x] Create production instance"); + expect(err).toContain("[x] Verify DNS records"); + expect(err).toContain("No deploy actions remain."); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + }); + + test("plain deploy persists production instance discovered from live API", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_live", + developmentConfig: {}, + productionConfig: {}, + }); + + await runDeploy({}); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_live"); + }); + + test("custom-domain DNS setup can skip verification and later resume", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeployUntilPause(); + mockLiveProduction(); + expect(stripAnsi(captured.err)).toContain("Check the Domains section in the Clerk Dashboard"); + expect(stripAnsi(captured.err)).toContain("Configure Google OAuth for production"); + + captured.clear(); + mockConfirm.mockReset(); + mockSelect.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("check"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetApplicationDomainStatus.mockReset(); + mockGetApplicationDomainStatus + .mockResolvedValueOnce( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ) + .mockResolvedValueOnce( + domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), + ); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_mock"); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "google-secret", + }, + }); + expect(err).toContain("DNS verified for example.com"); + expect(err).not.toContain("Issuing SSL certificates"); + expect(err).not.toContain("SSL certificates are usually issued"); + expect(err).not.toContain("SSL Issuing"); + expect(err).toContain("Production ready at https://example.com"); + }); + + test("OAuth setup can pause and resume at the pending provider", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + await runDnsHandoff(); + mockSelect.mockResolvedValueOnce("skip"); + + let pauseError: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + pauseError = caught as CliError; + } + expect(pauseError?.message).toContain("Deploy paused at: Google OAuth credential setup"); + expect(pauseError?.message).toContain("Run `clerk deploy` again"); + expect(pauseError?.exitCode).toBe(EXIT_CODE.GENERAL); + const pausedErr = stripAnsi(captured.err); + expect(pausedErr).toContain("Paused"); + + captured.clear(); + mockConfirm.mockReset(); + mockSelect.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_mock"); + expect(err).toContain("Saved Google OAuth credentials"); + expect(err).toContain("Production ready at https://example.com"); + }); + + test("Pausing OAuth mid-loop infers earlier completed providers from production config", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockFetchInstanceConfig.mockResolvedValue({ + connection_oauth_google: { enabled: true }, + connection_oauth_github: { enabled: true }, + }); + mockFetchInstanceConfigSchema.mockResolvedValue( + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + connection_oauth_github: basicOAuthSchema, + }), + ); + // Proceed → create prod → enter google creds → skip github before DNS verification. + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockSelect.mockResolvedValueOnce("have-credentials").mockResolvedValueOnce("skip"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + let pauseError: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + pauseError = caught as CliError; + } + expect(pauseError?.message).toContain("Deploy paused at: GitHub OAuth credential setup"); + expect(pauseError?.exitCode).toBe(EXIT_CODE.GENERAL); + mockLiveProduction({ + developmentConfig: { + connection_oauth_google: { enabled: true }, + connection_oauth_github: { enabled: true }, + }, + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + connection_oauth_github: { enabled: true, client_id: "", client_secret: "" }, + }, + }); + + // Resume and finish: should not re-prompt for google, should finalize. + captured.clear(); + mockConfirm.mockReset(); + mockSelect.mockReset(); + mockInput.mockReset(); + mockPassword.mockReset(); + mockPatchInstanceConfig.mockReset(); + mockSelect.mockResolvedValueOnce("have-credentials"); + mockInput.mockResolvedValueOnce("github-client-id"); + mockPassword.mockResolvedValueOnce("github-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + expect(mockPatchInstanceConfig).toHaveBeenCalledTimes(1); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { + connection_oauth_github: { + enabled: true, + client_id: "github-client-id", + client_secret: "github-secret", + }, + }); + expect(err).toContain("Production ready at https://example.com"); + }); + + test("OAuth success output stays attached to the save step before spacing the next provider", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_multi" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_multi", + developmentConfig: { + connection_oauth_apple: { enabled: true }, + connection_oauth_github: { enabled: true }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + client_id: "", + team_id: "", + key_id: "", + client_secret: "", + }, + connection_oauth_github: { enabled: true, client_id: "", client_secret: "" }, + }, + }); + mockIsAgent.mockReturnValue(false); + const validP8Path = join(tempDir, "AuthKey.p8"); + await Bun.write( + validP8Path, + "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n", + ); + mockSelect + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("have-credentials"); + mockInput + .mockResolvedValueOnce("com.example.app") + .mockResolvedValueOnce("TEAMID1234") + .mockResolvedValueOnce("KEYID12345") + .mockResolvedValueOnce(validP8Path) + .mockResolvedValueOnce("github-client-id"); + mockPassword.mockResolvedValueOnce("github-secret"); + mockPatchInstanceConfig.mockResolvedValue({}); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain( + "Saved Apple OAuth credentials\n│\n│ Configure GitHub OAuth for production", + ); + }); + + test("DNS verification timeout can skip and continue configuring production", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockSelect + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("check") + .mockResolvedValueOnce("skip"); + mockInput + .mockResolvedValueOnce("example.com") + .mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + + await runDeploy({}); + const err = stripAnsi(captured.err); + expect(err).toContain("DNS propagation can take several hours"); + expect(err).toContain("DNS, SSL, email DNS still pending for example.com"); + expect(err).toContain("DNS: pending"); + expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(2); + expect(err).toContain("Host: clerk.example.com"); + expect(err).toContain("Value: frontend-api.clerk.services"); + expect(err).toContain("Skipping DNS verification for now."); + expect(err).toContain("Saved Google OAuth credentials"); + expect(mockSelect).toHaveBeenCalledWith({ + message: "DNS verification", + choices: [ + { name: "Skip DNS verification for now", value: "skip" }, + { name: "Check again", value: "check" }, + ], + }); + expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "google-secret", + }, + }); + }); + + test("discovers and supports enabled OAuth providers from API schema", async () => { + await linkedProject(); + mockHumanFlow(); + mockFetchInstanceConfig.mockResolvedValueOnce({ + connection_oauth_google: { enabled: true }, + connection_oauth_coinbase: { enabled: true }, + connection_oauth_twitter: { enabled: true }, + connection_oauth_discord: { enabled: true }, + connection_oauth_microsoft: { enabled: false }, + unrelated_key: "ignored", + }); + mockFetchInstanceConfigSchema.mockResolvedValueOnce( + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + connection_oauth_coinbase: basicOAuthSchema, + connection_oauth_twitter: basicOAuthSchema, + connection_oauth_discord: basicOAuthSchema, + }), + ); + + await runDeployUntilPause(); + const err = stripAnsi(captured.err); + + expect(mockFetchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_dev_123"); + expect(mockFetchInstanceConfigSchema).toHaveBeenCalledWith("app_xyz789", "ins_dev_123", [ + "connection_oauth_google", + "connection_oauth_coinbase", + "connection_oauth_twitter", + "connection_oauth_discord", + ]); + expect(err).toContain("Configure Google OAuth credentials"); + expect(err).toContain("Configure Coinbase OAuth credentials"); + expect(err).toContain("Configure Twitter OAuth credentials"); + expect(err).toContain("Configure Discord OAuth credentials"); + expect(err).not.toContain("Configure Microsoft OAuth credentials"); + expect(err).not.toContain("not yet supported by automated `clerk deploy` setup"); + }); + + test("unlinked directory throws NOT_LINKED instead of warning and exiting 0", async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-")); + _setConfigDir(tempDir); + mockIsAgent.mockReturnValue(false); + + let thrown: CliError | undefined; + try { + await runDeploy({}); + } catch (caught) { + thrown = caught as CliError; + } + expect(thrown).toBeInstanceOf(CliError); + expect(thrown?.code).toBe(ERROR_CODE.NOT_LINKED); + expect(thrown?.message).toContain("No Clerk project linked"); + expect(mockFetchApplication).not.toHaveBeenCalled(); + }); + + test("recovers from 409 production_instance_exists and persists the recovered instance id", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + mockInput.mockResolvedValueOnce("example.com"); + mockCreateProductionInstance.mockReset(); + mockCreateProductionInstance.mockRejectedValueOnce( + new PlapiError( + 409, + JSON.stringify({ errors: [{ code: "production_instance_exists", message: "exists" }] }), + ), + ); + // First fetchApplication call (during resolveDeployContext) sees no production + // instance, so the wizard takes the startNewDeploy path and hits the 409. + mockFetchApplication.mockResolvedValueOnce({ + application_id: "app_xyz789", + name: "my-saas-app", + instances: [ + { + instance_id: "ins_dev_123", + environment_type: "development", + publishable_key: "pk_test_123", + }, + ], + }); + // Subsequent reads (the recovery refresh + reconcile) see the production + // instance that another flow created, with google OAuth already configured. + mockLiveProduction({ + instanceId: "ins_prod_recovered", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); + + await runDeploy({}); + + const err = stripAnsi(captured.err); + expect(err).toContain("A production instance already exists"); + expect(err).toContain("Resuming the existing deploy"); + expect(err).toContain("Production ready at https://example.com"); + expect(mockFetchApplication.mock.calls.length).toBeGreaterThanOrEqual(2); + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_recovered"); }); }); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 4f7b8023..0dcc12c1 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,257 +1,626 @@ -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"; - -const DEPLOY_PROMPT = `You are deploying a Clerk application to production. Follow these steps: +import { isInsideGutter, log } from "../../lib/log.ts"; +import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; +import { + CliError, + ERROR_CODE, + UserAbortError, + isPromptExitError, + throwUsageError, +} from "../../lib/errors.ts"; +import { setProfile } from "../../lib/config.ts"; +import { + createProductionInstance as apiCreateProductionInstance, + patchInstanceConfig, + type CnameTarget, + type ProductionInstanceResponse, +} from "../../lib/plapi.ts"; +import { + INTRO_PREAMBLE, + OAUTH_SECTION_INTRO, + type DeployPlanStep, + deployComponentLabels, + deployComponentStatus, + deployStatusPendingFooter, + domainAssociationSummary, + bindZoneFile, + dnsDashboardHandoff, + dnsIntro, + dnsRecords, + nextStepsBlock, + pendingDnsRecords, + printPlan, + productionSummary, +} from "./copy.ts"; +import { mapDeployError } from "./errors.ts"; +import { + providerLabel, + providerSetupIntro, + showOAuthWalkthrough, + type OAuthProvider, + type OAuthProviderDescriptor, +} from "./providers.ts"; +import { + chooseDnsVerificationAction, + chooseDnsVerificationRetryAction, + chooseOAuthCredentialAction, + collectCustomDomain, + collectOAuthCredentials, + confirmCreateProductionInstance, + confirmExportBindZone, + confirmProceed, +} from "./prompts.ts"; +import { + DeployPausedError, + deployPausedError, + type DeployContext, + type DeployOperationState, +} from "./state.ts"; +import { + buildDeployStatusReport, + loadDevelopmentOAuthProviders, + resolveDeployContext, + resolveDeployState, + resolveLiveApplicationContext, + resolveLiveDeploySnapshot, + waitForDeployStatus, + type DeployStatusOutcome, + type DiscoveredOAuthProviders, + type LiveDeploySnapshot, +} from "./status.ts"; + +type DeployOptions = Record; + +export async function deploy(_options: DeployOptions = {}) { + if (isAgent()) { + await emitAgentDeployHandoff(); + return; + } -## Prerequisites + intro("clerk deploy"); + try { + const ctx = await resolveDeployContext(); + await runDeploy(ctx); + } catch (error) { + if (error instanceof DeployPausedError && isInsideGutter()) { + outro("Paused"); + } + if (isPromptExitError(error) && isInsideGutter()) { + outro("Cancelled"); + throw new UserAbortError(); + } + throw error; + } finally { + // Successful and paused paths call outro themselves. This balances the + // intro gutter if an unexpected error escapes. + if (isInsideGutter()) { + outro("Failed"); + } + } +} -Ensure the following before starting: -- The user is authenticated (\`clerk auth login\` has been run) -- A Clerk application is linked to the project (\`clerk link\` has been run) -- The project has a development instance with a working configuration +async function emitAgentDeployHandoff(): Promise { + const ctx = await resolveDeployContext(); + if (!ctx.appId || !ctx.developmentInstanceId) { + throw new CliError( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } -## Step 1: Verify Subscription Compatibility + const state = await resolveDeployState(ctx); + const report = buildDeployStatusReport(state, null); + log.data(JSON.stringify(report, null, 2)); +} -Check that the development instance's features are covered by the application's subscription plan. +async function runDeploy(ctx: DeployContext): Promise { + if (!ctx.appId || !ctx.developmentInstanceId) { + throw new CliError( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } -- Fetch the development config: \`GET /v1/platform/applications/{appID}/instances/development/config\` -- Fetch the subscription: \`GET /v1/platform/applications/{appID}/subscription\` -- If any development features are not covered by the plan, the user must upgrade before deploying. + if (ctx.productionInstanceId) { + await reconcileExistingDeploy(ctx); + return; + } -## Step 2: Choose a Production Domain + await startNewDeploy(ctx); +} -Ask the user which domain setup they prefer: +async function startNewDeploy(ctx: DeployContext): Promise { + const { descriptors: oauthProviders, unsupported }: DiscoveredOAuthProviders = + await loadDevelopmentOAuthProviders(ctx); -**Option A: Custom domain** -- The user provides their own domain (e.g., example.com) -- DNS must be configured to point to Clerk. Check if the DNS provider supports Domain Connect for automatic setup. -- If Domain Connect is available, direct the user to the Domain Connect URL to authorize DNS changes. -- If not, provide the DNS records the user must add manually. -- Verify DNS propagation: \`POST /v1/platform/applications/{appID}/domains/{domainID}/dns_check\` + log.blank(); + log.info(INTRO_PREAMBLE); + log.blank(); + for (const line of printPlan(ctx.appLabel, buildNewDeployPlan(oauthProviders))) { + log.info(line); + } + log.blank(); -**Option B: Clerk-provided subdomain** -- A subdomain like \`{adjective}-{animal}-{number}.clerk.app\` is automatically assigned. -- No DNS configuration is needed. + warnUnsupportedOAuthProviders(unsupported.length); + const proceed = await confirmProceed(); + if (!proceed) { + log.info("No changes were made."); + outro("Cancelled"); + return; + } -## Step 3: Create the Production Instance + bar(); + const domain = await collectCustomDomain(); + const shouldCreateProductionInstance = await confirmProductionInstanceCreation(domain); + if (!shouldCreateProductionInstance) return; -Create or configure the production instance for the application. -- Add the domain: \`POST /v1/platform/applications/{appID}/domains\` with body \`{ "name": "", "is_satellite": false }\` -- Note: There is currently no dedicated endpoint to add a production instance to an existing app. This may require \`POST /v1/platform/applications\` with \`environment_types: ["development", "production"]\`. + const productionOrExists = await createProductionInstance(ctx, domain); + if (productionOrExists === "exists") { + log.blank(); + log.info( + "A production instance already exists for this application. Resuming the existing deploy.", + ); + log.blank(); + const refreshed = await withSpinner("Refreshing application state...", () => + resolveLiveApplicationContext(ctx.profile), + ); + ctx.productionInstanceId = refreshed.productionInstanceId; + if (refreshed.productionInstanceId) { + await persistProductionInstance(ctx, refreshed.productionInstanceId); + } + await reconcileExistingDeploy(ctx); + return; + } + const production = productionOrExists; + await persistProductionInstance(ctx, production.id); -## Step 4: Configure Social OAuth Providers + if (!production.active_domain) { + throw new CliError( + "Production instance was created but Clerk did not return a domain. " + + "Run `clerk deploy` again to retry domain provisioning.", + ); + } -For each social provider enabled in the development instance (e.g., Google, GitHub, Apple), production OAuth credentials are required. + log.blank(); + + const productionDomain = production.active_domain.name; + const cnameTargets = production.active_domain.cname_targets ?? []; + let completedOAuthProviders: OAuthProvider[] = []; + const operationState: DeployOperationState = { + appId: ctx.appId, + developmentInstanceId: ctx.developmentInstanceId, + productionInstanceId: production.id, + productionDomainId: production.active_domain.id, + domain: productionDomain, + pending: { type: "oauth", provider: oauthProviders[0]?.provider ?? "google" }, + oauthProviders: oauthProviders.map((descriptor) => descriptor.provider), + completedOAuthProviders, + cnameTargets, + }; -Check the dev config for \`connection_oauth_*\` keys. For each enabled provider: + await runDnsRecordHandoff({ ...operationState, pending: { type: "dns" } }, cnameTargets); -1. Collect the required credentials from the user: - - Most providers: \`client_id\` and \`client_secret\` - - Apple: also requires \`key_id\` and \`team_id\` + bar(); + completedOAuthProviders = await runOAuthSetup(ctx, operationState, oauthProviders); -2. When helping the user create OAuth credentials, provide these values: - - Authorized JavaScript origins: \`https://{domain}\` and \`https://www.{domain}\` - - Authorized redirect URI: \`https://accounts.{domain}/v1/oauth_callback\` + bar(); + const dnsStatus = await runDnsVerificationPrompt(ctx, { + ...operationState, + pending: { type: "dns" }, + completedOAuthProviders, + }); -3. Write credentials to production config: - \`PATCH /v1/platform/applications/{appID}/instances/production/config\` - Body: \`{ "connection_oauth_{provider}": { "enabled": true, "client_id": "...", "client_secret": "..." } }\` + await finishDeploy(ctx, productionDomain, completedOAuthProviders, dnsStatus); +} -Provider-specific documentation: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/{provider} +async function reconcileExistingDeploy(ctx: DeployContext): Promise { + if (ctx.productionInstanceId && ctx.profile.instances.production !== ctx.productionInstanceId) { + await persistProductionInstance(ctx, ctx.productionInstanceId); + } -## Step 5: Finalize + const snapshot = await resolveLiveDeploySnapshot(ctx); + if (!snapshot) { + log.blank(); + log.info("A production instance exists, but Clerk did not return a production domain yet."); + log.info("Run `clerk deploy` again after the domain is available from the API."); + outro("No deploy actions available"); + return; + } -After all configuration is complete: -- Inform the user their production application is ready at \`https://{domain}\` -- Remind them to redeploy their application with the updated Clerk production secret keys -- They can pull production keys with: \`clerk env pull --instance prod\` + log.blank(); + for (const line of printPlan(ctx.appLabel, buildLiveDeployPlan(snapshot))) { + log.info(line); + } + log.blank(); -## API Reference + warnUnsupportedOAuthProviders(snapshot.unsupportedOAuthProviderCount); -| 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 | + if (!snapshot.pending) { + log.info("No deploy actions remain."); + await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, "verified"); + return; + } -Refer to the Clerk Platform API docs for detailed request/response schemas.`; + let dnsStatus: DnsVerificationResult = snapshot.domainComplete ? "verified" : "pending"; -export async function deploy(options: { debug?: boolean }) { - if (isAgent()) { - log.data(DEPLOY_PROMPT); - return; + if ( + snapshot.pending.type === "oauth" || + snapshot.oauthProviders.length > snapshot.completedOAuthProviders.length + ) { + bar(); + const completed = await runOAuthSetup( + ctx, + { + ...snapshot, + pending: { + type: "oauth", + provider: + snapshot.oauthProviders.find( + (provider) => !snapshot.completedOAuthProviders.includes(provider), + ) ?? + snapshot.oauthProviders[0] ?? + "google", + }, + }, + snapshot.oauthProviderDescriptors, + ); + snapshot.completedOAuthProviders = completed; } - if (options.debug) { - const { setLogLevel } = await import("../../lib/log.ts"); - setLogLevel("debug"); + + if (!snapshot.domainComplete) { + dnsStatus = await runExistingDomainDnsVerification(ctx, { + ...snapshot, + pending: { type: "dns" }, + }); } - log.data("[mock] This command uses mocked data and is not yet wired up to real APIs.\n"); + await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, dnsStatus); +} - log.debug("Checking for authenticated user and linked application..."); +type DnsVerificationResult = "verified" | "pending"; - // Mock state — will be replaced with real lookups - const user = { id: "user_abc123", email: "kyle@clerk.dev" }; - const application = { id: "app_xyz789", name: "my-saas-app" }; +function warnUnsupportedOAuthProviders(count: number): void { + if (count === 0) return; - log.debug(`Found authenticated user: ${user.email} (${user.id})`); - log.debug(`Found linked application: ${application.name} (${application.id})`); + const plural = count === 1 ? "" : "s"; + const verb = count === 1 ? "is" : "are"; + log.warn( + `${count} OAuth provider${plural} ${verb} enabled in development but not yet supported by automated \`clerk deploy\` setup.`, + ); + log.warn( + "These providers may not have working production credentials. Configure them from the Clerk Dashboard before going live, or disable them in development first.", + ); + log.blank(); +} - log.debug("Checking for production instance..."); - log.debug("No production instance found."); +function buildNewDeployPlan(oauthProviders: readonly OAuthProviderDescriptor[]): DeployPlanStep[] { + return [ + { label: "Create production instance", status: "pending" }, + { label: "Choose a production domain you own", status: "pending" }, + ...oauthProviders.map((descriptor) => ({ + label: `Configure ${descriptor.label} OAuth credentials`, + status: "pending" as const, + })), + { label: "Verify DNS records", status: "pending" }, + ]; +} - // Mock state — check subscription vs dev instance features - log.debug("Checking development instance features against subscription..."); - const devFeatures = ["email_auth", "social_oauth"]; - const subscriptionFeatures = ["email_auth", "social_oauth"]; - const unsupported = devFeatures.filter((f) => !subscriptionFeatures.includes(f)); +function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { + return [ + { label: "Create production instance", status: "done" }, + { label: `Use production domain ${snapshot.domain}`, status: "done" }, + ...snapshot.oauthProviderDescriptors.map((descriptor): DeployPlanStep => { + const status: DeployPlanStep["status"] = snapshot.completedOAuthProviders.includes( + descriptor.provider, + ) + ? "done" + : "pending"; + return { + label: `Configure ${descriptor.label} OAuth credentials`, + status, + }; + }), + { label: "Verify DNS records", status: snapshot.domainComplete ? "done" : "pending" }, + ]; +} - if (unsupported.length > 0) { - log.debug(`Found features not covered by subscription: ${unsupported.join(", ")}`); - log.debug("User must upgrade their plan before deploying."); - return; +async function createProductionInstance( + ctx: DeployContext, + domain: string, +): Promise { + return withSpinner("Creating production instance...", async () => { + return mapDeployError( + apiCreateProductionInstance(ctx.appId, { + domain, + environment_type: "production", + clone_instance_id: ctx.developmentInstanceId, + }), + { onProductionInstanceExists: async () => "exists" }, + ); + }); +} + +async function confirmProductionInstanceCreation(domain: string): Promise { + for (const line of domainAssociationSummary(domain)) log.info(line); + log.blank(); + const confirmed = await confirmCreateProductionInstance(); + if (confirmed) { + log.blank(); + return true; } - log.debug("All development features are covered by subscription."); + log.blank(); + log.info("No production instance was created."); + outro("Cancelled"); + return false; +} + +async function runDnsRecordHandoff( + state: DeployOperationState, + cnameTargets: readonly CnameTarget[], +): Promise { + for (const line of dnsIntro(state.domain)) log.info(line); + log.blank(); + if (cnameTargets.length > 0) { + for (const line of dnsRecords(cnameTargets)) log.info(line); + log.blank(); + } - 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", - }, - ], - }); + for (const line of dnsDashboardHandoff(state.domain)) log.info(line); + log.blank(); + try { + await offerBindZoneExport(state.domain, cnameTargets); + log.blank(); + } catch (error) { + if (isPromptExitError(error)) { + throw deployPausedError(state, { interrupted: true }); + } + throw error; + } +} - let domain: string; +async function runExistingDomainDnsVerification( + ctx: DeployContext, + state: DeployOperationState, +): Promise { + await runDnsRecordHandoff(state, state.cnameTargets ?? []); + return runDnsVerificationPrompt(ctx, state); +} - 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 runDnsVerificationPrompt( + ctx: DeployContext, + state: DeployOperationState, +): Promise { + try { + const action = await chooseDnsVerificationAction(); + if (action === "skip") { + log.blank(); + log.info("Skipping DNS verification for now."); + return "pending"; + } + return await runDnsVerification(ctx, state); + } catch (error) { + if (isPromptExitError(error)) { + throw deployPausedError(state, { interrupted: true }); + } + throw error; } +} - log.debug("Creating production instance..."); - log.debug(`Production instance created with domain: ${domain}`); +async function runDnsVerification( + ctx: DeployContext, + state: DeployOperationState, +): Promise { + const domainIdOrName = state.productionDomainId ?? state.domain; - // DNS setup for custom domains - if (domainChoice === "custom-domain") { - log.debug(`Looking up DNS provider for ${domain}...`); + while (true) { + const outcome = await pollDeployStatus(ctx.appId, domainIdOrName, state.domain); - // 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 (outcome.verified) { + log.blank(); + log.info(deployComponentStatus(outcome.status)); + return "verified"; + } - 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}`); + log.blank(); + log.info(deployComponentStatus(outcome.status)); + log.blank(); + for (const line of deployStatusPendingFooter(state.domain, outcome.status)) { + log.warn(line); + } - await confirm({ - message: `We can automatically configure DNS for ${domain} via ${dnsProvider.name}. Open browser to continue?`, - default: true, - }); + // When all DNS components are verified but the server has not yet marked the + // deployment complete, the user cannot influence the remaining wait. + if (outcome.status.dns && outcome.status.ssl && outcome.status.mail) { + throw deployPausedError(state); + } - log.debug("Opening Domain Connect flow in browser..."); + const pendingRecords = state.cnameTargets + ? pendingDnsRecords(state.cnameTargets, outcome.status) + : []; + if (pendingRecords.length > 0) { + log.blank(); + for (const line of pendingRecords) log.info(line); + } + log.blank(); + let action: Awaited>; + try { + action = await chooseDnsVerificationRetryAction(); + } catch (error) { + if (isPromptExitError(error)) { + throw deployPausedError(state, { interrupted: true }); + } + throw error; + } + if (action === "skip") { + log.blank(); + log.info("Skipping DNS verification for now."); + return "pending"; + } } +} - // Check dev instance settings that require production credentials - log.debug("Checking development instance settings for production requirements..."); +async function pollDeployStatus( + appId: string, + domainIdOrName: string, + domain: string, +): Promise { + return waitForDeployStatus(appId, domainIdOrName, domain, { + runVerification: (progressLabel, work) => withSpinner(progressLabel, work), + onVerified: () => log.success(deployComponentLabels("dns", domain).done), + }); +} - // Mock state — dev instance has Google OAuth enabled - const devSettings = { - socialProviders: ["google"], - }; +async function offerBindZoneExport( + domain: string, + cnameTargets: readonly CnameTarget[] | undefined, +): Promise { + if (!cnameTargets || cnameTargets.length === 0) return; + const accepted = await confirmExportBindZone(); + if (!accepted) return; + const contents = bindZoneFile(domain, cnameTargets, new Date()); + const filePath = `${process.cwd()}/clerk-${domain}.zone`; + await Bun.write(filePath, contents); + log.success(`Wrote ${filePath}`); +} - if (devSettings.socialProviders.length > 0) { - log.debug( - `Found social providers requiring production credentials: ${devSettings.socialProviders.join(", ")}`, - ); +/** + * Configures every provider in `descriptors`, returning the full set of + * completed providers. If the user skips a provider or interrupts the prompt, + * this pauses by throwing `DeployPausedError` rather than returning a partial + * list, so a successful return always means OAuth is fully complete. + */ +async function runOAuthSetup( + ctx: DeployContext, + state: DeployOperationState, + descriptors: readonly OAuthProviderDescriptor[], +): Promise { + const completed = new Set(state.completedOAuthProviders as OAuthProvider[]); + + if (descriptors.length > 0) { + log.info(OAUTH_SECTION_INTRO); + log.blank(); + } - 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`; + for (const descriptor of descriptors) { + if (completed.has(descriptor.provider)) continue; + try { + const productionInstanceId = + state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production; + if (!productionInstanceId) { + throwUsageError( + "Cannot save OAuth credentials because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.", + ); + } - const 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", - }, + const saved = await collectAndSaveOAuthCredentials( + ctx, + descriptor, + state.domain, + productionInstanceId, + ); + if (!saved) { + throw deployPausedError({ + ...state, + pending: { type: "oauth", provider: descriptor.provider }, + completedOAuthProviders: [...completed], + }); + } + } catch (error) { + if (isPromptExitError(error)) { + throw deployPausedError( { - name: "I already have my credentials", - value: "have-credentials", + ...state, + pending: { type: "oauth", provider: descriptor.provider }, + completedOAuthProviders: [...completed], }, - ], - }); - - if (credentialChoice === "walkthrough") { - log.data( - `\n${bold(`When configuring your ${displayName} OAuth app, use these values:`)}\n`, + { interrupted: true }, ); - 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"); } + throw error; + } + completed.add(descriptor.provider); + if (descriptors.some((nextDescriptor) => !completed.has(nextDescriptor.provider))) { + log.blank(); + } + } - const clientId = await input({ - message: `${displayName} OAuth Client ID:`, - }); + return [...completed]; +} - await password({ - message: `${displayName} OAuth Client Secret:`, - }); +async function collectAndSaveOAuthCredentials( + ctx: DeployContext, + descriptor: OAuthProviderDescriptor, + domain: string, + productionInstanceId: string, +): Promise { + for (const line of providerSetupIntro(descriptor)) log.info(line); + log.blank(); - log.debug(`Received ${displayName} credentials (client ID: ${clientId.slice(0, 8)}...)`); - } + let choice = await chooseOAuthCredentialAction(descriptor); - log.debug("All social provider credentials collected."); + if (choice === "skip") { + return false; } - log.debug("Deploy complete."); + if (choice === "walkthrough") { + await showOAuthWalkthrough(descriptor, domain); + choice = await chooseOAuthCredentialAction(descriptor, { includeWalkthrough: false }); + if (choice === "skip") { + return false; + } + } - 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( + descriptor, + choice === "google-json" ? "google-json" : "manual", ); - printNextSteps(NEXT_STEPS.DEPLOY); + await withSpinner(`Saving ${descriptor.label} OAuth credentials...`, async () => { + await patchInstanceConfig(ctx.appId, productionInstanceId, { + [descriptor.configKey]: { + enabled: true, + ...credentials, + }, + }); + }); + log.success(`Saved ${descriptor.label} OAuth credentials`); + return true; +} + +async function persistProductionInstance(ctx: DeployContext, productionInstanceId: string) { + await setProfile(ctx.profileKey, { + ...ctx.profile, + instances: { + ...ctx.profile.instances, + production: productionInstanceId, + }, + }); + ctx.profile.instances.production = productionInstanceId; + ctx.productionInstanceId = productionInstanceId; +} + +async function finishDeploy( + ctx: DeployContext, + domain: string, + completedOAuthProviders: readonly string[], + dnsStatus: DnsVerificationResult, +): Promise { + log.blank(); + for (const line of productionSummary( + domain, + completedOAuthProviders.map((provider) => providerLabel(provider)), + dnsStatus, + )) { + log.info(line); + } + log.blank(); + const productionInstanceId = ctx.productionInstanceId ?? ctx.profile.instances.production; + if (!productionInstanceId) { + throwUsageError( + "Cannot print deploy next steps because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.", + ); + } + log.info(nextStepsBlock(ctx.appId, productionInstanceId)); + outro("Success"); } 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..891644a1 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -0,0 +1,261 @@ +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 { type OAuthPromptField, type OAuthProviderDescriptor } from "./providers.ts"; + +type OAuthCredentialAction = "have-credentials" | "walkthrough" | "google-json" | "skip"; +type DnsVerificationAction = "check" | "skip"; + +const PROVIDER_DOMAIN_SUFFIXES = [ + ".clerk.app", + ".vercel.app", + ".netlify.app", + ".pages.dev", + ".fly.dev", + ".render.com", + ".herokuapp.com", +]; + +export async function confirmProceed(): Promise { + return confirm({ message: "Proceed?", default: true }); +} + +export async function collectCustomDomain(): Promise { + const domain = await input({ + message: "Production domain (e.g. example.com)", + validate: (value) => validateDomain(value), + }); + return domain.trim(); +} + +export function validateDomain(value: string): true | string { + const domain = value.trim(); + if (!domain) return "Enter a domain."; + if (domain.startsWith("http://") || domain.startsWith("https://")) { + return "Enter a valid domain, such as example.com (without https://)."; + } + if (!/^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(domain)) { + return "Enter a valid domain, such as example.com (without https://)."; + } + if (PROVIDER_DOMAIN_SUFFIXES.some((suffix) => domain.toLowerCase().endsWith(suffix))) { + return `${domain} looks like a provider domain (e.g. *.vercel.app, *.clerk.app). Production needs a domain you own. See https://clerk.com/docs/guides/development/deployment/production`; + } + return true; +} + +export async function confirmCreateProductionInstance(): Promise { + return confirm({ + message: "Create production instance?", + default: true, + }); +} + +export async function chooseDnsVerificationAction(): Promise { + return select({ + message: "DNS verification", + choices: [ + { name: "Check DNS now", value: "check" }, + { name: "Skip DNS verification for now", value: "skip" }, + ], + }); +} + +export async function chooseDnsVerificationRetryAction(): Promise { + return select({ + message: "DNS verification", + choices: [ + { name: "Skip DNS verification for now", value: "skip" }, + { name: "Check again", value: "check" }, + ], + }); +} + +export async function confirmExportBindZone(): Promise { + return confirm({ + message: "Export DNS records as a BIND zone file?", + default: false, + }); +} + +export async function chooseOAuthCredentialAction( + descriptor: OAuthProviderDescriptor, + options: { includeWalkthrough?: boolean } = {}, +): Promise { + const choices: Array<{ name: string; value: OAuthCredentialAction }> = [ + { name: descriptor.credentialLabel, value: "have-credentials" }, + ]; + if (options.includeWalkthrough !== false) { + choices.push({ name: "Walk me through creating them", value: "walkthrough" }); + } + if (descriptor.credentialSources.includes("google-json")) { + choices.push({ + name: "Load credentials from a Google Cloud Console JSON file", + value: "google-json", + }); + } + choices.push({ + name: "Skip for now and run `clerk deploy` again later", + value: "skip", + }); + + return select({ + message: `${descriptor.label} 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( + descriptor: OAuthProviderDescriptor, + source: "manual" | "google-json" = "manual", +): Promise> { + if (descriptor.provider === "google" && source === "google-json") { + return collectGoogleJsonCredentials(); + } + + const credentials: Record = {}; + for (const field of descriptor.fields) { + credentials[field.key] = await collectOAuthField(descriptor, field); + } + return credentials; +} + +async function collectOAuthField( + descriptor: OAuthProviderDescriptor, + field: OAuthPromptField, +): Promise { + const message = `${descriptor.label} OAuth ${field.label}`; + let value: string; + if (field.filePath) { + const path = await input({ message, validate: validateSecretFilePath(field.label) }); + value = await readSecretFile(path); + } else if (field.type === "select") { + value = await select({ + message, + choices: (field.options ?? []).map((option) => ({ name: option, value: option })), + default: field.defaultValue, + }); + } else if (field.secret) { + value = await password({ message, validate: required(field.label) }); + } else { + value = await input({ + message, + default: field.defaultValue, + validate: required(field.label), + }); + } + return field.filePath ? value : value.trim(); +} + +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.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts new file mode 100644 index 00000000..b756b386 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; +import { + buildOAuthProviderDescriptors, + providerFields, + providerLabel, + type OAuthProviderDescriptor, +} from "./providers.ts"; +import type { InstanceConfigSchema } from "../../lib/plapi.ts"; + +const oauthSchema = (properties: Record) => ({ + type: "object", + description: "OAuth SSO connection configuration", + properties: { + enabled: { type: "boolean", default: false }, + authenticatable: { type: "boolean", default: true }, + block_email_subaddresses: { type: "boolean", default: false }, + ...properties, + }, +}); + +const basicOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "OAuth client ID" }, + client_secret: { + type: "string", + description: "OAuth client secret", + "x-clerk-sensitive": true, + }, +}); + +const schemaResponse = (properties: Record): InstanceConfigSchema => ({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://clerk.com/schemas/platform-config/2025-01-01", + type: "object", + properties: properties as InstanceConfigSchema["properties"], +}); + +function descriptorByProvider( + descriptors: readonly OAuthProviderDescriptor[], + provider: string, +): OAuthProviderDescriptor { + const descriptor = descriptors.find((item) => item.provider === provider); + if (!descriptor) throw new Error(`missing descriptor for ${provider}`); + return descriptor; +} + +describe("deploy OAuth provider descriptors", () => { + test("builds a descriptor for public providers from schema and shared metadata", () => { + const result = buildOAuthProviderDescriptors( + ["discord"], + schemaResponse({ connection_oauth_discord: basicOAuthSchema }), + ); + + expect(result.unsupported).toEqual([]); + const discord = descriptorByProvider(result.supported, "discord"); + expect(discord.label).toBe("Discord"); + expect(discord.configKey).toBe("connection_oauth_discord"); + expect(discord.docsUrl).toContain("/discord"); + expect(discord.fields.map((field) => field.key)).toEqual(["client_id", "client_secret"]); + expect(discord.fields.map((field) => field.label)).toEqual(["Client ID", "Client Secret"]); + expect(discord.fields[1]).toMatchObject({ secret: true }); + expect(discord.requiredCredentialKeys).toEqual(["client_id", "client_secret"]); + }); + + test("supports schema-compatible providers without a static deploy allowlist", () => { + const result = buildOAuthProviderDescriptors( + ["google", "example_schema_provider"], + schemaResponse({ + connection_oauth_google: basicOAuthSchema, + connection_oauth_example_schema_provider: basicOAuthSchema, + }), + ); + + expect(result.supported.map((item) => item.provider)).toEqual([ + "google", + "example_schema_provider", + ]); + expect(result.unsupported).toEqual([]); + }); + + test("Google descriptor exposes manual and JSON credential sources", () => { + const result = buildOAuthProviderDescriptors( + ["google"], + schemaResponse({ + connection_oauth_google: oauthSchema({ + client_id: { type: "string", description: "Google OAuth client ID" }, + client_secret: { + type: "string", + description: "Google OAuth client secret", + "x-clerk-sensitive": true, + }, + show_account_selector_prompt: { + type: "boolean", + description: "Whether to show the account selector prompt during OAuth flow", + default: false, + }, + }), + }), + ); + + const google = descriptorByProvider(result.supported, "google"); + expect(google.credentialSources).toEqual(["manual", "google-json"]); + expect(google.fields.map((field) => field.key)).toEqual(["client_id", "client_secret"]); + expect(result.unsupported).toEqual([]); + }); + + test("marks missing schema as unsupported", () => { + const result = buildOAuthProviderDescriptors(["discord"], schemaResponse({})); + + expect(result.supported).toEqual([]); + expect(result.unsupported).toEqual(["discord"]); + }); + + test("uses enum fields for provider-specific select prompts", () => { + const result = buildOAuthProviderDescriptors( + ["linear"], + schemaResponse({ + connection_oauth_linear: oauthSchema({ + client_id: { type: "string", description: "Linear OAuth client ID" }, + client_secret: { + type: "string", + description: "Linear OAuth client secret", + "x-clerk-sensitive": true, + }, + actor: { + type: "string", + description: "Actor type for Linear OAuth token", + enum: ["user", "application"], + default: "user", + }, + }), + }), + ); + + const linear = descriptorByProvider(result.supported, "linear"); + expect(linear.fields).toContainEqual({ + key: "actor", + label: "Actor", + description: "Actor type for Linear OAuth token", + type: "select", + options: ["user", "application"], + defaultValue: "user", + secret: false, + filePath: false, + }); + }); + + test("applies Apple production credential overrides", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ + connection_oauth_apple: oauthSchema({ + client_id: { type: "string", description: "Apple Services ID" }, + client_secret: { + type: "string", + description: "Apple Private Key", + "x-clerk-sensitive": true, + }, + key_id: { type: "string", description: "Apple Key ID" }, + team_id: { type: "string", description: "Apple Team ID" }, + bundle_id: { + type: "string", + description: "iOS app Bundle ID for native Sign in with Apple", + }, + }), + }), + ); + + const apple = descriptorByProvider(result.supported, "apple"); + expect(apple.fields.map((field) => field.key)).toEqual([ + "client_id", + "team_id", + "key_id", + "client_secret", + ]); + expect(apple.fields.map((field) => field.label)).toEqual([ + "Apple Services ID", + "Apple Team ID", + "Apple Key ID", + "Apple Private Key - path to .p8 file", + ]); + expect(apple.fields.find((field) => field.key === "client_secret")).toMatchObject({ + filePath: true, + label: "Apple Private Key - path to .p8 file", + }); + expect(apple.requiredCredentialKeys).toEqual([ + "client_id", + "team_id", + "key_id", + "client_secret", + ]); + }); + + test("keeps compatibility prompt labels only for behavioral overrides", () => { + expect(providerFields("google").map((field) => field.label)).toEqual([ + "Client ID", + "Client Secret", + ]); + expect(providerFields("microsoft").map((field) => field.label)).toEqual([ + "Client ID", + "Client Secret", + ]); + expect(providerFields("apple").map((field) => field.label)).toEqual([ + "Apple Services ID", + "Apple Team ID", + "Apple Key ID", + "Apple Private Key - path to .p8 file", + ]); + expect(providerFields("linear").map((field) => field.label)).toEqual([ + "Client ID", + "Client Secret", + ]); + }); + + test("providerLabel falls back to title-cased unknown slugs", () => { + expect(providerLabel("linkedin_oidc")).toBe("LinkedIn"); + expect(providerLabel("new_provider")).toBe("New Provider"); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts new file mode 100644 index 00000000..ecf55888 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -0,0 +1,467 @@ +import { OAUTH_PROVIDERS } from "@clerk/shared/oauth"; +import { bold, cyan, dim, yellow } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import { openBrowser } from "../../lib/open.ts"; +import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi.ts"; + +const DEFAULT_DOCS_URL_PREFIX = + "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; + +/** + * OAuth provider slug used by deploy. + */ +export type OAuthProvider = string; + +/** + * Existing prompt field shape consumed by the current deploy flow. + */ +export type OAuthField = { + key: string; + label: string; + secret?: boolean; + filePath?: boolean; +}; + +/** + * Prompt field metadata derived from the production config schema. + */ +export type OAuthPromptField = { + key: string; + label: string; + description?: string; + type: "text" | "password" | "select"; + options?: string[]; + defaultValue?: string; + secret: boolean; + filePath: boolean; +}; + +/** + * Complete deploy metadata for configuring an OAuth provider. + */ +export type OAuthProviderDescriptor = { + provider: string; + configKey: string; + label: string; + docsUrl: string; + credentialLabel: string; + redirectLabel: string; + setupCopy: string; + gotcha: string | null; + fields: OAuthPromptField[]; + requiredCredentialKeys: string[]; + credentialSources: Array<"manual" | "google-json">; +}; + +/** + * Descriptor builder output split by deploy support status. + */ +export type OAuthProviderDescriptorResult = { + supported: OAuthProviderDescriptor[]; + unsupported: string[]; +}; + +type ProviderOverride = { + credentialLabel?: string; + redirectLabel?: string; + setupCopy?: string; + gotcha?: string | null; + credentialSources?: Array<"manual" | "google-json">; + fieldOrder?: string[]; + fieldLabels?: Record; + filePathFields?: string[]; + omittedFields?: string[]; + requiredCredentialKeys?: string[]; +}; + +const PROVIDER_OVERRIDES = { + google: { + credentialLabel: "I already have my Client ID and Client Secret", + redirectLabel: "Authorized Redirect URI", + setupCopy: + "Production Google sign-in requires custom OAuth credentials from Google Cloud Console.", + gotcha: `${yellow("IMPORTANT")} Set the OAuth consent screen's publishing status to "In production". Apps left in "Testing" are limited to 100 test users and may break for end users.`, + credentialSources: ["manual", "google-json"], + }, + apple: { + credentialLabel: "I already have my Services ID, Team ID, Key ID, and .p8 file", + redirectLabel: "Return URL", + setupCopy: + "Production Apple sign-in requires an Apple Services ID, Team ID, Key ID, and private key file.", + gotcha: `${yellow("IMPORTANT")} Apple OAuth needs four artifacts: Apple Services ID, Apple Team ID, Apple Key ID, and Apple Private Key (.p8 file). The .p8 file cannot be re-downloaded - save it before leaving Apple's developer portal.`, + fieldOrder: ["client_id", "team_id", "key_id", "client_secret"], + fieldLabels: { + client_id: "Apple Services ID", + team_id: "Apple Team ID", + key_id: "Apple Key ID", + client_secret: "Apple Private Key - path to .p8 file", + }, + filePathFields: ["client_secret"], + omittedFields: ["bundle_id"], + requiredCredentialKeys: ["client_id", "team_id", "key_id", "client_secret"], + }, +} satisfies Record; + +type ProviderWithOverride = keyof typeof PROVIDER_OVERRIDES; + +const SYSTEM_FIELD_KEYS = new Set(["enabled", "authenticatable", "block_email_subaddresses"]); +const DEFAULT_FIELD_ORDER = ["client_id", "client_secret"]; +export const OAUTH_KEY_PREFIX = "connection_oauth_"; + +const SHARED_OAUTH_METADATA = new Map( + OAUTH_PROVIDERS.map((provider) => [provider.provider, provider]), +); + +const COMPATIBLE_PROVIDER_DESCRIPTORS = buildCompatibilityDescriptors(); + +/** + * Compatibility labels for the deploy flow that still consumes provider maps. + */ +export const PROVIDER_LABELS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.label]), +) as Record; + +/** + * Compatibility fields for the deploy flow that still consumes provider maps. + */ +export const PROVIDER_FIELDS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ + descriptor.provider, + descriptor.fields.map((field) => ({ + key: field.key, + label: field.label, + secret: field.secret || undefined, + filePath: field.filePath || undefined, + })), + ]), +) as Record; + +/** + * Compatibility credential action labels for the current prompt flow. + */ +export const PROVIDER_CREDENTIAL_LABELS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ + descriptor.provider, + descriptor.credentialLabel, + ]), +) as Record; + +/** + * Compatibility redirect labels for the current walkthrough flow. + */ +export const PROVIDER_REDIRECT_LABELS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [ + descriptor.provider, + descriptor.redirectLabel, + ]), +) as Record; + +/** + * Compatibility setup copy for the current walkthrough flow. + */ +export const PROVIDER_SETUP_COPY: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.setupCopy]), +) as Record; + +/** + * Compatibility gotchas for the current walkthrough flow. + */ +export const PROVIDER_GOTCHAS: Record = Object.fromEntries( + COMPATIBLE_PROVIDER_DESCRIPTORS.map((descriptor) => [descriptor.provider, descriptor.gotcha]), +) as Record; + +/** + * Build deploy OAuth provider descriptors from the instance config schema. + */ +export function buildOAuthProviderDescriptors( + providers: readonly string[], + schema: InstanceConfigSchema, +): OAuthProviderDescriptorResult { + const supported: OAuthProviderDescriptor[] = []; + const unsupported: string[] = []; + + for (const provider of providers) { + const descriptor = buildOAuthProviderDescriptor(provider, schema); + if (!descriptor) { + unsupported.push(provider); + continue; + } + + supported.push(descriptor); + } + + return { supported, unsupported }; +} + +/** + * Determine whether production config already contains every credential + * required by a schema-derived provider descriptor. + */ +export function hasProviderRequiredCredentials( + config: Record, + descriptor: OAuthProviderDescriptor, +): boolean { + const value = config[descriptor.configKey]; + if (!value || typeof value !== "object") return false; + const providerConfig = value as Record; + if (providerConfig.enabled !== true) return false; + return descriptor.requiredCredentialKeys.every((key) => { + const fieldValue = providerConfig[key]; + return typeof fieldValue === "string" && fieldValue.length > 0; + }); +} + +function buildOAuthProviderDescriptor( + provider: string, + schema: InstanceConfigSchema, +): OAuthProviderDescriptor | null { + const configKey = `${OAUTH_KEY_PREFIX}${provider}`; + const configSchema = schema.properties?.[configKey]; + if (configSchema?.type !== "object" || !configSchema.properties) return null; + + const override = providerOverride(provider) ?? {}; + const omittedFields = new Set(override.omittedFields ?? []); + const requiredFieldKeys = new Set(override.requiredCredentialKeys ?? DEFAULT_FIELD_ORDER); + const fields: OAuthPromptField[] = []; + + for (const [key, property] of Object.entries(configSchema.properties)) { + if (SYSTEM_FIELD_KEYS.has(key) || omittedFields.has(key) || property.readOnly) continue; + + const field = buildPromptField(key, property, override); + if (!field) { + if (requiredFieldKeys.has(key)) return null; + continue; + } + fields.push(field); + } + + fields.sort((a, b) => compareFields(a.key, b.key, override.fieldOrder)); + + const fieldKeys = new Set(fields.map((field) => field.key)); + const requiredCredentialKeys = + override.requiredCredentialKeys ?? defaultRequiredCredentialKeys(fieldKeys); + if (requiredCredentialKeys.length === 0) return null; + if (requiredCredentialKeys.some((key) => !fieldKeys.has(key))) return null; + + const label = providerLabel(provider); + return { + provider, + configKey, + label, + docsUrl: providerDocsUrl(provider), + credentialLabel: + override.credentialLabel ?? + `I already have my ${credentialListLabel(requiredCredentialKeys)}`, + redirectLabel: override.redirectLabel ?? "Redirect URI", + setupCopy: + override.setupCopy ?? `Production ${label} sign-in requires custom OAuth credentials.`, + gotcha: override.gotcha ?? null, + fields, + requiredCredentialKeys, + credentialSources: override.credentialSources ?? ["manual"], + }; +} + +function buildPromptField( + key: string, + property: ConfigSchemaProperty, + override: ProviderOverride, +): OAuthPromptField | null { + if (property.type !== "string") return null; + + const stringEnum = + property.enum?.every((value) => typeof value === "string") === true ? property.enum : undefined; + if (property.enum && !stringEnum) return null; + + const secret = property["x-clerk-sensitive"] === true; + return { + key, + label: override.fieldLabels?.[key] ?? fieldLabel(key), + description: property.description, + type: stringEnum ? "select" : secret ? "password" : "text", + options: stringEnum, + defaultValue: typeof property.default === "string" ? property.default : undefined, + secret, + filePath: override.filePathFields?.includes(key) === true, + }; +} + +function providerOverride(provider: string): ProviderOverride | undefined { + return (PROVIDER_OVERRIDES as Record)[provider]; +} + +function compareFields(a: string, b: string, overrideOrder: string[] | undefined): number { + const order = overrideOrder ?? DEFAULT_FIELD_ORDER; + const aIndex = order.indexOf(a); + const bIndex = order.indexOf(b); + if (aIndex !== -1 || bIndex !== -1) { + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + return aIndex - bIndex; + } + return a.localeCompare(b); +} + +function defaultRequiredCredentialKeys(fieldKeys: Set): string[] { + return DEFAULT_FIELD_ORDER.filter((key) => fieldKeys.has(key)); +} + +function providerDocsUrl(provider: string): string { + return ( + SHARED_OAUTH_METADATA.get(provider)?.docsUrl ?? + `${DEFAULT_DOCS_URL_PREFIX}/${provider.replaceAll("_", "-")}` + ); +} + +function fieldLabel(key: string): string { + if (key === "client_id") return "Client ID"; + return key + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function credentialListLabel(requiredCredentialKeys: readonly string[]): string { + const labels = requiredCredentialKeys.map(fieldLabel); + if (labels.length === 1) return labels[0] ?? ""; + if (labels.length === 2) return `${labels[0]} and ${labels[1]}`; + return `${labels.slice(0, -1).join(", ")}, and ${labels.at(-1)}`; +} + +function buildCompatibilityDescriptors(): OAuthProviderDescriptor[] { + const schemas = Object.fromEntries( + Object.keys(PROVIDER_OVERRIDES).map((provider) => [ + `${OAUTH_KEY_PREFIX}${provider}`, + { + type: "object", + properties: compatibilityProviderProperties(provider), + }, + ]), + ); + + return buildOAuthProviderDescriptors(Object.keys(PROVIDER_OVERRIDES), { + type: "object", + properties: schemas, + }).supported; +} + +function compatibilityProviderProperties(provider: string): Record { + const override = providerOverride(provider); + const keys = override?.requiredCredentialKeys ?? DEFAULT_FIELD_ORDER; + return Object.fromEntries( + keys.map((key) => [ + key, + { + type: "string", + "x-clerk-sensitive": key === "client_secret", + }, + ]), + ); +} + +/** + * Human-readable provider label, with shared metadata preferred. + */ +export function providerLabel(provider: string): string { + return SHARED_OAUTH_METADATA.get(provider)?.name ?? fieldLabel(provider); +} + +/** + * Prompt fields for existing deploy callers, falling back to standard OAuth credentials. + */ +export function providerFields(provider: OAuthProvider): OAuthField[] { + return fromOverride(provider, PROVIDER_FIELDS, [ + { key: "client_id", label: "Client ID" }, + { key: "client_secret", label: "Client Secret", secret: true }, + ]); +} + +/** + * Credential action label for existing deploy callers. + */ +export function providerCredentialLabel(provider: OAuthProvider): string { + return fromOverride( + provider, + PROVIDER_CREDENTIAL_LABELS, + "I already have my Client ID and Client Secret", + ); +} + +function providerRedirectLabel(provider: OAuthProvider): string { + return fromOverride(provider, PROVIDER_REDIRECT_LABELS, "Redirect URI"); +} + +function providerSetupCopy(provider: OAuthProvider): string { + return fromOverride( + provider, + PROVIDER_SETUP_COPY, + `Production ${providerLabel(provider)} sign-in requires custom OAuth credentials.`, + ); +} + +function providerGotcha(provider: OAuthProvider): string | null { + return fromOverride(provider, PROVIDER_GOTCHAS, null); +} + +function fromOverride( + provider: OAuthProvider, + map: Record, + fallback: T, +): T { + return hasProviderOverride(provider) ? map[provider] : fallback; +} + +function hasProviderOverride(provider: string): provider is ProviderWithOverride { + return provider in PROVIDER_OVERRIDES; +} + +function providerDescriptorFromInput( + provider: OAuthProvider | OAuthProviderDescriptor, +): OAuthProviderDescriptor | undefined { + return typeof provider === "string" ? undefined : provider; +} + +/** + * Build the provider setup intro shown before credential collection. + */ +export function providerSetupIntro(provider: OAuthProvider | OAuthProviderDescriptor): string[] { + const descriptor = providerDescriptorFromInput(provider); + const slug = descriptor?.provider ?? (provider as OAuthProvider); + const label = descriptor?.label ?? providerLabel(slug); + const setupCopy = descriptor?.setupCopy ?? providerSetupCopy(slug); + const docsUrl = descriptor?.docsUrl ?? providerDocsUrl(slug); + return [bold(`Configure ${label} OAuth for production`), setupCopy, dim(`Reference: ${docsUrl}`)]; +} + +/** + * Show OAuth provider walkthrough values and open provider docs. + */ +export async function showOAuthWalkthrough( + provider: OAuthProvider | OAuthProviderDescriptor, + domain: string, +): Promise { + const descriptor = providerDescriptorFromInput(provider); + const slug = descriptor?.provider ?? (provider as OAuthProvider); + const label = descriptor?.label ?? providerLabel(slug); + const docsUrl = descriptor?.docsUrl ?? providerDocsUrl(slug); + + log.info(`\nConfigure your ${bold(label)} OAuth app with these values:\n`); + log.info(` ${dim("Authorized JavaScript origins")}`); + log.info(` ${cyan(`https://${domain}`)}`); + log.info(` ${cyan(`https://www.${domain}`)}`); + log.info(` ${dim(descriptor?.redirectLabel ?? providerRedirectLabel(slug))}`); + log.info(` ${cyan(`https://accounts.${domain}/v1/oauth_callback`)}`); + const gotcha = descriptor?.gotcha ?? providerGotcha(slug); + if (gotcha) { + log.blank(); + log.info(gotcha); + } + log.blank(); + log.info(dim(`Provider guide: ${docsUrl}`)); + + const openResult = await openBrowser(docsUrl); + if (!openResult.ok) { + log.info(dim(`Open the setup guide: ${docsUrl}`)); + } + log.blank(); +} diff --git a/packages/cli-core/src/commands/deploy/state.ts b/packages/cli-core/src/commands/deploy/state.ts new file mode 100644 index 00000000..e1fe5c5d --- /dev/null +++ b/packages/cli-core/src/commands/deploy/state.ts @@ -0,0 +1,44 @@ +import { CliError, EXIT_CODE } from "../../lib/errors.ts"; +import { pausedMessage } from "./copy.ts"; +import type { CnameTarget } from "../../lib/plapi.ts"; +import { providerLabel, type OAuthProvider } from "./providers.ts"; +import type { Profile } from "../../lib/config.ts"; + +export type DeployOperationState = { + appId: string; + developmentInstanceId: string; + productionInstanceId?: string; + productionDomainId?: string; + domain: string; + pending: { type: "dns" } | { type: "oauth"; provider: string }; + oauthProviders: string[]; + completedOAuthProviders: string[]; + cnameTargets?: readonly CnameTarget[]; +}; + +export type DeployContext = { + profileKey: string; + profile: Profile; + appId: string; + appLabel: string; + developmentInstanceId: string; + productionInstanceId?: string; +}; + +export function pausedStepDescription(state: DeployOperationState): string { + if (state.pending.type === "dns") { + return `DNS verification for ${state.domain}`; + } + return `${providerLabel(state.pending.provider as OAuthProvider)} OAuth credential setup`; +} + +export class DeployPausedError extends CliError {} + +export function deployPausedError( + state: DeployOperationState, + options?: { interrupted?: boolean }, +): DeployPausedError { + return new DeployPausedError(pausedMessage(pausedStepDescription(state)), { + exitCode: options?.interrupted ? EXIT_CODE.SIGINT : EXIT_CODE.GENERAL, + }); +} diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts new file mode 100644 index 00000000..91f8df0b --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EXIT_CODE, PlapiError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockFetchApplication = mock(); +const mockListApplicationDomains = mock(); +const mockFetchInstanceConfig = mock(); +const mockFetchInstanceConfigSchema = mock(); +const mockGetApplicationDomainStatus = mock(); +const mockTriggerApplicationDomainDNSCheck = mock(); +const mockSleep = mock(); + +mock.module("../../lib/sleep.ts", () => ({ + sleep: (ms: number) => { + mockSleep(ms); + return Promise.resolve(); + }, +})); + +const { _setConfigDir, setProfile } = await import("../../lib/config.ts"); +const { setMode } = await import("../../mode.ts"); +const { deployStatus } = await import("./status-command.ts"); + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); +} + +function appWith(production: boolean) { + const instances = [{ instance_id: "ins_dev", environment_type: "development" }]; + if (production) instances.push({ instance_id: "ins_prod", environment_type: "production" }); + return { application_id: "app_1", name: "app", instances }; +} + +function completeDomainStatus() { + return { + status: "complete", + dns: { status: "complete" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function pendingDnsDomainStatus() { + return { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function pendingSslDomainStatus() { + return { + status: "incomplete", + dns: { status: "complete" }, + ssl: { status: "pending", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function mockDomain() { + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [ + { + host: "clerk.example.com", + value: "frontend-api.clerk.services", + required: true, + }, + ], + }, + ], + total_count: 1, + }); +} + +function mockOAuthComplete() { + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" || instanceId === "production" + ? { connection_oauth_google: { enabled: true, client_id: "x", client_secret: "y" } } + : { connection_oauth_google: { enabled: true } }, + ); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { + connection_oauth_google: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + }, + }, + }, + }); +} + +describe("deploy status", () => { + const captured = useCaptureLog(); + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + let tempDir = ""; + let exitCodeBefore: typeof process.exitCode; + + beforeEach(async () => { + captured.clear(); + setMode("agent"); + exitCodeBefore = process.exitCode; + process.exitCode = undefined; + process.env.CLERK_PLATFORM_API_KEY = "ak_test"; + stubFetch((...args) => routePlapiFetch(...args)); + tempDir = await mkdtemp(join(tmpdir(), "clerk-status-test-")); + _setConfigDir(tempDir); + await setProfile(process.cwd(), { + workspaceId: "", + appId: "app_1", + appName: "app", + instances: { development: "ins_dev" }, + } as never); + }); + + afterEach(async () => { + _setConfigDir(undefined); + if (tempDir) await rm(tempDir, { recursive: true, force: true }); + process.exitCode = exitCodeBefore ?? EXIT_CODE.SUCCESS; + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + setMode("human"); + tempDir = ""; + mockFetchApplication.mockReset(); + mockListApplicationDomains.mockReset(); + mockFetchInstanceConfig.mockReset(); + mockFetchInstanceConfigSchema.mockReset(); + mockGetApplicationDomainStatus.mockReset(); + mockTriggerApplicationDomainDNSCheck.mockReset(); + mockSleep.mockReset(); + }); + + test("agent mode not_started emits JSON with state not_started and exit 1", async () => { + mockFetchApplication.mockResolvedValue(appWith(false)); + + await deployStatus(); + + expect(process.exitCode).toBe(EXIT_CODE.GENERAL); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("not_started"); + expect(payload.complete).toBe(false); + expect(captured.out).not.toContain("error"); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); + }); + + test("agent mode complete triggers DNS check and emits complete state", async () => { + process.exitCode = EXIT_CODE.GENERAL; + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(completeDomainStatus()); + + await deployStatus(); + + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(EXIT_CODE.SUCCESS); + const payload = JSON.parse(captured.out); + expect(payload).toMatchObject({ + complete: true, + state: "complete", + domain: "example.com", + }); + expect(payload.domainStatus).toEqual({ dns: "complete", ssl: "complete", mail: "complete" }); + }); + + test("human mode not_started prints a readable status block and no JSON stdout", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(false)); + + await deployStatus(); + + expect(captured.out).toBe(""); + expect(stripAnsi(captured.err)).toContain("clerk deploy"); + }); + + test("agent mode domain pending reports pending DNS records and exit 1", async () => { + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingDnsDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingDnsDomainStatus()); + + await deployStatus(); + + expect(process.exitCode).toBe(EXIT_CODE.GENERAL); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(1); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledTimes(1); + expect(mockTriggerApplicationDomainDNSCheck.mock.invocationCallOrder[0]).toBeLessThan( + mockGetApplicationDomainStatus.mock.invocationCallOrder[0]!, + ); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("domain_pending"); + expect(payload.complete).toBe(false); + expect(payload.domainStatus).toEqual({ dns: "pending", ssl: "complete", mail: "complete" }); + expect(payload.pendingDnsRecords).toContainEqual({ + type: "CNAME", + host: "clerk.example.com", + value: "frontend-api.clerk.services", + }); + }); + + test("agent mode status snapshot failures surface as errors", async () => { + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockGetApplicationDomainStatus.mockRejectedValue( + new PlapiError(500, JSON.stringify({ errors: [{ code: "server_error" }] }), "https://x"), + ); + + await expect(deployStatus()).rejects.toBeInstanceOf(PlapiError); + + expect(captured.out).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + }); + + test("human mode shows a spinner while waiting for the DNS check to process", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(completeDomainStatus()); + + await deployStatus(); + + expect(stripAnsi(captured.err)).toContain("Waiting for Clerk DNS check to process"); + expect(mockSleep).toHaveBeenCalledWith(2000); + }); + + test("human mode shows dashboard monitoring guidance without agent handoff copy", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingSslDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingSslDomainStatus()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain( + "SSL still provisioning for example.com. Re-run `clerk deploy status` in a few minutes, DNS propagation can take time. Visit the Clerk Dashboard domains page to monitor its status there: https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(output).not.toContain("Ask the user to visit"); + expect(output).not.toContain("offer to open it"); + }); +}); + +async function routePlapiFetch( + input: string | URL | Request, + init?: RequestInit, +): Promise { + const url = new URL(input.toString()); + const method = init?.method ?? "GET"; + const path = url.pathname; + const json = async (value: unknown) => { + const body = await value; + return new Response(JSON.stringify(body ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + if (method === "GET" && path === "/v1/platform/applications/app_1") { + return json(mockFetchApplication("app_1")); + } + if (method === "GET" && path === "/v1/platform/applications/app_1/domains") { + return json(mockListApplicationDomains("app_1")); + } + if (method === "GET" && path.endsWith("/config/schema")) { + const instanceId = path.split("/").at(-3)!; + return json( + mockFetchInstanceConfigSchema("app_1", instanceId, url.searchParams.getAll("keys")), + ); + } + if (method === "GET" && path.endsWith("/config")) { + const instanceId = path.split("/").at(-2)!; + return json(mockFetchInstanceConfig("app_1", instanceId)); + } + if (method === "POST" && path.endsWith("/dns_check")) { + const domainIdOrName = path.split("/").at(-2)!; + return json(mockTriggerApplicationDomainDNSCheck("app_1", domainIdOrName)); + } + if (method === "GET" && path.endsWith("/status")) { + const domainIdOrName = path.split("/").at(-2)!; + return json(mockGetApplicationDomainStatus("app_1", domainIdOrName)); + } + + return new Response("Not Found", { status: 404 }); +} diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts new file mode 100644 index 00000000..4c975634 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -0,0 +1,125 @@ +import { isAgent } from "../../mode.ts"; +import { CliError, ERROR_CODE, EXIT_CODE } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { sleep } from "../../lib/sleep.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { deployComponentLabels } from "./copy.ts"; +import { + buildDeployStatusReport, + loadProductionDomain, + resolveDeployContext, + resolveDeployState, + triggerDeployStatusCheck, + waitForDeployStatus, + type DeployState, + type DeployStatusOutcome, + type DeployStatusReport, +} from "./status.ts"; +import type { DeployContext } from "./state.ts"; + +type DeployStatusOptions = { + wait?: boolean; +}; + +const DEPLOY_STATUS_PREFLIGHT_DELAY_MS = 2000; + +export async function deployStatus(options: DeployStatusOptions = {}): Promise { + const ctx = await resolveDeployContext(); + if (!ctx.appId || !ctx.developmentInstanceId) { + throw new CliError( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy status`.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + const preflightTriggered = await runPreflightDeployStatusCheck(ctx); + const state = await resolveDeployState(ctx); + const shouldWait = options.wait === true || !isAgent(); + const outcome = + state.kind === "active" && shouldWait + ? await runWait(state, { triggerCheck: !preflightTriggered }) + : null; + const report = buildDeployStatusReport(state, outcome); + + emitReport(report); + process.exitCode = report.complete ? EXIT_CODE.SUCCESS : EXIT_CODE.GENERAL; +} + +async function runPreflightDeployStatusCheck(ctx: DeployContext): Promise { + if (!ctx.productionInstanceId) return false; + + const domain = await loadProductionDomain(ctx); + if (!domain) return false; + + const domainIdOrName = domain.id ?? domain.name; + await triggerDeployStatusCheck(ctx.appId, domainIdOrName); + await withSpinner("Waiting for Clerk DNS check to process...", () => + sleep(DEPLOY_STATUS_PREFLIGHT_DELAY_MS), + ); + return true; +} + +function runWait( + state: Extract, + options: { triggerCheck?: boolean } = {}, +): Promise { + const { snapshot } = state; + const domainIdOrName = snapshot.productionDomainId ?? snapshot.domain; + return waitForDeployStatus( + snapshot.appId, + domainIdOrName, + snapshot.domain, + { + runVerification: (progressLabel, work) => withSpinner(progressLabel, work), + onVerified: () => { + if (!isAgent()) log.success(deployComponentLabels("dns", snapshot.domain).done); + }, + }, + options, + ); +} + +function emitReport(report: DeployStatusReport): void { + if (isAgent()) { + log.data(JSON.stringify(report, null, 2)); + return; + } + renderHuman(report); +} + +function renderHuman(report: DeployStatusReport): void { + log.blank(); + if (report.domain) { + log.info(`Deploy status for \`${report.domain}\``); + } else { + log.info("Deploy status"); + } + + if (report.domainStatus) { + log.info( + ` Domain DNS: ${report.domainStatus.dns} SSL: ${report.domainStatus.ssl} Email DNS: ${report.domainStatus.mail}`, + ); + } + + const oauthStatus = report.oauth.complete + ? "complete" + : `pending: ${report.oauth.pending.join(", ") || "none"}`; + log.info(` OAuth ${oauthStatus}`); + + if (report.oauth.unsupported.length > 0) { + log.warn( + ` ${report.oauth.unsupported.length} OAuth provider(s) enabled in dev are not supported by automated deploy: ${report.oauth.unsupported.join(", ")}. Configure them from the Clerk Dashboard.`, + ); + } + + log.blank(); + log.info(formatHumanNextAction(report.nextAction)); + log.blank(); +} + +function formatHumanNextAction(nextAction: string): string { + return nextAction.replace( + /Ask the user to visit the Clerk Dashboard domains page, or offer to open it: (https:\/\/\S+)/, + "Visit the Clerk Dashboard domains page to monitor its status there: $1", + ); +} diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts new file mode 100644 index 00000000..62435c39 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -0,0 +1,325 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { PlapiError } from "../../lib/errors.ts"; +import type { LiveDeploySnapshot } from "./status.ts"; + +const mockFetchApplication = mock(); +const mockListApplicationDomains = mock(); +const mockFetchInstanceConfig = mock(); +const mockFetchInstanceConfigSchema = mock(); +const mockGetApplicationDomainStatus = mock(); +const mockTriggerApplicationDomainDNSCheck = mock(); + +mock.module("../../lib/plapi.ts", () => ({ + fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), + fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), + fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), + getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), + triggerApplicationDomainDNSCheck: (...args: unknown[]) => + mockTriggerApplicationDomainDNSCheck(...args), +})); + +const { buildDeployStatusReport, resolveDeployState, waitForDeployStatus } = + await import("./status.ts"); + +const ctx = { + profileKey: "/tmp/x", + profile: { + workspaceId: "", + appId: "app_1", + instances: { development: "ins_dev" }, + }, + appId: "app_1", + appLabel: "app_1", + developmentInstanceId: "ins_dev", +} as const; + +const completeStatus = { + status: "complete", + dns: { status: "complete" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, +}; + +const passthroughHandlers = { + runVerification: (_label: string, work: (controls: { update: () => void }) => Promise) => + work({ update: () => {} }), +}; + +beforeEach(() => { + mockFetchInstanceConfig.mockResolvedValue({}); + mockFetchInstanceConfigSchema.mockResolvedValue({ properties: {} }); +}); + +afterEach(() => { + mockFetchApplication.mockReset(); + mockListApplicationDomains.mockReset(); + mockFetchInstanceConfig.mockReset(); + mockFetchInstanceConfigSchema.mockReset(); + mockGetApplicationDomainStatus.mockReset(); + mockTriggerApplicationDomainDNSCheck.mockReset(); +}); + +describe("resolveDeployState", () => { + test("returns not_started when the application has no production instance", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [{ instance_id: "ins_dev", environment_type: "development" }], + }); + + const state = await resolveDeployState({ ...ctx }); + + expect(state.kind).toBe("not_started"); + }); + + test("returns domain_provisioning when production instance exists but has no domain", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ data: [], total_count: 0 }); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state).toEqual({ + kind: "domain_provisioning", + appId: "app_1", + productionInstanceId: "ins_prod", + }); + }); + + test("returns active with a snapshot when instance and domain exist", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [ + { + host: "clerk.example.com", + value: "frontend-api.clerk.services", + required: true, + }, + ], + }, + ], + total_count: 1, + }); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { connection_oauth_google: { enabled: true, client_id: "id", client_secret: "secret" } } + : { connection_oauth_google: { enabled: true } }, + ); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { + connection_oauth_google: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + }, + }, + }, + }); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.domain).toBe("example.com"); + expect(state.snapshot.domainComplete).toBe(true); + expect(state.snapshot.oauthProviders).toEqual(["google"]); + expect(state.snapshot.completedOAuthProviders).toEqual(["google"]); + } + }); +}); + +describe("waitForDeployStatus", () => { + test("triggers a DNS check before polling and returns verified when complete", async () => { + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeStatus); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const outcome = await waitForDeployStatus("app_1", "dmn_1", "example.com", passthroughHandlers); + + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + expect(mockTriggerApplicationDomainDNSCheck.mock.invocationCallOrder[0]).toBeLessThan( + mockGetApplicationDomainStatus.mock.invocationCallOrder[0]!, + ); + expect(outcome).toEqual({ + verified: true, + status: { dns: true, ssl: true, mail: true }, + }); + }); + + test("continues polling when the DNS check is already in flight", async () => { + mockTriggerApplicationDomainDNSCheck.mockRejectedValue( + new PlapiError(409, JSON.stringify({ errors: [{ code: "conflict" }] }), "https://x"), + ); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const outcome = await waitForDeployStatus("app_1", "dmn_1", "example.com", passthroughHandlers); + + expect(outcome).toEqual({ + verified: true, + status: { dns: true, ssl: true, mail: true }, + }); + }); +}); + +describe("buildDeployStatusReport", () => { + const activeSnapshot = { + appId: "app_1", + developmentInstanceId: "ins_dev", + productionInstanceId: "ins_prod", + productionDomainId: "dmn_1", + domain: "example.com", + oauthProviders: ["google", "github"], + oauthProviderDescriptors: [], + completedOAuthProviders: ["google"], + cnameTargets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "clkmail.example.com", value: "mail.clerk.services", required: true }, + ], + domainComplete: false, + componentStatus: { dns: false, ssl: false, mail: false }, + unsupportedOAuthProviderCount: 0, + unsupportedOAuthProviders: [], + pending: { type: "oauth" as const, provider: "github" }, + } satisfies LiveDeploySnapshot; + + test("not_started reports incomplete with deploy next action", () => { + const report = buildDeployStatusReport({ kind: "not_started" }, null); + + expect(report.complete).toBe(false); + expect(report.state).toBe("not_started"); + expect(report.domain).toBeNull(); + expect(report.productionInstanceId).toBeNull(); + expect(report.domainStatus).toBeNull(); + expect(report.nextAction).toContain("clerk deploy"); + }); + + test("domain_provisioning reports production instance", () => { + const report = buildDeployStatusReport( + { kind: "domain_provisioning", appId: "app_1", productionInstanceId: "ins_prod" }, + null, + ); + + expect(report.state).toBe("domain_provisioning"); + expect(report.complete).toBe(false); + expect(report.productionInstanceId).toBe("ins_prod"); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + }); + + test("active with pending domain gives domain precedence over OAuth", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: false, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("domain_pending"); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(report.nextAction).toContain("Ask the user to visit"); + expect(report.nextAction).toContain("offer to open it"); + expect(report.domainStatus).toEqual({ dns: "pending", ssl: "complete", mail: "complete" }); + expect(report.pendingDnsRecords).toContainEqual({ + type: "CNAME", + host: "clerk.example.com", + value: "frontend-api.clerk.services", + }); + expect(report.oauth.pending).toEqual(["github"]); + }); + + test("active with pending email DNS reports only email CNAME records", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: true, ssl: true, mail: false } }, + ); + + expect(report.pendingDnsRecords).toEqual([ + { + type: "CNAME", + host: "clkmail.example.com", + value: "mail.clerk.services", + }, + ]); + }); + + test("active with complete domain but pending OAuth reports oauth_pending", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("oauth_pending"); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(report.oauth).toMatchObject({ + complete: false, + configured: ["google"], + pending: ["github"], + }); + }); + + test("active with complete domain and OAuth reports complete", () => { + const allDone = { + ...activeSnapshot, + completedOAuthProviders: ["google", "github"], + } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: allDone }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("complete"); + expect(report.complete).toBe(true); + expect(report.domainStatus).toEqual({ dns: "complete", ssl: "complete", mail: "complete" }); + expect(report.nextAction).toContain("https://example.com"); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + }); + + test("unsupported OAuth providers surface without blocking completion", () => { + const withUnsupported = { + ...activeSnapshot, + completedOAuthProviders: ["google", "github"], + unsupportedOAuthProviders: ["discord"], + unsupportedOAuthProviderCount: 1, + } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: withUnsupported }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.complete).toBe(true); + expect(report.oauth.unsupported).toEqual(["discord"]); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts new file mode 100644 index 00000000..0d2836df --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -0,0 +1,540 @@ +import { resolveProfile } from "../../lib/config.ts"; +import { PlapiError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { + fetchApplication, + fetchInstanceConfig, + fetchInstanceConfigSchema, + getApplicationDomainStatus, + listApplicationDomains, + triggerApplicationDomainDNSCheck, + type ApplicationDomain, + type DomainStatusResponse, +} from "../../lib/plapi.ts"; +import { sleep } from "../../lib/sleep.ts"; +import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; +import { + cnameTargetPending, + deployComponentLabels, + deployStatusRetryMessage, + domainsDashboardUrl, + type DeployComponentStatus, +} from "./copy.ts"; +import { mapDeployError } from "./errors.ts"; +import { + OAUTH_KEY_PREFIX, + buildOAuthProviderDescriptors, + hasProviderRequiredCredentials, + type OAuthProvider, + type OAuthProviderDescriptor, +} from "./providers.ts"; +import type { DeployContext, DeployOperationState } from "./state.ts"; + +const DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS = 3000; +const DEPLOY_STATUS_MAX_RETRIES = 5; +const DEPLOY_STATUS_BACKOFF_FACTOR = 2; + +export interface DeployProgressHandlers { + runVerification( + progressLabel: string, + work: (controls: SpinnerControls) => Promise, + ): Promise; + onVerified?(): void; +} + +export type DeployStatusOutcome = { verified: boolean; status: DeployComponentStatus }; + +export type DeployStatusState = + | "complete" + | "domain_pending" + | "oauth_pending" + | "domain_provisioning" + | "not_started"; + +export interface DeployStatusReport { + complete: boolean; + state: DeployStatusState; + domain: string | null; + productionInstanceId: string | null; + domainStatus: { dns: string; ssl: string; mail: string } | null; + pendingDnsRecords: { type: "CNAME"; host: string; value: string }[]; + oauth: { complete: boolean; configured: string[]; pending: string[]; unsupported: string[] }; + nextAction: string; +} + +export type LiveDeploySnapshot = Omit< + DeployOperationState, + "pending" | "oauthProviders" | "completedOAuthProviders" +> & { + pending: DeployOperationState["pending"] | undefined; + oauthProviders: OAuthProvider[]; + oauthProviderDescriptors: OAuthProviderDescriptor[]; + completedOAuthProviders: OAuthProvider[]; + domainComplete: boolean; + componentStatus: DeployComponentStatus; + unsupportedOAuthProviderCount: number; + unsupportedOAuthProviders: string[]; +}; + +export type DeployState = + | { kind: "not_started" } + | { kind: "domain_provisioning"; appId: string; productionInstanceId: string } + | { kind: "active"; snapshot: LiveDeploySnapshot }; + +type SnapshotOptions = { + /** + * When true, a failed domain-status read throws instead of being treated as + * pending. The read-only status path enables this so transient API errors are + * surfaced rather than reported as legitimate progress. The interactive deploy + * flow leaves it off, letting the user retry from the on-screen status. + */ + throwOnStatusError?: boolean; +}; + +export type DiscoveredOAuthProviders = { + descriptors: OAuthProviderDescriptor[]; + unsupported: string[]; +}; + +export async function resolveDeployContext(): Promise { + const resolved = await withSpinner("Resolving linked Clerk application...", () => + resolveProfile(process.cwd()), + ); + if (!resolved) { + return { + profileKey: process.cwd(), + profile: { + workspaceId: "", + appId: "", + instances: { development: "" }, + }, + appId: "", + appLabel: "", + developmentInstanceId: "", + }; + } + + return { + profileKey: resolved.path, + profile: resolved.profile, + ...(await withSpinner("Checking for production instance...", () => + resolveLiveApplicationContext(resolved.profile), + )), + }; +} + +export async function resolveLiveApplicationContext(profile: DeployContext["profile"]): Promise<{ + appId: string; + appLabel: string; + developmentInstanceId: string; + productionInstanceId?: string; +}> { + const app = await fetchApplication(profile.appId); + const development = app.instances.find((entry) => entry.environment_type === "development"); + const production = app.instances.find((entry) => entry.environment_type === "production"); + return { + appId: app.application_id, + appLabel: app.name || profile.appName || app.application_id, + developmentInstanceId: development?.instance_id ?? profile.instances.development, + productionInstanceId: production?.instance_id, + }; +} + +export async function resolveDeployState(ctx: DeployContext): Promise { + const live = await resolveLiveApplicationContext(ctx.profile); + if (!live.productionInstanceId) return { kind: "not_started" }; + + // The read-only status path surfaces domain-status read failures instead of + // masking them as pending, so a transient API error is not reported as + // legitimate progress. + const snapshot = await resolveLiveDeploySnapshot( + { + ...ctx, + productionInstanceId: live.productionInstanceId, + }, + { throwOnStatusError: true }, + ); + if (!snapshot) { + return { + kind: "domain_provisioning", + appId: live.appId, + productionInstanceId: live.productionInstanceId, + }; + } + return { kind: "active", snapshot }; +} + +export async function loadDevelopmentOAuthProviders( + ctx: DeployContext, +): Promise { + return withSpinner("Reading development configuration...", async () => { + const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); + const providerSlugs = discoverEnabledOAuthProviderSlugs(config); + const schemaKeys = providerSlugs.map((provider) => `${OAUTH_KEY_PREFIX}${provider}`); + const schema = + schemaKeys.length > 0 + ? await fetchInstanceConfigSchema(ctx.appId, ctx.developmentInstanceId, schemaKeys) + : { properties: {} }; + const result = buildOAuthProviderDescriptors(providerSlugs, schema); + return { + descriptors: result.supported, + unsupported: result.unsupported, + }; + }); +} + +export async function resolveLiveDeploySnapshot( + ctx: DeployContext, + options: SnapshotOptions = {}, +): Promise { + const productionInstanceId = ctx.productionInstanceId; + if (!productionInstanceId) return undefined; + + const [domain, oauth] = await Promise.all([ + loadProductionDomain(ctx), + loadDevelopmentOAuthProviders(ctx), + ]); + if (!domain) return undefined; + + const { descriptors: oauthProviderDescriptors, unsupported } = oauth; + const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); + const { productionConfig, deployStatus } = await loadProductionState( + ctx, + productionInstanceId, + domain.id, + options, + ); + const completedOAuthProviders = oauthProviderDescriptors + .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) + .map((descriptor) => descriptor.provider); + const pendingOAuthDescriptor = oauthProviderDescriptors.find( + (descriptor) => !completedOAuthProviders.includes(descriptor.provider), + ); + + const baseState = { + appId: ctx.appId, + developmentInstanceId: ctx.developmentInstanceId, + productionInstanceId, + productionDomainId: domain.id, + domain: domain.name, + oauthProviders, + oauthProviderDescriptors, + completedOAuthProviders, + cnameTargets: domain.cname_targets ?? [], + componentStatus: deployComponentStatusFromDomainStatus(deployStatus), + unsupportedOAuthProviderCount: unsupported.length, + unsupportedOAuthProviders: unsupported, + }; + + const domainComplete = deployStatus.status === "complete"; + return { + ...baseState, + domainComplete, + pending: resolvePendingStep(pendingOAuthDescriptor, domainComplete), + }; +} + +function resolvePendingStep( + pendingOAuthDescriptor: OAuthProviderDescriptor | undefined, + domainComplete: boolean, +): DeployOperationState["pending"] | undefined { + if (pendingOAuthDescriptor) { + return { type: "oauth", provider: pendingOAuthDescriptor.provider }; + } + if (!domainComplete) { + return { type: "dns" }; + } + return undefined; +} + +export async function loadInitialDeployStatus( + appId: string, + domainIdOrName: string, + options: SnapshotOptions = {}, +): Promise { + const status = mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + if (options.throwOnStatusError) return status; + + try { + return await status; + } catch (error) { + log.debug( + `deploy: snapshot domain-status read failed, treating DNS as pending: ${error instanceof Error ? error.message : String(error)}`, + ); + return pendingDomainStatus(); + } +} + +export async function loadProductionState( + ctx: DeployContext, + productionInstanceId: string, + domainIdOrName: string, + options: SnapshotOptions = {}, +): Promise<{ + productionConfig: Record; + deployStatus: DomainStatusResponse; +}> { + return withSpinner("Reading production configuration...", async () => { + const [productionConfig, deployStatus] = await Promise.all([ + fetchInstanceConfig(ctx.appId, productionInstanceId), + loadInitialDeployStatus(ctx.appId, domainIdOrName, options), + ]); + return { productionConfig, deployStatus }; + }); +} + +export function pendingDomainStatus(): DomainStatusResponse { + return { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "not_started", required: true }, + mail: { status: "not_started", required: true }, + }; +} + +function domainComponentState(value: boolean): "complete" | "pending" { + return value ? "complete" : "pending"; +} + +export function buildDeployStatusReport( + state: DeployState, + outcome: DeployStatusOutcome | null, +): DeployStatusReport { + if (state.kind === "not_started") { + return { + complete: false, + state: "not_started", + domain: null, + productionInstanceId: null, + domainStatus: null, + pendingDnsRecords: [], + oauth: { complete: false, configured: [], pending: [], unsupported: [] }, + nextAction: + "No production instance yet. `clerk deploy` configures production interactively and " + + "needs a human terminal, ask the user to run `clerk deploy`, then run `clerk deploy status` to verify.", + }; + } + + if (state.kind === "domain_provisioning") { + const domainsAction = domainSettingsNextAction( + domainsDashboardUrl(state.appId, state.productionInstanceId), + ); + return { + complete: false, + state: "domain_provisioning", + domain: null, + productionInstanceId: state.productionInstanceId, + domainStatus: null, + pendingDnsRecords: [], + oauth: { complete: false, configured: [], pending: [], unsupported: [] }, + nextAction: + "A production instance exists but its domain is still provisioning. " + + "Run `clerk deploy status` again shortly, or ask the user to finish `clerk deploy`. " + + domainsAction, + }; + } + + const { snapshot } = state; + const componentStatus = outcome?.status ?? snapshot.componentStatus; + const domainComplete = outcome ? outcome.verified : snapshot.domainComplete; + const oauthPending = snapshot.oauthProviders.filter( + (provider) => !snapshot.completedOAuthProviders.includes(provider), + ); + const oauthComplete = oauthPending.length === 0; + const complete = domainComplete && oauthComplete; + const reportState = resolveActiveReportState(domainComplete, complete); + + const pendingDnsRecords: DeployStatusReport["pendingDnsRecords"] = !domainComplete + ? (snapshot.cnameTargets ?? []) + .filter((target) => cnameTargetPending(target, componentStatus)) + .map((target) => ({ type: "CNAME" as const, host: target.host, value: target.value })) + : []; + + return { + complete, + state: reportState, + domain: snapshot.domain, + productionInstanceId: snapshot.productionInstanceId ?? null, + domainStatus: { + dns: domainComponentState(componentStatus.dns), + ssl: domainComponentState(componentStatus.ssl), + mail: domainComponentState(componentStatus.mail), + }, + pendingDnsRecords, + oauth: { + complete: oauthComplete, + configured: [...snapshot.completedOAuthProviders], + pending: oauthPending, + unsupported: [...snapshot.unsupportedOAuthProviders], + }, + nextAction: deployNextAction( + reportState, + snapshot.domain, + componentStatus, + oauthPending, + snapshot.productionInstanceId + ? domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId) + : null, + ), + }; +} + +function resolveActiveReportState(domainComplete: boolean, complete: boolean): DeployStatusState { + if (complete) return "complete"; + if (!domainComplete) return "domain_pending"; + return "oauth_pending"; +} + +function deployNextAction( + state: DeployStatusState, + domain: string, + componentStatus: DeployComponentStatus, + oauthPending: string[], + domainsUrl: string | null, +): string { + const domainsAction = domainsUrl ? ` ${domainSettingsNextAction(domainsUrl)}` : ""; + + if (state === "complete") { + return `Production is deployed and verified at https://${domain}. No action needed.${domainsAction}`; + } + if (state === "oauth_pending") { + return ( + `Domain verified, but these OAuth providers are missing production credentials: ` + + `${oauthPending.join(", ")}. Ask the user to finish \`clerk deploy\`, then run \`clerk deploy status\`.` + + domainsAction + ); + } + + const pendingComponents = [ + !componentStatus.dns ? "DNS" : null, + !componentStatus.ssl ? "SSL" : null, + !componentStatus.mail ? "email DNS" : null, + ].filter((value): value is string => value !== null); + + if (pendingComponents.length === 0) { + return ( + `Production setup for ${domain} is still finalizing on Clerk's side. ` + + `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` + ); + } + + return ( + `${pendingComponents.join(", ")} still provisioning for ${domain}. ` + + `Re-run \`clerk deploy status\` in a few minutes, DNS propagation can take time.` + + domainsAction + ); +} + +function domainSettingsNextAction(domainsUrl: string): string { + return `Ask the user to visit the Clerk Dashboard domains page, or offer to open it: ${domainsUrl}`; +} + +export async function loadProductionDomain( + ctx: DeployContext, +): Promise { + const domains = await listApplicationDomains(ctx.appId); + return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; +} + +export function discoverEnabledOAuthProviderSlugs(config: Record): string[] { + const providers: string[] = []; + for (const [key, value] of Object.entries(config)) { + if (!key.startsWith(OAUTH_KEY_PREFIX)) continue; + if (!value || typeof value !== "object") continue; + if ((value as Record).enabled !== true) continue; + providers.push(key.slice(OAUTH_KEY_PREFIX.length)); + } + return providers; +} + +export async function waitForDeployStatus( + appId: string, + domainIdOrName: string, + domain: string, + handlers: DeployProgressHandlers, + options: { triggerCheck?: boolean } = {}, +): Promise { + if (options.triggerCheck !== false) { + await triggerDeployStatusCheck(appId, domainIdOrName); + } + let response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + let status = deployComponentStatusFromDomainStatus(response); + + const labels = deployComponentLabels("dns", domain); + const verified = await handlers.runVerification(labels.progress, async (spinner) => { + if (response.status === "complete") return true; + + let retriesRemaining = DEPLOY_STATUS_MAX_RETRIES; + let nextRetryDelay = DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS; + while (retriesRemaining > 0) { + await sleepWithRetryCountdown( + labels.progress, + DEPLOY_STATUS_MAX_RETRIES - retriesRemaining + 1, + DEPLOY_STATUS_MAX_RETRIES, + nextRetryDelay, + spinner, + ); + retriesRemaining--; + nextRetryDelay *= DEPLOY_STATUS_BACKOFF_FACTOR; + response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + status = deployComponentStatusFromDomainStatus(response); + if (response.status === "complete") return true; + } + return false; + }); + + if (!verified) { + return { verified: false, status }; + } + handlers.onVerified?.(); + return { verified: true, status }; +} + +async function sleepWithRetryCountdown( + message: string, + currentRetry: number, + totalRetries: number, + delayMs: number, + spinner: SpinnerControls, +): Promise { + let remainingMs = delayMs; + while (remainingMs > 0) { + const tickMs = Math.min(1000, remainingMs); + spinner.update( + deployStatusRetryMessage(message, currentRetry, totalRetries, Math.ceil(remainingMs / 1000)), + ); + await sleep(tickMs); + remainingMs -= tickMs; + } +} + +export async function triggerDeployStatusCheck( + appId: string, + domainIdOrName: string, +): Promise { + try { + await mapDeployError(triggerApplicationDomainDNSCheck(appId, domainIdOrName)); + } catch (error) { + if (error instanceof PlapiError && error.status === 409 && error.code === "conflict") { + log.debug("DNS check is already in flight; continuing to poll domain status."); + return; + } + throw error; + } +} + +export function deployComponentStatusFromDomainStatus( + response: DomainStatusResponse, +): DeployComponentStatus { + return { + dns: checkStatusComplete(response.dns), + ssl: checkStatusComplete(response.ssl), + mail: checkStatusComplete(response.mail), + }; +} + +function checkStatusComplete(check: { status: string; required?: boolean } | undefined): boolean { + if (!check) return false; + if (check.required === false) return true; + return check.status === "complete"; +} diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index a5cc787d..05d2f158 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -1,4 +1,5 @@ import { isAgent } from "../mode.ts"; +import { ExitPromptError } from "@inquirer/core"; /** Standard process exit codes used by the CLI. */ export const EXIT_CODE = { @@ -45,6 +46,16 @@ export const ERROR_CODE = { DOCTOR_FAILED: "doctor_failed", /** Frontend API request failed. */ FAPI_ERROR: "fapi_error", + /** Subscription plan does not cover the dev instance's enabled features. */ + PLAN_INSUFFICIENT: "plan_insufficient", + /** Application already has a production instance; flow should re-derive state. */ + PRODUCTION_INSTANCE_EXISTS: "production_instance_exists", + /** `home_url` is a provider domain (e.g. *.vercel.app) and not allowed. */ + PROVIDER_DOMAIN_NOT_ALLOWED: "provider_domain_not_allowed", + /** `home_url` is already claimed by another instance. */ + HOME_URL_TAKEN: "home_url_taken", + /** PLAPI rejected a request parameter as malformed. */ + FORM_PARAM_INVALID: "form_param_invalid", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; @@ -216,6 +227,15 @@ function parseApiBody(status: number, body: string): ParsedApiBody { }; } +export function isPromptExitError(error: unknown): boolean { + return ( + error instanceof ExitPromptError || + (error instanceof Error && + error.name === "ExitPromptError" && + error.message.includes("User force closed the prompt")) + ); +} + /** * Base class for HTTP API errors. * diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 30d1e96a..4c3c87b8 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -10,10 +10,15 @@ mock.module("./credential-store.ts", () => ({ const { fetchApplication, fetchInstanceConfig, + fetchInstanceConfigSchema, putInstanceConfig, patchInstanceConfig, listApplications, createApplication, + createProductionInstance, + getApplicationDomainStatus, + triggerApplicationDomainDNSCheck, + listApplicationDomains, } = await import("./plapi.ts"); const { AuthError, PlapiError } = await import("./errors.ts"); @@ -122,6 +127,39 @@ describe("plapi", () => { expect(requestedUrl).toStartWith("https://api.clerk.com/"); }); + describe("fetchInstanceConfigSchema", () => { + test("constructs schema URL without keys", async () => { + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify({ properties: {} }), { status: 200 }); + }); + + await fetchInstanceConfigSchema("app_abc", "ins_def"); + + expect(requestedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_def/config/schema", + ); + }); + + test("appends repeated keys query params", async () => { + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify({ properties: {} }), { status: 200 }); + }); + + await fetchInstanceConfigSchema("app_abc", "ins_def", [ + "connection_oauth_google,connection_oauth_discord", + "connection_oauth_apple", + ]); + + expect(requestedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_def/config/schema?keys=connection_oauth_google&keys=connection_oauth_discord&keys=connection_oauth_apple", + ); + }); + }); + describe("putInstanceConfig", () => { test("sends PUT method with correct URL", async () => { let capturedMethod = ""; @@ -380,4 +418,148 @@ describe("plapi", () => { } }); }); + + describe("createProductionInstance", () => { + test("sends POST to instances with clone params", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + let capturedBody = ""; + const responseBody = { + object: "instance" as const, + id: "ins_prod_123", + environment_type: "production" as const, + active_domain: { + object: "domain" as const, + id: "dmn_123", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + development_origin: "", + cname_targets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }, + publishable_key: "pk_live_123", + secret_key: "sk_live_123", + created_at: 1770000000000, + updated_at: 1770000000000, + }; + 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", { + domain: "example.com", + environment_type: "production", + clone_instance_id: "ins_dev_123", + }); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe("https://api.clerk.com/v1/platform/applications/app_abc/instances"); + expect(JSON.parse(capturedBody)).toEqual({ + domain: "example.com", + environment_type: "production", + clone_instance_id: "ins_dev_123", + }); + expect(result).toEqual(responseBody); + }); + }); + + describe("getApplicationDomainStatus", () => { + test("sends GET to domain status and returns parsed status", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + const responseBody = { + status: "complete", + dns: { status: "complete", cnames: {} }, + ssl: { status: "complete", required: true, failure_hints: [] }, + mail: { status: "complete", required: true }, + } as const; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await getApplicationDomainStatus("app_abc", "dmn_123"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/domains/dmn_123/status", + ); + expect(result).toEqual(responseBody); + }); + }); + + describe("triggerApplicationDomainDNSCheck", () => { + test("sends POST to domain dns_check and returns parsed status", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + const responseBody = { + status: "incomplete", + domain_id: "dmn_123", + last_run_at: 1779739200000, + dns: { status: "not_started", cnames: {} }, + ssl: { status: "not_started", required: true, failure_hints: [] }, + mail: { status: "not_started", required: true }, + } as const; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await triggerApplicationDomainDNSCheck("app_abc", "dmn_123"); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/domains/dmn_123/dns_check", + ); + expect(result).toEqual(responseBody); + }); + }); + + describe("listApplicationDomains", () => { + test("sends GET to application domains and returns parsed domains", async () => { + let capturedMethod = ""; + let capturedUrl = ""; + const responseBody = { + data: [ + { + object: "domain" as const, + id: "dmn_123", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + ], + created_at: "2026-05-06T00:00:00Z", + updated_at: "2026-05-06T00:00:00Z", + }, + ], + total_count: 1, + }; + stubFetch(async (input, init) => { + capturedMethod = init?.method ?? "GET"; + capturedUrl = input.toString(); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await listApplicationDomains("app_abc"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe("https://api.clerk.com/v1/platform/applications/app_abc/domains"); + expect(result).toEqual(responseBody); + }); + }); }); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 5a9ca3ac..53f78ba2 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -67,7 +67,7 @@ export async function getAuthToken(): Promise { /** * Local wrapper that adds the standard Bearer auth + Accept headers and * throws PlapiError on non-ok responses. Debug logging is centralized in - * `loggedFetch` — don't add inline `log.debug` calls here or in callers. + * `loggedFetch`; don't add inline `log.debug` calls here or in callers. */ async function plapiFetch(method: string, url: URL, init?: { body?: string }): Promise { const token = await getAuthToken(); @@ -99,18 +99,39 @@ function appendKeys(url: URL, keys?: string[]): void { } } +export type ConfigSchemaProperty = { + type?: string; + description?: string; + default?: unknown; + enum?: string[]; + readOnly?: boolean; + properties?: Record; + required?: string[]; + minLength?: number; + maxLength?: number; + pattern?: string; + "x-clerk-sensitive"?: boolean; +}; + +export type InstanceConfigSchema = { + $schema?: string; + $id?: string; + type?: string; + properties?: Record; +}; + export async function fetchInstanceConfigSchema( applicationId: string, instanceId: string, keys?: string[], -): Promise> { +): Promise { const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config/schema`, getPlapiBaseUrl(), ); appendKeys(url, keys); const response = await plapiFetch("GET", url); - return response.json() as Promise>; + return response.json() as Promise; } export async function fetchInstanceConfig( @@ -140,6 +161,74 @@ export interface Application { instances: ApplicationInstance[]; } +export type DomainSummary = { + id: string; + name: string; +}; + +export type CnameTarget = { + host: string; + value: string; + required: boolean; +}; + +export type ApplicationDomain = { + object: "domain"; + id: string; + name: string; + is_satellite: boolean; + is_provider_domain: boolean; + frontend_api_url: string; + accounts_portal_url?: string; + proxy_url?: string; + development_origin: string; + cname_targets?: CnameTarget[]; + created_at: string; + updated_at: string; +}; + +export type ListApplicationDomainsResponse = { + data: ApplicationDomain[]; + total_count: number; +}; + +export type ProductionInstanceResponse = { + id: string; + object: "instance"; + environment_type: "production"; + active_domain: ApplicationDomain | null; + secret_key?: string; + publishable_key: string; + created_at: number; + updated_at: number; +}; + +export type CreateProductionInstanceParams = { + domain: string; + environment_type: "production"; + clone_instance_id?: string; +}; + +export type DeployStatus = "complete" | "incomplete"; + +type DomainCheckStatus = { + status: string; + required?: boolean; +}; + +export type DomainStatusResponse = { + status: DeployStatus; + dns?: DomainCheckStatus; + ssl?: DomainCheckStatus; + mail?: DomainCheckStatus; + proxy?: DomainCheckStatus; +}; + +export type TriggerDNSCheckResponse = DomainStatusResponse & { + domain_id: string; + last_run_at: number | null; +}; + export async function fetchApplication(applicationId: string): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); url.searchParams.set("include_secret_keys", "true"); @@ -147,6 +236,47 @@ export async function fetchApplication(applicationId: string): Promise; } +export async function listApplicationDomains( + applicationId: string, +): Promise { + const url = new URL(`/v1/platform/applications/${applicationId}/domains`, getPlapiBaseUrl()); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function createProductionInstance( + applicationId: string, + params: CreateProductionInstanceParams, +): Promise { + const url = new URL(`/v1/platform/applications/${applicationId}/instances`, getPlapiBaseUrl()); + const response = await plapiFetch("POST", url, { body: JSON.stringify(params) }); + return response.json() as Promise; +} + +export async function getApplicationDomainStatus( + applicationId: string, + domainIdOrName: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/status`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function triggerApplicationDomainDNSCheck( + applicationId: string, + domainIdOrName: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/domains/${domainIdOrName}/dns_check`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("POST", url); + return response.json() as Promise; +} + async function sendInstanceConfig( method: "PUT" | "PATCH", applicationId: string, diff --git a/packages/cli-core/src/lib/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..535315b1 --- /dev/null +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -0,0 +1,45 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { useCaptureLog } from "../test/lib/stubs.ts"; + +mock.module("../mode.ts", () => ({ + getMode: () => "human", + isAgent: () => false, + isHuman: () => true, + setMode: () => {}, +})); + +const { withSpinner } = await import("./spinner.ts"); + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, "g"), ""); +} + +describe("withSpinner", () => { + const captured = useCaptureLog(); + const originalCI = process.env.CI; + const originalIsTTY = process.stderr.isTTY; + + beforeEach(() => { + delete process.env.CI; + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: true, + }); + }); + + afterEach(() => { + process.env.CI = originalCI; + Object.defineProperty(process.stderr, "isTTY", { + configurable: true, + value: originalIsTTY, + }); + }); + + test("lets callbacks update the active spinner message", async () => { + await withSpinner("Checking status...", async ({ update }) => { + update("Checking status... Retrying in 5"); + }); + + expect(stripAnsi(captured.err)).toContain("Checking status... Retrying in 5"); + }); +}); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index bbb5d8b7..2ac74c68 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -23,8 +23,17 @@ export function intro(title?: string) { pushPrefix(); } -/** Print outro bracket: └ message — restores normal log output. - * Pass a string[] to render as next steps after the bracket. */ +/** + * Print outro bracket: + * + * ``` + * │ + * └ $message + * ``` + * + * Then restores normal log output. Pass a string[] to render as next steps + * after the bracket. + **/ export function outro(messageOrSteps?: string | readonly string[]) { if (!isHuman()) return; popPrefix(); @@ -52,18 +61,26 @@ function createSpinner() { const interactive = isInteractive(); let timer: ReturnType | undefined; let frame = 0; + let currentMessage = ""; + + const render = () => { + const char = cyan(FRAMES[frame++ % FRAMES.length]!); + log.ui(`\r\x1b[K${char} ${currentMessage}`); + }; return { start(message: string) { + currentMessage = message; if (!interactive) { log.ui(`${S_STEP_DONE} ${message}\n`); return; } log.ui("\x1b[?25l"); // hide cursor - timer = setInterval(() => { - const char = cyan(FRAMES[frame++ % FRAMES.length]!); - log.ui(`\r\x1b[K${char} ${message}`); - }, INTERVAL); + timer = setInterval(render, INTERVAL); + }, + update(message: string) { + currentMessage = message; + if (interactive) render(); }, stop(finalMessage?: string) { if (timer) { @@ -90,17 +107,21 @@ function createSpinner() { }; } +export type SpinnerControls = { + update(message: string): void; +}; + export async function withSpinner( message: string, - fn: () => Promise, + fn: (controls: SpinnerControls) => Promise, doneMessage?: string, ): Promise { - if (!isHuman()) return fn(); + if (!isHuman()) return fn({ update: () => {} }); const s = createSpinner(); s.start(message); try { - const result = await fn(); + const result = await fn({ update: s.update }); s.stop(doneMessage ?? message.replace(/\.{3}$/, "")); return result; } catch (error) { diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 1f6b56d6..edc8c506 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -73,6 +73,10 @@ describe("generateCompletions", () => { expect(names).toContain("patch"); expect(names).toContain("put"); }); + + test("completes deploy subcommands", () => { + expect(completionNames("deploy", "")).toContain("status"); + }); }); describe("alias completion", () => { diff --git a/scripts/cleanup-test-users.test.ts b/scripts/cleanup-test-users.test.ts new file mode 100644 index 00000000..6ea8bb33 --- /dev/null +++ b/scripts/cleanup-test-users.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { plapiGet } from "./cleanup-test-users.ts"; + +const originalFetch = globalThis.fetch; + +describe("cleanup-test-users", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("retries transient Platform API failures before returning JSON", async () => { + globalThis.fetch = mock(async () => { + const attempts = (globalThis.fetch as unknown as ReturnType).mock.calls.length; + if (attempts === 1) { + return new Response("temporary failure", { status: 500 }); + } + + return Response.json({ instances: [] }); + }) as unknown as typeof fetch; + + const result = await plapiGet("/v1/platform/applications/app_123", "ak_test", { + delayMs: 0, + }); + + expect(result).toEqual({ instances: [] }); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/scripts/cleanup-test-users.ts b/scripts/cleanup-test-users.ts index ed06d7c9..b0aa6ed8 100644 --- a/scripts/cleanup-test-users.ts +++ b/scripts/cleanup-test-users.ts @@ -13,11 +13,63 @@ const PLAPI_BASE = process.env.CLERK_PLATFORM_API_URL ?? "https://api.clerk.com"; const BAPI_BASE = process.env.CLERK_BACKEND_API_URL ?? "https://api.clerk.com"; const TEST_EMAIL_SUFFIX = "+clerk_test@clerkcookie.com"; +const DEFAULT_RETRY_OPTIONS = { + attempts: 3, + delayMs: 500, +}; + +type RetryOptions = { + attempts?: number; + delayMs?: number; +}; + +async function sleep(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => setTimeout(resolve, ms)); +} -async function plapiGet(path: string, apiKey: string): Promise { - const res = await fetch(`${PLAPI_BASE}${path}`, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); +function isRetriableStatus(status: number): boolean { + return status === 429 || (status >= 500 && status <= 599); +} + +async function fetchWithRetry( + input: string, + init: RequestInit, + options: RetryOptions = {}, +): Promise { + const attempts = options.attempts ?? DEFAULT_RETRY_OPTIONS.attempts; + const delayMs = options.delayMs ?? DEFAULT_RETRY_OPTIONS.delayMs; + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const res = await fetch(input, init); + if (!isRetriableStatus(res.status) || attempt === attempts) { + return res; + } + } catch (err) { + lastError = err; + if (attempt === attempts) throw err; + } + + await sleep(delayMs * attempt); + } + + throw lastError instanceof Error ? lastError : new Error("Request failed"); +} + +export async function plapiGet( + path: string, + apiKey: string, + options?: RetryOptions, +): Promise { + const res = await fetchWithRetry( + `${PLAPI_BASE}${path}`, + { + headers: { Authorization: `Bearer ${apiKey}` }, + }, + options, + ); if (!res.ok) { const body = await res.text(); throw new Error(`Platform API ${path} failed (${res.status}): ${body}`); @@ -26,7 +78,7 @@ async function plapiGet(path: string, apiKey: string): Promise { } async function bapiGet(path: string, secretKey: string): Promise { - const res = await fetch(`${BAPI_BASE}${path}`, { + const res = await fetchWithRetry(`${BAPI_BASE}${path}`, { headers: { Authorization: `Bearer ${secretKey}` }, }); if (!res.ok) { @@ -37,7 +89,7 @@ async function bapiGet(path: string, secretKey: string): Promise { } async function bapiDelete(path: string, secretKey: string): Promise { - const res = await fetch(`${BAPI_BASE}${path}`, { + const res = await fetchWithRetry(`${BAPI_BASE}${path}`, { method: "DELETE", headers: { Authorization: `Bearer ${secretKey}` }, }); @@ -110,4 +162,6 @@ async function main() { console.log(`Deleted ${deleted}/${testUsers.length} test users.`); } -main(); +if (import.meta.main) { + main(); +} diff --git a/skills/clerk-cli/SKILL.md b/skills/clerk-cli/SKILL.md index 7ff12b0f..6e04bf66 100644 --- a/skills/clerk-cli/SKILL.md +++ b/skills/clerk-cli/SKILL.md @@ -2,11 +2,12 @@ name: clerk-cli description: >- Operate the Clerk CLI (`clerk` binary) for authentication, user/org/session management, - instance config, env keys, and any Clerk Backend or Platform API call. Use when the user - mentions Clerk management tasks, "list clerk users", "create a clerk user", "update - organization", "pull clerk config", "clerk env pull", "clerk doctor", "clerk api", or - any ad-hoc Clerk API request. Prefer the CLI over raw HTTP: it handles auth, key - resolution, app/instance targeting, and formatting automatically. + deploy verification, instance config, env keys, and any Clerk Backend or Platform API + call. Use when the user mentions Clerk management tasks, "list clerk users", "create a + clerk user", "update organization", "pull clerk config", "clerk env pull", + "clerk doctor", "clerk deploy", "clerk deploy status", "clerk api", or any ad-hoc + Clerk API request. Prefer the CLI over raw HTTP: it handles auth, key resolution, + app/instance targeting, and formatting automatically. --- # Clerk CLI @@ -209,6 +210,8 @@ node -e 'const d=require("/tmp/users.json"); console.log(d.data.length, d.hasMor | `clerk users create` | Create a user from curated flags or a raw BAPI body. Confirmation prompt unless `--yes`. | `--email`, `--phone`, `--username`, `--password`, `--first-name`, `--last-name`, `--external-id`, `-d, --data`, `--file`, `--dry-run`, `--yes`, `--json` | | `clerk users open [user-id]` | Open a user's dashboard page. Agent mode requires `user-id` and prints a JSON descriptor instead of launching a browser. | (see `--help`) | | `clerk open [subpath]` | Open the linked app's dashboard in a browser. Agent mode: prints a JSON descriptor instead of opening. | (see `--help`) | +| `clerk deploy` | Human-mode production deploy wizard. Agent mode: emits a read-only JSON handoff and tells the agent whether to ask the human to run the wizard, wait for provisioning, finish OAuth, or do nothing. | `--mode agent`, `--mode human`, `--verbose` | +| `clerk deploy status` | Read-only deploy verification. Triggers a DNS check, reports aggregate domain and OAuth readiness, and exits `0` only when complete. Agent mode does one quick check by default; pass `--wait` to keep waiting. | `--mode agent`, `--wait`, `--verbose` | | `clerk doctor` | Health check (CLI version, login, link, env, config, completion; plus host-execution probe in agent mode). | `--json`, `--spotlight`, `--verbose`, `--fix` | | `clerk api [path]` | Authenticated HTTP to Backend/Platform API. | `-X`, `-d`, `--file`, `--dry-run`, `--yes`, `--include`, `--app`, `--secret-key`, `--instance`, `--platform` | | `clerk api ls [filter]` | Discover endpoints from the bundled OpenAPI catalog. | (see `--help`) | @@ -232,6 +235,7 @@ The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` - **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. - **`apps list` and `apps create` default to JSON** when piped. - **`users` defaults to JSON when piped, like `apps`.** `clerk users list` and `clerk users create` emit JSON in agent mode. Bare `clerk users` (no subcommand) is a usage error in agent mode — pass `list`, `create`, or `open` explicitly. `clerk users open` requires the `user-id` positional in agent mode and prints a JSON descriptor instead of launching a browser. +- **`deploy` has an agent handoff plus a verification gate.** In agent mode, bare `clerk deploy` is read-only and emits a JSON handoff. It never drives the interactive wizard. Do not tell Claude or another agent to run `! clerk deploy`, because the wizard needs interactive stdin prompts. Ask the human to run `clerk deploy` in a new terminal window when needed, then run `clerk deploy status --mode agent` to verify completion. See [references/agent-mode.md](references/agent-mode.md#deploy-handoff-and-verification). - **`--input-json `** expands JSON into flags on any command (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Piped stdin is also accepted: `echo '{"yes":true}' | clerk init`. Place `--input-json` after the leaf subcommand. Full rules in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). Full matrix and sandbox details in [references/agent-mode.md](references/agent-mode.md). diff --git a/skills/clerk-cli/references/agent-mode.md b/skills/clerk-cli/references/agent-mode.md index 271c62e4..3b1e4cf2 100644 --- a/skills/clerk-cli/references/agent-mode.md +++ b/skills/clerk-cli/references/agent-mode.md @@ -61,6 +61,8 @@ Force human mode with `--mode human` or `CLERK_MODE=human`. Typical AI-agent inv | `clerk users` (no subcommand) | Interactive action picker | Prints the action list and exits with a usage error (code `2`) — pass `list` / `create` / `open` | | `clerk users open [user-id]` | Picks a user interactively, opens browser | Requires `user-id`; prints `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` and does not open a browser | | `clerk open [subpath]` | Opens the browser to the URL | Does not open a browser. Prints a JSON descriptor (`{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}`) on stdout so the agent can surface it | +| `clerk deploy` | Interactive production deploy wizard | Read-only handoff. Emits deploy status JSON on stdout and exits `0` for linked projects. Does not prompt, mutate, trigger DNS checks, or poll. | +| `clerk deploy status` | Verify production deploy state | Read-only verification gate. Triggers one DNS check for active production domains, waits briefly, reads one live status snapshot, emits status JSON on stdout, exits `0` when complete and `1` when incomplete. It does not keep waiting or back off in agent mode unless `--wait` is passed. Use `--wait` when the user asks the agent to keep waiting for DNS, SSL, email DNS, or final Clerk-side readiness. | | `clerk auth login` when already authenticated | Prompt to re-auth | Silent no-op | | `clerk init` | Full interactive scaffold flow | Runs non-interactively. Explicit `--app` or a linked profile uses the real-app auth/link/env flow; without it, an authenticated agent on a keyless-capable framework creates a real app and links it. Pass `--keyless` to opt into auto-generated dev keys for new projects on keyless-capable frameworks. Without `--keyless`, an unauthenticated agent (or non-keyless framework with no app target) prints manual setup guidance. | | Color / spinners | Enabled | Disabled | @@ -130,21 +132,23 @@ Errors use the standard agent-mode format: bad JSON → `invalid_json`, missing ## Structured outputs you can rely on -| Command | Structured output | -| --------------------------------------------- | --------------------------------------------------------------------------------------- | -| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | -| `clerk apps list --json` | Array of application objects | -| `clerk apps create --json` | Single application object | -| `clerk users list` (agent mode or `--json`) | `{data: [...users], hasMore}` envelope (BAPI user shape inside `data`) | -| `clerk users create` (agent mode or `--json`) | Single user object (raw BAPI shape) | -| `clerk users open` (agent mode) | `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` | -| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | -| `clerk api --include` | Response headers on stderr, body on stdout | -| `clerk config pull` | Instance config JSON | -| `clerk config schema` | JSON Schema | -| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | -| `clerk open --print` | Plain dashboard URL on stdout | -| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | +| Command | Structured output | +| --------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | +| `clerk apps list --json` | Array of application objects | +| `clerk apps create --json` | Single application object | +| `clerk users list` (agent mode or `--json`) | `{data: [...users], hasMore}` envelope (BAPI user shape inside `data`) | +| `clerk users create` (agent mode or `--json`) | Single user object (raw BAPI shape) | +| `clerk users open` (agent mode) | `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` | +| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | +| `clerk api --include` | Response headers on stderr, body on stdout | +| `clerk config pull` | Instance config JSON | +| `clerk config schema` | JSON Schema | +| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | +| `clerk open --print` | Plain dashboard URL on stdout | +| `clerk deploy` (agent mode) | Deploy handoff report with `complete`, `state`, domain status, OAuth status, and `nextAction` | +| `clerk deploy status` (agent mode) | Deploy verification report with the same shape, plus exit `0` complete or `1` incomplete | +| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | For commands without an explicit `--json` flag, `clerk api` is your escape hatch: hit the underlying endpoint directly. @@ -181,6 +185,70 @@ clerk api /users --app app_abc123 --instance prod The same advice applies to linking in agent mode: `clerk link --app app_abc123` is deterministic and works non-interactively. If you omit `--app`, the command only succeeds when silent autolink can prove the target app from existing publishable keys. +### Deploy handoff and verification + +Do not try to drive the interactive deploy wizard from an agent. Use the handoff and check commands instead. + +```sh +# 1. Inspect current production deploy state without mutating anything. +clerk deploy --mode agent + +# 2. If the handoff says a human action is needed, ask the user to run this +# in a new terminal window, not through `! clerk deploy`: +clerk deploy --mode human + +# 3. After the user finishes or DNS has had time to propagate, verify: +clerk deploy status --mode agent + +# 4. If the user asks you to keep waiting, use the retrying wait loop: +clerk deploy status --mode agent --wait +``` + +`clerk deploy --mode agent` is read-only. It resolves the linked app and current production deploy snapshot, then emits JSON on stdout. It does **not** trigger DNS checks, poll, create production instances, patch OAuth config, or prompt. Linked projects exit `0` because this is an informational handoff. Not-linked and API failures still use the normal agent error envelope on stderr. + +Never try to run the human wizard through Claude's `! clerk deploy` shell escape or any non-interactive agent shell. The deploy wizard asks for domain, DNS export, OAuth, and verification inputs over stdin, so it needs a real human terminal. Tell the user to open a new terminal window in the project directory and run `clerk deploy` or `clerk deploy --mode human` there. After they finish, return to agent mode and run `clerk deploy status --mode agent`. + +`clerk deploy status --mode agent` is the gate. It is also read-only with respect to deploy configuration, but for an active production domain it triggers one Clerk DNS check, waits briefly, reads a live status/config snapshot, then reports DNS, SSL, email DNS, aggregate domain readiness, and OAuth completeness. By default it does not keep waiting or exponentially back off in agent mode. If the check is incomplete and the user asks the agent to continue waiting, run `clerk deploy status --mode agent --wait` instead of manually sleeping and retrying. `--wait` uses the shared poll loop: one immediate status read, then up to 5 exponential-backoff retries until aggregate domain status is complete. It emits the same status JSON. It exits: + +| Exit | Meaning | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | Deploy is complete and verified. | +| `1` | The check ran successfully, but deploy is incomplete. Read `state` and `nextAction`. | +| else | A real CLI error occurred. Read the standard agent error envelope on stderr. | + +Deploy-specific agent errors still use the standard envelope and may include typed codes such as `plan_insufficient`, `provider_domain_not_allowed`, `home_url_taken`, or `form_param_invalid`. + +When a production instance exists, `nextAction` includes the full Clerk Dashboard domains URL so agents can send the user directly to the same page the human CLI prints in its next steps. Always show that URL to the user. Ask whether they want you to open it for them instead of omitting or paraphrasing it away. + +The deploy report has this shape: + +```json +{ + "complete": false, + "state": "domain_pending", + "domain": "example.com", + "productionInstanceId": "ins_...", + "domainStatus": { "dns": "complete", "ssl": "pending", "mail": "complete" }, + "pendingDnsRecords": [{ "type": "CNAME", "host": "clerk.example.com", "value": "..." }], + "oauth": { "complete": true, "configured": ["google"], "pending": [], "unsupported": [] }, + "nextAction": "SSL still provisioning for example.com. Re-run `clerk deploy status` in a few minutes, DNS propagation can take time. Ask the user to visit the Clerk Dashboard domains page, or offer to open it: https://dashboard.clerk.com/apps/app_.../instances/ins_.../domains" +} +``` + +`complete` is `true` only when the aggregate domain status is complete and all supported OAuth providers enabled in development have production credentials. The `domainStatus` object is a component summary; DNS, SSL, and email DNS can all read `complete` while `state` remains `domain_pending` if Clerk-side finalization is still pending. + +State precedence: + +| State | What to do | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `not_started` | Ask the human to run `clerk deploy --mode human`, then run `clerk deploy status --mode agent`. | +| `domain_provisioning` | Wait briefly or ask the human to finish `clerk deploy`, then run `clerk deploy status --mode agent`. | +| `domain_pending` | Surface `pendingDnsRecords` when present. Re-run `clerk deploy status --mode agent` after DNS, SSL, or email DNS propagation. | +| `oauth_pending` | Ask the human to finish the OAuth credential steps in `clerk deploy --mode human`, then verify with `deploy status`. | +| `complete` | No action needed. | + +Unsupported OAuth providers do not block `complete`, because the wizard cannot configure them automatically. They are still surfaced in `oauth.unsupported` so you can warn the user to review them in the Clerk Dashboard. + ### Use the catalog, not hard-coded paths ```sh