diff --git a/.agents/skills/audit-clerk-skill b/.agents/skills/audit-clerk-skill new file mode 120000 index 00000000..5a0f5935 --- /dev/null +++ b/.agents/skills/audit-clerk-skill @@ -0,0 +1 @@ +../../.claude/skills/audit-clerk-skill \ No newline at end of file diff --git a/.agents/skills/changesets b/.agents/skills/changesets new file mode 120000 index 00000000..5582b9f2 --- /dev/null +++ b/.agents/skills/changesets @@ -0,0 +1 @@ +../../.claude/skills/changesets \ No newline at end of file diff --git a/.changeset/deploy-status.md b/.changeset/deploy-status.md new file mode 100644 index 00000000..59abf6da --- /dev/null +++ b/.changeset/deploy-status.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk deploy status`, a read-only command that verifies a production deploy, including DNS, SSL, email DNS, and OAuth credential completeness. Agent-mode `clerk deploy` now emits a tailored read-only handoff instead of a hard usage error. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 0817b834..60bd3ef2 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -24,6 +24,8 @@ bun run test This runs each unit and integration test file as a separate `bun test` subprocess via `scripts/run-tests.ts`, isolating module state between files. E2E fixtures are excluded and require separate setup (see `rules/e2e.md`). +When running multiple test files directly with `bun test`, always pass `--isolate` or `--parallel`. `--parallel` implies `--isolate`. Without isolation, Bun can share module mocks across files and produce order-dependent failures. + Prefer `spyOn()` for mocking, and always restore spies in `afterAll` with `mockRestore()`. Never use `for` or `forEach` loops inside a single test to verify multiple inputs or cases — use `test.each` (or `it.each` / `describe.each`) so each case is its own reported test case with its own name, setup/teardown, and pinpointed failure output. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index a4eef392..de46450c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,8 @@ Locally, prefer `bun run test:e2e:op` so secrets are injected from 1Password in- CI runs `bun run format:check` (fails if unformatted), `bun run lint`, `bun test`, and `bun run test:e2e` on every PR to `main`. E2E tests only run for PRs from the same repository (not external forks) and target the production Clerk API with a dedicated test application. +When running multiple test files directly with `bun test`, always pass `--isolate` or `--parallel`. `--parallel` implies `--isolate`. Without isolation, Bun can share module mocks across files and produce order-dependent failures. Prefer `bun run test` for the full suite because it already isolates test files through `scripts/run-tests.ts`. + ## Versioning The `CLI_VERSION` global is injected at compile time via `bun build --compile --define "CLI_VERSION=..."`. Local `build:compile` omits it, so the binary reports `0.0.0-dev`. The CI release workflow injects the real version. diff --git a/README.md b/README.md index 564efe72..2ee89064 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Commands: completion [shell] Generate shell autocompletion script skill Manage the bundled Clerk CLI agent skill update [options] Update the Clerk CLI to the latest version - deploy [options] Deploy a Clerk application to production + deploy Deploy a Clerk application to production help [command] Display help for command Give AI agents better Clerk context: install the Clerk skills diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index bdca753a..540fbed9 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -53,6 +53,15 @@ test("deploy relies on global options", () => { expect(optionNames).toEqual([]); }); +test("deploy status exposes wait option", () => { + const program = createProgram(); + const deploy = program.commands.find((command) => command.name() === "deploy")!; + const status = deploy.commands.find((command) => command.name() === "status")!; + const optionNames = status.options.map((option) => option.long); + + expect(optionNames).toContain("--wait"); +}); + describe("parseIntegerOption (via users list --limit / --offset)", () => { function parseUsersList(args: readonly string[]) { return createProgram().parseAsync(["users", "list", ...args], { from: "user" }); diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 92c5203e..c4996d1e 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -46,6 +46,7 @@ import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; import { update } from "./commands/update/index.ts"; import { deploy } from "./commands/deploy/index.ts"; +import { deployStatus } from "./commands/deploy/status-command.ts"; import { isClerkSkillInstalled } from "./lib/skill-detection.ts"; import { orgsEnable, orgsDisable } from "./commands/orgs/index.ts"; import { billingEnable, billingDisable } from "./commands/billing/index.ts"; @@ -926,7 +927,15 @@ Tutorial — enable completions for your shell: ]) .action(update); - program.command("deploy").description("Deploy a Clerk application to production").action(deploy); + const deployCmd = program + .command("deploy") + .description("Deploy a Clerk application to production"); + deployCmd.command("run", { isDefault: true, hidden: true }).action(deploy); + deployCmd + .command("status") + .description("Show production deploy status (read-only)") + .option("--wait", "Wait for DNS, SSL, and email DNS verification with retries") + .action(deployStatus); registerExtras(program); diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 65c4c73f..89c368e2 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -89,6 +89,7 @@ mock.module("../../lib/autoclaim.ts", () => ({ attemptAutoclaim: async () => ({ status: "not_keyless" }), })); +const { setLogLevel } = await import("../../lib/log.ts"); const { login } = await import("./login.ts"); describe("login", () => { @@ -114,6 +115,7 @@ describe("login", () => { mockEnsureFirstApplication.mockResolvedValue(undefined); mockIsHuman.mockReturnValue(false); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); + setLogLevel("info"); consoleSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); try { @@ -592,6 +594,45 @@ describe("login", () => { expect(parsed.searchParams.get("clerk_client")).toBe("cli"); }); + test("does not emit the OAuth authorize URL through debug logging", async () => { + setLogLevel("debug"); + mockGetValidToken.mockResolvedValue(null); + mockBunSpawn(); + + const mockServer = { + port: 54321, + waitForCallback: mock().mockResolvedValue({ code: "fresh-auth-code" }), + stop: mock(), + }; + mockStartAuthServer.mockReturnValue(mockServer); + + mockExchangeCodeForToken.mockResolvedValue({ + access_token: "new-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "new-refresh-token", + }); + mockCreateOAuthSession.mockReturnValue({ + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + expiresAt: 123, + tokenType: "Bearer", + }); + mockStoreToken.mockResolvedValue(undefined); + mockFetchUserInfo.mockResolvedValue({ + userId: "user_new", + email: "new@example.com", + }); + mockSetAuth.mockResolvedValue(undefined); + + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + await runLogin({ showNextSteps: false }); + + expect(captured.err).not.toContain("https://test.example.com/oauth/authorize"); + expect(captured.err).not.toContain("test-state-value"); + expect(captured.err).not.toContain("test-code-challenge"); + }); + test("calls ensureFirstApplication after a successful OAuth flow", async () => { mockGetValidToken.mockResolvedValue(null); mockBunSpawn(); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 92e6673b..c2c3eab6 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -57,7 +57,6 @@ async function performOAuthFlow(): Promise { // Critical fallback: the OAuth callback can't complete unless the user // reaches the authorize URL somehow. const urlString = authorizeUrl.toString(); - log.debug(`Opening browser to URL: ${urlString}`); const result = await openBrowser(urlString); if (!result.ok) { log.warn( diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 1457def8..bcaf8baf 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -6,14 +6,16 @@ Guides a user through deploying their Clerk application to production. When the CLI reaches the DNS configuration step, it displays the required CNAME records and then prompts the user to export those records as a BIND zone file (`./clerk-.zone`). The prompt defaults to "no" and the file is overwritten silently on subsequent runs. On new deploys, this handoff happens before OAuth setup so DNS propagation can start while the user configures providers. -After all OAuth providers are configured, DNS verification runs a chain of three per-component spinners covering mail, DNS, and SSL in sequence. Each spinner resolves independently and emits a confirmation line as its component flips true, so the user sees incremental progress instead of a single opaque wait. All three spinners share the same 10-second cap. If polling times out, the retry handoff lists only DNS records tied to still-pending DNS-backed checks. For example, when DNS and mail are verified but SSL is still pending, the CLI shows green checks for the verified components and does not reprint already verified records. +After all OAuth providers are configured, DNS verification checks DNS, SSL, and email DNS from the same domain status response. The wizard uses one shared exponential-backoff loop instead of waiting for each component separately. If polling times out, the retry handoff lists only DNS records tied to still-pending DNS-backed checks. For example, when DNS and email DNS are verified but SSL is still pending, the CLI shows green checks for the verified components and does not reprint already verified records. ## Usage ```sh clerk deploy # Interactive, idempotent wizard (human mode) clerk deploy --verbose # With debug output -clerk deploy --mode agent # Exit with human-mode-required guidance +clerk deploy --mode agent # Emit a read-only handoff for agents +clerk deploy status # Verify deploy completion without prompts +clerk deploy status --mode agent --wait # Agent verification with retrying wait ``` ## Global Options @@ -24,7 +26,40 @@ clerk deploy --mode agent # Exit with human-mode-required guidance ## Agent Mode -When running in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), this command exits with a usage error explaining that human mode is required. Production deploy configuration depends on interactive prompts for domain, DNS, and OAuth credential collection, so agents should hand off to a human-run terminal session. +When running `clerk deploy` in agent mode (`--mode agent`, `CLERK_MODE=agent`, or non-TTY context), the command emits a structured JSON handoff via `log.data` on stdout. The handoff is read-only: it resolves the linked app, production instance, domain, domain status snapshot, and OAuth completeness, but it does not prompt, mutate config, trigger a DNS check, or poll. + +The handoff state is one of: + +| State | Meaning | +| --------------------- | ----------------------------------------------------------------------------------------------- | +| `not_started` | No production instance exists. Ask the human to run `clerk deploy`, then verify afterward. | +| `domain_provisioning` | A production instance exists, but PLAPI has not returned a production domain yet. | +| `domain_pending` | A production domain exists, but DNS, SSL, email DNS, or final server-side readiness is pending. | +| `oauth_pending` | The domain is verified, but supported OAuth providers still need production credentials. | +| `complete` | Production is deployed and verified. | + +Agent-mode `clerk deploy` exits successfully for linked projects because it is informational. The pass/fail gate is `clerk deploy status`. + +### `clerk deploy status` + +`clerk deploy status` is the read-only verification command for agents and automation. It resolves the same live deploy state, triggers a DNS check for active production domains, and reports DNS, SSL, email DNS, and OAuth completeness. Human mode waits with the shared exponential-backoff poll loop; agent mode performs one quick DNS check and returns the resulting status immediately by default. Pass `--wait` in agent mode to wait with the same poll loop: one immediate status read, then up to 5 retries with exponential backoff. + +In agent mode, `clerk deploy status` emits JSON on stdout with: + +- `complete`: `true` only when the domain is verified and all supported OAuth providers enabled in development have production credentials. +- `state`: `complete`, `domain_pending`, `oauth_pending`, `domain_provisioning`, or `not_started`. +- `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. +- `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks. +- `oauth`: configured, pending, and unsupported provider slugs. +- `nextAction`: the next step an agent should present to the user, including the Clerk Dashboard domains URL when a production instance exists. Agents should ask whether to open that URL for the user. + +Exit codes: + +| Exit | Meaning | +| ---- | --------------------------------------------------------------------------------------------- | +| `0` | Deploy is complete and verified. | +| `1` | The check ran successfully, but deploy is incomplete. Inspect `state` and `nextAction`. | +| else | A real CLI error occurred, such as not linked or an API failure, via the standard error path. | Agent mode is detected via the mode system (`src/mode.ts`), which checks in priority order: @@ -32,22 +67,23 @@ Agent mode is detected via the mode system (`src/mode.ts`), which checks in prio 2. `CLERK_MODE` environment variable 3. TTY detection (`process.stdout.isTTY`) -Agent mode does not call PLAPI and exits before the human-mode wizard starts. +The human-mode wizard still starts only in human mode. ## PLAPI Lifecycle Human mode reads and writes deploy state through the Platform API on every run. The CLI does not persist deploy progress locally; the only profile write is the ordinary `instances.production` value once the production instance has been created. -| Step | Endpoint | Behavior | -| -------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Create production instance | `POST /v1/platform/applications/{appID}/instances` | Returns `id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and timestamps. DNS records are read from `active_domain.cname_targets[]`. | -| | | Performs clone feature compatibility validation before creating production resources. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | -| Poll domain status | `GET /v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | Returns aggregate `status` plus nested DNS, SSL, mail, and proxy component status. The CLI drives three sequential per-component spinners (mail, DNS, SSL) that all share the same 10-second cap; each spinner emits a success line as its component flips complete. The aggregate `status` guards proxy and other server-side readiness gates. It performs one immediate status read, then polls every 3s. | -| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Returns the updated config snapshot. Used to persist production `connection_oauth_*` credentials. | +| Step | Endpoint | Behavior | +| -------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create production instance | `POST /v1/platform/applications/{appID}/instances` | Returns `id`, `environment_type`, `active_domain`, `publishable_key`, `secret_key` (once), and timestamps. DNS records are read from `active_domain.cname_targets[]`. | +| | | Performs clone feature compatibility validation before creating production resources. 402 `unsupported_subscription_plan_features` → `ERROR_CODE.PLAN_INSUFFICIENT` listing missing features. 409 `production_instance_exists` → CLI re-derives state via `fetchApplication` and falls through to `reconcileExistingDeploy`. | +| Trigger domain DNS check | `POST /v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | Starts a server-side DNS check. `clerk deploy status` triggers this before resolving the live state. Agent-mode `clerk deploy status` returns after the post-trigger status snapshot by default; `--wait` polls active production domains with the shared retry loop. A 409 conflict means a check is already running. | +| Poll domain status | `GET /v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | Returns aggregate `status` plus nested DNS, SSL, email DNS, and proxy component status. The CLI drives one shared DNS verification loop over the full status response. The aggregate `status` guards proxy and other server-side readiness gates. It performs one immediate status read, then polls every 3s. | +| Save OAuth credentials | `PATCH /v1/platform/applications/{appID}/instances/{instanceID}/config` | Returns the updated config snapshot. Used to persist production `connection_oauth_*` credentials. | After displaying the DNS records block, when CNAME records are present the CLI prompts "Export DNS records as a BIND zone file? (y/N)". On yes, it writes `./clerk-.zone` in the current working directory. The file is a standard BIND fragment containing `$ORIGIN`, `$TTL 300`, and one fully-qualified CNAME per record. The default is "no" and the file is overwritten silently on subsequent runs. Most major DNS providers (Cloudflare, Route 53, Google Cloud DNS, and others) support importing BIND zone fragments. The same prompt appears on both new deploys and resumed deploys. -PLAPI errors are translated to typed `CliError`s by `commands/deploy/errors.ts`. The CLI does not auto-retry SSL or mail provisioning. When domain status polling times out with SSL or mail still incomplete, the CLI surfaces the component status and instructs the user to rerun `clerk deploy` once DNS propagates. +PLAPI errors are translated to typed `CliError`s by `commands/deploy/errors.ts`. The CLI does not auto-retry SSL issuance or email DNS verification beyond the shared DNS verification loop. When domain status polling times out with SSL or email DNS still incomplete, the CLI surfaces the component status and instructs the user to rerun `clerk deploy` once DNS propagates. If the user presses Ctrl-C after the production instance has been created, the wizard tells them to run `clerk deploy` again and exits with SIGINT code 130. The next run derives the current DNS or OAuth step from API state and resumes without starting another production instance. @@ -102,16 +138,17 @@ sequenceDiagram All endpoints are on the **Platform API** (`/v1/platform/...`) and are live HTTP calls. The deploy command calls the helpers in `lib/plapi.ts` directly. -| Step | Method | Endpoint | Helper | -| --------------------------- | ------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Auth | n/a | Local config | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | -| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `fetchInstanceConfig` from `lib/plapi.ts`. Discovers enabled `connection_oauth_*` providers. | -| Read instance config schema | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config/schema` | `fetchInstanceConfigSchema`. Reads schemas for supported OAuth config keys so deploy can derive credential prompts. | -| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `patchInstanceConfig`. Writes production OAuth credentials. | -| Read application | `GET` | `/v1/platform/applications/{appID}` | `fetchApplication`. Resolves development and production instance IDs. | -| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | `listApplicationDomains`. Recovers production domain name and CNAME targets on each run. | -| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and DNS records nested under `active_domain.cname_targets[]`. | -| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the mail, DNS, and SSL sequential spinners in `pollDeployStatus`; each spinner resolves independently within the shared 10-second cap. | +| Step | Method | Endpoint | Helper | +| --------------------------- | ------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Auth | n/a | Local config | Token stored from `clerk auth login` or `CLERK_PLATFORM_API_KEY`. | +| Read instance config | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `fetchInstanceConfig` from `lib/plapi.ts`. Discovers enabled `connection_oauth_*` providers. | +| Read instance config schema | `GET` | `/v1/platform/applications/{appID}/instances/{instanceID}/config/schema` | `fetchInstanceConfigSchema`. Reads schemas for supported OAuth config keys so deploy can derive credential prompts. | +| Patch instance config | `PATCH` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | `patchInstanceConfig`. Writes production OAuth credentials. | +| Read application | `GET` | `/v1/platform/applications/{appID}` | `fetchApplication`. Resolves development and production instance IDs. | +| List production domains | `GET` | `/v1/platform/applications/{appID}/domains` | `listApplicationDomains`. Recovers production domain name and CNAME targets on each run. | +| Create production instance | `POST` | `/v1/platform/applications/{appID}/instances` | `createProductionInstance`. Returns prod instance, primary domain, keys, and DNS records nested under `active_domain.cname_targets[]`. | +| Trigger domain DNS check | `POST` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/dns_check` | `triggerApplicationDomainDNSCheck`. Called by the wizard and by `clerk deploy status`; agent mode waits briefly, then reads one status snapshot. | +| Poll domain status | `GET` | `/v1/platform/applications/{appID}/domains/{domainIDOrName}/status` | `getApplicationDomainStatus`. Drives the wizard spinner and the human-mode `clerk deploy status` wait loop over the full domain status response. | ## OAuth Provider Config Format diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index b669f1a5..64d061f0 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -3,6 +3,7 @@ import { bindZoneFile, deployComponentLabels, deployStatusRetryMessage, + dnsRecords, nextStepsBlock, pendingDnsRecords, } from "./copy.ts"; @@ -60,10 +61,10 @@ describe("bindZoneFile", () => { }); describe("deployComponentLabels", () => { - test("returns mail in-progress and done labels", () => { + test("returns email DNS in-progress and done labels", () => { expect(deployComponentLabels("mail", "example.com")).toEqual({ - progress: "Verifying mail sender for example.com...", - done: "Mail sender verified", + progress: "Verifying email DNS records for example.com...", + done: "Email DNS records verified", }); }); @@ -84,8 +85,8 @@ describe("deployComponentLabels", () => { describe("deployStatusRetryMessage", () => { test("includes current retry count and countdown", () => { - expect(deployStatusRetryMessage("Verifying mail sender for example.com...", 2, 5, 30)).toBe( - "Verifying mail sender for example.com... 2/5 attempts, retrying in 30s", + expect(deployStatusRetryMessage("Verifying DNS records for example.com...", 2, 5, 30)).toBe( + "Verifying DNS records for example.com... 2/5 attempts, retrying in 30s", ); }); }); @@ -114,7 +115,7 @@ describe("pendingDnsRecords", () => { expect(pendingDnsRecords(targets, { dns: true, ssl: false, mail: true })).toEqual([]); }); - test("returns only email records when mail remains pending", () => { + test("returns only email records when email DNS remains pending", () => { const output = pendingDnsRecords(targets, { dns: true, ssl: true, mail: false }).join("\n"); expect(output).toContain("clkmail.example.com"); @@ -130,3 +131,15 @@ describe("pendingDnsRecords", () => { expect(output).not.toContain("clkmail.example.com"); }); }); + +describe("dnsRecords", () => { + test("labels DKIM CNAME records as email DNS records", () => { + const output = dnsRecords([ + { host: "clk._domainkey.example.com", value: "dkim1.clerk.services", required: true }, + { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: true }, + ]).join("\n"); + + expect(output).toContain("Email (Clerk handles SPF/DKIM automatically)"); + expect(output).not.toContain("\n CNAME\n Type:"); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 476f2dcb..3c77a474 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -84,7 +84,7 @@ export function pendingDnsRecords( return dnsRecords(pendingTargets); } -function cnameTargetPending(target: CnameTarget, status: DeployComponentStatus): boolean { +export function cnameTargetPending(target: CnameTarget, status: DeployComponentStatus): boolean { if (isMailCnameTarget(target)) return !status.mail; return !status.dns; } @@ -102,8 +102,8 @@ function cnameTargetLabel(host: string): string { case "accounts": return "Account portal"; case "clkmail": - case "clk._domainkey": - case "clk2._domainkey": + case "clk": + case "clk2": return "Email (Clerk handles SPF/DKIM automatically)"; default: return "CNAME"; @@ -129,12 +129,6 @@ export type DeployComponentStatus = { export type DeployComponent = "mail" | "dns" | "ssl"; -export const DEPLOY_COMPONENT_ORDER = [ - "mail", - "dns", - "ssl", -] as const satisfies readonly DeployComponent[]; - export function deployComponentLabels( component: DeployComponent, domain: string, @@ -142,8 +136,8 @@ export function deployComponentLabels( switch (component) { case "mail": return { - progress: `Verifying mail sender for ${domain}...`, - done: "Mail sender verified", + progress: `Verifying email DNS records for ${domain}...`, + done: "Email DNS records verified", }; case "dns": return { @@ -159,14 +153,13 @@ export function deployComponentLabels( } /** - * Status line for the three independent components Clerk verifies after - * the production instance is created: DNS propagation, SSL issuance via Let's - * Encrypt, and SendGrid mail sender verification. Each flips true on its own - * schedule, see the deploy endpoints handbook for timing details. + * Status line for the domain checks Clerk verifies after the production + * instance is created: DNS propagation, SSL issuance via Let's Encrypt, and + * email DNS records. Each value comes from the same domain status response. */ export function deployComponentStatus(status: DeployComponentStatus): string { const mark = (ok: boolean) => (ok ? green("✓") : yellow("pending")); - return `DNS: ${mark(status.dns)} SSL: ${mark(status.ssl)} Mail: ${mark(status.mail)}`; + return `DNS: ${mark(status.dns)} SSL: ${mark(status.ssl)} Email DNS: ${mark(status.mail)}`; } export function deployStatusRetryMessage( @@ -187,7 +180,7 @@ export function deployStatusPendingFooter(domain: string, status: DeployComponen const pending: string[] = []; if (!status.dns) pending.push("DNS"); if (!status.ssl) pending.push("SSL"); - if (!status.mail) pending.push("mail"); + if (!status.mail) pending.push("email DNS"); const lead = pending.length === 0 @@ -244,7 +237,7 @@ export function nextStepsBlock(appId: string, productionInstanceId: string): str ${dim("https://clerk.com/docs/guides/secure/best-practices/csp-headers")} 6. View and manage domain configuration in the Clerk Dashboard - ${dim(`https://dashboard.clerk.com/apps/${appId}/instances/${productionInstanceId}/domains`)} + ${dim(domainsDashboardUrl(appId, productionInstanceId))} ${yellow("NOTE")} Production keys only work on your production domain. They will not work on localhost. To run your dev environment, keep using your dev keys. @@ -252,6 +245,10 @@ ${yellow("NOTE")} Production keys only work on your production domain. They wil ${dim("Reference: https://clerk.com/docs/guides/development/deployment/production#api-keys-and-environment-variables")}`; } +export function domainsDashboardUrl(appId: string, productionInstanceId: string): string { + return `https://dashboard.clerk.com/apps/${appId}/instances/${productionInstanceId}/domains`; +} + export function pausedMessage(stepDescription: string): string { return `Deploy paused at: ${stepDescription} diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index a2257918..3f97738a 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -72,6 +72,7 @@ mock.module("../../lib/sleep.ts", () => ({ const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts"); const { deploy } = await import("./index.ts"); const { providerSetupIntro } = await import("./providers.ts"); +const { collectCustomDomain } = await import("./prompts.ts"); function stripAnsi(value: string): string { return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); @@ -176,6 +177,7 @@ function schemaForEnabledOAuth(config: Record) { describe("deploy", () => { let consoleSpy: ReturnType; + let writeSpy: ReturnType; const captured = useCaptureLog(); let tempDir: string; @@ -273,6 +275,15 @@ describe("deploy", () => { mockTriggerApplicationDomainDNSCheck.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); + // Guard the real filesystem. When the BIND export prompt is accepted the + // deploy flow writes `clerk-.zone` to the cwd, which would otherwise + // leak an artifact into the repo on every run. Intercept only `.zone` writes + // so config writes (setProfile) still hit disk in the temp dir. + const realBunWrite = Bun.write.bind(Bun) as (...args: unknown[]) => Promise; + writeSpy = spyOn(Bun, "write").mockImplementation(((destination: unknown, ...rest: unknown[]) => + String(destination).endsWith(".zone") + ? Promise.resolve(0) + : realBunWrite(destination, ...rest)) as typeof Bun.write); }); afterEach(async () => { @@ -296,6 +307,7 @@ describe("deploy", () => { mockTriggerApplicationDomainDNSCheck.mockReset(); mockSleep.mockReset(); consoleSpy?.mockRestore(); + writeSpy?.mockRestore(); }); function runDeploy(options: Parameters[0] = {}) { @@ -446,24 +458,76 @@ describe("deploy", () => { }); describe("agent mode", () => { - test("exits with human mode guidance", async () => { + test("not_started emits JSON handoff telling human to run wizard", async () => { mockIsAgent.mockReturnValue(true); + await linkedProject(); - await expect(runDeploy({})).rejects.toMatchObject({ - code: "usage_error", - exitCode: EXIT_CODE.USAGE, - message: - "clerk deploy requires human mode because production configuration uses interactive prompts. Run `clerk deploy --mode human` from an interactive terminal.", - }); + await runDeploy({}); - expect(captured.out).toBe(""); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("not_started"); + expect(payload.nextAction).toContain("clerk deploy"); + expect(payload.nextAction).toContain("clerk deploy status"); }); test("does not trigger interactive prompts", async () => { mockIsAgent.mockReturnValue(true); + await linkedProject(); + + await runDeploy(); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + }); + + test("complete deploy emits no-action handoff", async () => { + mockIsAgent.mockReturnValue(true); + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_mock" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_mock", + domain: "example.com", + productionConfig: { + connection_oauth_google: { + enabled: true, + client_id: "google-client-id.apps.googleusercontent.com", + client_secret: "REDACTED", + }, + }, + }); + + await runDeploy({}); + + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("complete"); + expect(payload.complete).toBe(true); + expect(captured.err).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); + expect(mockSleep).not.toHaveBeenCalled(); + expect(mockCreateProductionInstance).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("domain status read failures surface as errors", async () => { + mockIsAgent.mockReturnValue(true); + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_mock" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_mock", + domain: "example.com", + }); + mockGetApplicationDomainStatus.mockRejectedValue( + new PlapiError(500, JSON.stringify({ errors: [{ code: "server_error" }] }), "https://x"), + ); - await expect(runDeploy()).rejects.toBeInstanceOf(CliError); + await expect(runDeploy({})).rejects.toBeInstanceOf(PlapiError); + expect(captured.out).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); expect(mockSelect).not.toHaveBeenCalled(); expect(mockInput).not.toHaveBeenCalled(); expect(mockConfirm).not.toHaveBeenCalled(); @@ -711,7 +775,7 @@ describe("deploy", () => { expect(terminalOutput).not.toContain("Done"); }); - test("DNS verification emits per-component spinner labels in mail/dns/ssl order", async () => { + test("DNS verification checks all domain status components together", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm @@ -727,12 +791,6 @@ describe("deploy", () => { .mockResolvedValueOnce( domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), ) - .mockResolvedValueOnce( - domainStatus({ status: "incomplete", dns: false, ssl: false, mail: true }), - ) - .mockResolvedValueOnce( - domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), - ) .mockResolvedValueOnce( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); @@ -741,17 +799,12 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - const mailIdx = err.indexOf("Mail sender verified"); - const dnsIdx = err.indexOf("DNS verified for example.com"); - const sslIdx = err.indexOf("SSL certificate issued for example.com"); - expect(mailIdx).toBeGreaterThan(-1); - expect(dnsIdx).toBeGreaterThan(-1); - expect(sslIdx).toBeGreaterThan(-1); - expect(mailIdx).toBeLessThan(dnsIdx); - expect(dnsIdx).toBeLessThan(sslIdx); + expect(err).toContain("DNS verified for example.com"); + expect(err).not.toContain("Mail sender verified"); + expect(err).not.toContain("SSL certificate issued for example.com"); }); - test("DNS verification gives each component its own retry budget", async () => { + test("DNS verification uses one shared retry budget for all domain status components", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm @@ -780,12 +833,6 @@ describe("deploy", () => { .mockResolvedValueOnce( domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), ) - .mockResolvedValueOnce( - domainStatus({ status: "incomplete", dns: false, ssl: false, mail: true }), - ) - .mockResolvedValueOnce( - domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), - ) .mockResolvedValueOnce( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); @@ -793,10 +840,8 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("Mail sender verified"); expect(err).toContain("DNS verified for example.com"); - expect(err).toContain("SSL certificate issued for example.com"); - expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(8); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(6); }); test("DNS verification pauses when status stays incomplete despite all exposed booleans true (proxy_ok case)", async () => { @@ -875,6 +920,12 @@ describe("deploy", () => { ); }); + test("trims the collected production domain before returning it", async () => { + mockInput.mockResolvedValueOnce(" example.com "); + + await expect(collectCustomDomain()).resolves.toBe("example.com"); + }); + test("Ctrl-C before changes are made reports cancelled instead of done", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -1380,24 +1431,21 @@ describe("deploy", () => { mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - const writeSpy = spyOn(Bun, "write").mockResolvedValue(0); - try { - await runDeploy({}); - const err = stripAnsi(captured.err); - - const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone")); - expect(zoneCall).toBeDefined(); - const pathArg = zoneCall![0]; - const contentArg = zoneCall![1]; - expect(String(pathArg)).toMatch(/clerk-example\.com\.zone$/); - expect(String(contentArg)).toContain("$ORIGIN example.com."); - expect(String(contentArg)).toContain("$TTL 300"); - expect(String(contentArg)).toContain("IN\tCNAME"); - expect(err).toContain("Wrote "); - expect(err).toContain("clerk-example.com.zone"); - } finally { - writeSpy.mockRestore(); - } + await runDeploy({}); + const err = stripAnsi(captured.err); + + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeDefined(); + const pathArg = zoneCall![0]; + const contentArg = zoneCall![1]; + expect(String(pathArg)).toMatch(/clerk-example\.com\.zone$/); + expect(String(contentArg)).toContain("$ORIGIN example.com."); + expect(String(contentArg)).toContain("$TTL 300"); + expect(String(contentArg)).toContain("IN\tCNAME"); + expect(err).toContain("Wrote "); + expect(err).toContain("clerk-example.com.zone"); }); test("BIND export prompt writes no file when the user declines", async () => { @@ -1419,17 +1467,14 @@ describe("deploy", () => { mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - const writeSpy = spyOn(Bun, "write").mockResolvedValue(0); - try { - await runDeploy({}); - const err = stripAnsi(captured.err); + await runDeploy({}); + const err = stripAnsi(captured.err); - const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone")); - expect(zoneCall).toBeUndefined(); - expect(err).not.toContain("Wrote "); - } finally { - writeSpy.mockRestore(); - } + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeUndefined(); + expect(err).not.toContain("Wrote "); }); test("BIND export prompt is skipped when cnameTargets is empty", async () => { @@ -1451,21 +1496,18 @@ describe("deploy", () => { mockPassword.mockResolvedValueOnce("google-secret"); mockPatchInstanceConfig.mockResolvedValueOnce({}); - const writeSpy = spyOn(Bun, "write").mockResolvedValue(0); - try { - await runDeploy({}); + await runDeploy({}); - // confirm() was never called for the BIND prompt in this run. - const bindPromptCalls = mockConfirm.mock.calls.filter((call) => { - const arg = call[0] as { message?: string } | undefined; - return typeof arg?.message === "string" && arg.message.includes("BIND zone file"); - }); - expect(bindPromptCalls.length).toBe(0); - const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone")); - expect(zoneCall).toBeUndefined(); - } finally { - writeSpy.mockRestore(); - } + // confirm() was never called for the BIND prompt in this run. + const bindPromptCalls = mockConfirm.mock.calls.filter((call) => { + const arg = call[0] as { message?: string } | undefined; + return typeof arg?.message === "string" && arg.message.includes("BIND zone file"); + }); + expect(bindPromptCalls.length).toBe(0); + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeUndefined(); }); test("DNS verification timeout names the specific pending components from domain status", async () => { @@ -1486,8 +1528,8 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("SSL, mail still pending for example.com"); - expect(err).not.toContain("DNS, SSL, mail still pending"); + expect(err).toContain("SSL, email DNS still pending for example.com"); + expect(err).not.toContain("DNS, SSL, email DNS still pending"); }); test("DNS verification treats absent components as pending", async () => { @@ -1508,7 +1550,7 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("DNS: pending SSL: ✓ Mail: ✓"); + expect(err).toContain("DNS: pending SSL: ✓ Email DNS: ✓"); expect(err).toContain("DNS still pending for example.com"); expect(err).not.toContain("Domain Verified"); }); @@ -1535,7 +1577,7 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("DNS: ✓ SSL: pending Mail: ✓"); + expect(err).toContain("DNS: ✓ SSL: pending Email DNS: ✓"); expect(err).toContain("SSL still pending for example.com"); expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(1); }); @@ -1683,6 +1725,21 @@ describe("deploy", () => { expect(mockInput).not.toHaveBeenCalled(); }); + test("plain deploy persists production instance discovered from live API", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_live", + developmentConfig: {}, + productionConfig: {}, + }); + + await runDeploy({}); + + const config = await readConfig(); + expect(config.profiles[process.cwd()]?.instances.production).toBe("ins_prod_live"); + }); + test("custom-domain DNS setup can skip verification and later resume", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -1906,7 +1963,7 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); expect(err).toContain("DNS propagation can take several hours"); - expect(err).toContain("DNS, SSL, mail still pending for example.com"); + expect(err).toContain("DNS, SSL, email DNS still pending for example.com"); expect(err).toContain("DNS: pending"); expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(2); expect(err).toContain("Host: clerk.example.com"); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 5cf2056a..ba1a4332 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,39 +1,26 @@ import { isAgent } from "../../mode.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; -import { sleep } from "../../lib/sleep.ts"; -import { bar, intro, outro, withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; +import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; import { CliError, ERROR_CODE, - PlapiError, UserAbortError, isPromptExitError, throwUsageError, } from "../../lib/errors.ts"; -import { resolveProfile, setProfile } from "../../lib/config.ts"; +import { setProfile } from "../../lib/config.ts"; import { createProductionInstance as apiCreateProductionInstance, - fetchApplication, - fetchInstanceConfig, - fetchInstanceConfigSchema, - getApplicationDomainStatus, - listApplicationDomains, patchInstanceConfig, - triggerApplicationDomainDNSCheck, - type ApplicationDomain, type CnameTarget, - type DomainStatusResponse, type ProductionInstanceResponse, } from "../../lib/plapi.ts"; import { INTRO_PREAMBLE, OAUTH_SECTION_INTRO, - type DeployComponentStatus, type DeployPlanStep, - DEPLOY_COMPONENT_ORDER, deployComponentLabels, deployComponentStatus, - deployStatusRetryMessage, deployStatusPendingFooter, domainAssociationSummary, bindZoneFile, @@ -47,9 +34,6 @@ import { } from "./copy.ts"; import { mapDeployError } from "./errors.ts"; import { - OAUTH_KEY_PREFIX, - buildOAuthProviderDescriptors, - hasProviderRequiredCredentials, providerLabel, providerSetupIntro, showOAuthWalkthrough, @@ -72,18 +56,25 @@ import { type DeployContext, type DeployOperationState, } from "./state.ts"; - -const DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS = 3000; -const DEPLOY_STATUS_MAX_RETRIES = 5; -const DEPLOY_STATUS_BACKOFF_FACTOR = 2; +import { + buildDeployStatusReport, + loadDevelopmentOAuthProviders, + resolveDeployContext, + resolveDeployState, + resolveLiveApplicationContext, + resolveLiveDeploySnapshot, + waitForDeployStatus, + type DeployStatusOutcome, + type DiscoveredOAuthProviders, + type LiveDeploySnapshot, +} from "./status.ts"; type DeployOptions = Record; export async function deploy(_options: DeployOptions = {}) { if (isAgent()) { - throwUsageError( - "clerk deploy requires human mode because production configuration uses interactive prompts. Run `clerk deploy --mode human` from an interactive terminal.", - ); + await emitAgentDeployHandoff(); + return; } intro("clerk deploy"); @@ -108,48 +99,18 @@ export async function deploy(_options: DeployOptions = {}) { } } -async function resolveDeployContext(): Promise { - const resolved = await withSpinner("Resolving linked Clerk application...", () => - resolveProfile(process.cwd()), - ); - if (!resolved) { - return { - profileKey: process.cwd(), - profile: { - workspaceId: "", - appId: "", - instances: { development: "" }, - }, - appId: "", - appLabel: "", - developmentInstanceId: "", - }; +async function emitAgentDeployHandoff(): Promise { + const ctx = await resolveDeployContext(); + if (!ctx.appId || !ctx.developmentInstanceId) { + throw new CliError( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", + { code: ERROR_CODE.NOT_LINKED }, + ); } - return { - profileKey: resolved.path, - profile: resolved.profile, - ...(await withSpinner("Checking for production instance...", () => - resolveLiveApplicationContext(resolved.profile), - )), - }; -} - -async function resolveLiveApplicationContext(profile: DeployContext["profile"]): Promise<{ - appId: string; - appLabel: string; - developmentInstanceId: string; - productionInstanceId?: string; -}> { - const app = await fetchApplication(profile.appId); - const development = app.instances.find((entry) => entry.environment_type === "development"); - const production = app.instances.find((entry) => entry.environment_type === "production"); - return { - appId: app.application_id, - appLabel: app.name || profile.appName || app.application_id, - developmentInstanceId: development?.instance_id ?? profile.instances.development, - productionInstanceId: production?.instance_id, - }; + const state = await resolveDeployState(ctx); + const report = buildDeployStatusReport(state, null); + log.data(JSON.stringify(report, null, 2)); } async function runDeploy(ctx: DeployContext): Promise { @@ -169,7 +130,8 @@ async function runDeploy(ctx: DeployContext): Promise { } async function startNewDeploy(ctx: DeployContext): Promise { - const { descriptors: oauthProviders, unsupported } = await loadDevelopmentOAuthProviders(ctx); + const { descriptors: oauthProviders, unsupported }: DiscoveredOAuthProviders = + await loadDevelopmentOAuthProviders(ctx); log.blank(); log.info(INTRO_PREAMBLE); @@ -253,6 +215,10 @@ async function startNewDeploy(ctx: DeployContext): Promise { } async function reconcileExistingDeploy(ctx: DeployContext): Promise { + if (ctx.productionInstanceId && ctx.profile.instances.production !== ctx.productionInstanceId) { + await persistProductionInstance(ctx, ctx.productionInstanceId); + } + const snapshot = await resolveLiveDeploySnapshot(ctx); if (!snapshot) { log.blank(); @@ -276,7 +242,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { return; } - let dnsStatus: DnsVerificationResult = snapshot.dnsComplete ? "verified" : "pending"; + let dnsStatus: DnsVerificationResult = snapshot.domainComplete ? "verified" : "pending"; if ( snapshot.pending.type === "oauth" || @@ -303,35 +269,16 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { snapshot.completedOAuthProviders = completed; } - if (!snapshot.dnsComplete) { - const nextDnsStatus = await runExistingDomainDnsVerification(ctx, { + if (!snapshot.domainComplete) { + dnsStatus = await runExistingDomainDnsVerification(ctx, { ...snapshot, pending: { type: "dns" }, }); - dnsStatus = nextDnsStatus; } await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, dnsStatus); } -type LiveDeploySnapshot = Omit< - DeployOperationState, - "pending" | "oauthProviders" | "completedOAuthProviders" -> & { - pending?: DeployOperationState["pending"]; - oauthProviders: OAuthProvider[]; - oauthProviderDescriptors: OAuthProviderDescriptor[]; - completedOAuthProviders: OAuthProvider[]; - cnameTargets?: readonly CnameTarget[]; - dnsComplete: boolean; - unsupportedOAuthProviderCount: number; -}; - -type DiscoveredOAuthProviders = { - descriptors: OAuthProviderDescriptor[]; - unsupported: string[]; -}; - type DnsVerificationResult = "verified" | "pending"; function warnUnsupportedOAuthProviders(count: number): void { @@ -348,119 +295,6 @@ function warnUnsupportedOAuthProviders(count: number): void { log.blank(); } -async function loadDevelopmentOAuthProviders( - ctx: DeployContext, -): Promise { - return withSpinner("Reading development configuration...", async () => { - const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); - const providerSlugs = discoverEnabledOAuthProviderSlugs(config); - const schemaKeys = providerSlugs.map((provider) => `${OAUTH_KEY_PREFIX}${provider}`); - const schema = - schemaKeys.length > 0 - ? await fetchInstanceConfigSchema(ctx.appId, ctx.developmentInstanceId, schemaKeys) - : { properties: {} }; - const result = buildOAuthProviderDescriptors(providerSlugs, schema); - return { - descriptors: result.supported, - unsupported: result.unsupported, - }; - }); -} - -async function resolveLiveDeploySnapshot( - ctx: DeployContext, -): Promise { - const productionInstanceId = ctx.productionInstanceId; - if (!productionInstanceId) return undefined; - - const [domain, oauth] = await Promise.all([ - loadProductionDomain(ctx), - loadDevelopmentOAuthProviders(ctx), - ]); - if (!domain) return undefined; - - const { descriptors: oauthProviderDescriptors, unsupported } = oauth; - const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); - const { productionConfig, deployStatus } = await loadProductionState( - ctx, - productionInstanceId, - domain.id, - ); - const completedOAuthProviders = oauthProviderDescriptors - .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) - .map((descriptor) => descriptor.provider); - const pendingOAuthDescriptor = oauthProviderDescriptors.find( - (descriptor) => !completedOAuthProviders.includes(descriptor.provider), - ); - - const baseState = { - appId: ctx.appId, - developmentInstanceId: ctx.developmentInstanceId, - productionInstanceId, - productionDomainId: domain.id, - domain: domain.name, - oauthProviders, - oauthProviderDescriptors, - completedOAuthProviders, - cnameTargets: domain.cname_targets ?? [], - unsupportedOAuthProviderCount: unsupported.length, - }; - - const dnsComplete = deployStatus.status === "complete"; - const pending = pendingOAuthDescriptor - ? ({ type: "oauth", provider: pendingOAuthDescriptor.provider } as const) - : !dnsComplete - ? ({ type: "dns" } as const) - : undefined; - - return { ...baseState, dnsComplete, pending }; -} - -async function loadInitialDeployStatus( - appId: string, - domainIdOrName: string, -): Promise { - try { - return await getApplicationDomainStatus(appId, domainIdOrName); - } catch (error) { - log.debug( - `deploy: snapshot domain-status read failed, treating DNS as pending: ${error instanceof Error ? error.message : String(error)}`, - ); - return pendingDomainStatus(); - } -} - -async function loadProductionState( - ctx: DeployContext, - productionInstanceId: string, - domainIdOrName: string, -): Promise<{ - productionConfig: Record; - deployStatus: DomainStatusResponse; -}> { - return withSpinner("Reading production configuration...", async () => { - const [productionConfig, deployStatus] = await Promise.all([ - fetchInstanceConfig(ctx.appId, productionInstanceId), - loadInitialDeployStatus(ctx.appId, domainIdOrName), - ]); - return { productionConfig, deployStatus }; - }); -} - -function pendingDomainStatus(): DomainStatusResponse { - return { - status: "incomplete", - dns: { status: "not_started" }, - ssl: { status: "not_started", required: true }, - mail: { status: "not_started", required: true }, - }; -} - -async function loadProductionDomain(ctx: DeployContext): Promise { - const domains = await listApplicationDomains(ctx.appId); - return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; -} - function buildNewDeployPlan(oauthProviders: readonly OAuthProviderDescriptor[]): DeployPlanStep[] { return [ { label: "Create production instance", status: "pending" }, @@ -488,21 +322,10 @@ function buildLiveDeployPlan(snapshot: LiveDeploySnapshot): DeployPlanStep[] { status, }; }), - { label: "Verify DNS records", status: snapshot.dnsComplete ? "done" : "pending" }, + { label: "Verify DNS records", status: snapshot.domainComplete ? "done" : "pending" }, ]; } -function discoverEnabledOAuthProviderSlugs(config: Record): string[] { - const providers: string[] = []; - for (const [key, value] of Object.entries(config)) { - if (!key.startsWith(OAUTH_KEY_PREFIX)) continue; - if (!value || typeof value !== "object") continue; - if ((value as Record).enabled !== true) continue; - providers.push(key.slice(OAUTH_KEY_PREFIX.length)); - } - return providers; -} - async function createProductionInstance( ctx: DeployContext, domain: string, @@ -639,94 +462,15 @@ async function runDnsVerification( } } -type DeployStatusOutcome = - | { verified: true; status: DeployComponentStatus } - | { verified: false; status: DeployComponentStatus }; - async function pollDeployStatus( appId: string, domainIdOrName: string, domain: string, ): Promise { - await triggerDeployStatusCheck(appId, domainIdOrName); - let response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); - let status = deployComponentStatusFromDomainStatus(response); - for (const component of DEPLOY_COMPONENT_ORDER) { - let retriesRemaining = DEPLOY_STATUS_MAX_RETRIES; - let nextRetryDelay = DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS; - const labels = deployComponentLabels(component, domain); - const flipped = await withSpinner(labels.progress, async (spinner) => { - if (status[component]) return true; - while (retriesRemaining > 0) { - await sleepWithRetryCountdown( - labels.progress, - DEPLOY_STATUS_MAX_RETRIES - retriesRemaining + 1, - DEPLOY_STATUS_MAX_RETRIES, - nextRetryDelay, - spinner, - ); - retriesRemaining--; - nextRetryDelay *= DEPLOY_STATUS_BACKOFF_FACTOR; - response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); - status = deployComponentStatusFromDomainStatus(response); - if (status[component]) return true; - } - return false; - }); - if (!flipped) return { verified: false, status }; - log.success(labels.done); - } - - if (response.status !== "complete") { - return { verified: false, status }; - } - return { verified: true, status }; -} - -async function sleepWithRetryCountdown( - message: string, - currentRetry: number, - totalRetries: number, - delayMs: number, - spinner: SpinnerControls, -): Promise { - let remainingMs = delayMs; - while (remainingMs > 0) { - const tickMs = Math.min(1000, remainingMs); - spinner.update( - deployStatusRetryMessage(message, currentRetry, totalRetries, Math.ceil(remainingMs / 1000)), - ); - await sleep(tickMs); - remainingMs -= tickMs; - } -} - -async function triggerDeployStatusCheck(appId: string, domainIdOrName: string): Promise { - try { - await mapDeployError(triggerApplicationDomainDNSCheck(appId, domainIdOrName)); - } catch (error) { - if (error instanceof PlapiError && error.status === 409 && error.code === "conflict") { - log.debug("DNS check is already in flight; continuing to poll domain status."); - return; - } - throw error; - } -} - -function deployComponentStatusFromDomainStatus( - response: DomainStatusResponse, -): DeployComponentStatus { - return { - dns: checkStatusComplete(response.dns), - ssl: checkStatusComplete(response.ssl), - mail: checkStatusComplete(response.mail), - }; -} - -function checkStatusComplete(check: { status: string; required?: boolean } | undefined): boolean { - if (!check) return false; - if (check.required === false) return true; - return check.status === "complete"; + return waitForDeployStatus(appId, domainIdOrName, domain, { + runVerification: (progressLabel, work) => withSpinner(progressLabel, work), + onVerified: () => log.success(deployComponentLabels("dns", domain).done), + }); } async function offerBindZoneExport( diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index 0a69bc7b..3fee78f1 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -23,10 +23,11 @@ export async function confirmProceed(): Promise { } export async function collectCustomDomain(): Promise { - return input({ + const domain = await input({ message: "Production domain (e.g. example.com)", validate: (value) => validateDomain(value), }); + return domain.trim(); } export function validateDomain(value: string): true | string { diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts new file mode 100644 index 00000000..91f8df0b --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EXIT_CODE, PlapiError } from "../../lib/errors.ts"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockFetchApplication = mock(); +const mockListApplicationDomains = mock(); +const mockFetchInstanceConfig = mock(); +const mockFetchInstanceConfigSchema = mock(); +const mockGetApplicationDomainStatus = mock(); +const mockTriggerApplicationDomainDNSCheck = mock(); +const mockSleep = mock(); + +mock.module("../../lib/sleep.ts", () => ({ + sleep: (ms: number) => { + mockSleep(ms); + return Promise.resolve(); + }, +})); + +const { _setConfigDir, setProfile } = await import("../../lib/config.ts"); +const { setMode } = await import("../../mode.ts"); +const { deployStatus } = await import("./status-command.ts"); + +function stripAnsi(value: string): string { + return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); +} + +function appWith(production: boolean) { + const instances = [{ instance_id: "ins_dev", environment_type: "development" }]; + if (production) instances.push({ instance_id: "ins_prod", environment_type: "production" }); + return { application_id: "app_1", name: "app", instances }; +} + +function completeDomainStatus() { + return { + status: "complete", + dns: { status: "complete" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function pendingDnsDomainStatus() { + return { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function pendingSslDomainStatus() { + return { + status: "incomplete", + dns: { status: "complete" }, + ssl: { status: "pending", required: true }, + mail: { status: "complete", required: true }, + }; +} + +function mockDomain() { + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [ + { + host: "clerk.example.com", + value: "frontend-api.clerk.services", + required: true, + }, + ], + }, + ], + total_count: 1, + }); +} + +function mockOAuthComplete() { + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" || instanceId === "production" + ? { connection_oauth_google: { enabled: true, client_id: "x", client_secret: "y" } } + : { connection_oauth_google: { enabled: true } }, + ); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { + connection_oauth_google: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + }, + }, + }, + }); +} + +describe("deploy status", () => { + const captured = useCaptureLog(); + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + let tempDir = ""; + let exitCodeBefore: typeof process.exitCode; + + beforeEach(async () => { + captured.clear(); + setMode("agent"); + exitCodeBefore = process.exitCode; + process.exitCode = undefined; + process.env.CLERK_PLATFORM_API_KEY = "ak_test"; + stubFetch((...args) => routePlapiFetch(...args)); + tempDir = await mkdtemp(join(tmpdir(), "clerk-status-test-")); + _setConfigDir(tempDir); + await setProfile(process.cwd(), { + workspaceId: "", + appId: "app_1", + appName: "app", + instances: { development: "ins_dev" }, + } as never); + }); + + afterEach(async () => { + _setConfigDir(undefined); + if (tempDir) await rm(tempDir, { recursive: true, force: true }); + process.exitCode = exitCodeBefore ?? EXIT_CODE.SUCCESS; + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + setMode("human"); + tempDir = ""; + mockFetchApplication.mockReset(); + mockListApplicationDomains.mockReset(); + mockFetchInstanceConfig.mockReset(); + mockFetchInstanceConfigSchema.mockReset(); + mockGetApplicationDomainStatus.mockReset(); + mockTriggerApplicationDomainDNSCheck.mockReset(); + mockSleep.mockReset(); + }); + + test("agent mode not_started emits JSON with state not_started and exit 1", async () => { + mockFetchApplication.mockResolvedValue(appWith(false)); + + await deployStatus(); + + expect(process.exitCode).toBe(EXIT_CODE.GENERAL); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("not_started"); + expect(payload.complete).toBe(false); + expect(captured.out).not.toContain("error"); + expect(mockTriggerApplicationDomainDNSCheck).not.toHaveBeenCalled(); + }); + + test("agent mode complete triggers DNS check and emits complete state", async () => { + process.exitCode = EXIT_CODE.GENERAL; + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(completeDomainStatus()); + + await deployStatus(); + + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(EXIT_CODE.SUCCESS); + const payload = JSON.parse(captured.out); + expect(payload).toMatchObject({ + complete: true, + state: "complete", + domain: "example.com", + }); + expect(payload.domainStatus).toEqual({ dns: "complete", ssl: "complete", mail: "complete" }); + }); + + test("human mode not_started prints a readable status block and no JSON stdout", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(false)); + + await deployStatus(); + + expect(captured.out).toBe(""); + expect(stripAnsi(captured.err)).toContain("clerk deploy"); + }); + + test("agent mode domain pending reports pending DNS records and exit 1", async () => { + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingDnsDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingDnsDomainStatus()); + + await deployStatus(); + + expect(process.exitCode).toBe(EXIT_CODE.GENERAL); + expect(mockGetApplicationDomainStatus).toHaveBeenCalledTimes(1); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledTimes(1); + expect(mockTriggerApplicationDomainDNSCheck.mock.invocationCallOrder[0]).toBeLessThan( + mockGetApplicationDomainStatus.mock.invocationCallOrder[0]!, + ); + const payload = JSON.parse(captured.out); + expect(payload.state).toBe("domain_pending"); + expect(payload.complete).toBe(false); + expect(payload.domainStatus).toEqual({ dns: "pending", ssl: "complete", mail: "complete" }); + expect(payload.pendingDnsRecords).toContainEqual({ + type: "CNAME", + host: "clerk.example.com", + value: "frontend-api.clerk.services", + }); + }); + + test("agent mode status snapshot failures surface as errors", async () => { + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockGetApplicationDomainStatus.mockRejectedValue( + new PlapiError(500, JSON.stringify({ errors: [{ code: "server_error" }] }), "https://x"), + ); + + await expect(deployStatus()).rejects.toBeInstanceOf(PlapiError); + + expect(captured.out).toBe(""); + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + }); + + test("human mode shows a spinner while waiting for the DNS check to process", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(completeDomainStatus()); + + await deployStatus(); + + expect(stripAnsi(captured.err)).toContain("Waiting for Clerk DNS check to process"); + expect(mockSleep).toHaveBeenCalledWith(2000); + }); + + test("human mode shows dashboard monitoring guidance without agent handoff copy", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingSslDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingSslDomainStatus()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain( + "SSL still provisioning for example.com. Re-run `clerk deploy status` in a few minutes, DNS propagation can take time. Visit the Clerk Dashboard domains page to monitor its status there: https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(output).not.toContain("Ask the user to visit"); + expect(output).not.toContain("offer to open it"); + }); +}); + +async function routePlapiFetch( + input: string | URL | Request, + init?: RequestInit, +): Promise { + const url = new URL(input.toString()); + const method = init?.method ?? "GET"; + const path = url.pathname; + const json = async (value: unknown) => { + const body = await value; + return new Response(JSON.stringify(body ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + if (method === "GET" && path === "/v1/platform/applications/app_1") { + return json(mockFetchApplication("app_1")); + } + if (method === "GET" && path === "/v1/platform/applications/app_1/domains") { + return json(mockListApplicationDomains("app_1")); + } + if (method === "GET" && path.endsWith("/config/schema")) { + const instanceId = path.split("/").at(-3)!; + return json( + mockFetchInstanceConfigSchema("app_1", instanceId, url.searchParams.getAll("keys")), + ); + } + if (method === "GET" && path.endsWith("/config")) { + const instanceId = path.split("/").at(-2)!; + return json(mockFetchInstanceConfig("app_1", instanceId)); + } + if (method === "POST" && path.endsWith("/dns_check")) { + const domainIdOrName = path.split("/").at(-2)!; + return json(mockTriggerApplicationDomainDNSCheck("app_1", domainIdOrName)); + } + if (method === "GET" && path.endsWith("/status")) { + const domainIdOrName = path.split("/").at(-2)!; + return json(mockGetApplicationDomainStatus("app_1", domainIdOrName)); + } + + return new Response("Not Found", { status: 404 }); +} diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts new file mode 100644 index 00000000..4c975634 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -0,0 +1,125 @@ +import { isAgent } from "../../mode.ts"; +import { CliError, ERROR_CODE, EXIT_CODE } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { sleep } from "../../lib/sleep.ts"; +import { withSpinner } from "../../lib/spinner.ts"; +import { deployComponentLabels } from "./copy.ts"; +import { + buildDeployStatusReport, + loadProductionDomain, + resolveDeployContext, + resolveDeployState, + triggerDeployStatusCheck, + waitForDeployStatus, + type DeployState, + type DeployStatusOutcome, + type DeployStatusReport, +} from "./status.ts"; +import type { DeployContext } from "./state.ts"; + +type DeployStatusOptions = { + wait?: boolean; +}; + +const DEPLOY_STATUS_PREFLIGHT_DELAY_MS = 2000; + +export async function deployStatus(options: DeployStatusOptions = {}): Promise { + const ctx = await resolveDeployContext(); + if (!ctx.appId || !ctx.developmentInstanceId) { + throw new CliError( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy status`.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + const preflightTriggered = await runPreflightDeployStatusCheck(ctx); + const state = await resolveDeployState(ctx); + const shouldWait = options.wait === true || !isAgent(); + const outcome = + state.kind === "active" && shouldWait + ? await runWait(state, { triggerCheck: !preflightTriggered }) + : null; + const report = buildDeployStatusReport(state, outcome); + + emitReport(report); + process.exitCode = report.complete ? EXIT_CODE.SUCCESS : EXIT_CODE.GENERAL; +} + +async function runPreflightDeployStatusCheck(ctx: DeployContext): Promise { + if (!ctx.productionInstanceId) return false; + + const domain = await loadProductionDomain(ctx); + if (!domain) return false; + + const domainIdOrName = domain.id ?? domain.name; + await triggerDeployStatusCheck(ctx.appId, domainIdOrName); + await withSpinner("Waiting for Clerk DNS check to process...", () => + sleep(DEPLOY_STATUS_PREFLIGHT_DELAY_MS), + ); + return true; +} + +function runWait( + state: Extract, + options: { triggerCheck?: boolean } = {}, +): Promise { + const { snapshot } = state; + const domainIdOrName = snapshot.productionDomainId ?? snapshot.domain; + return waitForDeployStatus( + snapshot.appId, + domainIdOrName, + snapshot.domain, + { + runVerification: (progressLabel, work) => withSpinner(progressLabel, work), + onVerified: () => { + if (!isAgent()) log.success(deployComponentLabels("dns", snapshot.domain).done); + }, + }, + options, + ); +} + +function emitReport(report: DeployStatusReport): void { + if (isAgent()) { + log.data(JSON.stringify(report, null, 2)); + return; + } + renderHuman(report); +} + +function renderHuman(report: DeployStatusReport): void { + log.blank(); + if (report.domain) { + log.info(`Deploy status for \`${report.domain}\``); + } else { + log.info("Deploy status"); + } + + if (report.domainStatus) { + log.info( + ` Domain DNS: ${report.domainStatus.dns} SSL: ${report.domainStatus.ssl} Email DNS: ${report.domainStatus.mail}`, + ); + } + + const oauthStatus = report.oauth.complete + ? "complete" + : `pending: ${report.oauth.pending.join(", ") || "none"}`; + log.info(` OAuth ${oauthStatus}`); + + if (report.oauth.unsupported.length > 0) { + log.warn( + ` ${report.oauth.unsupported.length} OAuth provider(s) enabled in dev are not supported by automated deploy: ${report.oauth.unsupported.join(", ")}. Configure them from the Clerk Dashboard.`, + ); + } + + log.blank(); + log.info(formatHumanNextAction(report.nextAction)); + log.blank(); +} + +function formatHumanNextAction(nextAction: string): string { + return nextAction.replace( + /Ask the user to visit the Clerk Dashboard domains page, or offer to open it: (https:\/\/\S+)/, + "Visit the Clerk Dashboard domains page to monitor its status there: $1", + ); +} diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts new file mode 100644 index 00000000..62435c39 --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -0,0 +1,325 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { PlapiError } from "../../lib/errors.ts"; +import type { LiveDeploySnapshot } from "./status.ts"; + +const mockFetchApplication = mock(); +const mockListApplicationDomains = mock(); +const mockFetchInstanceConfig = mock(); +const mockFetchInstanceConfigSchema = mock(); +const mockGetApplicationDomainStatus = mock(); +const mockTriggerApplicationDomainDNSCheck = mock(); + +mock.module("../../lib/plapi.ts", () => ({ + fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), + fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), + fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), + getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), + triggerApplicationDomainDNSCheck: (...args: unknown[]) => + mockTriggerApplicationDomainDNSCheck(...args), +})); + +const { buildDeployStatusReport, resolveDeployState, waitForDeployStatus } = + await import("./status.ts"); + +const ctx = { + profileKey: "/tmp/x", + profile: { + workspaceId: "", + appId: "app_1", + instances: { development: "ins_dev" }, + }, + appId: "app_1", + appLabel: "app_1", + developmentInstanceId: "ins_dev", +} as const; + +const completeStatus = { + status: "complete", + dns: { status: "complete" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, +}; + +const passthroughHandlers = { + runVerification: (_label: string, work: (controls: { update: () => void }) => Promise) => + work({ update: () => {} }), +}; + +beforeEach(() => { + mockFetchInstanceConfig.mockResolvedValue({}); + mockFetchInstanceConfigSchema.mockResolvedValue({ properties: {} }); +}); + +afterEach(() => { + mockFetchApplication.mockReset(); + mockListApplicationDomains.mockReset(); + mockFetchInstanceConfig.mockReset(); + mockFetchInstanceConfigSchema.mockReset(); + mockGetApplicationDomainStatus.mockReset(); + mockTriggerApplicationDomainDNSCheck.mockReset(); +}); + +describe("resolveDeployState", () => { + test("returns not_started when the application has no production instance", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [{ instance_id: "ins_dev", environment_type: "development" }], + }); + + const state = await resolveDeployState({ ...ctx }); + + expect(state.kind).toBe("not_started"); + }); + + test("returns domain_provisioning when production instance exists but has no domain", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ data: [], total_count: 0 }); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state).toEqual({ + kind: "domain_provisioning", + appId: "app_1", + productionInstanceId: "ins_prod", + }); + }); + + test("returns active with a snapshot when instance and domain exist", async () => { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [ + { + host: "clerk.example.com", + value: "frontend-api.clerk.services", + required: true, + }, + ], + }, + ], + total_count: 1, + }); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { connection_oauth_google: { enabled: true, client_id: "id", client_secret: "secret" } } + : { connection_oauth_google: { enabled: true } }, + ); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { + connection_oauth_google: { + type: "object", + properties: { + enabled: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + }, + }, + }, + }); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.domain).toBe("example.com"); + expect(state.snapshot.domainComplete).toBe(true); + expect(state.snapshot.oauthProviders).toEqual(["google"]); + expect(state.snapshot.completedOAuthProviders).toEqual(["google"]); + } + }); +}); + +describe("waitForDeployStatus", () => { + test("triggers a DNS check before polling and returns verified when complete", async () => { + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeStatus); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const outcome = await waitForDeployStatus("app_1", "dmn_1", "example.com", passthroughHandlers); + + expect(mockTriggerApplicationDomainDNSCheck).toHaveBeenCalledWith("app_1", "dmn_1"); + expect(mockTriggerApplicationDomainDNSCheck.mock.invocationCallOrder[0]).toBeLessThan( + mockGetApplicationDomainStatus.mock.invocationCallOrder[0]!, + ); + expect(outcome).toEqual({ + verified: true, + status: { dns: true, ssl: true, mail: true }, + }); + }); + + test("continues polling when the DNS check is already in flight", async () => { + mockTriggerApplicationDomainDNSCheck.mockRejectedValue( + new PlapiError(409, JSON.stringify({ errors: [{ code: "conflict" }] }), "https://x"), + ); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); + + const outcome = await waitForDeployStatus("app_1", "dmn_1", "example.com", passthroughHandlers); + + expect(outcome).toEqual({ + verified: true, + status: { dns: true, ssl: true, mail: true }, + }); + }); +}); + +describe("buildDeployStatusReport", () => { + const activeSnapshot = { + appId: "app_1", + developmentInstanceId: "ins_dev", + productionInstanceId: "ins_prod", + productionDomainId: "dmn_1", + domain: "example.com", + oauthProviders: ["google", "github"], + oauthProviderDescriptors: [], + completedOAuthProviders: ["google"], + cnameTargets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "clkmail.example.com", value: "mail.clerk.services", required: true }, + ], + domainComplete: false, + componentStatus: { dns: false, ssl: false, mail: false }, + unsupportedOAuthProviderCount: 0, + unsupportedOAuthProviders: [], + pending: { type: "oauth" as const, provider: "github" }, + } satisfies LiveDeploySnapshot; + + test("not_started reports incomplete with deploy next action", () => { + const report = buildDeployStatusReport({ kind: "not_started" }, null); + + expect(report.complete).toBe(false); + expect(report.state).toBe("not_started"); + expect(report.domain).toBeNull(); + expect(report.productionInstanceId).toBeNull(); + expect(report.domainStatus).toBeNull(); + expect(report.nextAction).toContain("clerk deploy"); + }); + + test("domain_provisioning reports production instance", () => { + const report = buildDeployStatusReport( + { kind: "domain_provisioning", appId: "app_1", productionInstanceId: "ins_prod" }, + null, + ); + + expect(report.state).toBe("domain_provisioning"); + expect(report.complete).toBe(false); + expect(report.productionInstanceId).toBe("ins_prod"); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + }); + + test("active with pending domain gives domain precedence over OAuth", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: false, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("domain_pending"); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(report.nextAction).toContain("Ask the user to visit"); + expect(report.nextAction).toContain("offer to open it"); + expect(report.domainStatus).toEqual({ dns: "pending", ssl: "complete", mail: "complete" }); + expect(report.pendingDnsRecords).toContainEqual({ + type: "CNAME", + host: "clerk.example.com", + value: "frontend-api.clerk.services", + }); + expect(report.oauth.pending).toEqual(["github"]); + }); + + test("active with pending email DNS reports only email CNAME records", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: true, ssl: true, mail: false } }, + ); + + expect(report.pendingDnsRecords).toEqual([ + { + type: "CNAME", + host: "clkmail.example.com", + value: "mail.clerk.services", + }, + ]); + }); + + test("active with complete domain but pending OAuth reports oauth_pending", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("oauth_pending"); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(report.oauth).toMatchObject({ + complete: false, + configured: ["google"], + pending: ["github"], + }); + }); + + test("active with complete domain and OAuth reports complete", () => { + const allDone = { + ...activeSnapshot, + completedOAuthProviders: ["google", "github"], + } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: allDone }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("complete"); + expect(report.complete).toBe(true); + expect(report.domainStatus).toEqual({ dns: "complete", ssl: "complete", mail: "complete" }); + expect(report.nextAction).toContain("https://example.com"); + expect(report.nextAction).toContain( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + }); + + test("unsupported OAuth providers surface without blocking completion", () => { + const withUnsupported = { + ...activeSnapshot, + completedOAuthProviders: ["google", "github"], + unsupportedOAuthProviders: ["discord"], + unsupportedOAuthProviderCount: 1, + } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: withUnsupported }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.complete).toBe(true); + expect(report.oauth.unsupported).toEqual(["discord"]); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts new file mode 100644 index 00000000..0d2836df --- /dev/null +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -0,0 +1,540 @@ +import { resolveProfile } from "../../lib/config.ts"; +import { PlapiError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { + fetchApplication, + fetchInstanceConfig, + fetchInstanceConfigSchema, + getApplicationDomainStatus, + listApplicationDomains, + triggerApplicationDomainDNSCheck, + type ApplicationDomain, + type DomainStatusResponse, +} from "../../lib/plapi.ts"; +import { sleep } from "../../lib/sleep.ts"; +import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; +import { + cnameTargetPending, + deployComponentLabels, + deployStatusRetryMessage, + domainsDashboardUrl, + type DeployComponentStatus, +} from "./copy.ts"; +import { mapDeployError } from "./errors.ts"; +import { + OAUTH_KEY_PREFIX, + buildOAuthProviderDescriptors, + hasProviderRequiredCredentials, + type OAuthProvider, + type OAuthProviderDescriptor, +} from "./providers.ts"; +import type { DeployContext, DeployOperationState } from "./state.ts"; + +const DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS = 3000; +const DEPLOY_STATUS_MAX_RETRIES = 5; +const DEPLOY_STATUS_BACKOFF_FACTOR = 2; + +export interface DeployProgressHandlers { + runVerification( + progressLabel: string, + work: (controls: SpinnerControls) => Promise, + ): Promise; + onVerified?(): void; +} + +export type DeployStatusOutcome = { verified: boolean; status: DeployComponentStatus }; + +export type DeployStatusState = + | "complete" + | "domain_pending" + | "oauth_pending" + | "domain_provisioning" + | "not_started"; + +export interface DeployStatusReport { + complete: boolean; + state: DeployStatusState; + domain: string | null; + productionInstanceId: string | null; + domainStatus: { dns: string; ssl: string; mail: string } | null; + pendingDnsRecords: { type: "CNAME"; host: string; value: string }[]; + oauth: { complete: boolean; configured: string[]; pending: string[]; unsupported: string[] }; + nextAction: string; +} + +export type LiveDeploySnapshot = Omit< + DeployOperationState, + "pending" | "oauthProviders" | "completedOAuthProviders" +> & { + pending: DeployOperationState["pending"] | undefined; + oauthProviders: OAuthProvider[]; + oauthProviderDescriptors: OAuthProviderDescriptor[]; + completedOAuthProviders: OAuthProvider[]; + domainComplete: boolean; + componentStatus: DeployComponentStatus; + unsupportedOAuthProviderCount: number; + unsupportedOAuthProviders: string[]; +}; + +export type DeployState = + | { kind: "not_started" } + | { kind: "domain_provisioning"; appId: string; productionInstanceId: string } + | { kind: "active"; snapshot: LiveDeploySnapshot }; + +type SnapshotOptions = { + /** + * When true, a failed domain-status read throws instead of being treated as + * pending. The read-only status path enables this so transient API errors are + * surfaced rather than reported as legitimate progress. The interactive deploy + * flow leaves it off, letting the user retry from the on-screen status. + */ + throwOnStatusError?: boolean; +}; + +export type DiscoveredOAuthProviders = { + descriptors: OAuthProviderDescriptor[]; + unsupported: string[]; +}; + +export async function resolveDeployContext(): Promise { + const resolved = await withSpinner("Resolving linked Clerk application...", () => + resolveProfile(process.cwd()), + ); + if (!resolved) { + return { + profileKey: process.cwd(), + profile: { + workspaceId: "", + appId: "", + instances: { development: "" }, + }, + appId: "", + appLabel: "", + developmentInstanceId: "", + }; + } + + return { + profileKey: resolved.path, + profile: resolved.profile, + ...(await withSpinner("Checking for production instance...", () => + resolveLiveApplicationContext(resolved.profile), + )), + }; +} + +export async function resolveLiveApplicationContext(profile: DeployContext["profile"]): Promise<{ + appId: string; + appLabel: string; + developmentInstanceId: string; + productionInstanceId?: string; +}> { + const app = await fetchApplication(profile.appId); + const development = app.instances.find((entry) => entry.environment_type === "development"); + const production = app.instances.find((entry) => entry.environment_type === "production"); + return { + appId: app.application_id, + appLabel: app.name || profile.appName || app.application_id, + developmentInstanceId: development?.instance_id ?? profile.instances.development, + productionInstanceId: production?.instance_id, + }; +} + +export async function resolveDeployState(ctx: DeployContext): Promise { + const live = await resolveLiveApplicationContext(ctx.profile); + if (!live.productionInstanceId) return { kind: "not_started" }; + + // The read-only status path surfaces domain-status read failures instead of + // masking them as pending, so a transient API error is not reported as + // legitimate progress. + const snapshot = await resolveLiveDeploySnapshot( + { + ...ctx, + productionInstanceId: live.productionInstanceId, + }, + { throwOnStatusError: true }, + ); + if (!snapshot) { + return { + kind: "domain_provisioning", + appId: live.appId, + productionInstanceId: live.productionInstanceId, + }; + } + return { kind: "active", snapshot }; +} + +export async function loadDevelopmentOAuthProviders( + ctx: DeployContext, +): Promise { + return withSpinner("Reading development configuration...", async () => { + const config = await fetchInstanceConfig(ctx.appId, ctx.developmentInstanceId); + const providerSlugs = discoverEnabledOAuthProviderSlugs(config); + const schemaKeys = providerSlugs.map((provider) => `${OAUTH_KEY_PREFIX}${provider}`); + const schema = + schemaKeys.length > 0 + ? await fetchInstanceConfigSchema(ctx.appId, ctx.developmentInstanceId, schemaKeys) + : { properties: {} }; + const result = buildOAuthProviderDescriptors(providerSlugs, schema); + return { + descriptors: result.supported, + unsupported: result.unsupported, + }; + }); +} + +export async function resolveLiveDeploySnapshot( + ctx: DeployContext, + options: SnapshotOptions = {}, +): Promise { + const productionInstanceId = ctx.productionInstanceId; + if (!productionInstanceId) return undefined; + + const [domain, oauth] = await Promise.all([ + loadProductionDomain(ctx), + loadDevelopmentOAuthProviders(ctx), + ]); + if (!domain) return undefined; + + const { descriptors: oauthProviderDescriptors, unsupported } = oauth; + const oauthProviders = oauthProviderDescriptors.map((descriptor) => descriptor.provider); + const { productionConfig, deployStatus } = await loadProductionState( + ctx, + productionInstanceId, + domain.id, + options, + ); + const completedOAuthProviders = oauthProviderDescriptors + .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) + .map((descriptor) => descriptor.provider); + const pendingOAuthDescriptor = oauthProviderDescriptors.find( + (descriptor) => !completedOAuthProviders.includes(descriptor.provider), + ); + + const baseState = { + appId: ctx.appId, + developmentInstanceId: ctx.developmentInstanceId, + productionInstanceId, + productionDomainId: domain.id, + domain: domain.name, + oauthProviders, + oauthProviderDescriptors, + completedOAuthProviders, + cnameTargets: domain.cname_targets ?? [], + componentStatus: deployComponentStatusFromDomainStatus(deployStatus), + unsupportedOAuthProviderCount: unsupported.length, + unsupportedOAuthProviders: unsupported, + }; + + const domainComplete = deployStatus.status === "complete"; + return { + ...baseState, + domainComplete, + pending: resolvePendingStep(pendingOAuthDescriptor, domainComplete), + }; +} + +function resolvePendingStep( + pendingOAuthDescriptor: OAuthProviderDescriptor | undefined, + domainComplete: boolean, +): DeployOperationState["pending"] | undefined { + if (pendingOAuthDescriptor) { + return { type: "oauth", provider: pendingOAuthDescriptor.provider }; + } + if (!domainComplete) { + return { type: "dns" }; + } + return undefined; +} + +export async function loadInitialDeployStatus( + appId: string, + domainIdOrName: string, + options: SnapshotOptions = {}, +): Promise { + const status = mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + if (options.throwOnStatusError) return status; + + try { + return await status; + } catch (error) { + log.debug( + `deploy: snapshot domain-status read failed, treating DNS as pending: ${error instanceof Error ? error.message : String(error)}`, + ); + return pendingDomainStatus(); + } +} + +export async function loadProductionState( + ctx: DeployContext, + productionInstanceId: string, + domainIdOrName: string, + options: SnapshotOptions = {}, +): Promise<{ + productionConfig: Record; + deployStatus: DomainStatusResponse; +}> { + return withSpinner("Reading production configuration...", async () => { + const [productionConfig, deployStatus] = await Promise.all([ + fetchInstanceConfig(ctx.appId, productionInstanceId), + loadInitialDeployStatus(ctx.appId, domainIdOrName, options), + ]); + return { productionConfig, deployStatus }; + }); +} + +export function pendingDomainStatus(): DomainStatusResponse { + return { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "not_started", required: true }, + mail: { status: "not_started", required: true }, + }; +} + +function domainComponentState(value: boolean): "complete" | "pending" { + return value ? "complete" : "pending"; +} + +export function buildDeployStatusReport( + state: DeployState, + outcome: DeployStatusOutcome | null, +): DeployStatusReport { + if (state.kind === "not_started") { + return { + complete: false, + state: "not_started", + domain: null, + productionInstanceId: null, + domainStatus: null, + pendingDnsRecords: [], + oauth: { complete: false, configured: [], pending: [], unsupported: [] }, + nextAction: + "No production instance yet. `clerk deploy` configures production interactively and " + + "needs a human terminal, ask the user to run `clerk deploy`, then run `clerk deploy status` to verify.", + }; + } + + if (state.kind === "domain_provisioning") { + const domainsAction = domainSettingsNextAction( + domainsDashboardUrl(state.appId, state.productionInstanceId), + ); + return { + complete: false, + state: "domain_provisioning", + domain: null, + productionInstanceId: state.productionInstanceId, + domainStatus: null, + pendingDnsRecords: [], + oauth: { complete: false, configured: [], pending: [], unsupported: [] }, + nextAction: + "A production instance exists but its domain is still provisioning. " + + "Run `clerk deploy status` again shortly, or ask the user to finish `clerk deploy`. " + + domainsAction, + }; + } + + const { snapshot } = state; + const componentStatus = outcome?.status ?? snapshot.componentStatus; + const domainComplete = outcome ? outcome.verified : snapshot.domainComplete; + const oauthPending = snapshot.oauthProviders.filter( + (provider) => !snapshot.completedOAuthProviders.includes(provider), + ); + const oauthComplete = oauthPending.length === 0; + const complete = domainComplete && oauthComplete; + const reportState = resolveActiveReportState(domainComplete, complete); + + const pendingDnsRecords: DeployStatusReport["pendingDnsRecords"] = !domainComplete + ? (snapshot.cnameTargets ?? []) + .filter((target) => cnameTargetPending(target, componentStatus)) + .map((target) => ({ type: "CNAME" as const, host: target.host, value: target.value })) + : []; + + return { + complete, + state: reportState, + domain: snapshot.domain, + productionInstanceId: snapshot.productionInstanceId ?? null, + domainStatus: { + dns: domainComponentState(componentStatus.dns), + ssl: domainComponentState(componentStatus.ssl), + mail: domainComponentState(componentStatus.mail), + }, + pendingDnsRecords, + oauth: { + complete: oauthComplete, + configured: [...snapshot.completedOAuthProviders], + pending: oauthPending, + unsupported: [...snapshot.unsupportedOAuthProviders], + }, + nextAction: deployNextAction( + reportState, + snapshot.domain, + componentStatus, + oauthPending, + snapshot.productionInstanceId + ? domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId) + : null, + ), + }; +} + +function resolveActiveReportState(domainComplete: boolean, complete: boolean): DeployStatusState { + if (complete) return "complete"; + if (!domainComplete) return "domain_pending"; + return "oauth_pending"; +} + +function deployNextAction( + state: DeployStatusState, + domain: string, + componentStatus: DeployComponentStatus, + oauthPending: string[], + domainsUrl: string | null, +): string { + const domainsAction = domainsUrl ? ` ${domainSettingsNextAction(domainsUrl)}` : ""; + + if (state === "complete") { + return `Production is deployed and verified at https://${domain}. No action needed.${domainsAction}`; + } + if (state === "oauth_pending") { + return ( + `Domain verified, but these OAuth providers are missing production credentials: ` + + `${oauthPending.join(", ")}. Ask the user to finish \`clerk deploy\`, then run \`clerk deploy status\`.` + + domainsAction + ); + } + + const pendingComponents = [ + !componentStatus.dns ? "DNS" : null, + !componentStatus.ssl ? "SSL" : null, + !componentStatus.mail ? "email DNS" : null, + ].filter((value): value is string => value !== null); + + if (pendingComponents.length === 0) { + return ( + `Production setup for ${domain} is still finalizing on Clerk's side. ` + + `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` + ); + } + + return ( + `${pendingComponents.join(", ")} still provisioning for ${domain}. ` + + `Re-run \`clerk deploy status\` in a few minutes, DNS propagation can take time.` + + domainsAction + ); +} + +function domainSettingsNextAction(domainsUrl: string): string { + return `Ask the user to visit the Clerk Dashboard domains page, or offer to open it: ${domainsUrl}`; +} + +export async function loadProductionDomain( + ctx: DeployContext, +): Promise { + const domains = await listApplicationDomains(ctx.appId); + return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0]; +} + +export function discoverEnabledOAuthProviderSlugs(config: Record): string[] { + const providers: string[] = []; + for (const [key, value] of Object.entries(config)) { + if (!key.startsWith(OAUTH_KEY_PREFIX)) continue; + if (!value || typeof value !== "object") continue; + if ((value as Record).enabled !== true) continue; + providers.push(key.slice(OAUTH_KEY_PREFIX.length)); + } + return providers; +} + +export async function waitForDeployStatus( + appId: string, + domainIdOrName: string, + domain: string, + handlers: DeployProgressHandlers, + options: { triggerCheck?: boolean } = {}, +): Promise { + if (options.triggerCheck !== false) { + await triggerDeployStatusCheck(appId, domainIdOrName); + } + let response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + let status = deployComponentStatusFromDomainStatus(response); + + const labels = deployComponentLabels("dns", domain); + const verified = await handlers.runVerification(labels.progress, async (spinner) => { + if (response.status === "complete") return true; + + let retriesRemaining = DEPLOY_STATUS_MAX_RETRIES; + let nextRetryDelay = DEPLOY_STATUS_INITIAL_RETRY_DELAY_MS; + while (retriesRemaining > 0) { + await sleepWithRetryCountdown( + labels.progress, + DEPLOY_STATUS_MAX_RETRIES - retriesRemaining + 1, + DEPLOY_STATUS_MAX_RETRIES, + nextRetryDelay, + spinner, + ); + retriesRemaining--; + nextRetryDelay *= DEPLOY_STATUS_BACKOFF_FACTOR; + response = await mapDeployError(getApplicationDomainStatus(appId, domainIdOrName)); + status = deployComponentStatusFromDomainStatus(response); + if (response.status === "complete") return true; + } + return false; + }); + + if (!verified) { + return { verified: false, status }; + } + handlers.onVerified?.(); + return { verified: true, status }; +} + +async function sleepWithRetryCountdown( + message: string, + currentRetry: number, + totalRetries: number, + delayMs: number, + spinner: SpinnerControls, +): Promise { + let remainingMs = delayMs; + while (remainingMs > 0) { + const tickMs = Math.min(1000, remainingMs); + spinner.update( + deployStatusRetryMessage(message, currentRetry, totalRetries, Math.ceil(remainingMs / 1000)), + ); + await sleep(tickMs); + remainingMs -= tickMs; + } +} + +export async function triggerDeployStatusCheck( + appId: string, + domainIdOrName: string, +): Promise { + try { + await mapDeployError(triggerApplicationDomainDNSCheck(appId, domainIdOrName)); + } catch (error) { + if (error instanceof PlapiError && error.status === 409 && error.code === "conflict") { + log.debug("DNS check is already in flight; continuing to poll domain status."); + return; + } + throw error; + } +} + +export function deployComponentStatusFromDomainStatus( + response: DomainStatusResponse, +): DeployComponentStatus { + return { + dns: checkStatusComplete(response.dns), + ssl: checkStatusComplete(response.ssl), + mail: checkStatusComplete(response.mail), + }; +} + +function checkStatusComplete(check: { status: string; required?: boolean } | undefined): boolean { + if (!check) return false; + if (check.required === false) return true; + return check.status === "complete"; +} diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 1f6b56d6..edc8c506 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -73,6 +73,10 @@ describe("generateCompletions", () => { expect(names).toContain("patch"); expect(names).toContain("put"); }); + + test("completes deploy subcommands", () => { + expect(completionNames("deploy", "")).toContain("status"); + }); }); describe("alias completion", () => { diff --git a/skills/clerk-cli/SKILL.md b/skills/clerk-cli/SKILL.md index 7ff12b0f..6e04bf66 100644 --- a/skills/clerk-cli/SKILL.md +++ b/skills/clerk-cli/SKILL.md @@ -2,11 +2,12 @@ name: clerk-cli description: >- Operate the Clerk CLI (`clerk` binary) for authentication, user/org/session management, - instance config, env keys, and any Clerk Backend or Platform API call. Use when the user - mentions Clerk management tasks, "list clerk users", "create a clerk user", "update - organization", "pull clerk config", "clerk env pull", "clerk doctor", "clerk api", or - any ad-hoc Clerk API request. Prefer the CLI over raw HTTP: it handles auth, key - resolution, app/instance targeting, and formatting automatically. + deploy verification, instance config, env keys, and any Clerk Backend or Platform API + call. Use when the user mentions Clerk management tasks, "list clerk users", "create a + clerk user", "update organization", "pull clerk config", "clerk env pull", + "clerk doctor", "clerk deploy", "clerk deploy status", "clerk api", or any ad-hoc + Clerk API request. Prefer the CLI over raw HTTP: it handles auth, key resolution, + app/instance targeting, and formatting automatically. --- # Clerk CLI @@ -209,6 +210,8 @@ node -e 'const d=require("/tmp/users.json"); console.log(d.data.length, d.hasMor | `clerk users create` | Create a user from curated flags or a raw BAPI body. Confirmation prompt unless `--yes`. | `--email`, `--phone`, `--username`, `--password`, `--first-name`, `--last-name`, `--external-id`, `-d, --data`, `--file`, `--dry-run`, `--yes`, `--json` | | `clerk users open [user-id]` | Open a user's dashboard page. Agent mode requires `user-id` and prints a JSON descriptor instead of launching a browser. | (see `--help`) | | `clerk open [subpath]` | Open the linked app's dashboard in a browser. Agent mode: prints a JSON descriptor instead of opening. | (see `--help`) | +| `clerk deploy` | Human-mode production deploy wizard. Agent mode: emits a read-only JSON handoff and tells the agent whether to ask the human to run the wizard, wait for provisioning, finish OAuth, or do nothing. | `--mode agent`, `--mode human`, `--verbose` | +| `clerk deploy status` | Read-only deploy verification. Triggers a DNS check, reports aggregate domain and OAuth readiness, and exits `0` only when complete. Agent mode does one quick check by default; pass `--wait` to keep waiting. | `--mode agent`, `--wait`, `--verbose` | | `clerk doctor` | Health check (CLI version, login, link, env, config, completion; plus host-execution probe in agent mode). | `--json`, `--spotlight`, `--verbose`, `--fix` | | `clerk api [path]` | Authenticated HTTP to Backend/Platform API. | `-X`, `-d`, `--file`, `--dry-run`, `--yes`, `--include`, `--app`, `--secret-key`, `--instance`, `--platform` | | `clerk api ls [filter]` | Discover endpoints from the bundled OpenAPI catalog. | (see `--help`) | @@ -232,6 +235,7 @@ The CLI auto-detects agent mode when stdout is not a TTY, or when `--mode agent` - **`doctor --fix` is ignored.** Parse `doctor --json` output's `remedy` field and act on it yourself. - **`apps list` and `apps create` default to JSON** when piped. - **`users` defaults to JSON when piped, like `apps`.** `clerk users list` and `clerk users create` emit JSON in agent mode. Bare `clerk users` (no subcommand) is a usage error in agent mode — pass `list`, `create`, or `open` explicitly. `clerk users open` requires the `user-id` positional in agent mode and prints a JSON descriptor instead of launching a browser. +- **`deploy` has an agent handoff plus a verification gate.** In agent mode, bare `clerk deploy` is read-only and emits a JSON handoff. It never drives the interactive wizard. Do not tell Claude or another agent to run `! clerk deploy`, because the wizard needs interactive stdin prompts. Ask the human to run `clerk deploy` in a new terminal window when needed, then run `clerk deploy status --mode agent` to verify completion. See [references/agent-mode.md](references/agent-mode.md#deploy-handoff-and-verification). - **`--input-json `** expands JSON into flags on any command (e.g. `clerk init --input-json '{"framework":"next","yes":true}'`). Piped stdin is also accepted: `echo '{"yes":true}' | clerk init`. Place `--input-json` after the leaf subcommand. Full rules in [references/agent-mode.md](references/agent-mode.md#passing-options-as-json---input-json). Full matrix and sandbox details in [references/agent-mode.md](references/agent-mode.md). diff --git a/skills/clerk-cli/references/agent-mode.md b/skills/clerk-cli/references/agent-mode.md index 271c62e4..3b1e4cf2 100644 --- a/skills/clerk-cli/references/agent-mode.md +++ b/skills/clerk-cli/references/agent-mode.md @@ -61,6 +61,8 @@ Force human mode with `--mode human` or `CLERK_MODE=human`. Typical AI-agent inv | `clerk users` (no subcommand) | Interactive action picker | Prints the action list and exits with a usage error (code `2`) — pass `list` / `create` / `open` | | `clerk users open [user-id]` | Picks a user interactively, opens browser | Requires `user-id`; prints `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` and does not open a browser | | `clerk open [subpath]` | Opens the browser to the URL | Does not open a browser. Prints a JSON descriptor (`{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}`) on stdout so the agent can surface it | +| `clerk deploy` | Interactive production deploy wizard | Read-only handoff. Emits deploy status JSON on stdout and exits `0` for linked projects. Does not prompt, mutate, trigger DNS checks, or poll. | +| `clerk deploy status` | Verify production deploy state | Read-only verification gate. Triggers one DNS check for active production domains, waits briefly, reads one live status snapshot, emits status JSON on stdout, exits `0` when complete and `1` when incomplete. It does not keep waiting or back off in agent mode unless `--wait` is passed. Use `--wait` when the user asks the agent to keep waiting for DNS, SSL, email DNS, or final Clerk-side readiness. | | `clerk auth login` when already authenticated | Prompt to re-auth | Silent no-op | | `clerk init` | Full interactive scaffold flow | Runs non-interactively. Explicit `--app` or a linked profile uses the real-app auth/link/env flow; without it, an authenticated agent on a keyless-capable framework creates a real app and links it. Pass `--keyless` to opt into auto-generated dev keys for new projects on keyless-capable frameworks. Without `--keyless`, an unauthenticated agent (or non-keyless framework with no app target) prints manual setup guidance. | | Color / spinners | Enabled | Disabled | @@ -130,21 +132,23 @@ Errors use the standard agent-mode format: bad JSON → `invalid_json`, missing ## Structured outputs you can rely on -| Command | Structured output | -| --------------------------------------------- | --------------------------------------------------------------------------------------- | -| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | -| `clerk apps list --json` | Array of application objects | -| `clerk apps create --json` | Single application object | -| `clerk users list` (agent mode or `--json`) | `{data: [...users], hasMore}` envelope (BAPI user shape inside `data`) | -| `clerk users create` (agent mode or `--json`) | Single user object (raw BAPI shape) | -| `clerk users open` (agent mode) | `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` | -| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | -| `clerk api --include` | Response headers on stderr, body on stdout | -| `clerk config pull` | Instance config JSON | -| `clerk config schema` | JSON Schema | -| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | -| `clerk open --print` | Plain dashboard URL on stdout | -| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | +| Command | Structured output | +| --------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `clerk doctor --json` | `[{name, status, message, detail?, remedy?, fix?}]` | +| `clerk apps list --json` | Array of application objects | +| `clerk apps create --json` | Single application object | +| `clerk users list` (agent mode or `--json`) | `{data: [...users], hasMore}` envelope (BAPI user shape inside `data`) | +| `clerk users create` (agent mode or `--json`) | Single user object (raw BAPI shape) | +| `clerk users open` (agent mode) | `{url, appId, appName, instanceId, instanceLabel, userId, opened: false}` | +| `clerk api ` | Raw API JSON (Backend or Platform) on stdout | +| `clerk api --include` | Response headers on stderr, body on stdout | +| `clerk config pull` | Instance config JSON | +| `clerk config schema` | JSON Schema | +| `clerk open [subpath]` | `{url, appId, appName, instanceId, instanceLabel, subpath, opened: false}` (agent mode) | +| `clerk open --print` | Plain dashboard URL on stdout | +| `clerk deploy` (agent mode) | Deploy handoff report with `complete`, `state`, domain status, OAuth status, and `nextAction` | +| `clerk deploy status` (agent mode) | Deploy verification report with the same shape, plus exit `0` complete or `1` incomplete | +| Any command (agent mode) | On error: `{"error":{"code","message","docsUrl?","errors?"}}` on stderr | For commands without an explicit `--json` flag, `clerk api` is your escape hatch: hit the underlying endpoint directly. @@ -181,6 +185,70 @@ clerk api /users --app app_abc123 --instance prod The same advice applies to linking in agent mode: `clerk link --app app_abc123` is deterministic and works non-interactively. If you omit `--app`, the command only succeeds when silent autolink can prove the target app from existing publishable keys. +### Deploy handoff and verification + +Do not try to drive the interactive deploy wizard from an agent. Use the handoff and check commands instead. + +```sh +# 1. Inspect current production deploy state without mutating anything. +clerk deploy --mode agent + +# 2. If the handoff says a human action is needed, ask the user to run this +# in a new terminal window, not through `! clerk deploy`: +clerk deploy --mode human + +# 3. After the user finishes or DNS has had time to propagate, verify: +clerk deploy status --mode agent + +# 4. If the user asks you to keep waiting, use the retrying wait loop: +clerk deploy status --mode agent --wait +``` + +`clerk deploy --mode agent` is read-only. It resolves the linked app and current production deploy snapshot, then emits JSON on stdout. It does **not** trigger DNS checks, poll, create production instances, patch OAuth config, or prompt. Linked projects exit `0` because this is an informational handoff. Not-linked and API failures still use the normal agent error envelope on stderr. + +Never try to run the human wizard through Claude's `! clerk deploy` shell escape or any non-interactive agent shell. The deploy wizard asks for domain, DNS export, OAuth, and verification inputs over stdin, so it needs a real human terminal. Tell the user to open a new terminal window in the project directory and run `clerk deploy` or `clerk deploy --mode human` there. After they finish, return to agent mode and run `clerk deploy status --mode agent`. + +`clerk deploy status --mode agent` is the gate. It is also read-only with respect to deploy configuration, but for an active production domain it triggers one Clerk DNS check, waits briefly, reads a live status/config snapshot, then reports DNS, SSL, email DNS, aggregate domain readiness, and OAuth completeness. By default it does not keep waiting or exponentially back off in agent mode. If the check is incomplete and the user asks the agent to continue waiting, run `clerk deploy status --mode agent --wait` instead of manually sleeping and retrying. `--wait` uses the shared poll loop: one immediate status read, then up to 5 exponential-backoff retries until aggregate domain status is complete. It emits the same status JSON. It exits: + +| Exit | Meaning | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | Deploy is complete and verified. | +| `1` | The check ran successfully, but deploy is incomplete. Read `state` and `nextAction`. | +| else | A real CLI error occurred. Read the standard agent error envelope on stderr. | + +Deploy-specific agent errors still use the standard envelope and may include typed codes such as `plan_insufficient`, `provider_domain_not_allowed`, `home_url_taken`, or `form_param_invalid`. + +When a production instance exists, `nextAction` includes the full Clerk Dashboard domains URL so agents can send the user directly to the same page the human CLI prints in its next steps. Always show that URL to the user. Ask whether they want you to open it for them instead of omitting or paraphrasing it away. + +The deploy report has this shape: + +```json +{ + "complete": false, + "state": "domain_pending", + "domain": "example.com", + "productionInstanceId": "ins_...", + "domainStatus": { "dns": "complete", "ssl": "pending", "mail": "complete" }, + "pendingDnsRecords": [{ "type": "CNAME", "host": "clerk.example.com", "value": "..." }], + "oauth": { "complete": true, "configured": ["google"], "pending": [], "unsupported": [] }, + "nextAction": "SSL still provisioning for example.com. Re-run `clerk deploy status` in a few minutes, DNS propagation can take time. Ask the user to visit the Clerk Dashboard domains page, or offer to open it: https://dashboard.clerk.com/apps/app_.../instances/ins_.../domains" +} +``` + +`complete` is `true` only when the aggregate domain status is complete and all supported OAuth providers enabled in development have production credentials. The `domainStatus` object is a component summary; DNS, SSL, and email DNS can all read `complete` while `state` remains `domain_pending` if Clerk-side finalization is still pending. + +State precedence: + +| State | What to do | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `not_started` | Ask the human to run `clerk deploy --mode human`, then run `clerk deploy status --mode agent`. | +| `domain_provisioning` | Wait briefly or ask the human to finish `clerk deploy`, then run `clerk deploy status --mode agent`. | +| `domain_pending` | Surface `pendingDnsRecords` when present. Re-run `clerk deploy status --mode agent` after DNS, SSL, or email DNS propagation. | +| `oauth_pending` | Ask the human to finish the OAuth credential steps in `clerk deploy --mode human`, then verify with `deploy status`. | +| `complete` | No action needed. | + +Unsupported OAuth providers do not block `complete`, because the wizard cannot configure them automatically. They are still surfaced in `oauth.unsupported` so you can warn the user to review them in the Clerk Dashboard. + ### Use the catalog, not hard-coded paths ```sh