From 6bb21b23b6f67a5ecfebe0b99d07f0b94f3ff570 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Wed, 16 Sep 2026 20:43:52 -0700 Subject: [PATCH 01/24] Fix misleading text in clerk deploy wizard and help Several messages in `clerk deploy`, `clerk deploy status`, and `clerk deploy --help` hid what the command does or left out what the user needs before a step that is costly to undo (GROW-1235). Wording only: no prompts, API calls, or exit codes change. - `clerk deploy --help` describes the interactive setup the bare command runs and the JSON report it prints under an agent; both test agents had read the old help and concluded the CLI could only watch a deploy - The preamble says a hosting provider's generated URL can't be the production domain and that a subdomain you control is fine - The confirmation screen lists all five DNS hosts (including the two DKIM records that used to appear only after creation) and says a record will be needed for each - The DNS check reports records as "not found yet" rather than as a failure, sets expectations at minutes rather than 48 hours, branches on what is actually pending (records to add vs. SSL to wait for vs. record list missing from the API), and links the Dashboard Domains page for changing the domain - `clerk auth login` prints the claimed app's Dashboard URL; the wizard prints the new production instance's URL and reworks next steps to say the pulled keys go on the host alongside the other Clerk variables and to end with a real sign-up - The Google walkthrough adds a tip with the app name to use on the OAuth consent screen - Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling (206 apps were polled ten or more times in 30 days), says the keys still have to reach the host at `complete`, and handles an empty record list without pointing at it - Dashboard links now follow `CLERK_DASHBOARD_URL`; `buildDashboardUrl` moves to `lib/environment.ts` so the deploy copy module doesn't import a command, and the human-mode `deploy status` rewrite accepts `http://` for a local Dashboard --- .changeset/deploy-wizard-copy.md | 5 + packages/cli-core/src/cli-program.test.ts | 38 ++++ packages/cli-core/src/commands/auth/README.md | 2 +- .../cli-core/src/commands/auth/login.test.ts | 38 +++- packages/cli-core/src/commands/auth/login.ts | 11 +- .../cli-core/src/commands/deploy/README.md | 10 +- .../cli-core/src/commands/deploy/copy.test.ts | 215 +++++++++++++++++- packages/cli-core/src/commands/deploy/copy.ts | 172 +++++++++++--- .../src/commands/deploy/index.test.ts | 112 ++++++++- .../cli-core/src/commands/deploy/index.ts | 39 +++- .../cli-core/src/commands/deploy/providers.ts | 10 + .../commands/deploy/status-command.test.ts | 28 ++- .../src/commands/deploy/status-command.ts | 4 +- .../src/commands/deploy/status.test.ts | 90 +++++++- .../cli-core/src/commands/deploy/status.ts | 75 ++++-- packages/cli-core/src/commands/open/index.ts | 16 +- packages/cli-core/src/lib/environment.ts | 13 ++ 17 files changed, 792 insertions(+), 86 deletions(-) create mode 100644 .changeset/deploy-wizard-copy.md diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md new file mode 100644 index 000000000..24e06064d --- /dev/null +++ b/.changeset/deploy-wizard-copy.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +`clerk deploy` copy fixes: `--help` describes what the bare command does and its agent-mode JSON report; the preamble says a host-generated URL can't be the production domain; the confirmation screen lists all five DNS record hosts, including DKIM; the DNS check reports records as "not found yet" with a minutes-not-days expectation; the wizard and `clerk auth login` print the app's Dashboard URL; the Google walkthrough includes the consent-screen app name. Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, and at `complete` says the production keys still have to reach the host alongside the other Clerk variables. diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 45cee7322..acdd25fa3 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -64,6 +64,44 @@ test("deploy relies on global options", () => { expect(optionNames).toEqual([]); }); +test("deploy --help describes the bare command, not only the status subcommand", () => { + // The wizard is a hidden default subcommand, so without the long + // description the help lists only `status` and reads as if the CLI can only + // watch a deploy. Both headless test agents concluded exactly that. + const program = createProgram(); + program.configureOutput({ getOutHelpWidth: () => 80 }); + const deploy = program.commands.find((command) => command.name() === "deploy")!; + deploy.configureOutput({ getOutHelpWidth: () => 80 }); + const help = deploy.helpInformation().replace(new RegExp(String.raw`\x1b\[[0-9;]*m`, "g"), ""); + + expect(help).toBe( + [ + "Usage: clerk deploy [options] [command]", + "", + "Deploy a Clerk application to production.", + "", + "Running `clerk deploy` with no subcommand starts an interactive setup that", + "creates the production instance, prints the DNS records you must add, collects", + "production OAuth credentials, and verifies the domain. It needs a terminal;", + "re-run it at any time to resume where you left off.", + "", + "When run by an agent (or without a TTY), it is read-only: it prints a JSON", + "status report with the current state and a `nextAction` field saying what to", + "do next. `clerk deploy status` prints the same report.", + "", + "Options:", + " -h, --help Display help for command", + "", + "Commands:", + " help [command] Display help for command", + " status [options] Show production deploy status (read-only)", + "", + ].join("\n"), + ); + // The root `clerk --help` table keeps the one-line summary. + expect(deploy.summary()).toBe("Deploy a Clerk application to production"); +}); + test("deploy status exposes wait option", () => { const program = createProgram(); const deploy = program.commands.find((command) => command.name() === "deploy")!; diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index ced584a3d..4a7a7b918 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -25,7 +25,7 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown. - **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown. - **Any other error** — treated as transient; breadcrumb is preserved so the next login retries. -- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. +- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. #### API Endpoints diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index e04b01743..e569bf6fc 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -92,8 +92,12 @@ mock.module("../../lib/first-application.ts", () => ({ ensureFirstApplication: () => mockEnsureFirstApplication(), })); +const mockAttemptAutoclaim = mock(async (_cwd: string): Promise => ({ + status: "not_keyless", +})); + mock.module("../../lib/autoclaim.ts", () => ({ - attemptAutoclaim: async () => ({ status: "not_keyless" }), + attemptAutoclaim: (cwd: string) => mockAttemptAutoclaim(cwd), })); const { setLogLevel } = await import("../../lib/log.ts"); @@ -127,6 +131,8 @@ describe("login", () => { mockOpenBrowser.mockReset(); mockEnsureFirstApplication.mockReset(); mockEnsureFirstApplication.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockReset(); + mockAttemptAutoclaim.mockResolvedValue({ status: "not_keyless" }); mockIsHuman.mockReturnValue(false); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); mockRevokeToken.mockResolvedValue("revoked"); @@ -521,6 +527,36 @@ describe("login", () => { expect(captured.err).toContain("Linked to `app_abc123`"); }); + test("prints where the claimed app now lives in the Dashboard", async () => { + // First time this app has a home in an account; nothing else in the flow + // says where it is, so the claim line has to. + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "claimed", + envPulled: true, + app: { + application_id: "app_claimed", + name: "bad-agent", + instances: [ + { + instance_id: "ins_dev_claimed", + environment_type: "development", + publishable_key: "pk_test_claimed", + }, + ], + }, + }); + + await runLogin(); + + expect(captured.err).toContain("Claimed and linked application: `bad-agent`"); + expect(captured.err).toContain( + "Your app now lives in your Clerk account: https://dashboard.clerk.com/apps/app_claimed/instances/ins_dev_claimed", + ); + }); + test("shows default next steps when not linked", async () => { mockGetValidToken.mockResolvedValue(null); mockOAuthSuccess(); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 29a80d12c..5de6d7cc9 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -6,7 +6,7 @@ import { revokeToken, type UserInfo, } from "../../lib/token-exchange.ts"; -import { getOAuthConfig } from "../../lib/environment.ts"; +import { buildDashboardUrl, getOAuthConfig } from "../../lib/environment.ts"; import { createOAuthSession, getStoredSession, @@ -230,6 +230,15 @@ async function handleAutoclaim(cwd: string): Promise { if (result.status === "claimed") { const label = result.app.name || result.app.application_id; log.success(`Claimed and linked application: \`${label}\``); + // First time this app has a home in an account; say where it is. + const development = result.app.instances.find( + (instance) => instance.environment_type === "development", + ); + if (development) { + log.info( + `Your app now lives in your Clerk account: ${buildDashboardUrl(result.app.application_id, development.instance_id)}`, + ); + } } const warning = CLAIM_WARNINGS[result.status]; diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 211d36ea9..015acbd6e 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -51,7 +51,7 @@ In agent mode, `clerk deploy status` emits JSON on stdout with: - `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. +- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. Exit codes: @@ -176,10 +176,10 @@ Most providers ask for `client_id` and `client_secret`. Provider-specific schema The CLI keeps small local overrides for provider setup details that schema does not fully describe: -| Provider | Override | -| -------- | ------------------------------------------------------------------------------------------ | -| Google | Optional Google Cloud Console JSON import and OAuth consent screen warning | -| Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | +| Provider | Override | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Google | Optional Google Cloud Console JSON import, OAuth consent screen warning, and a tip naming the linked application for the consent screen | +| Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | For Google, the wizard can load `client_id` and `client_secret` from the top-level `web` object in a Google Cloud Console OAuth client JSON file, or from `installed` for desktop-style client downloads. The file contents are used in memory and are not written to CLI config. diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 0d60a6c9a..0b44a2d40 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -1,14 +1,26 @@ import { test, expect, describe } from "bun:test"; import { + DEPLOY_COMMAND_DESCRIPTION, + DEPLOY_COMMAND_SUMMARY, + INTRO_PREAMBLE, bindZoneFile, deployComponentLabels, + deployStatusPendingFooter, deployStatusRetryMessage, + dnsIntro, dnsRecords, + domainAssociationSummary, + domainsDashboardUrl, + instanceDashboardUrl, nextStepsBlock, pendingDnsRecords, + productionDnsHosts, } from "./copy.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; +const stripAnsi = (value: string): string => + value.replace(new RegExp(String.raw`\x1b\[[0-9;]*m`, "g"), ""); + describe("bindZoneFile", () => { const fixedDate = new Date("2026-05-20T18:30:00.000Z"); @@ -111,12 +123,209 @@ describe("deployStatusRetryMessage", () => { }); describe("nextStepsBlock", () => { - test("links directly to the production instance domain settings", () => { - const output = nextStepsBlock("app_123", "ins_456"); + test("links to the production instance home and its domain settings", () => { + const output = stripAnsi(nextStepsBlock("app_123", "ins_456", "example.com")); - expect(output).toContain("View and manage domain configuration in the Clerk Dashboard"); + expect(output).toContain("Manage this instance in the Clerk Dashboard"); + expect(output).toContain("- Users, settings, and billing:"); + expect(output).toContain("- DNS and SSL status:"); + // The instance root on its own line, not only as a prefix of the domains URL. + expect(output).toContain("https://dashboard.clerk.com/apps/app_123/instances/ins_456\n"); expect(output).toContain("https://dashboard.clerk.com/apps/app_123/instances/ins_456/domains"); }); + + test("says the pulled keys go on the host alongside the other Clerk variables", () => { + // `env pull --instance prod` writes only the two keys. The routing + // variables `init` wrote have to be carried over by hand, or sign-in + // silently falls back to the hosted Account Portal. + const output = nextStepsBlock("app_123", "ins_456", "example.com"); + + expect(output).toContain("- Add the same pk_live_/sk_live_ values there."); + expect(output).toContain("- Also copy the other Clerk variables from your env file"); + expect(output).toContain("NEXT_PUBLIC_CLERK_SIGN_IN_URL"); + expect(output).toContain("writes only the two keys"); + }); + + test("ends with a real sign-up on the production domain", () => { + const output = nextStepsBlock("app_123", "ins_456", "example.com"); + + expect(output).toContain("sign up at https://example.com to confirm it works"); + }); +}); + +describe("domainAssociationSummary", () => { + test("lists every record host the domain will need, including both DKIM hosts", () => { + // The confirmation screen runs before the instance exists, so this is a + // prediction from the domain alone. It must match what the create call + // returns, or the user commits without seeing the full list. + const output = domainAssociationSummary("example.com").join("\n"); + + expect(output).toContain("clerk.example.com"); + expect(output).toContain("accounts.example.com"); + expect(output).toContain("clkmail.example.com"); + expect(output).toContain("clk._domainkey.example.com"); + expect(output).toContain("clk2._domainkey.example.com"); + expect(productionDnsHosts("example.com")).toHaveLength(5); + }); + + test("lead sentence says records are coming, with no record count in it", () => { + const [lead] = domainAssociationSummary("example.com"); + + expect(lead).toContain("Clerk will use these subdomains for"); + // Disclose the obligation before the one-way step without demanding + // action the user can't take yet. + expect(lead).toContain("You'll add a DNS record for each after the instance is created:"); + expect(lead).not.toMatch(/\b(three|five|3|5)\b/); + }); + + test("labels the DKIM hosts as email records, not bare CNAMEs", () => { + const output = domainAssociationSummary("example.com").join("\n"); + + expect(output).not.toContain("CNAME clk._domainkey"); + expect(output).toMatch(/Email .*clk\._domainkey\.example\.com/); + }); +}); + +describe("dnsIntro", () => { + test("sets the propagation expectation as minutes, with 48 hours as the outlier", () => { + const output = dnsIntro("example.com").join("\n"); + + expect(output).toContain( + "usually propagate within minutes, but can occasionally take up to 48 hours", + ); + expect(output).not.toContain("It can take up to 48 hours"); + }); +}); + +describe("deployStatusPendingFooter", () => { + const DOMAINS_URL = "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains"; + + test("reports missing DNS records as not found yet, not as a failure", () => { + const output = deployStatusPendingFooter( + "example.com", + { + dns: false, + ssl: false, + mail: false, + }, + DOMAINS_URL, + ).join("\n"); + + expect(output).toContain("DNS and email DNS records not found yet for example.com."); + expect(output).toContain( + "Add them at your DNS provider, then run `clerk deploy` again to resume.", + ); + expect(output).toContain("usually takes minutes, but can occasionally take up to 48 hours"); + expect(output).toContain( + "change the domain in the Clerk Dashboard: https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + expect(output).not.toContain("still pending"); + expect(output).not.toContain("SSL"); + }); + + test("names only the email records when the Frontend API records are verified", () => { + const output = deployStatusPendingFooter( + "example.com", + { + dns: true, + ssl: false, + mail: false, + }, + DOMAINS_URL, + ).join("\n"); + + // Capitalized at the sentence start; lowercase "email DNS" is only right + // mid-sentence ("DNS and email DNS"). + expect(output).toContain("Email DNS records not found yet for example.com."); + expect(output).not.toContain("email DNS records not found"); + expect(output).not.toContain("DNS and email DNS"); + }); + + test("says when the record list is missing instead of telling the user to add nothing", () => { + // DNS unverified but the API returned no targets: "add them" would point + // at an empty list. One follow-up line, so blank line + sentence. + const output = deployStatusPendingFooter( + "example.com", + { dns: false, ssl: false, mail: false }, + DOMAINS_URL, + false, + ).join("\n"); + + expect(output).toContain("DNS and email DNS records not found yet for example.com.\n\n"); + expect(output).toContain("Clerk didn't return the list of records to add."); + expect(output).toContain( + `Find them on the Domains page in the Clerk Dashboard: ${DOMAINS_URL}`, + ); + expect(output).toContain("run `clerk deploy` again to resume"); + expect(output).not.toContain(" - "); + expect(output).not.toContain("Add them at your DNS provider"); + }); + + test("tells the user to wait, not act, when only SSL is pending", () => { + // SSL is Clerk's side; there are no records the user could add. + const output = deployStatusPendingFooter( + "example.com", + { + dns: true, + ssl: false, + mail: true, + }, + DOMAINS_URL, + ).join("\n"); + + expect(output).toContain("SSL certificate still pending for example.com."); + expect(output).toContain("run `clerk deploy` again in a few minutes to resume"); + // One follow-up line: a blank line and a sentence, not a one-item list. + expect(output).toContain("example.com.\n\nClerk issues it"); + expect(output).not.toContain(" - "); + expect(output).not.toContain("not found yet"); + expect(output).not.toContain("change the domain in the Clerk Dashboard"); + }); + + test.each([ + { label: "records pending", status: { dns: false, ssl: false, mail: false } }, + { label: "SSL only pending", status: { dns: true, ssl: false, mail: true } }, + { label: "all components verified", status: { dns: true, ssl: true, mail: true } }, + ])("always says how to resume and that re-running is safe ($label)", ({ status }) => { + const output = deployStatusPendingFooter("example.com", status, DOMAINS_URL).join("\n"); + expect(output).toMatch(/run `clerk deploy` again.*to resume/i); + expect(output).toContain("The production instance is already created."); + }); +}); + +describe("dashboard URLs", () => { + test("instance URL is the instance root and the domains URL is nested under it", () => { + expect(instanceDashboardUrl("app_1", "ins_prod")).toBe( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod", + ); + expect(domainsDashboardUrl("app_1", "ins_prod")).toBe( + "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + ); + }); +}); + +describe("INTRO_PREAMBLE", () => { + test("rules out host-generated URLs as a production domain and says why", () => { + // Described rather than named: the rule covers every host, and the reject + // list at the prompt is where specific hosts belong. + expect(INTRO_PREAMBLE).toContain("The URL a hosting provider generated"); + expect(INTRO_PREAMBLE).toContain("for your deployment won't work here."); + expect(INTRO_PREAMBLE).not.toMatch(/railway|vercel|netlify/i); + // Subdomains you control are fine; the old "development subdomain" line + // read as if they weren't. + expect(INTRO_PREAMBLE).toContain("app.example.com"); + expect(INTRO_PREAMBLE).not.toContain("development subdomain"); + }); +}); + +describe("DEPLOY_COMMAND_DESCRIPTION", () => { + test("describes the bare command and the agent-mode report", () => { + expect(DEPLOY_COMMAND_DESCRIPTION.startsWith(`${DEPLOY_COMMAND_SUMMARY}.`)).toBe(true); + expect(DEPLOY_COMMAND_DESCRIPTION).toContain("with no subcommand starts an interactive setup"); + expect(DEPLOY_COMMAND_DESCRIPTION).toContain("creates the production instance"); + expect(DEPLOY_COMMAND_DESCRIPTION).toContain("When run by an agent"); + expect(DEPLOY_COMMAND_DESCRIPTION).toContain("`nextAction`"); + }); }); describe("pendingDnsRecords", () => { diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 3b8306685..537fb4d4d 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -1,18 +1,38 @@ import { bold, cyan, dim, green, yellow } from "../../lib/color.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; +import { buildDashboardUrl } from "../../lib/environment.ts"; export type DeployPlanStep = { label: string; status: "done" | "pending"; }; +export const DEPLOY_COMMAND_SUMMARY = "Deploy a Clerk application to production"; + +/** + * Long description for `clerk deploy --help`. The wizard is registered as a + * hidden default subcommand, so without this prose the help lists only + * `status` and reads as if the CLI can only watch a deploy, not perform one. + */ +export const DEPLOY_COMMAND_DESCRIPTION = `${DEPLOY_COMMAND_SUMMARY}. + +Running \`clerk deploy\` with no subcommand starts an interactive setup that +creates the production instance, prints the DNS records you must add, collects +production OAuth credentials, and verifies the domain. It needs a terminal; +re-run it at any time to resume where you left off. + +When run by an agent (or without a TTY), it is read-only: it prints a JSON +status report with the current state and a \`nextAction\` field saying what to +do next. \`clerk deploy status\` prints the same report.`; + export const INTRO_PREAMBLE = `This will prepare your linked Clerk app for production by cloning your development instance into a new production instance and walking you through the setup the dashboard would otherwise guide you through. Before you begin you will need: - - A domain you own (production cannot use a development subdomain). - - The ability to add DNS records on that domain. + - A domain you own where you can add DNS records (example.com, or a + subdomain like app.example.com). The URL a hosting provider generated + for your deployment won't work here. - OAuth credentials for any social providers you have enabled in dev. ${dim("Reference: https://clerk.com/docs/guides/development/deployment/production")}`; @@ -37,7 +57,7 @@ export function dnsIntro(domain: string): string[] { "Clerk uses DNS records to provide session management and emails", "verified from your domain.", "", - `${yellow("NOTE")} It can take up to 48 hours for DNS records to fully propagate.`, + `${yellow("NOTE")} DNS records usually propagate within minutes, but can occasionally take up to 48 hours.`, `${dim(cyan("TIP"))} If you can't add a CNAME for the Frontend API, you can use a proxy:`, dim(" https://clerk.com/docs/guides/dashboard/dns-domains/proxy-fapi"), dim("Reference: https://clerk.com/docs/guides/development/deployment/production#dns-records"), @@ -56,13 +76,31 @@ export function clerkSubdomains(domain: string): { }; } -export function domainAssociationSummary(domain: string): string[] { +/** + * Every record host a new production domain needs, derived from the domain + * alone so the confirmation screen can show the full list before the instance + * exists. The DKIM selector is fixed server-side for domains the CLI creates + * (`clk`, so `clk._domainkey` and `clk2._domainkey`); the real targets, with + * their per-instance values, come back from the create call afterwards. + */ +export function productionDnsHosts(domain: string): string[] { const { frontendApi, accountPortal, mail } = clerkSubdomains(domain); - const hosts = [frontendApi, accountPortal, mail]; return [ - `Clerk will associate these subdomains with ${cyan(domain)}:`, + frontendApi, + accountPortal, + mail, + `clk._domainkey.${domain}`, + `clk2._domainkey.${domain}`, + ]; +} + +export function domainAssociationSummary(domain: string): string[] { + return [ + // Disclose the obligation before the one-way create step, without asking + // for action the user can't take yet (record values arrive after creation). + `Clerk will use these subdomains for ${cyan(domain)}. You'll add a DNS record for each after the instance is created:`, "", - ...hosts.map((host) => ` ${cnameTargetLabel(host)} ${host}`), + ...productionDnsHosts(domain).map((host) => ` ${cnameTargetLabel(host)} ${host}`), "", "This will create a Clerk production instance for your application.", ]; @@ -191,21 +229,77 @@ export function deployStatusRetryMessage( * components are complete. The user keeps the deploy state; rerunning * `clerk deploy` resumes from whichever component is still pending. */ -export function deployStatusPendingFooter(domain: string, status: DeployComponentStatus): string[] { - const pending: string[] = []; - if (!status.dns) pending.push("DNS"); - if (!status.ssl) pending.push("SSL"); - if (!status.mail) pending.push("email DNS"); +/** + * What is actually outstanding after a domain check, from the user's point of + * view. Records are theirs to add; SSL and final readiness are Clerk's side. + * `records_unavailable` is the case where DNS is unverified but the API gave + * us no record list to show — telling the user to "add the records" then + * points at nothing. Shared by the wizard footer and the agent `nextAction` + * so the two surfaces can't disagree. + */ +export type DomainPendingState = + | "records_available" + | "records_unavailable" + | "ssl_pending" + | "finalizing"; + +export function classifyDomainPending( + status: DeployComponentStatus, + hasPendingRecords: boolean, +): DomainPendingState { + if (!status.dns || !status.mail) { + return hasPendingRecords ? "records_available" : "records_unavailable"; + } + if (!status.ssl) return "ssl_pending"; + return "finalizing"; +} - const lead = - pending.length === 0 - ? `Production setup for ${domain} is still finalizing.` - : `${pending.join(", ")} still pending for ${domain}.`; +/** "DNS", "email DNS", or "DNS and email DNS" — whichever records are unverified. */ +export function pendingRecordComponents(status: DeployComponentStatus): string { + const records: string[] = []; + if (!status.dns) records.push("DNS"); + if (!status.mail) records.push("email DNS"); + return records.join(" and "); +} +export function deployStatusPendingFooter( + domain: string, + status: DeployComponentStatus, + domainsUrl?: string, + hasPendingRecords = true, +): string[] { + const state = classifyDomainPending(status, hasPendingRecords); + const records = capitalizeFirst(pendingRecordComponents(status)); + + // A lead line, then either a bulleted list (several follow-ups) or a blank + // line and one sentence (a single follow-up). An empty string is a blank + // line; the caller renders it with `log.blank()`. + if (state === "records_available") { + return [ + `${records} records not found yet for ${domain}.`, + " - Add them at your DNS provider, then run `clerk deploy` again to resume. The production instance is already created.", + " - Propagation usually takes minutes, but can occasionally take up to 48 hours.", + ` - If you can't add DNS records for this domain, change the domain in the Clerk Dashboard${domainsUrl ? `: ${domainsUrl}` : "."}`, + ]; + } + if (state === "records_unavailable") { + return [ + `${records} records not found yet for ${domain}.`, + "", + `Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard${domainsUrl ? `: ${domainsUrl}` : ""}, then run \`clerk deploy\` again to resume. The production instance is already created.`, + ]; + } + if (state === "ssl_pending") { + return [ + `SSL certificate still pending for ${domain}.`, + "", + "Clerk issues it automatically now that DNS is verified; run `clerk deploy` again in a few minutes to resume. The production instance is already created.", + ]; + } return [ - lead, - "DNS propagation can take several hours depending on your provider.", - "Run `clerk deploy` again to resume. The production instance is already created.", + `Production setup for ${domain} is still finalizing on Clerk's side.`, + "", + "Run `clerk deploy` again in a few minutes to resume. The production instance is already created.", ]; } @@ -230,23 +324,29 @@ export function productionSummary( ]; } -export function nextStepsBlock(appId: string, productionInstanceId: string): string { - return `${bold("Next steps")}\n${nextStepsBody(appId, productionInstanceId)}`; +export function nextStepsBlock( + appId: string, + productionInstanceId: string, + domain: string, +): string { + return `${bold("Next steps")}\n${nextStepsBody(appId, productionInstanceId, domain)}`; } -export function nextStepsBody(appId: string, productionInstanceId: string): string { +export function nextStepsBody(appId: string, productionInstanceId: string, domain: string): string { return ` 1. Pull production keys into your environment clerk env pull --instance prod - This writes pk_live_... and sk_live_... to your .env. They replace your + This writes pk_live_... and sk_live_... to your env file. They replace your pk_test_... and sk_test_... keys. 2. Update env vars on your hosting provider Vercel, AWS, GCP, Heroku, Render, etc. all expose env vars in their UI. - Add the same pk_live_/sk_live_ values there. + - Add the same pk_live_/sk_live_ values there. + - Also copy the other Clerk variables from your env file, such as + NEXT_PUBLIC_CLERK_SIGN_IN_URL. \`env pull\` writes only the two keys. - 3. Redeploy your app + 3. Redeploy your app, then sign up at https://${domain} to confirm it works 4. (If applicable) Update webhook URLs and signing secrets ${dim("https://clerk.com/docs/guides/development/webhooks/syncing#configure-your-production-instance")} @@ -254,8 +354,11 @@ export function nextStepsBody(appId: string, productionInstanceId: string): stri 5. (If applicable) Update your Content Security Policy ${dim("https://clerk.com/docs/guides/secure/best-practices/csp-headers")} - 6. View and manage domain configuration in the Clerk Dashboard - ${dim(domainsDashboardUrl(appId, productionInstanceId))} + 6. Manage this instance in the Clerk Dashboard + - Users, settings, and billing: + ${dim(instanceDashboardUrl(appId, productionInstanceId))} + - DNS and SSL status: + ${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. @@ -263,8 +366,21 @@ ${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")}`; } +/** + * Component labels are lowercase so they read naturally mid-sentence ("DNS + * and email DNS"); when one of them opens a sentence it needs a capital. + */ +export function capitalizeFirst(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +/** Dashboard home for one instance: users, settings, billing. */ +export function instanceDashboardUrl(appId: string, instanceId: string): string { + return buildDashboardUrl(appId, instanceId); +} + export function domainsDashboardUrl(appId: string, productionInstanceId: string): string { - return `https://dashboard.clerk.com/apps/${appId}/instances/${productionInstanceId}/domains`; + return buildDashboardUrl(appId, productionInstanceId, "domains"); } export function pausedMessage(stepDescription: string): string { diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4ccd8c9d6..4e543dfed 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -882,7 +882,9 @@ describe("deploy", () => { expect(error?.message).toContain("Deploy paused at: DNS verification"); expect(error?.exitCode).toBe(EXIT_CODE.GENERAL); - expect(err).toContain("Production setup for example.com is still finalizing."); + expect(err).toContain( + "Production setup for example.com is still finalizing on Clerk's side.", + ); expect(err).toContain("Paused"); expect(err).not.toContain("Production ready at"); }); @@ -979,6 +981,9 @@ describe("deploy", () => { expect(err).toContain("Next steps"); expect(err).toContain("clerk env pull --instance prod"); expect(err).toContain("Update env vars on your hosting provider"); + expect(err).toContain("Also copy the other Clerk variables from your env file"); + expect(err).toContain("sign up at https://example.com to confirm it works"); + expect(err).toContain("Manage this instance in the Clerk Dashboard"); expect(err).toContain( "https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", ); @@ -997,7 +1002,9 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); - expect(err).toContain("Clerk will associate these subdomains with example.com"); + expect(err).toContain( + "Clerk will use these subdomains for example.com. You'll add a DNS record for each after the instance is created:", + ); expect(err).toContain("clerk.example.com"); expect(err).toContain("accounts.example.com"); expect(err).toContain("clkmail.example.com"); @@ -1037,7 +1044,9 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("Clerk will associate these subdomains with example.com"); + expect(err).toContain( + "Clerk will use these subdomains for example.com. You'll add a DNS record for each after the instance is created:", + ); expect(err).toContain("No production instance was created."); expect(mockCreateProductionInstance).not.toHaveBeenCalled(); expect(mockConfirm).toHaveBeenCalledWith({ @@ -1253,6 +1262,58 @@ describe("deploy", () => { expect(err).not.toContain("https://accounts.example.com/v1/oauth_callback"); }); + test("Google OAuth walkthrough prints the consent-screen app name users will see", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + stubCreateProductionInstance({ + frontendApiUrl: "https://clerk-fapi.example.com", + cnameTargets: [], + }); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true); // Create production instance? + mockOpenBrowser.mockResolvedValueOnce({ ok: true, launcher: "test" }); + mockSelect + .mockResolvedValueOnce("walkthrough") // Google OAuth credentials + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("skip"); // DNS verification + mockInput.mockResolvedValueOnce("example.com").mockResolvedValueOnce("fake-client-id-12345"); + mockPassword.mockResolvedValueOnce("fake-secret"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + // Google requires the consent screen before it will create a client, and + // the name entered there is what end users see on the sign-in prompt. + // The fixture's application is named "my-saas-app". + expect(err).toContain("The consent screen's app name is what users see"); + expect(err).toContain('Your Clerk app is named "my-saas-app"'); + // Guidance follows the values to paste and the IMPORTANT note, not before. + const tipAt = err.indexOf("The consent screen's app name is what users see"); + expect(err.indexOf("Authorized Redirect URI")).toBeLessThan(tipAt); + expect(err.indexOf("IMPORTANT")).toBeLessThan(tipAt); + }); + + test("names the Clerk production instance and where it lives once created", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + stubCreateProductionInstance({ cnameTargets: [] }); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true); // Create production instance? + mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("example.com"); + + await runDeployUntilPause(); + const err = stripAnsi(captured.err); + + // "Clerk production instance", not just "production instance": the user + // also has a deployment on their host, and this is the one Clerk manages. + expect(err).toMatch( + /Clerk production instance created\. Manage it in the Dashboard: https:\/\/dashboard\.clerk\.com\/apps\/app_xyz789\/instances\/ins_\S+/, + ); + }); + test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_apple" }, @@ -1669,8 +1730,8 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("SSL, email DNS still pending for example.com"); - expect(err).not.toContain("DNS, SSL, email DNS still pending"); + expect(err).toContain("Email DNS records not found yet for example.com"); + expect(err).not.toContain("DNS and email DNS records"); }); test("DNS verification treats absent components as pending", async () => { @@ -1692,10 +1753,41 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain("DNS: pending SSL: ✓ Email DNS: ✓"); - expect(err).toContain("DNS still pending for example.com"); + expect(err).toContain("DNS records not found yet for example.com"); expect(err).not.toContain("Domain Verified"); }); + test("DNS verification footer says the record list is missing when the API returned no targets", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + stubCreateProductionInstance({ cnameTargets: [] }); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true); // Create production instance? (no zone-file prompt: no targets) + mockInput.mockResolvedValueOnce("example.com"); + mockSelect + .mockResolvedValueOnce("have-credentials") + .mockResolvedValueOnce("check") + .mockResolvedValueOnce("skip"); + mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com"); + mockPassword.mockResolvedValueOnce("google-secret"); + mockPatchInstanceConfig.mockResolvedValueOnce({}); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("DNS and email DNS records not found yet for example.com."); + expect(err).toContain("Clerk didn't return the list of records to add."); + expect(err).toContain( + "Find them on the Domains page in the Clerk Dashboard: https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", + ); + expect(err).not.toContain("Add them at your DNS provider"); + expect(err).not.toContain("Add the following records at your DNS provider:"); + }); + test("DNS verification timeout does not reprint DNS records when only SSL remains pending", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -1719,7 +1811,8 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain("DNS: ✓ SSL: pending Email DNS: ✓"); - expect(err).toContain("SSL still pending for example.com"); + expect(err).toContain("SSL certificate still pending for example.com"); + expect(err).not.toContain("not found yet"); expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(1); }); @@ -2103,8 +2196,9 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("DNS propagation can take several hours"); - expect(err).toContain("DNS, SSL, email DNS still pending for example.com"); + expect(err).toContain("Propagation usually takes minutes"); + expect(err).toContain("DNS and email DNS records not found yet for example.com"); + expect(err).not.toContain("still pending"); 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 6f0958e0d..eb5c93edd 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -15,6 +15,8 @@ import { type ProductionInstanceResponse, } from "../../lib/plapi.ts"; import { + DEPLOY_COMMAND_DESCRIPTION, + DEPLOY_COMMAND_SUMMARY, INTRO_PREAMBLE, OAUTH_SECTION_INTRO, type DeployPlanStep, @@ -26,6 +28,8 @@ import { dnsDashboardHandoff, dnsIntro, dnsRecords, + domainsDashboardUrl, + instanceDashboardUrl, nextStepsBody, pendingDnsRecords, pausedOperationNotice, @@ -182,6 +186,11 @@ async function startNewDeploy(ctx: DeployContext): Promise { } const production = productionOrExists; await persistProductionInstance(ctx, production.id); + // "Clerk production instance", not just "production instance": the user + // also has a deployment on their host, and this is the one Clerk manages. + log.success( + `Clerk production instance created. Manage it in the Dashboard: ${instanceDashboardUrl(ctx.appId, production.id)}`, + ); if (!production.active_domain) { throw new CliError( @@ -436,8 +445,21 @@ async function runDnsVerification( log.blank(); log.info(deployComponentStatus(outcome.status)); log.blank(); - for (const line of deployStatusPendingFooter(state.domain, outcome.status)) { - log.warn(line); + const productionInstanceId = + state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production; + // Computed before the footer so the footer can tell the user when there is + // no record list to print, instead of saying "add them" over nothing. + const pendingRecords = state.cnameTargets + ? pendingDnsRecords(state.cnameTargets, outcome.status) + : []; + for (const line of deployStatusPendingFooter( + state.domain, + outcome.status, + productionInstanceId ? domainsDashboardUrl(ctx.appId, productionInstanceId) : undefined, + pendingRecords.length > 0, + )) { + if (line === "") log.blank(); + else log.warn(line); } // When all DNS components are verified but the server has not yet marked the @@ -446,9 +468,6 @@ async function runDnsVerification( throw deployPausedError(state); } - const pendingRecords = state.cnameTargets - ? pendingDnsRecords(state.cnameTargets, outcome.status) - : []; if (pendingRecords.length > 0) { log.blank(); for (const line of pendingRecords) log.info(line); @@ -577,7 +596,7 @@ async function collectAndSaveOAuthCredentials( } if (choice === "walkthrough") { - await showOAuthWalkthrough(descriptor, domain, frontendApiUrl); + await showOAuthWalkthrough(descriptor, domain, frontendApiUrl, ctx.appLabel); choice = await chooseOAuthCredentialAction(descriptor, { includeWalkthrough: false }); if (choice === "skip") { return false; @@ -638,15 +657,19 @@ async function finishDeploy( prefix: isInsideGutter() ? `${dim("│")} ` : "", label: "Next steps", fallback: bold, - body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`, + body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId, domain))}\n`, }); await outro("Success"); } export function registerDeploy(program: Program): void { + // `summary` is what the root `clerk --help` table shows; `description` is + // the prose on `clerk deploy --help`, where the hidden default subcommand + // would otherwise leave no trace of what the bare command does. const deployCmd = program .command("deploy") - .description("Deploy a Clerk application to production"); + .summary(DEPLOY_COMMAND_SUMMARY) + .description(DEPLOY_COMMAND_DESCRIPTION); deployCmd.command("run", { isDefault: true, hidden: true }).action(deploy); deployCmd .command("status") diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 3a2aabc99..eecd895e8 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -453,6 +453,7 @@ export async function showOAuthWalkthrough( provider: OAuthProvider | OAuthProviderDescriptor, domain: string, frontendApiUrl?: string, + appName?: string, ): Promise { const descriptor = providerDescriptorFromInput(provider); const slug = descriptor?.provider ?? (provider as OAuthProvider); @@ -472,6 +473,15 @@ export async function showOAuthWalkthrough( log.blank(); log.info(gotcha); } + // Google makes you fill in the OAuth consent screen before it will create a + // client, and the app name entered there is what end users see on the + // sign-in prompt. Guidance, not a value to paste, so it follows the values. + if (slug === "google" && appName) { + log.blank(); + log.info( + `${dim(cyan("TIP"))} The consent screen's app name is what users see on Google's sign-in prompt. Your Clerk app is named "${appName}".`, + ); + } log.blank(); log.info(dim(`Provider guide: ${docsUrl}`)); diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index f9f263989..11d871398 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -339,6 +339,32 @@ describe("deploy status", () => { expect(captured.out).toBe(""); }); + test("human mode rewrites the agent clause for a plain-http Dashboard URL too", async () => { + // Dashboard links follow CLERK_DASHBOARD_URL, which is http:// for a local + // Dashboard; the human rewrite must not depend on https. + const previous = process.env.CLERK_DASHBOARD_URL; + process.env.CLERK_DASHBOARD_URL = "http://localhost:4000"; + try { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingSslDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingSslDomainStatus()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain( + "Visit the Clerk Dashboard domains page to monitor its status there: http://localhost:4000/apps/app_1/instances/ins_prod/domains", + ); + expect(output).not.toContain("Ask the user to visit"); + } finally { + if (previous === undefined) delete process.env.CLERK_DASHBOARD_URL; + else process.env.CLERK_DASHBOARD_URL = previous; + } + }); + test("human mode shows dashboard monitoring guidance without agent handoff copy", async () => { setMode("human"); mockFetchApplication.mockResolvedValue(appWith(true)); @@ -351,7 +377,7 @@ describe("deploy status", () => { 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", + "SSL certificate still pending for example.com. Clerk issues it automatically now that DNS is verified; re-run `clerk deploy status` in a few minutes. 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"); diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index 21e8c894a..bb19213bf 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -175,7 +175,9 @@ function renderHuman(report: DeployStatusReport): void { 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+)/, + // `https?`: the URL follows CLERK_DASHBOARD_URL, which is plain http for a + // local Dashboard. + /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 index 62435c391..46771d493 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -302,9 +302,97 @@ describe("buildDeployStatusReport", () => { expect(report.complete).toBe(true); expect(report.domainStatus).toEqual({ dns: "complete", ssl: "complete", mail: "complete" }); expect(report.nextAction).toContain("https://example.com"); + // Nothing left to monitor on the Domains page once complete; the pointer + // is the instance root, where users, settings, and billing live. expect(report.nextAction).toContain( - "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + "Manage users, settings, and billing for this instance: https://dashboard.clerk.com/apps/app_1/instances/ins_prod", + ); + expect(report.nextAction).not.toContain("/domains"); + expect(report.nextAction).not.toContain("Ask the user to visit"); + }); + + test("complete next action says the keys still have to reach the host", () => { + // Complete on Clerk's side only: the app runs on development keys until + // the production keys are set on the host, and the report can't tell + // whether that happened — so "if you haven't already", never "no action". + 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.nextAction).toContain( + "Clerk's production setup for https://example.com is verified. If you haven't already:", + ); + expect(report.nextAction).toContain("clerk env pull --instance prod"); + expect(report.nextAction).toContain("alongside the other Clerk variables from your env file"); + expect(report.nextAction).toContain("sign up at https://example.com to confirm"); + expect(report.nextAction).not.toContain("No action needed"); + }); + + test("pending DNS records tell the agent to add them, not to keep polling", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: false, ssl: false, mail: true } }, + ); + + expect(report.state).toBe("domain_pending"); + expect(report.nextAction).toContain("DNS records not found yet for example.com."); + expect(report.nextAction).toContain("Add the records in `pendingDnsRecords`"); + expect(report.nextAction).toContain("re-run `clerk deploy status --wait`"); + expect(report.nextAction).not.toContain("still provisioning"); + }); + + test("pending email DNS records are named on their own when the Frontend API is verified", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: true, ssl: true, mail: false } }, + ); + + expect(report.nextAction).toContain("Email DNS records not found yet for example.com."); + expect(report.nextAction).not.toContain("email DNS records not found"); + expect(report.nextAction).not.toContain("DNS and email DNS"); + }); + + test("SSL-only pending keeps the wait instruction, since there is nothing to add", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: true, ssl: false, mail: true } }, + ); + + expect(report.state).toBe("domain_pending"); + expect(report.nextAction).toContain( + "SSL certificate still pending for example.com. Clerk issues it automatically now that DNS is verified; re-run `clerk deploy status` in a few minutes.", + ); + expect(report.nextAction).not.toContain("not found yet"); + // DNS is verified in this state, so the old "DNS propagation can take + // time" clause would be wrong here. + expect(report.nextAction).not.toContain("DNS propagation"); + }); + + test("pending DNS with no record list says so instead of pointing at an empty array", () => { + // cname_targets is optional on the API's domain object. When it's absent, + // "add the records in pendingDnsRecords" would send the agent to []. + const noTargets = { ...activeSnapshot, cnameTargets: [] } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: noTargets }, + { verified: false, status: { dns: false, ssl: false, mail: true } }, + ); + + expect(report.state).toBe("domain_pending"); + expect(report.pendingDnsRecords).toEqual([]); + expect(report.nextAction).toContain( + "DNS records not found yet for example.com, but this report has no record list.", ); + expect(report.nextAction).toContain("Find the records to add on the Domains page"); + expect(report.nextAction).toContain("re-run `clerk deploy status --wait`"); + expect(report.nextAction).not.toContain("Add the records in `pendingDnsRecords`"); + expect(report.nextAction).not.toContain("still provisioning"); + // The Dashboard URL appears once, via the shared trailing clause. + expect(report.nextAction.match(/\/domains/g)).toHaveLength(1); }); test("unsupported OAuth providers surface without blocking completion", () => { diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index 86e6d3a20..7a05e805f 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -17,7 +17,11 @@ import { cnameTargetPending, deployComponentLabels, deployStatusRetryMessage, + capitalizeFirst, + classifyDomainPending, domainsDashboardUrl, + instanceDashboardUrl, + pendingRecordComponents, type DeployComponentStatus, } from "./copy.ts"; import { mapDeployError } from "./errors.ts"; @@ -382,9 +386,13 @@ export function buildDeployStatusReport( reportState, snapshot.domain, componentStatus, + pendingDnsRecords.length > 0, oauthPending, snapshot.productionInstanceId - ? domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId) + ? { + domains: domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId), + instance: instanceDashboardUrl(snapshot.appId, snapshot.productionInstanceId), + } : null, ), }; @@ -424,13 +432,28 @@ function deployNextAction( state: DeployStatusState, domain: string, componentStatus: DeployComponentStatus, + hasPendingRecords: boolean, oauthPending: string[], - domainsUrl: string | null, + urls: { domains: string; instance: string } | null, ): string { - const domainsAction = domainsUrl ? ` ${domainSettingsNextAction(domainsUrl)}` : ""; + const domainsAction = urls ? ` ${domainSettingsNextAction(urls.domains)}` : ""; if (state === "complete") { - return `Production is deployed and verified at https://${domain}. No action needed.${domainsAction}`; + // Complete on Clerk's side only. The app keeps running on development + // keys until the production keys reach the host, and the report can't + // tell whether that already happened — hence "if you haven't already". + // Nothing is left to monitor on the Domains page here, so the pointer is + // the instance itself (users, settings, billing) rather than the shared + // "visit the domains page" clause every pending state carries. + const instanceAction = urls + ? ` Manage users, settings, and billing for this instance: ${urls.instance}` + : ""; + return ( + `Clerk's production setup for https://${domain} is verified. If you haven't already: ` + + `run \`clerk env pull --instance prod\`, set those keys on your host alongside the other ` + + `Clerk variables from your env file, redeploy, then sign up at https://${domain} to confirm.` + + instanceAction + ); } if (state === "oauth_pending") { return ( @@ -440,23 +463,45 @@ function deployNextAction( ); } - const pendingComponents = [ - !componentStatus.dns ? "DNS" : null, - !componentStatus.ssl ? "SSL" : null, - !componentStatus.mail ? "email DNS" : null, - ].filter((value): value is string => value !== null); + // DNS and email DNS are records someone has to add at the registrar; SSL is + // Clerk's side and waits on them. Polling can't move the first kind along, + // so those get the "add the records" instruction and only SSL keeps "wait". + const pending = classifyDomainPending(componentStatus, hasPendingRecords); + const records = capitalizeFirst(pendingRecordComponents(componentStatus)); + + if (pending === "records_available") { + return ( + `${records} records not found yet for ${domain}. ` + + `Add the records in \`pendingDnsRecords\` at the domain's DNS provider if you haven't already, ` + + `then re-run \`clerk deploy status --wait\`. Propagation usually takes minutes.` + + domainsAction + ); + } - if (pendingComponents.length === 0) { + if (pending === "records_unavailable") { + // The report has nothing to hand over; the Dashboard clause appended below + // carries the URL, so this sentence doesn't repeat it. return ( - `Production setup for ${domain} is still finalizing on Clerk's side. ` + - `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` + `${records} records not found yet for ${domain}, but this report has no record list. ` + + `Find the records to add on the Domains page in the Clerk Dashboard, then re-run ` + + `\`clerk deploy status --wait\`.` + + domainsAction + ); + } + + if (pending === "ssl_pending") { + // Records are verified; the certificate is Clerk's side and nobody can + // speed it up. Same message the wizard's footer prints for this state. + return ( + `SSL certificate still pending for ${domain}. Clerk issues it automatically now that ` + + `DNS is verified; 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 + `Production setup for ${domain} is still finalizing on Clerk's side. ` + + `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` ); } diff --git a/packages/cli-core/src/commands/open/index.ts b/packages/cli-core/src/commands/open/index.ts index c1ae8a91b..8489d565b 100644 --- a/packages/cli-core/src/commands/open/index.ts +++ b/packages/cli-core/src/commands/open/index.ts @@ -2,7 +2,7 @@ import { createArgument } from "@commander-js/extra-typings"; import type { Program } from "../../cli-program.ts"; import { resolveProfile } from "../../lib/config.ts"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; -import { getDashboardUrl } from "../../lib/environment.ts"; +import { buildDashboardUrl } from "../../lib/environment.ts"; import { openBrowser } from "../../lib/open.ts"; import { log } from "../../lib/log.ts"; import { bold, cyan, dim } from "../../lib/color.ts"; @@ -16,17 +16,9 @@ interface OpenOptions { print?: boolean; } -/** - * Build the dashboard deep-link URL for the linked app's instance. - * Exported for tests and reuse. - */ -export function buildDashboardUrl(appId: string, instanceId: string, subpath?: string): string { - const host = getDashboardUrl().replace(/\/$/, ""); - const base = `${host}/apps/${appId}/instances/${instanceId}`; - if (!subpath) return base; - const cleaned = subpath.replace(/^\//, "").replace(/\/$/, ""); - return cleaned ? `${base}/${cleaned}` : base; -} +// Re-exported for existing importers; the implementation lives in +// lib/environment.ts next to the host resolution it depends on. +export { buildDashboardUrl }; export async function openDashboard( subpath: string | undefined, diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 1031c9cad..e8bba3dd6 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -152,6 +152,19 @@ export function getDashboardUrl(): string { ); } +/** + * Dashboard deep link for one instance of an app, optionally to a page under + * it (`domains`, `users`, ...). Lives here rather than in the `open` command + * so copy modules can build links without importing a command's module graph. + */ +export function buildDashboardUrl(appId: string, instanceId: string, subpath?: string): string { + const host = getDashboardUrl().replace(/\/$/, ""); + const base = `${host}/apps/${appId}/instances/${instanceId}`; + if (!subpath) return base; + const cleaned = subpath.replace(/^\//, "").replace(/\/$/, ""); + return cleaned ? `${base}/${cleaned}` : base; +} + /** * Remote MCP server URL for the active environment. * From bfcefa0e60377e039bb06f813797e847ecf229cf Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Wed, 16 Sep 2026 21:45:35 -0700 Subject: [PATCH 02/24] Address review of clerk deploy wording changes Follow-ups from the first review round of GROW-1235. Two user-facing gaps, three code tightenings, and one test gap. - Human-mode `clerk deploy status` now prints the pending DNS records before its next-step sentence, and the sentence says what happens once they're added; before, a person was told to add records in `pendingDnsRecords`, a JSON field they never see - Each `pendingDnsRecords` entry now carries Clerk's `required` flag (additive to the agent JSON) so `deploy status` labels optional records the same way the wizard does instead of marking all of them required - The confirmation screen labels its mail rows "Email" / "Email (DKIM)"; "Clerk handles SPF/DKIM automatically" stays on the post-creation block, where it describes record contents rather than contradicting the new "you'll add a record for each" lead - The DNS records heading and the wizard's retry bullet say "if you haven't already" when shown after a check that didn't find them, since the user may be waiting on propagation; the first hand-over keeps the plain wording - `pendingCnameTargets` is the single source of "are there records to show" for the wizard footer, the wizard's record block, and the agent report; the wizard previously used a display formatter's line count as that predicate, and the footer's flag is now a required parameter - `clerk auth login` guards the instance lookup on the claim response so a missing array can't fail a claim that already succeeded server-side - Tests cover the finalizing state on the footer and the agent report, the empty-record-list case on both surfaces, and the human `deploy status` output - Remove `nextStepsBlock` and the `pendingDnsRecords` formatter wrapper, which had no production callers; move the missing-record-list URL to its own line; break the changeset into bullets --- .changeset/deploy-wizard-copy.md | 10 +- .../cli-core/src/commands/auth/login.test.ts | 18 +++ packages/cli-core/src/commands/auth/login.ts | 4 +- .../cli-core/src/commands/deploy/README.md | 4 +- .../cli-core/src/commands/deploy/copy.test.ts | 119 +++++++++++------- packages/cli-core/src/commands/deploy/copy.ts | 74 ++++++++--- .../src/commands/deploy/index.test.ts | 12 +- .../cli-core/src/commands/deploy/index.ts | 12 +- .../commands/deploy/status-command.test.ts | 88 +++++++++++++ .../src/commands/deploy/status-command.ts | 37 ++++-- .../src/commands/deploy/status.test.ts | 17 +++ .../cli-core/src/commands/deploy/status.ts | 13 +- 12 files changed, 317 insertions(+), 91 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index 24e06064d..efdf0b3ae 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -2,4 +2,12 @@ "clerk": patch --- -`clerk deploy` copy fixes: `--help` describes what the bare command does and its agent-mode JSON report; the preamble says a host-generated URL can't be the production domain; the confirmation screen lists all five DNS record hosts, including DKIM; the DNS check reports records as "not found yet" with a minutes-not-days expectation; the wizard and `clerk auth login` print the app's Dashboard URL; the Google walkthrough includes the consent-screen app name. Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, and at `complete` says the production keys still have to reach the host alongside the other Clerk variables. +`clerk deploy` copy fixes: + +- `clerk deploy --help` describes what the bare command does and the JSON report it prints under an agent. +- The preamble says a hosting provider's generated URL can't be the production domain. +- The confirmation screen lists all five DNS record hosts, including DKIM, and says a record will be needed for each. +- The DNS check reports records as "not found yet" with a minutes-not-days expectation, tells you what to do based on what's actually pending, and links the Dashboard Domains page for changing the domain. +- `clerk auth login` prints the claimed app's Dashboard URL; the wizard prints the new production instance's URL and its next steps say the pulled keys go on the host alongside the other Clerk variables. +- The Google walkthrough adds a tip with the app name to use on the OAuth consent screen. +- Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, and at `complete` says the production keys still have to reach the host. Human-mode `clerk deploy status` prints the pending records. diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index e569bf6fc..f3cc86db5 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -557,6 +557,24 @@ describe("login", () => { ); }); + test("a claim response without instances still reports the claim and does not throw", async () => { + // The Dashboard line is a nice-to-have; a missing array in API JSON must + // not turn a claim that already succeeded server-side into a failed login. + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "claimed", + envPulled: true, + app: { application_id: "app_claimed", name: "bad-agent" }, + }); + + await runLogin(); + + expect(captured.err).toContain("Claimed and linked application: `bad-agent`"); + expect(captured.err).not.toContain("Your app now lives"); + }); + test("shows default next steps when not linked", async () => { mockGetValidToken.mockResolvedValue(null); mockOAuthSuccess(); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 5de6d7cc9..992dc3288 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -231,7 +231,9 @@ async function handleAutoclaim(cwd: string): Promise { const label = result.app.name || result.app.application_id; log.success(`Claimed and linked application: \`${label}\``); // First time this app has a home in an account; say where it is. - const development = result.app.instances.find( + // Deserialized API JSON; a missing array must not fail a claim that + // already succeeded server-side. + const development = result.app.instances?.find( (instance) => instance.environment_type === "development", ); if (development) { diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 015acbd6e..519b7ad76 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -49,9 +49,9 @@ 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`, `not_started`, or `interrupted`. - `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. -- `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks. +- `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks, each with `host`, `value`, and Clerk's `required` flag (some targets are optional). - `oauth`: configured, pending, and unsupported provider slugs. -- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. +- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. Human mode prints the same sentence with the Dashboard clause reworded for a person and, when records are pending, prints the records themselves first, and the sentence then says only what happens once they are added rather than naming the JSON field. Exit codes: diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 0b44a2d40..8bdcd5e74 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -12,8 +12,8 @@ import { domainAssociationSummary, domainsDashboardUrl, instanceDashboardUrl, - nextStepsBlock, - pendingDnsRecords, + nextStepsBody, + pendingCnameTargets, productionDnsHosts, } from "./copy.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; @@ -91,6 +91,22 @@ describe("dnsRecords", () => { }); }); +describe("dnsRecords heading", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + ]; + + test("first hand-over tells the user to add the records", () => { + expect(dnsRecords(targets)[0]).toBe("Add the following records at your DNS provider:"); + }); + + test("after a check that didn't find them, the heading allows that they may already be added", () => { + expect(dnsRecords(targets, { afterCheck: true })[0]).toBe( + "Add the following records at your DNS provider if you haven't already:", + ); + }); +}); + describe("deployComponentLabels", () => { test("returns email DNS in-progress and done labels", () => { expect(deployComponentLabels("mail", "example.com")).toEqual({ @@ -122,9 +138,9 @@ describe("deployStatusRetryMessage", () => { }); }); -describe("nextStepsBlock", () => { +describe("nextStepsBody", () => { test("links to the production instance home and its domain settings", () => { - const output = stripAnsi(nextStepsBlock("app_123", "ins_456", "example.com")); + const output = stripAnsi(nextStepsBody("app_123", "ins_456", "example.com")); expect(output).toContain("Manage this instance in the Clerk Dashboard"); expect(output).toContain("- Users, settings, and billing:"); @@ -138,7 +154,7 @@ describe("nextStepsBlock", () => { // `env pull --instance prod` writes only the two keys. The routing // variables `init` wrote have to be carried over by hand, or sign-in // silently falls back to the hosted Account Portal. - const output = nextStepsBlock("app_123", "ins_456", "example.com"); + const output = nextStepsBody("app_123", "ins_456", "example.com"); expect(output).toContain("- Add the same pk_live_/sk_live_ values there."); expect(output).toContain("- Also copy the other Clerk variables from your env file"); @@ -147,7 +163,7 @@ describe("nextStepsBlock", () => { }); test("ends with a real sign-up on the production domain", () => { - const output = nextStepsBlock("app_123", "ins_456", "example.com"); + const output = nextStepsBody("app_123", "ins_456", "example.com"); expect(output).toContain("sign up at https://example.com to confirm it works"); }); @@ -178,11 +194,16 @@ describe("domainAssociationSummary", () => { expect(lead).not.toMatch(/\b(three|five|3|5)\b/); }); - test("labels the DKIM hosts as email records, not bare CNAMEs", () => { + test("labels the mail hosts plainly, without the 'Clerk handles it' parenthetical", () => { + // The lead says the user will add a record for each row; a label saying + // Clerk handles SPF/DKIM automatically would contradict it on this screen. const output = domainAssociationSummary("example.com").join("\n"); + expect(output).toContain("Email clkmail.example.com"); + expect(output).toContain("Email (DKIM) clk._domainkey.example.com"); + expect(output).toContain("Email (DKIM) clk2._domainkey.example.com"); + expect(output).not.toContain("Clerk handles SPF/DKIM"); expect(output).not.toContain("CNAME clk._domainkey"); - expect(output).toMatch(/Email .*clk\._domainkey\.example\.com/); }); }); @@ -209,11 +230,12 @@ describe("deployStatusPendingFooter", () => { mail: false, }, DOMAINS_URL, + true, ).join("\n"); expect(output).toContain("DNS and email DNS records not found yet for example.com."); expect(output).toContain( - "Add them at your DNS provider, then run `clerk deploy` again to resume.", + "Add them at your DNS provider if you haven't already, then run `clerk deploy` again to resume.", ); expect(output).toContain("usually takes minutes, but can occasionally take up to 48 hours"); expect(output).toContain( @@ -232,6 +254,7 @@ describe("deployStatusPendingFooter", () => { mail: false, }, DOMAINS_URL, + true, ).join("\n"); // Capitalized at the sentence start; lowercase "email DNS" is only right @@ -253,9 +276,9 @@ describe("deployStatusPendingFooter", () => { expect(output).toContain("DNS and email DNS records not found yet for example.com.\n\n"); expect(output).toContain("Clerk didn't return the list of records to add."); - expect(output).toContain( - `Find them on the Domains page in the Clerk Dashboard: ${DOMAINS_URL}`, - ); + // URL on its own line so terminal autolinkers don't swallow punctuation. + expect(output).toContain("Find them on the Domains page in the Clerk Dashboard, then run"); + expect(output).toContain(`already created.\n ${DOMAINS_URL}`); expect(output).toContain("run `clerk deploy` again to resume"); expect(output).not.toContain(" - "); expect(output).not.toContain("Add them at your DNS provider"); @@ -271,6 +294,7 @@ describe("deployStatusPendingFooter", () => { mail: true, }, DOMAINS_URL, + true, ).join("\n"); expect(output).toContain("SSL certificate still pending for example.com."); @@ -282,17 +306,54 @@ describe("deployStatusPendingFooter", () => { expect(output).not.toContain("change the domain in the Clerk Dashboard"); }); + test("says Clerk is still finalizing when every component is verified", () => { + // The fallthrough branch: all three verified, server hasn't flipped the + // domain to complete yet. One follow-up line, so blank line + sentence. + const output = deployStatusPendingFooter( + "example.com", + { dns: true, ssl: true, mail: true }, + DOMAINS_URL, + false, + ).join("\n"); + + expect(output).toContain( + "Production setup for example.com is still finalizing on Clerk's side.\n\nRun `clerk deploy` again in a few minutes to resume.", + ); + expect(output).not.toContain("not found yet"); + expect(output).not.toContain("SSL"); + }); + test.each([ { label: "records pending", status: { dns: false, ssl: false, mail: false } }, { label: "SSL only pending", status: { dns: true, ssl: false, mail: true } }, { label: "all components verified", status: { dns: true, ssl: true, mail: true } }, ])("always says how to resume and that re-running is safe ($label)", ({ status }) => { - const output = deployStatusPendingFooter("example.com", status, DOMAINS_URL).join("\n"); + const output = deployStatusPendingFooter("example.com", status, DOMAINS_URL, true).join("\n"); expect(output).toMatch(/run `clerk deploy` again.*to resume/i); expect(output).toContain("The production instance is already created."); }); }); +describe("pendingCnameTargets", () => { + const targets: CnameTarget[] = [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "clkmail.example.com", value: "mail.clerk.services", required: true }, + { host: "clk._domainkey.example.com", value: "dkim1.clerk.services", required: true }, + ]; + + test("returns only the mail targets when only email DNS is unverified", () => { + const pending = pendingCnameTargets(targets, { dns: true, ssl: true, mail: false }); + expect(pending.map((t) => t.host)).toEqual([ + "clkmail.example.com", + "clk._domainkey.example.com", + ]); + }); + + test("returns nothing when only SSL is pending", () => { + expect(pendingCnameTargets(targets, { dns: true, ssl: false, mail: true })).toEqual([]); + }); +}); + describe("dashboard URLs", () => { test("instance URL is the instance root and the domains URL is nested under it", () => { expect(instanceDashboardUrl("app_1", "ins_prod")).toBe( @@ -328,38 +389,6 @@ describe("DEPLOY_COMMAND_DESCRIPTION", () => { }); }); -describe("pendingDnsRecords", () => { - const targets: CnameTarget[] = [ - { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, - { host: "accounts.example.com", value: "accounts.clerk.services", required: true }, - { - host: "clkmail.example.com", - value: "mail.example.com.nam1.clerk.services", - required: true, - }, - ]; - - test("returns no records when only SSL remains pending", () => { - expect(pendingDnsRecords(targets, { dns: true, ssl: false, mail: true })).toEqual([]); - }); - - test("returns only email records when email DNS remains pending", () => { - const output = pendingDnsRecords(targets, { dns: true, ssl: true, mail: false }).join("\n"); - - expect(output).toContain("clkmail.example.com"); - expect(output).not.toContain("clerk.example.com"); - expect(output).not.toContain("accounts.example.com"); - }); - - test("returns non-email records when DNS remains pending", () => { - const output = pendingDnsRecords(targets, { dns: false, ssl: true, mail: true }).join("\n"); - - expect(output).toContain("clerk.example.com"); - expect(output).toContain("accounts.example.com"); - expect(output).not.toContain("clkmail.example.com"); - }); -}); - describe("dnsRecords", () => { test("labels DKIM CNAME records as email DNS records", () => { const output = dnsRecords([ diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 537fb4d4d..5880666a3 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -100,14 +100,26 @@ export function domainAssociationSummary(domain: string): string[] { // for action the user can't take yet (record values arrive after creation). `Clerk will use these subdomains for ${cyan(domain)}. You'll add a DNS record for each after the instance is created:`, "", - ...productionDnsHosts(domain).map((host) => ` ${cnameTargetLabel(host)} ${host}`), + ...productionDnsHosts(domain).map((host) => ` ${confirmationHostLabel(host)} ${host}`), "", "This will create a Clerk production instance for your application.", ]; } -export function dnsRecords(targets: readonly CnameTarget[]): string[] { - const lines = ["Add the following records at your DNS provider:"]; +/** + * `afterCheck`: the records are being shown again because a DNS check didn't + * find them. The user may already have added them and be waiting on + * propagation, so the heading hedges. The first hand-over doesn't. + */ +export function dnsRecords( + targets: readonly CnameTarget[], + options: { afterCheck?: boolean } = {}, +): string[] { + const lines = [ + options.afterCheck + ? "Add the following records at your DNS provider if you haven't already:" + : "Add the following records at your DNS provider:", + ]; for (const target of targets) { const label = cnameTargetLabel(target.host); const optional = target.required ? "" : ` ${dim("(optional)")}`; @@ -126,13 +138,17 @@ export function dnsRecords(targets: readonly CnameTarget[]): string[] { return lines; } -export function pendingDnsRecords( +/** + * The targets the user still has to add, given what the domain check found. + * The single source of "are there records to show" for both the wizard and + * the agent report; formatters and classifiers derive from this, never from + * each other. + */ +export function pendingCnameTargets( targets: readonly CnameTarget[], status: DeployComponentStatus, -): string[] { - const pendingTargets = targets.filter((target) => cnameTargetPending(target, status)); - if (pendingTargets.length === 0) return []; - return dnsRecords(pendingTargets); +): CnameTarget[] { + return targets.filter((target) => cnameTargetPending(target, status)); } export function cnameTargetPending(target: CnameTarget, status: DeployComponentStatus): boolean { @@ -145,6 +161,29 @@ function isMailCnameTarget(target: CnameTarget): boolean { return prefix === "clkmail" || prefix === "clk" || prefix === "clk2"; } +/** + * Labels for the confirmation screen, where the lead line says the user will + * add a record for each host. "Clerk handles SPF/DKIM automatically" belongs + * on the post-creation records block (it's about record contents); next to + * that lead it reads as "nothing for you to do on these rows". + */ +function confirmationHostLabel(host: string): string { + const prefix = host.split(".", 1)[0]; + switch (prefix) { + case "clerk": + return "Frontend API"; + case "accounts": + return "Account portal"; + case "clkmail": + return "Email"; + case "clk": + case "clk2": + return "Email (DKIM)"; + default: + return "CNAME"; + } +} + function cnameTargetLabel(host: string): string { const prefix = host.split(".", 1)[0]; switch (prefix) { @@ -265,8 +304,8 @@ export function pendingRecordComponents(status: DeployComponentStatus): string { export function deployStatusPendingFooter( domain: string, status: DeployComponentStatus, - domainsUrl?: string, - hasPendingRecords = true, + domainsUrl: string | undefined, + hasPendingRecords: boolean, ): string[] { const state = classifyDomainPending(status, hasPendingRecords); const records = capitalizeFirst(pendingRecordComponents(status)); @@ -277,16 +316,19 @@ export function deployStatusPendingFooter( if (state === "records_available") { return [ `${records} records not found yet for ${domain}.`, - " - Add them at your DNS provider, then run `clerk deploy` again to resume. The production instance is already created.", + " - Add them at your DNS provider if you haven't already, then run `clerk deploy` again to resume. The production instance is already created.", " - Propagation usually takes minutes, but can occasionally take up to 48 hours.", ` - If you can't add DNS records for this domain, change the domain in the Clerk Dashboard${domainsUrl ? `: ${domainsUrl}` : "."}`, ]; } if (state === "records_unavailable") { + // URL on its own line: mid-sentence, terminal autolinkers swallow the + // trailing punctuation. return [ `${records} records not found yet for ${domain}.`, "", - `Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard${domainsUrl ? `: ${domainsUrl}` : ""}, then run \`clerk deploy\` again to resume. The production instance is already created.`, + "Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, then run `clerk deploy` again to resume. The production instance is already created.", + ...(domainsUrl ? [` ${domainsUrl}`] : []), ]; } if (state === "ssl_pending") { @@ -324,14 +366,6 @@ export function productionSummary( ]; } -export function nextStepsBlock( - appId: string, - productionInstanceId: string, - domain: string, -): string { - return `${bold("Next steps")}\n${nextStepsBody(appId, productionInstanceId, domain)}`; -} - export function nextStepsBody(appId: string, productionInstanceId: string, domain: string): string { return ` 1. Pull production keys into your environment diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4e543dfed..f5f577708 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1781,11 +1781,12 @@ describe("deploy", () => { expect(err).toContain("DNS and email DNS records not found yet for example.com."); expect(err).toContain("Clerk didn't return the list of records to add."); + expect(err).toContain("Find them on the Domains page in the Clerk Dashboard, then run"); expect(err).toContain( - "Find them on the Domains page in the Clerk Dashboard: https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", + " https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", ); expect(err).not.toContain("Add them at your DNS provider"); - expect(err).not.toContain("Add the following records at your DNS provider:"); + expect(err).not.toContain("Add the following records at your DNS provider"); }); test("DNS verification timeout does not reprint DNS records when only SSL remains pending", async () => { @@ -1814,6 +1815,7 @@ describe("deploy", () => { expect(err).toContain("SSL certificate still pending for example.com"); expect(err).not.toContain("not found yet"); expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(1); + expect(err).not.toContain("if you haven't already:"); }); test("plain deploy can skip DNS verification and continue configuring production", async () => { @@ -2200,7 +2202,11 @@ describe("deploy", () => { expect(err).toContain("DNS and email DNS records not found yet for example.com"); expect(err).not.toContain("still pending"); expect(err).toContain("DNS: pending"); - expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(2); + // First hand-over uses the plain heading; the reprint after a failed check hedges. + expect(err.match(/Add the following records at your DNS provider:/g)).toHaveLength(1); + expect( + err.match(/Add the following records at your DNS provider if you haven't already:/g), + ).toHaveLength(1); expect(err).toContain("Host: clerk.example.com"); expect(err).toContain("Value: frontend-api.clerk.services"); expect(err).toContain("Skipping DNS verification for now."); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index eb5c93edd..5501edfc9 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -31,7 +31,7 @@ import { domainsDashboardUrl, instanceDashboardUrl, nextStepsBody, - pendingDnsRecords, + pendingCnameTargets, pausedOperationNotice, printPlan, productionSummary, @@ -449,14 +449,12 @@ async function runDnsVerification( state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production; // Computed before the footer so the footer can tell the user when there is // no record list to print, instead of saying "add them" over nothing. - const pendingRecords = state.cnameTargets - ? pendingDnsRecords(state.cnameTargets, outcome.status) - : []; + const pendingTargets = pendingCnameTargets(state.cnameTargets ?? [], outcome.status); for (const line of deployStatusPendingFooter( state.domain, outcome.status, productionInstanceId ? domainsDashboardUrl(ctx.appId, productionInstanceId) : undefined, - pendingRecords.length > 0, + pendingTargets.length > 0, )) { if (line === "") log.blank(); else log.warn(line); @@ -468,9 +466,9 @@ async function runDnsVerification( throw deployPausedError(state); } - if (pendingRecords.length > 0) { + if (pendingTargets.length > 0) { log.blank(); - for (const line of pendingRecords) log.info(line); + for (const line of dnsRecords(pendingTargets, { afterCheck: true })) log.info(line); } log.blank(); let action: Awaited>; diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 11d871398..8e8965d80 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -222,6 +222,7 @@ describe("deploy status", () => { type: "CNAME", host: "clerk.example.com", value: "frontend-api.clerk.services", + required: true, }); }); @@ -339,6 +340,93 @@ describe("deploy status", () => { expect(captured.out).toBe(""); }); + test("human mode prints the pending records and refers to them, not to the JSON field", async () => { + // The agent reads `pendingDnsRecords` from the JSON; a person has no JSON, + // so the records are printed and the sentence points at them. + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingDnsDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingDnsDomainStatus()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain( + "Add the following records at your DNS provider if you haven't already:", + ); + expect(output).toContain("Host: clerk.example.com"); + expect(output).toContain("Value: frontend-api.clerk.services"); + // The records block already says "add these"; the sentence says what's next. + expect(output).toContain("Once they're added, re-run `clerk deploy status --wait`"); + expect(output).not.toContain("Add the records above"); + expect(output).not.toContain("pendingDnsRecords"); + expect(output).not.toContain("Ask the user"); + }); + + test("human mode keeps Clerk's optional flag on a pending record instead of inventing one", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + 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 }, + { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: false }, + ], + }, + ], + total_count: 1, + }); + mockOAuthComplete(); + const pendingBoth = { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "complete", required: true }, + mail: { status: "not_started", required: true }, + }; + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingBoth); + mockGetApplicationDomainStatus.mockResolvedValue(pendingBoth); + + await deployStatus(); + + const output = stripAnsi(captured.err); + // The wizard prints the same record as optional; status must agree. + expect(output).toMatch( + /Email \(Clerk handles SPF\/DKIM automatically\) \(optional\)\n\s+Type: CNAME\n\s+Host: clk2\._domainkey\.example\.com/, + ); + expect(output).not.toMatch(/Frontend API \(optional\)/); + }); + + test("agent report carries each pending record's required flag", async () => { + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingDnsDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingDnsDomainStatus()); + + await deployStatus(); + + const payload = JSON.parse(captured.out); + expect(payload.pendingDnsRecords).toEqual([ + { + type: "CNAME", + host: "clerk.example.com", + value: "frontend-api.clerk.services", + required: true, + }, + ]); + }); + test("human mode rewrites the agent clause for a plain-http Dashboard URL too", async () => { // Dashboard links follow CLERK_DASHBOARD_URL, which is http:// for a local // Dashboard; the human rewrite must not depend on https. diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index bb19213bf..1e4db2a67 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -4,7 +4,7 @@ import { log } from "../../lib/log.ts"; import { interruptedExitCode } from "../../lib/signals.ts"; import { sleep } from "../../lib/sleep.ts"; import { withSpinner } from "../../lib/spinner.ts"; -import { deployComponentLabels, type DeployComponentStatus } from "./copy.ts"; +import { deployComponentLabels, dnsRecords, type DeployComponentStatus } from "./copy.ts"; import { buildDeployStatusReport, buildInterruptedDeployStatusReport, @@ -133,7 +133,8 @@ function emitReport(report: DeployStatusReport): void { renderHuman(report); } -function renderHuman(report: DeployStatusReport): void { +/** Exported so the human rendering can be exercised directly. */ +export function renderHuman(report: DeployStatusReport): void { log.blank(); if (report.domain) { log.info(`Deploy status for \`${report.domain}\``); @@ -168,16 +169,38 @@ function renderHuman(report: DeployStatusReport): void { ); } + // The agent gets these as `pendingDnsRecords` in the JSON; a person has no + // JSON, so print the records themselves before the sentence that refers to + // them. + if (report.pendingDnsRecords.length > 0) { + log.blank(); + const targets = report.pendingDnsRecords.map((record) => ({ + host: record.host, + value: record.value, + required: record.required, + })); + for (const line of dnsRecords(targets, { afterCheck: true })) log.info(line); + } + log.blank(); log.info(formatHumanNextAction(report.nextAction)); log.blank(); } function formatHumanNextAction(nextAction: string): string { - return nextAction.replace( - // `https?`: the URL follows CLERK_DASHBOARD_URL, which is plain http for a - // local Dashboard. - /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", + return ( + nextAction + // The records block printed above already says "add these"; the sentence + // only needs to say what happens next. + .replace( + "Add the records in `pendingDnsRecords` at the domain's DNS provider if you haven't already, then re-run", + "Once they're added, re-run", + ) + .replace( + // `https?`: the URL follows CLERK_DASHBOARD_URL, which is plain http for a + // local Dashboard. + /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 index 46771d493..7a488dffe 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -251,6 +251,7 @@ describe("buildDeployStatusReport", () => { type: "CNAME", host: "clerk.example.com", value: "frontend-api.clerk.services", + required: true, }); expect(report.oauth.pending).toEqual(["github"]); }); @@ -266,6 +267,7 @@ describe("buildDeployStatusReport", () => { type: "CNAME", host: "clkmail.example.com", value: "mail.clerk.services", + required: true, }, ]); }); @@ -395,6 +397,21 @@ describe("buildDeployStatusReport", () => { expect(report.nextAction.match(/\/domains/g)).toHaveLength(1); }); + test("all components verified but not yet complete says Clerk is still finalizing", () => { + const report = buildDeployStatusReport( + { kind: "active", snapshot: activeSnapshot }, + { verified: false, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.state).toBe("domain_pending"); + expect(report.pendingDnsRecords).toEqual([]); + expect(report.nextAction).toContain( + "Production setup for example.com is still finalizing on Clerk's side.", + ); + expect(report.nextAction).not.toContain("not found yet"); + expect(report.nextAction).not.toContain("SSL"); + }); + test("unsupported OAuth providers surface without blocking completion", () => { const withUnsupported = { ...activeSnapshot, diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index 7a05e805f..c878dad37 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -14,7 +14,7 @@ import { import { sleep } from "../../lib/sleep.ts"; import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; import { - cnameTargetPending, + pendingCnameTargets, deployComponentLabels, deployStatusRetryMessage, capitalizeFirst, @@ -71,7 +71,7 @@ export interface DeployStatusReport { domain: string | null; productionInstanceId: string | null; domainStatus: { dns: string; ssl: string; mail: string } | null; - pendingDnsRecords: { type: "CNAME"; host: string; value: string }[]; + pendingDnsRecords: { type: "CNAME"; host: string; value: string; required: boolean }[]; oauth: { complete: boolean; configured: string[]; pending: string[]; unsupported: string[] }; nextAction: string; } @@ -360,9 +360,12 @@ export function buildDeployStatusReport( 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 })) + ? pendingCnameTargets(snapshot.cnameTargets ?? [], componentStatus).map((target) => ({ + type: "CNAME" as const, + host: target.host, + value: target.value, + required: target.required, + })) : []; return { From e612a8bb318143a913e77e2133b1ba0148a14cbb Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Wed, 16 Sep 2026 22:19:08 -0700 Subject: [PATCH 03/24] Fix contradictions found in deploy wording self-review A render-everything pass plus four independent reviews (parity, test mutation, cold read, code review) of the earlier wording changes turned up places where the text contradicted the situation on screen or leaked agent-directed prose to people. - The DNS check footer said "run `clerk deploy` again to resume" while the wizard was still running and a "Check again" prompt was next on screen. It now points at the prompt and gives the resume command as the fallback for people who skip. Only the "still finalizing" case exits, so only it keeps "run again". - When DNS verification was skipped, the closing screen said "Production ready" above "DNS pending" and told the user to sign up on a domain that doesn't resolve yet. The headline and step 3 now depend on whether DNS was verified. - Human `deploy status` told a person to "ask the user" in the not_started, domain_provisioning, and oauth_pending states, suggested `--wait` (a no-op for humans, who always wait) instead of the wizard that resumes setup, and printed "OAuth: pending: none" before any instance existed. All three reworded; the OAuth row is omitted when nothing was checked. - The agent report said OAuth was complete and "no action needed" while listing providers the CLI could not configure. In production those providers have no credentials and their sign-in fails, so `complete` and `oauth_pending` now name them. `oauth.complete` keeps its meaning. - `oauth_pending` dropped the Domains-page clause: the domain is verified in that state. - Resuming the wizard reprinted every DNS record, including ones already verified, under "Add the following records". It now prints only the outstanding ones with the hedged heading, the same filter the check screen uses. - `clerk deploy --help` mentions `--wait`. - Confirmation-screen labels pad to one column and share one label function with the records block instead of a duplicate switch. - Tests for the branches the mutation review found uncovered: human status for every state, null Dashboard URLs, the footer URL asserted from the footer rather than the next-steps block, the Google TIP only for Google and only with an app name, the claim URL picking the development instance, and `required: false` in the agent JSON. --- .changeset/deploy-wizard-copy.md | 4 +- packages/cli-core/src/cli-program.test.ts | 3 +- .../cli-core/src/commands/auth/login.test.ts | 33 ++++ .../cli-core/src/commands/deploy/README.md | 2 +- .../cli-core/src/commands/deploy/copy.test.ts | 80 +++++++-- packages/cli-core/src/commands/deploy/copy.ts | 72 ++++---- .../src/commands/deploy/index.test.ts | 62 ++++++- .../cli-core/src/commands/deploy/index.ts | 22 ++- .../commands/deploy/status-command.test.ts | 160 +++++++++++++++++- .../src/commands/deploy/status-command.ts | 39 ++++- .../src/commands/deploy/status.test.ts | 69 +++++++- .../cli-core/src/commands/deploy/status.ts | 17 +- 12 files changed, 490 insertions(+), 73 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index efdf0b3ae..d01ec92d9 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -10,4 +10,6 @@ - The DNS check reports records as "not found yet" with a minutes-not-days expectation, tells you what to do based on what's actually pending, and links the Dashboard Domains page for changing the domain. - `clerk auth login` prints the claimed app's Dashboard URL; the wizard prints the new production instance's URL and its next steps say the pulled keys go on the host alongside the other Clerk variables. - The Google walkthrough adds a tip with the app name to use on the OAuth consent screen. -- Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, and at `complete` says the production keys still have to reach the host. Human-mode `clerk deploy status` prints the pending records. +- The DNS check footer points at the "Check again" prompt that follows it instead of telling you to quit and re-run; the closing screen no longer says "Production ready" or "sign up at your domain" when DNS verification was skipped. +- Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. +- Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, at `complete` says the production keys still have to reach the host, and names OAuth providers the CLI could not configure so an agent doesn't report OAuth as done. Human-mode `clerk deploy status` prints the pending records, never says "ask the user", and resumes with `clerk deploy` rather than a flag that only affects agents. diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index acdd25fa3..8654d18ee 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -87,7 +87,8 @@ test("deploy --help describes the bare command, not only the status subcommand", "", "When run by an agent (or without a TTY), it is read-only: it prints a JSON", "status report with the current state and a `nextAction` field saying what to", - "do next. `clerk deploy status` prints the same report.", + "do next. `clerk deploy status` prints the same report; add `--wait` to keep", + "checking until DNS, SSL, and email DNS are verified.", "", "Options:", " -h, --help Display help for command", diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index f3cc86db5..8c7837216 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -557,6 +557,39 @@ describe("login", () => { ); }); + test("links to the development instance even when production is listed first", async () => { + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "claimed", + envPulled: true, + app: { + application_id: "app_claimed", + name: "bad-agent", + instances: [ + { + instance_id: "ins_prod_claimed", + environment_type: "production", + publishable_key: "pk_live_claimed", + }, + { + instance_id: "ins_dev_claimed", + environment_type: "development", + publishable_key: "pk_test_claimed", + }, + ], + }, + }); + + await runLogin(); + + expect(captured.err).toContain( + "https://dashboard.clerk.com/apps/app_claimed/instances/ins_dev_claimed", + ); + expect(captured.err).not.toContain("ins_prod_claimed"); + }); + test("a claim response without instances still reports the claim and does not throw", async () => { // The Dashboard line is a nice-to-have; a missing array in API JSON must // not turn a claim that already succeeded server-side into a failed login. diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 519b7ad76..47c13bfff 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -51,7 +51,7 @@ In agent mode, `clerk deploy status` emits JSON on stdout with: - `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. - `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks, each with `host`, `value`, and Clerk's `required` flag (some targets are optional). - `oauth`: configured, pending, and unsupported provider slugs. -- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. Human mode prints the same sentence with the Dashboard clause reworded for a person and, when records are pending, prints the records themselves first, and the sentence then says only what happens once they are added rather than naming the JSON field. +- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints the same sentence reworded for a person: no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked. Exit codes: diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 8bdcd5e74..8c8fb6ef6 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -15,6 +15,7 @@ import { nextStepsBody, pendingCnameTargets, productionDnsHosts, + productionSummary, } from "./copy.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; @@ -165,7 +166,31 @@ describe("nextStepsBody", () => { test("ends with a real sign-up on the production domain", () => { const output = nextStepsBody("app_123", "ins_456", "example.com"); + expect(output).toContain( + "3. Redeploy your app, then sign up at https://example.com to confirm it works", + ); + }); + + test("does not send the user to a domain that is not verified yet", () => { + // After skipping DNS verification the domain doesn't resolve, so step 3 + // has to route through the wizard before the sign-up check. + const output = nextStepsBody("app_123", "ins_456", "example.com", "pending"); + + expect(output).toContain("3. Run `clerk deploy` again once your DNS records are added"); expect(output).toContain("sign up at https://example.com to confirm it works"); + expect(output).not.toContain("3. Redeploy your app"); + }); +}); + +describe("productionSummary", () => { + test("only calls production ready once the domain is verified", () => { + const verified = stripAnsi(productionSummary("example.com", ["Google"], "verified").join("\n")); + const pending = stripAnsi(productionSummary("example.com", [], "pending").join("\n")); + + expect(verified).toContain("Production ready at https://example.com"); + expect(pending).toContain("Production instance created for https://example.com"); + expect(pending).toContain("Domain DNS pending"); + expect(pending).not.toContain("Production ready"); }); }); @@ -199,9 +224,12 @@ describe("domainAssociationSummary", () => { // Clerk handles SPF/DKIM automatically would contradict it on this screen. const output = domainAssociationSummary("example.com").join("\n"); - expect(output).toContain("Email clkmail.example.com"); - expect(output).toContain("Email (DKIM) clk._domainkey.example.com"); - expect(output).toContain("Email (DKIM) clk2._domainkey.example.com"); + expect(output).toContain("Email clkmail.example.com"); + expect(output).toContain("Email (DKIM) clk._domainkey.example.com"); + expect(output).toContain("Email (DKIM) clk2._domainkey.example.com"); + // Labels pad to one column so the hosts line up. + expect(output).toContain("Frontend API clerk.example.com"); + expect(output).toContain("Account portal accounts.example.com"); expect(output).not.toContain("Clerk handles SPF/DKIM"); expect(output).not.toContain("CNAME clk._domainkey"); }); @@ -234,9 +262,12 @@ describe("deployStatusPendingFooter", () => { ).join("\n"); expect(output).toContain("DNS and email DNS records not found yet for example.com."); + // A "Check again" prompt follows this footer, so it points there first and + // gives the resume command as the fallback. expect(output).toContain( - "Add them at your DNS provider if you haven't already, then run `clerk deploy` again to resume.", + "Add them at your DNS provider if you haven't already, then choose Check again below.", ); + expect(output).toContain("skip for now and run `clerk deploy` later to resume"); expect(output).toContain("usually takes minutes, but can occasionally take up to 48 hours"); expect(output).toContain( "change the domain in the Clerk Dashboard: https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", @@ -277,9 +308,11 @@ describe("deployStatusPendingFooter", () => { expect(output).toContain("DNS and email DNS records not found yet for example.com.\n\n"); expect(output).toContain("Clerk didn't return the list of records to add."); // URL on its own line so terminal autolinkers don't swallow punctuation. - expect(output).toContain("Find them on the Domains page in the Clerk Dashboard, then run"); + expect(output).toContain( + "Find them on the Domains page in the Clerk Dashboard, add them, then choose Check again below.", + ); expect(output).toContain(`already created.\n ${DOMAINS_URL}`); - expect(output).toContain("run `clerk deploy` again to resume"); + expect(output).toContain("run `clerk deploy` later to resume"); expect(output).not.toContain(" - "); expect(output).not.toContain("Add them at your DNS provider"); }); @@ -298,7 +331,8 @@ describe("deployStatusPendingFooter", () => { ).join("\n"); expect(output).toContain("SSL certificate still pending for example.com."); - expect(output).toContain("run `clerk deploy` again in a few minutes to resume"); + expect(output).toContain("choose Check again below in a few minutes"); + expect(output).toContain("run `clerk deploy` later to resume"); // One follow-up line: a blank line and a sentence, not a one-item list. expect(output).toContain("example.com.\n\nClerk issues it"); expect(output).not.toContain(" - "); @@ -324,13 +358,31 @@ describe("deployStatusPendingFooter", () => { }); test.each([ - { label: "records pending", status: { dns: false, ssl: false, mail: false } }, - { label: "SSL only pending", status: { dns: true, ssl: false, mail: true } }, - { label: "all components verified", status: { dns: true, ssl: true, mail: true } }, - ])("always says how to resume and that re-running is safe ($label)", ({ status }) => { - const output = deployStatusPendingFooter("example.com", status, DOMAINS_URL, true).join("\n"); - expect(output).toMatch(/run `clerk deploy` again.*to resume/i); - expect(output).toContain("The production instance is already created."); + { label: "records pending", status: { dns: false, ssl: false, mail: false }, records: true }, + { label: "records missing", status: { dns: false, ssl: false, mail: false }, records: false }, + { label: "SSL only pending", status: { dns: true, ssl: false, mail: true }, records: false }, + { + label: "all components verified", + status: { dns: true, ssl: true, mail: true }, + records: false, + }, + ])("always says how to resume and that re-running is safe ($label)", ({ status, records }) => { + const output = deployStatusPendingFooter("example.com", status, DOMAINS_URL, records).join( + "\n", + ); + expect(output).toMatch(/run `clerk deploy` (again|later).*to resume/i); + expect(output).toMatch(/production instance is already created\./i); + }); + + test("omits the Dashboard URL cleanly when no production instance id is known", () => { + const output = deployStatusPendingFooter( + "example.com", + { dns: false, ssl: false, mail: false }, + undefined, + true, + ).join("\n"); + expect(output).toContain("change the domain in the Clerk Dashboard."); + expect(output).not.toContain("undefined"); }); }); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 5880666a3..bc4273bda 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -23,7 +23,8 @@ re-run it at any time to resume where you left off. When run by an agent (or without a TTY), it is read-only: it prints a JSON status report with the current state and a \`nextAction\` field saying what to -do next. \`clerk deploy status\` prints the same report.`; +do next. \`clerk deploy status\` prints the same report; add \`--wait\` to keep +checking until DNS, SSL, and email DNS are verified.`; export const INTRO_PREAMBLE = `This will prepare your linked Clerk app for production by cloning your development instance into a new production instance and walking you through @@ -100,7 +101,9 @@ export function domainAssociationSummary(domain: string): string[] { // for action the user can't take yet (record values arrive after creation). `Clerk will use these subdomains for ${cyan(domain)}. You'll add a DNS record for each after the instance is created:`, "", - ...productionDnsHosts(domain).map((host) => ` ${confirmationHostLabel(host)} ${host}`), + ...productionDnsHosts(domain).map( + (host) => ` ${cnameTargetLabel(host, { terse: true }).padEnd(14)} ${host}`, + ), "", "This will create a Clerk production instance for your application.", ]; @@ -162,12 +165,15 @@ function isMailCnameTarget(target: CnameTarget): boolean { } /** - * Labels for the confirmation screen, where the lead line says the user will - * add a record for each host. "Clerk handles SPF/DKIM automatically" belongs - * on the post-creation records block (it's about record contents); next to - * that lead it reads as "nothing for you to do on these rows". + * Human label for a record host. `terse` is for the confirmation screen, where + * the lead line says the user will add a record for each host: "Clerk handles + * SPF/DKIM automatically" belongs on the records block (it's about record + * contents); next to that lead it reads as "nothing for you to do on these + * rows". */ -function confirmationHostLabel(host: string): string { +function cnameTargetLabel(host: string, options: { terse?: boolean } = {}): string { + // `host.split(".", 1)[0]` yields only the first label, so DKIM records + // (clk._domainkey, clk2._domainkey) arrive here as "clk"/"clk2". const prefix = host.split(".", 1)[0]; switch (prefix) { case "clerk": @@ -175,28 +181,10 @@ function confirmationHostLabel(host: string): string { case "accounts": return "Account portal"; case "clkmail": - return "Email"; + return options.terse ? "Email" : "Email (Clerk handles SPF/DKIM automatically)"; case "clk": case "clk2": - return "Email (DKIM)"; - default: - return "CNAME"; - } -} - -function cnameTargetLabel(host: string): string { - const prefix = host.split(".", 1)[0]; - switch (prefix) { - case "clerk": - return "Frontend API"; - case "accounts": - return "Account portal"; - // `host.split(".", 1)[0]` yields only the first label, so DKIM records - // (clk._domainkey, clk2._domainkey) arrive here as "clk"/"clk2". - case "clkmail": - case "clk": - case "clk2": - return "Email (Clerk handles SPF/DKIM automatically)"; + return options.terse ? "Email (DKIM)" : "Email (Clerk handles SPF/DKIM automatically)"; default: return "CNAME"; } @@ -313,10 +301,14 @@ export function deployStatusPendingFooter( // A lead line, then either a bulleted list (several follow-ups) or a blank // line and one sentence (a single follow-up). An empty string is a blank // line; the caller renders it with `log.blank()`. + // The wizard is still running when this prints: a "Check again" prompt + // follows, so the resume command is the fallback, not the instruction. + const resume = + "You can also skip for now and run `clerk deploy` later to resume; the production instance is already created."; if (state === "records_available") { return [ `${records} records not found yet for ${domain}.`, - " - Add them at your DNS provider if you haven't already, then run `clerk deploy` again to resume. The production instance is already created.", + ` - Add them at your DNS provider if you haven't already, then choose Check again below. ${resume}`, " - Propagation usually takes minutes, but can occasionally take up to 48 hours.", ` - If you can't add DNS records for this domain, change the domain in the Clerk Dashboard${domainsUrl ? `: ${domainsUrl}` : "."}`, ]; @@ -327,7 +319,7 @@ export function deployStatusPendingFooter( return [ `${records} records not found yet for ${domain}.`, "", - "Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, then run `clerk deploy` again to resume. The production instance is already created.", + `Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, add them, then choose Check again below. ${resume}`, ...(domainsUrl ? [` ${domainsUrl}`] : []), ]; } @@ -335,7 +327,7 @@ export function deployStatusPendingFooter( return [ `SSL certificate still pending for ${domain}.`, "", - "Clerk issues it automatically now that DNS is verified; run `clerk deploy` again in a few minutes to resume. The production instance is already created.", + `Clerk issues it automatically now that DNS is verified; choose Check again below in a few minutes. ${resume}`, ]; } return [ @@ -359,14 +351,28 @@ export function productionSummary( domainStatus: "verified" | "pending" = "verified", ): string[] { return [ - `Production ready at ${cyan(`https://${domain}`)}`, + domainStatus === "verified" + ? `Production ready at ${cyan(`https://${domain}`)}` + : `Production instance created for ${cyan(`https://${domain}`)}`, "", ` Domain ${domainStatus === "verified" ? "Verified" : "DNS pending"}`, ` OAuth ${completedOAuthProviderLabels.length ? completedOAuthProviderLabels.join(", ") : "Not applicable"}`, ]; } -export function nextStepsBody(appId: string, productionInstanceId: string, domain: string): string { +export function nextStepsBody( + appId: string, + productionInstanceId: string, + domain: string, + domainStatus: "verified" | "pending" = "verified", +): string { + // Until DNS is verified the domain doesn't resolve, so "sign up there" + // would send the user to a page that doesn't exist yet. + const step3 = + domainStatus === "verified" + ? `Redeploy your app, then sign up at https://${domain} to confirm it works` + : `Run \`clerk deploy\` again once your DNS records are added, then redeploy your app + and sign up at https://${domain} to confirm it works`; return ` 1. Pull production keys into your environment clerk env pull --instance prod @@ -380,7 +386,7 @@ export function nextStepsBody(appId: string, productionInstanceId: string, domai - Also copy the other Clerk variables from your env file, such as NEXT_PUBLIC_CLERK_SIGN_IN_URL. \`env pull\` writes only the two keys. - 3. Redeploy your app, then sign up at https://${domain} to confirm it works + 3. ${step3} 4. (If applicable) Update webhook URLs and signing secrets ${dim("https://clerk.com/docs/guides/development/webhooks/syncing#configure-your-production-instance")} diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index f5f577708..2f6fea260 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -70,7 +70,7 @@ mock.module("../../lib/open.ts", () => ({ const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts"); const { deploy } = await import("./index.ts"); -const { providerSetupIntro } = await import("./providers.ts"); +const { providerSetupIntro, showOAuthWalkthrough } = await import("./providers.ts"); const { collectCustomDomain } = await import("./prompts.ts"); function stripAnsi(value: string): string { @@ -1294,6 +1294,21 @@ describe("deploy", () => { expect(err.indexOf("IMPORTANT")).toBeLessThan(tipAt); }); + test("the consent-screen TIP is Google-only and needs an app name", async () => { + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); + + await showOAuthWalkthrough("github", "example.com", "https://clerk.example.com", "my-app"); + const github = stripAnsi(captured.err); + captured.clear(); + await showOAuthWalkthrough("google", "example.com", "https://clerk.example.com"); + const googleNoName = stripAnsi(captured.err); + + expect(github).toContain("Configure your GitHub OAuth app"); + expect(github).not.toContain("consent screen's app name"); + expect(googleNoName).not.toContain("consent screen's app name"); + expect(googleNoName).not.toContain("undefined"); + }); + test("names the Clerk production instance and where it lives once created", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -1734,6 +1749,39 @@ describe("deploy", () => { expect(err).not.toContain("DNS and email DNS records"); }); + test("resume prints only the records still outstanding, with the hedged heading", async () => { + // Frontend API DNS is already verified on resume; reprinting its record + // under "Add the following records" would send the user to add it again. + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + cnameTargets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "clkmail.example.com", value: "mail.clerk.services", required: true }, + ], + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: false }), + ); + mockConfirm.mockResolvedValueOnce(false); // BIND export + mockSelect.mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain( + "Add the following records at your DNS provider if you haven't already:", + ); + expect(err).not.toMatch(/Add the following records at your DNS provider:/); + expect(err).toContain("Host: clkmail.example.com"); + expect(err).not.toContain("Host: clerk.example.com"); + }); + test("DNS verification treats absent components as pending", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, @@ -1781,9 +1829,13 @@ describe("deploy", () => { expect(err).toContain("DNS and email DNS records not found yet for example.com."); expect(err).toContain("Clerk didn't return the list of records to add."); - expect(err).toContain("Find them on the Domains page in the Clerk Dashboard, then run"); expect(err).toContain( - " https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", + "Find them on the Domains page in the Clerk Dashboard, add them, then choose Check again below.", + ); + // The URL line belongs to the footer, so it precedes the skip, not the + // Next steps block (which also prints this URL). + expect(err).toMatch( + /already created\.\n│ {4}https:\/\/dashboard\.clerk\.com\/apps\/app_xyz789\/instances\/ins_prod_mock\/domains\n[\s\S]*Skipping DNS verification/, ); expect(err).not.toContain("Add them at your DNS provider"); expect(err).not.toContain("Add the following records at your DNS provider"); @@ -2200,6 +2252,10 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain("Propagation usually takes minutes"); expect(err).toContain("DNS and email DNS records not found yet for example.com"); + // The footer, not the Next steps block, carries the change-domain URL. + expect(err).toContain( + "change the domain in the Clerk Dashboard: https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", + ); expect(err).not.toContain("still pending"); expect(err).toContain("DNS: pending"); // First hand-over uses the plain heading; the reprint after a failed check hedges. diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 5501edfc9..675407c38 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -19,6 +19,7 @@ import { DEPLOY_COMMAND_SUMMARY, INTRO_PREAMBLE, OAUTH_SECTION_INTRO, + type DeployComponentStatus, type DeployPlanStep, deployComponentLabels, deployComponentStatus, @@ -288,10 +289,11 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { } if (!snapshot.domainComplete) { - dnsStatus = await runExistingDomainDnsVerification(ctx, { - ...snapshot, - pending: { type: "dns" }, - }); + dnsStatus = await runExistingDomainDnsVerification( + ctx, + { ...snapshot, pending: { type: "dns" } }, + snapshot.componentStatus, + ); } await finishDeploy(ctx, snapshot.domain, snapshot.completedOAuthProviders, dnsStatus); @@ -378,11 +380,12 @@ async function confirmProductionInstanceCreation(domain: string): Promise { for (const line of dnsIntro(state.domain)) log.info(line); log.blank(); if (cnameTargets.length > 0) { - for (const line of dnsRecords(cnameTargets)) log.info(line); + for (const line of dnsRecords(cnameTargets, options)) log.info(line); log.blank(); } @@ -402,8 +405,13 @@ async function runDnsRecordHandoff( async function runExistingDomainDnsVerification( ctx: DeployContext, state: DeployOperationState, + componentStatus: DeployComponentStatus, ): Promise { - await runDnsRecordHandoff(state, state.cnameTargets ?? []); + // On resume some records may already be verified; only the outstanding ones + // are records to add, and the user may have added those already. + await runDnsRecordHandoff(state, pendingCnameTargets(state.cnameTargets ?? [], componentStatus), { + afterCheck: true, + }); return runDnsVerificationPrompt(ctx, state); } @@ -655,7 +663,7 @@ async function finishDeploy( prefix: isInsideGutter() ? `${dim("│")} ` : "", label: "Next steps", fallback: bold, - body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId, domain))}\n`, + body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId, domain, dnsStatus))}\n`, }); await outro("Success"); } diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 8e8965d80..402aafa39 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -196,9 +196,119 @@ describe("deploy status", () => { await deployStatus(); expect(captured.out).toBe(""); - expect(stripAnsi(captured.err)).toContain("clerk deploy"); + const output = stripAnsi(captured.err); + // The person reading this is the user, so the agent's "ask the user" is + // reworded, and OAuth was never checked so its row is not printed. + expect(output).toContain("No production instance yet."); + expect(output).toContain("Run `clerk deploy` to set it up."); + expect(output).not.toContain("ask the user"); + expect(output).not.toContain("human terminal"); + expect(output).not.toContain("OAuth"); + }); + + test("human mode domain_provisioning does not claim OAuth was checked or address an agent", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockListApplicationDomains.mockResolvedValue({ data: [], total_count: 0 }); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain("its domain is still provisioning"); + expect(output).toContain("or run `clerk deploy` to finish setup."); + expect(output).not.toContain("ask the user"); + expect(output).not.toContain("OAuth"); + }); + + test("human mode oauth_pending tells the person to finish the wizard, not to ask themselves", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + // Production config has the provider enabled but no credentials. + mockFetchInstanceConfig.mockImplementation(() => ({ + connection_oauth_google: { enabled: true }, + })); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(completeDomainStatus()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain("OAuth pending: google"); + expect(output).toContain( + "missing production credentials: google. Run `clerk deploy` to finish setup.", + ); + expect(output).not.toContain("Ask the user"); + // The domain is verified; nothing to monitor on the Domains page. + expect(output).not.toContain("domains page"); + }); + + test("human mode records-missing says Clerk returned no list and how to resume", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + 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: "", + }, + ], + total_count: 1, + }); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingDnsDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingDnsDomainStatus()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain( + "DNS records not found yet for example.com, but Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, add them, then run `clerk deploy` again to resume.", + ); + expect(output).not.toContain("this report"); + expect(output).not.toContain("--wait"); + expect(output).not.toContain("Add the following records"); }); + test.each([ + { + label: "finalizing", + domain: () => ({ ...completeDomainStatus(), status: "incomplete" }), + expected: "still finalizing on Clerk's side", + }, + { + label: "complete", + domain: completeDomainStatus, + expected: + "Manage users, settings, and billing for this instance: https://dashboard.clerk.com/apps/app_1/instances/ins_prod", + }, + ])( + "human mode $label prints no records block and no agent copy", + async ({ domain, expected }) => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(domain()); + mockGetApplicationDomainStatus.mockResolvedValue(domain()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain(expected); + expect(output).not.toContain("Add the following records"); + expect(output).not.toContain("Ask the user"); + expect(output).not.toContain("--wait"); + }, + ); + test("agent mode domain pending reports pending DNS records and exit 1", async () => { mockFetchApplication.mockResolvedValue(appWith(true)); mockDomain(); @@ -358,8 +468,11 @@ describe("deploy status", () => { ); expect(output).toContain("Host: clerk.example.com"); expect(output).toContain("Value: frontend-api.clerk.services"); - // The records block already says "add these"; the sentence says what's next. - expect(output).toContain("Once they're added, re-run `clerk deploy status --wait`"); + // The records block already says "add these"; the sentence says what's + // next. Human mode already waits, so `--wait` is never suggested; the + // wizard is what resumes setup. + expect(output).toContain("Once they're added, run `clerk deploy` again to resume."); + expect(output).not.toContain("--wait"); expect(output).not.toContain("Add the records above"); expect(output).not.toContain("pendingDnsRecords"); expect(output).not.toContain("Ask the user"); @@ -427,6 +540,45 @@ describe("deploy status", () => { ]); }); + test("agent report passes an optional record through as required: false", async () => { + mockFetchApplication.mockResolvedValue(appWith(true)); + 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 }, + { host: "accounts.example.com", value: "accounts.clerk.services", required: false }, + ], + }, + ], + total_count: 1, + }); + mockOAuthComplete(); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(pendingDnsDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(pendingDnsDomainStatus()); + + await deployStatus(); + + const payload = JSON.parse(captured.out); + expect( + payload.pendingDnsRecords.map((r: { host: string; required: boolean }) => [ + r.host, + r.required, + ]), + ).toEqual([ + ["clerk.example.com", true], + ["accounts.example.com", false], + ]); + }); + test("human mode rewrites the agent clause for a plain-http Dashboard URL too", async () => { // Dashboard links follow CLERK_DASHBOARD_URL, which is http:// for a local // Dashboard; the human rewrite must not depend on https. @@ -469,6 +621,8 @@ describe("deploy status", () => { ); expect(output).not.toContain("Ask the user to visit"); expect(output).not.toContain("offer to open it"); + // No records are outstanding, so no records block. + expect(output).not.toContain("Add the following records"); }); }); diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index 1e4db2a67..21116bb2f 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -158,10 +158,14 @@ export function renderHuman(report: DeployStatusReport): void { ); } - const oauthStatus = report.oauth.complete - ? "complete" - : `pending: ${report.oauth.pending.join(", ") || "none"}`; - log.info(` OAuth ${oauthStatus}`); + // No domain status means no production instance was read, so OAuth was + // never checked either; "pending: none" would claim it was. + if (report.domainStatus) { + const oauthStatus = report.oauth.complete + ? "complete" + : `pending: ${report.oauth.pending.join(", ") || "none"}`; + log.info(` OAuth ${oauthStatus}`); + } if (report.oauth.unsupported.length > 0) { log.warn( @@ -187,14 +191,37 @@ export function renderHuman(report: DeployStatusReport): void { log.blank(); } +/** + * The report's `nextAction` is written for an agent. A person running + * `clerk deploy status` has no JSON and is the user, so the sentences that + * name the JSON field or say "ask the user" are reworded here. Human mode + * already waits, so `--wait` is never suggested; the resume command is the + * wizard, as the wizard's own footer says. + */ function formatHumanNextAction(nextAction: string): string { return ( nextAction // The records block printed above already says "add these"; the sentence // only needs to say what happens next. .replace( - "Add the records in `pendingDnsRecords` at the domain's DNS provider if you haven't already, then re-run", - "Once they're added, re-run", + "Add the records in `pendingDnsRecords` at the domain's DNS provider if you haven't already, then re-run `clerk deploy status --wait`.", + "Once they're added, run `clerk deploy` again to resume.", + ) + .replace( + /but this report has no record list\. Find the records to add on the Domains page in the Clerk Dashboard, then re-run `clerk deploy status --wait`\./, + "but Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, add them, then run `clerk deploy` again to resume.", + ) + .replace( + "needs a human terminal, ask the user to run `clerk deploy`, then run `clerk deploy status` to verify.", + "needs a terminal. Run `clerk deploy` to set it up.", + ) + .replace( + "or ask the user to finish `clerk deploy`.", + "or run `clerk deploy` to finish setup.", + ) + .replace( + "Ask the user to finish `clerk deploy`, then run `clerk deploy status`.", + "Run `clerk deploy` to finish setup.", ) .replace( // `https?`: the URL follows CLERK_DASHBOARD_URL, which is plain http for a diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 7a488dffe..60b1fce8e 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -280,9 +280,9 @@ describe("buildDeployStatusReport", () => { 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.nextAction).toContain("missing production credentials: github"); + // The domain is verified, so there is nothing to monitor on the Domains page. + expect(report.nextAction).not.toContain("/domains"); expect(report.oauth).toMatchObject({ complete: false, configured: ["google"], @@ -427,4 +427,67 @@ describe("buildDeployStatusReport", () => { expect(report.complete).toBe(true); expect(report.oauth.unsupported).toEqual(["discord"]); }); + + test.each<{ label: string; completed: string[] }>([ + { label: "complete", completed: ["google", "github"] }, + { label: "oauth_pending", completed: ["google"] }, + ])( + "names providers the CLI could not configure so the agent does not call OAuth done ($label)", + ({ completed }) => { + // In development Clerk supplies shared OAuth credentials; in production + // it doesn't. A provider the CLI skipped has a sign-in button that fails + // for real users, and `oauth.complete` only covers what the CLI manages. + const withUnsupported = { + ...activeSnapshot, + completedOAuthProviders: completed, + unsupportedOAuthProviders: ["discord"], + unsupportedOAuthProviderCount: 1, + } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: withUnsupported }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.nextAction).toContain( + "These providers are enabled in development but the CLI could not configure them for production: discord.", + ); + expect(report.nextAction).toContain("users signing in with them will fail"); + }, + ); + + test("does not mention unsupported providers when there are none", () => { + const report = buildDeployStatusReport( + { + kind: "active", + snapshot: { ...activeSnapshot, completedOAuthProviders: ["google", "github"] }, + }, + { verified: true, status: { dns: true, ssl: true, mail: true } }, + ); + + expect(report.nextAction).not.toContain("could not configure"); + }); + + test.each([ + { label: "complete", verified: true, status: { dns: true, ssl: true, mail: true } }, + { label: "records pending", verified: false, status: { dns: false, ssl: false, mail: false } }, + { label: "SSL pending", verified: false, status: { dns: true, ssl: false, mail: true } }, + ])( + "omits Dashboard links cleanly when the production instance id is unknown ($label)", + ({ verified, status }) => { + const noInstance = { + ...activeSnapshot, + productionInstanceId: undefined, + completedOAuthProviders: ["google", "github"], + } satisfies LiveDeploySnapshot; + const report = buildDeployStatusReport( + { kind: "active", snapshot: noInstance }, + { verified, status }, + ); + + expect(report.productionInstanceId).toBeNull(); + expect(report.nextAction).not.toContain("dashboard.clerk.com"); + expect(report.nextAction).not.toContain("undefined"); + expect(report.nextAction).not.toContain("Clerk Dashboard domains page"); + }, + ); }); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index c878dad37..3df097464 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -391,6 +391,7 @@ export function buildDeployStatusReport( componentStatus, pendingDnsRecords.length > 0, oauthPending, + snapshot.unsupportedOAuthProviders, snapshot.productionInstanceId ? { domains: domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId), @@ -437,9 +438,20 @@ function deployNextAction( componentStatus: DeployComponentStatus, hasPendingRecords: boolean, oauthPending: string[], + oauthUnsupported: readonly string[], urls: { domains: string; instance: string } | null, ): string { const domainsAction = urls ? ` ${domainSettingsNextAction(urls.domains)}` : ""; + // In development Clerk supplies shared OAuth credentials; in production it + // doesn't, so a provider the CLI couldn't configure has a sign-in button + // that fails for real users. `oauth.complete` only covers what the CLI + // manages, so the report has to say this out loud. + const unsupportedAction = + oauthUnsupported.length > 0 + ? ` These providers are enabled in development but the CLI could not configure them for ` + + `production: ${oauthUnsupported.join(", ")}. Configure them in the Clerk Dashboard before ` + + `going live, or users signing in with them will fail.` + : ""; if (state === "complete") { // Complete on Clerk's side only. The app keeps running on development @@ -455,14 +467,17 @@ function deployNextAction( `Clerk's production setup for https://${domain} is verified. If you haven't already: ` + `run \`clerk env pull --instance prod\`, set those keys on your host alongside the other ` + `Clerk variables from your env file, redeploy, then sign up at https://${domain} to confirm.` + + unsupportedAction + instanceAction ); } if (state === "oauth_pending") { + // The domain is verified, so there is nothing to monitor on the Domains + // page; the wizard is the only way to supply credentials. 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 + unsupportedAction ); } From 78a549d89282b75379743c52daedca2d2cb87b64 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Wed, 16 Sep 2026 22:28:02 -0700 Subject: [PATCH 04/24] Address reviewer round on deploy self-review fixes - The confirmation screen no longer promises one DNS record per row. The server omits the Account portal record when the portal is disabled on the cloned instance, so the lead now says records are coming and the exact list is printed after creation. Label column width is derived from the labels instead of a hand-kept constant. - Human `deploy status` already prints a warning row for OAuth providers the CLI could not configure; the same fact added to the agent sentence is now dropped for humans so one screen doesn't say it twice. - `productionSummary` and `nextStepsBody` require the DNS status argument. A forgotten argument would print "Production ready" over a domain that doesn't resolve, the contradiction the previous commit removed. --- .changeset/deploy-wizard-copy.md | 2 +- .../cli-core/src/commands/deploy/README.md | 2 +- .../cli-core/src/commands/deploy/copy.test.ts | 16 +++++++--- packages/cli-core/src/commands/deploy/copy.ts | 19 ++++++++---- .../src/commands/deploy/index.test.ts | 4 +-- .../commands/deploy/status-command.test.ts | 30 +++++++++++++++++++ .../src/commands/deploy/status-command.ts | 5 ++++ 7 files changed, 64 insertions(+), 14 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index d01ec92d9..fe193653a 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -6,7 +6,7 @@ - `clerk deploy --help` describes what the bare command does and the JSON report it prints under an agent. - The preamble says a hosting provider's generated URL can't be the production domain. -- The confirmation screen lists all five DNS record hosts, including DKIM, and says a record will be needed for each. +- The confirmation screen lists all five DNS record hosts, including DKIM, and says DNS records will be needed for them once the instance exists. - The DNS check reports records as "not found yet" with a minutes-not-days expectation, tells you what to do based on what's actually pending, and links the Dashboard Domains page for changing the domain. - `clerk auth login` prints the claimed app's Dashboard URL; the wizard prints the new production instance's URL and its next steps say the pulled keys go on the host alongside the other Clerk variables. - The Google walkthrough adds a tip with the app name to use on the OAuth consent screen. diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 47c13bfff..36606bf01 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -51,7 +51,7 @@ In agent mode, `clerk deploy status` emits JSON on stdout with: - `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. - `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks, each with `host`, `value`, and Clerk's `required` flag (some targets are optional). - `oauth`: configured, pending, and unsupported provider slugs. -- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints the same sentence reworded for a person: no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked. +- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints the same sentence reworded for a person: no unsupported-provider clause (the warning row above already says it), no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked. Exit codes: diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 8c8fb6ef6..26a450173 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -141,7 +141,7 @@ describe("deployStatusRetryMessage", () => { describe("nextStepsBody", () => { test("links to the production instance home and its domain settings", () => { - const output = stripAnsi(nextStepsBody("app_123", "ins_456", "example.com")); + const output = stripAnsi(nextStepsBody("app_123", "ins_456", "example.com", "verified")); expect(output).toContain("Manage this instance in the Clerk Dashboard"); expect(output).toContain("- Users, settings, and billing:"); @@ -155,7 +155,7 @@ describe("nextStepsBody", () => { // `env pull --instance prod` writes only the two keys. The routing // variables `init` wrote have to be carried over by hand, or sign-in // silently falls back to the hosted Account Portal. - const output = nextStepsBody("app_123", "ins_456", "example.com"); + const output = nextStepsBody("app_123", "ins_456", "example.com", "verified"); expect(output).toContain("- Add the same pk_live_/sk_live_ values there."); expect(output).toContain("- Also copy the other Clerk variables from your env file"); @@ -164,7 +164,7 @@ describe("nextStepsBody", () => { }); test("ends with a real sign-up on the production domain", () => { - const output = nextStepsBody("app_123", "ins_456", "example.com"); + const output = nextStepsBody("app_123", "ins_456", "example.com", "verified"); expect(output).toContain( "3. Redeploy your app, then sign up at https://example.com to confirm it works", @@ -215,7 +215,9 @@ describe("domainAssociationSummary", () => { expect(lead).toContain("Clerk will use these subdomains for"); // Disclose the obligation before the one-way step without demanding // action the user can't take yet. - expect(lead).toContain("You'll add a DNS record for each after the instance is created:"); + expect(lead).toContain( + "You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:", + ); expect(lead).not.toMatch(/\b(three|five|3|5)\b/); }); @@ -230,6 +232,12 @@ describe("domainAssociationSummary", () => { // Labels pad to one column so the hosts line up. expect(output).toContain("Frontend API clerk.example.com"); expect(output).toContain("Account portal accounts.example.com"); + // The server omits the Account portal record when the portal is disabled + // on the cloned instance, so the lead can't promise one record per row. + expect(output).toContain( + "You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:", + ); + expect(output).not.toContain("a DNS record for each"); expect(output).not.toContain("Clerk handles SPF/DKIM"); expect(output).not.toContain("CNAME clk._domainkey"); }); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index bc4273bda..e4fa0c693 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -96,14 +96,18 @@ export function productionDnsHosts(domain: string): string[] { } export function domainAssociationSummary(domain: string): string[] { + const hosts = productionDnsHosts(domain); + const labels = hosts.map((host) => cnameTargetLabel(host, { terse: true })); + const width = Math.max(...labels.map((label) => label.length)); return [ // Disclose the obligation before the one-way create step, without asking // for action the user can't take yet (record values arrive after creation). - `Clerk will use these subdomains for ${cyan(domain)}. You'll add a DNS record for each after the instance is created:`, + // "The exact list": the server omits the Account portal record when the + // portal is disabled on the instance being cloned, and this screen runs + // before the CLI can know that. + `Clerk will use these subdomains for ${cyan(domain)}. You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:`, "", - ...productionDnsHosts(domain).map( - (host) => ` ${cnameTargetLabel(host, { terse: true }).padEnd(14)} ${host}`, - ), + ...hosts.map((host, i) => ` ${labels[i]!.padEnd(width)} ${host}`), "", "This will create a Clerk production instance for your application.", ]; @@ -345,10 +349,13 @@ each enabled provider. ${dim("Reference: https://clerk.com/docs/guides/configure/auth-strategies/social-connections/overview")}`; +// `domainStatus` is required on both closing-screen functions: it selects the +// headline and step 3, and a caller that forgot it would print "Production +// ready" over a domain that doesn't resolve yet. export function productionSummary( domain: string, completedOAuthProviderLabels: readonly string[], - domainStatus: "verified" | "pending" = "verified", + domainStatus: "verified" | "pending", ): string[] { return [ domainStatus === "verified" @@ -364,7 +371,7 @@ export function nextStepsBody( appId: string, productionInstanceId: string, domain: string, - domainStatus: "verified" | "pending" = "verified", + domainStatus: "verified" | "pending", ): string { // Until DNS is verified the domain doesn't resolve, so "sign up there" // would send the user to a page that doesn't exist yet. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 2f6fea260..32a7ebdce 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1003,7 +1003,7 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); expect(err).toContain( - "Clerk will use these subdomains for example.com. You'll add a DNS record for each after the instance is created:", + "Clerk will use these subdomains for example.com. You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:", ); expect(err).toContain("clerk.example.com"); expect(err).toContain("accounts.example.com"); @@ -1045,7 +1045,7 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain( - "Clerk will use these subdomains for example.com. You'll add a DNS record for each after the instance is created:", + "Clerk will use these subdomains for example.com. You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:", ); expect(err).toContain("No production instance was created."); expect(mockCreateProductionInstance).not.toHaveBeenCalled(); diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 402aafa39..b64db387d 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -277,6 +277,36 @@ describe("deploy status", () => { expect(output).not.toContain("Add the following records"); }); + test("human mode says the unsupported-provider warning once, in its own row", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + mockDomain(); + mockOAuthComplete(); + // discord is enabled in both configs but absent from the schema, so the + // CLI can't configure it: the "unsupported" case. + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" || instanceId === "production" + ? { + connection_oauth_google: { enabled: true, client_id: "x", client_secret: "y" }, + connection_oauth_discord: { enabled: true }, + } + : { + connection_oauth_google: { enabled: true }, + connection_oauth_discord: { enabled: true }, + }, + ); + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(completeDomainStatus()); + mockGetApplicationDomainStatus.mockResolvedValue(completeDomainStatus()); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain("not supported by automated deploy: discord"); + // The agent sentence carries the same fact; a person already has the row. + expect(output).not.toContain("could not configure them for production"); + expect(JSON.stringify(captured.out)).not.toContain("could not configure"); + }); + test.each([ { label: "finalizing", diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index 21116bb2f..73954e839 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -211,6 +211,11 @@ function formatHumanNextAction(nextAction: string): string { /but this report has no record list\. Find the records to add on the Domains page in the Clerk Dashboard, then re-run `clerk deploy status --wait`\./, "but Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, add them, then run `clerk deploy` again to resume.", ) + // The unsupported-provider warning row above already says this. + .replace( + / These providers are enabled in development but the CLI could not configure them for production: [^.]+\. Configure them in the Clerk Dashboard before going live, or users signing in with them will fail\./, + "", + ) .replace( "needs a human terminal, ask the user to run `clerk deploy`, then run `clerk deploy status` to verify.", "needs a terminal. Run `clerk deploy` to set it up.", From cc961eaa2844956ab85812c937692d20b8f25c8d Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Wed, 16 Sep 2026 22:45:32 -0700 Subject: [PATCH 05/24] Keep every DNS record in the zone file on resume Filtering the resume screen to the records still outstanding also filtered the list handed to the BIND zone-file export, so the file could be written with a partial record set, and the export offer disappeared when only SSL was pending. The screen and the export now take separate lists: the screen shows only what is outstanding, the file always has every record for the domain. - Two resume tests: partial DNS verification with the export accepted asserts the screen omits the verified record while the file contains it; SSL-only pending asserts the export is still offered. --- .../src/commands/deploy/index.test.ts | 61 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 24 ++++++-- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 32a7ebdce..0485765e3 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1782,6 +1782,67 @@ describe("deploy", () => { expect(err).not.toContain("Host: clerk.example.com"); }); + test("resume exports every record to the zone file even when the screen lists only the outstanding ones", async () => { + // A zone file with some of the domain's records isn't one to import. + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + cnameTargets: [ + { host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }, + { host: "clkmail.example.com", value: "mail.clerk.services", required: true }, + ], + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: false }), + ); + mockConfirm.mockResolvedValueOnce(true); // BIND export: yes + mockSelect.mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).not.toContain("Host: clerk.example.com"); + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeDefined(); + const zone = String(zoneCall![1]); + expect(zone).toContain("clerk.example.com"); + expect(zone).toContain("clkmail.example.com"); + }); + + test("resume still offers the zone-file export when only SSL is pending", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: true }), + ); + mockConfirm.mockResolvedValueOnce(true); // BIND export: yes + mockSelect.mockResolvedValueOnce("skip"); + + await runDeploy({}); + + expect(mockConfirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("BIND") }), + ); + const zoneCall = writeSpy.mock.calls.find((call: unknown[]) => + String(call[0]).endsWith(".zone"), + ); + expect(zoneCall).toBeDefined(); + }); + test("DNS verification treats absent components as pending", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_123" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 675407c38..0ff4b7587 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -219,7 +219,11 @@ async function startNewDeploy(ctx: DeployContext): Promise { cnameTargets, }; - await runDnsRecordHandoff({ ...operationState, pending: { type: "dns" } }, cnameTargets); + await runDnsRecordHandoff( + { ...operationState, pending: { type: "dns" } }, + cnameTargets, + cnameTargets, + ); bar(); completedOAuthProviders = await runOAuthSetup(ctx, operationState, oauthProviders); @@ -377,22 +381,29 @@ async function confirmProductionInstanceCreation(domain: string): Promise { for (const line of dnsIntro(state.domain)) log.info(line); log.blank(); - if (cnameTargets.length > 0) { - for (const line of dnsRecords(cnameTargets, options)) log.info(line); + if (display.length > 0) { + for (const line of dnsRecords(display, options)) log.info(line); log.blank(); } for (const line of dnsDashboardHandoff(state.domain)) log.info(line); log.blank(); try { - await offerBindZoneExport(state.domain, cnameTargets); + await offerBindZoneExport(state.domain, exportTargets); log.blank(); } catch (error) { if (error instanceof UserAbortError) { @@ -409,7 +420,8 @@ async function runExistingDomainDnsVerification( ): Promise { // On resume some records may already be verified; only the outstanding ones // are records to add, and the user may have added those already. - await runDnsRecordHandoff(state, pendingCnameTargets(state.cnameTargets ?? [], componentStatus), { + const allTargets = state.cnameTargets ?? []; + await runDnsRecordHandoff(state, pendingCnameTargets(allTargets, componentStatus), allTargets, { afterCheck: true, }); return runDnsVerificationPrompt(ctx, state); From 71e72f68fd2ea570b9911ac123b388cbb54a49a2 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 08:21:45 -0700 Subject: [PATCH 06/24] Separate the confirmation screen from the typed domain The domain the user types at the prompt is echoed by the prompt library, and the confirmation screen's lead sentence printed immediately under it with no gap, so the answer and the instructions ran together. A blank line now precedes the screen, matching the spacing every other block in the wizard uses. Found by running the wizard by hand against a local fake Clerk API. --- packages/cli-core/src/commands/deploy/index.test.ts | 2 ++ packages/cli-core/src/commands/deploy/index.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 0485765e3..d7f0e28a5 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1005,6 +1005,8 @@ describe("deploy", () => { expect(err).toContain( "Clerk will use these subdomains for example.com. You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:", ); + // A blank line separates the domain the user just typed from this screen. + expect(err).toMatch(/│[ \t]*\n│[ \t]+Clerk will use these subdomains for example\.com\./); expect(err).toContain("clerk.example.com"); expect(err).toContain("accounts.example.com"); expect(err).toContain("clkmail.example.com"); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 0ff4b7587..f16b1596f 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -367,6 +367,9 @@ async function createProductionInstance( } async function confirmProductionInstanceCreation(domain: string): Promise { + // Separate this screen from the domain the user just typed; without it the + // echoed answer runs straight into the lead sentence. + log.blank(); for (const line of domainAssociationSummary(domain)) log.info(line); log.blank(); const confirmed = await confirmCreateProductionInstance(); From 33d8316fa6ac6c2289658f7db33a9f2c619b4281 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 08:48:24 -0700 Subject: [PATCH 07/24] Name each DNS record host one way and link the Domains page Found by running the flow by hand against a local fake Clerk API. Three strings on the screen printed after the instance is created, all unchanged from main: - The three email records all read "Email (Clerk handles SPF/DKIM automatically)". Identical labels don't tell the rows apart, and the parenthetical contradicted a screen asking the user to add those records. The confirmation screen already called the same hosts "Email" and "Email (DKIM)", so the two screens disagreed once this branch added that screen's labels. One label function now serves every screen that lists records. "DKIM" stays: it is the standard name and already appears in the host. - What Clerk manages moves to a single line under the block, where it reassures instead of contradicting. - The Domains-page pointer had no URL even though one was printed a few lines above. It now carries the link. - "After OAuth setup, you can verify DNS or skip and finish" implied that skipping the check finishes the deploy. It now says OAuth is next, that this command checks the records, and that skipping means re-running `clerk deploy` later to finish. It avoids "wizard", which appears nowhere else the user can see. --- .changeset/deploy-wizard-copy.md | 1 + .../cli-core/src/commands/deploy/copy.test.ts | 51 +++++++++++++++++-- packages/cli-core/src/commands/deploy/copy.ts | 34 ++++++++----- .../src/commands/deploy/index.test.ts | 12 ++--- .../cli-core/src/commands/deploy/index.ts | 9 +++- .../commands/deploy/status-command.test.ts | 2 +- 6 files changed, 85 insertions(+), 24 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index fe193653a..934f1061d 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -12,4 +12,5 @@ - The Google walkthrough adds a tip with the app name to use on the OAuth consent screen. - The DNS check footer points at the "Check again" prompt that follows it instead of telling you to quit and re-run; the closing screen no longer says "Production ready" or "sign up at your domain" when DNS verification was skipped. - Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. +- Each DNS record host is named the same way on every screen, the note about what Clerk manages moved off the rows the user has to add, the Domains-page pointer carries its link, and the closing line says what happens next instead of implying that skipping the check finishes the deploy. - Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, at `complete` says the production keys still have to reach the host, and names OAuth providers the CLI could not configure so an agent doesn't report OAuth as done. Human-mode `clerk deploy status` prints the pending records, never says "ask the user", and resumes with `clerk deploy` rather than a flag that only affects agents. diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 26a450173..2813b2701 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -8,6 +8,7 @@ import { deployStatusPendingFooter, deployStatusRetryMessage, dnsIntro, + dnsDashboardHandoff, dnsRecords, domainAssociationSummary, domainsDashboardUrl, @@ -85,9 +86,7 @@ describe("dnsRecords", () => { expect(output).toContain(" Host: clk2._domainkey.example.com"); // Both DKIM hosts must carry the email label, not fall through to the // generic "CNAME" default (the host's first label is "clk"/"clk2"). - expect( - output.filter((line) => line.includes("Email (Clerk handles SPF/DKIM automatically)")), - ).toHaveLength(2); + expect(output.filter((line) => line.includes("Email (DKIM)"))).toHaveLength(2); expect(output.some((line) => line.trimStart().startsWith("CNAME"))).toBe(false); }); }); @@ -456,7 +455,51 @@ describe("dnsRecords", () => { { host: "clk2._domainkey.example.com", value: "dkim2.clerk.services", required: true }, ]).join("\n"); - expect(output).toContain("Email (Clerk handles SPF/DKIM automatically)"); + expect(output).toContain("Email (DKIM)"); expect(output).not.toContain("\n CNAME\n Type:"); + // Said once under the block, never on a row the user must act on. + expect(output).toContain( + "The email records point at Clerk, so you don't need to create SPF or DKIM values yourself.", + ); + expect(output).not.toMatch(/Email \(DKIM\).*Clerk handles/); + }); + + test("labels the mail host and both DKIM hosts the same way the confirmation screen does", () => { + // One label set across screens: a host named two ways reads as two records. + const records = dnsRecords([ + { host: "clkmail.example.com", value: "mail.clerk.services", required: true }, + { host: "clk._domainkey.example.com", value: "dkim1.clerk.services", required: true }, + ]).join("\n"); + const confirmation = stripAnsi(domainAssociationSummary("example.com").join("\n")); + + for (const label of ["Email", "Email (DKIM)"]) { + expect(records).toContain(label); + expect(confirmation).toContain(label); + } + expect(records).not.toContain("Clerk handles SPF/DKIM automatically"); + expect(confirmation).not.toContain("Clerk handles SPF/DKIM automatically"); + }); +}); + +describe("dnsDashboardHandoff", () => { + const DOMAINS_URL = "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains"; + + test("links the Domains page and says how to resume if the check is skipped", () => { + const output = dnsDashboardHandoff("example.com", DOMAINS_URL).join("\n"); + + expect(output).toContain(`Clerk Dashboard:\n ${DOMAINS_URL}`); + // "wizard" appears nowhere else the user can see, so it isn't introduced here. + expect(output).not.toContain("wizard"); + expect(output).toContain("then this command checks these records"); + expect(output).toContain("run `clerk deploy` again later to finish"); + // "skip and finish" read as though skipping completed the deploy. + expect(output).not.toContain("skip and finish"); + }); + + test("ends the sentence cleanly when no Dashboard URL is known", () => { + const output = dnsDashboardHandoff("example.com", undefined).join("\n"); + + expect(output).toContain("on the Domains page in the Clerk Dashboard."); + expect(output).not.toContain("undefined"); }); }); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index e4fa0c693..d74be61c9 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -97,7 +97,7 @@ export function productionDnsHosts(domain: string): string[] { export function domainAssociationSummary(domain: string): string[] { const hosts = productionDnsHosts(domain); - const labels = hosts.map((host) => cnameTargetLabel(host, { terse: true })); + const labels = hosts.map((host) => cnameTargetLabel(host)); const width = Math.max(...labels.map((label) => label.length)); return [ // Disclose the obligation before the one-way create step, without asking @@ -139,6 +139,11 @@ export function dnsRecords( ); } lines.push( + "", + // These are CNAMEs pointing at Clerk, so the user never generates or + // rotates key material and never hand-writes an SPF record. Said once + // here rather than on each email row. + "The email records point at Clerk, so you don't need to create SPF or DKIM values yourself.", "", `${yellow("NOTE")} If your DNS host proxies these records, set them to "DNS only" or verification will fail.`, ); @@ -169,13 +174,14 @@ function isMailCnameTarget(target: CnameTarget): boolean { } /** - * Human label for a record host. `terse` is for the confirmation screen, where - * the lead line says the user will add a record for each host: "Clerk handles - * SPF/DKIM automatically" belongs on the records block (it's about record - * contents); next to that lead it reads as "nothing for you to do on these - * rows". + * Human label for a record host, used by every screen that lists records so + * the confirmation screen and the records block can't name the same host two + * ways. "DKIM" stays: it is the standard name for these records and already + * appears in the host (`clk._domainkey`). What Clerk manages on the user's + * behalf is said once under the block instead of on each row, where it read + * as "nothing to do here". */ -function cnameTargetLabel(host: string, options: { terse?: boolean } = {}): string { +function cnameTargetLabel(host: string): string { // `host.split(".", 1)[0]` yields only the first label, so DKIM records // (clk._domainkey, clk2._domainkey) arrive here as "clk"/"clk2". const prefix = host.split(".", 1)[0]; @@ -185,19 +191,23 @@ function cnameTargetLabel(host: string, options: { terse?: boolean } = {}): stri case "accounts": return "Account portal"; case "clkmail": - return options.terse ? "Email" : "Email (Clerk handles SPF/DKIM automatically)"; + return "Email"; case "clk": case "clk2": - return options.terse ? "Email (DKIM)" : "Email (Clerk handles SPF/DKIM automatically)"; + return "Email (DKIM)"; default: return "CNAME"; } } -export function dnsDashboardHandoff(domain: string): string[] { +export function dnsDashboardHandoff(domain: string, domainsUrl: string | undefined): string[] { return [ - `Check the Domains section in the Clerk Dashboard for ${domain} to monitor DNS propagation and SSL issuance.`, - "After OAuth setup, you can verify DNS or skip and finish. DNS propagation can take time.", + // "this command", not "the wizard": nothing the user sees uses that word. + // Skipping the check leaves setup unfinished, so name what resumes it. + `Monitor DNS propagation and SSL issuance for ${domain} on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, + ...(domainsUrl ? [` ${domainsUrl}`] : []), + "", + "Next you'll set up OAuth, then this command checks these records. If they haven't taken effect yet, you can skip the check and run `clerk deploy` again later to finish.", ]; } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index d7f0e28a5..4a687303b 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -901,7 +901,7 @@ describe("deploy", () => { expect(err).toContain("[ ] Create production instance"); expect(err).toContain("[ ] Verify DNS records"); expect(err).toContain("[ ] Configure Google OAuth credentials"); - expect(err).toContain("Check the Domains section in the Clerk Dashboard"); + expect(err).toContain("on the Domains page in the Clerk Dashboard"); }); test("asks directly for an owned production domain and accepts short domains", async () => { @@ -1012,9 +1012,9 @@ describe("deploy", () => { expect(err).toContain("clkmail.example.com"); expect(err).toContain("This will create a Clerk production instance"); expect(err).toContain("Add the following records at your DNS provider"); - expect(err).toContain("Check the Domains section in the Clerk Dashboard"); + expect(err).toContain("on the Domains page in the Clerk Dashboard"); expect(err).toContain("propagation and SSL issuance"); - expect(err).toContain("DNS propagation can take time"); + expect(err).toContain("then this command checks these records"); expect(mockConfirm).toHaveBeenCalledTimes(3); expect(mockConfirm).toHaveBeenCalledWith({ message: "Create production instance?", @@ -1983,8 +1983,8 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); - expect(err).toContain("Check the Domains section in the Clerk Dashboard"); - expect(err).toContain("DNS propagation can take time"); + expect(err).toContain("on the Domains page in the Clerk Dashboard"); + expect(err).toContain("then this command checks these records"); expect(err).toContain("Configure Google OAuth for production"); }); @@ -2100,7 +2100,7 @@ describe("deploy", () => { await runDeployUntilPause(); mockLiveProduction(); - expect(stripAnsi(captured.err)).toContain("Check the Domains section in the Clerk Dashboard"); + expect(stripAnsi(captured.err)).toContain("on the Domains page in the Clerk Dashboard"); expect(stripAnsi(captured.err)).toContain("Configure Google OAuth for production"); captured.clear(); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index f16b1596f..b2fe8cc28 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -403,7 +403,14 @@ async function runDnsRecordHandoff( log.blank(); } - for (const line of dnsDashboardHandoff(state.domain)) log.info(line); + const handoffInstanceId = state.productionInstanceId; + for (const line of dnsDashboardHandoff( + state.domain, + handoffInstanceId ? domainsDashboardUrl(state.appId, handoffInstanceId) : undefined, + )) { + if (line === "") log.blank(); + else log.info(line); + } log.blank(); try { await offerBindZoneExport(state.domain, exportTargets); diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index b64db387d..283374fcc 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -545,7 +545,7 @@ describe("deploy status", () => { const output = stripAnsi(captured.err); // The wizard prints the same record as optional; status must agree. expect(output).toMatch( - /Email \(Clerk handles SPF\/DKIM automatically\) \(optional\)\n\s+Type: CNAME\n\s+Host: clk2\._domainkey\.example\.com/, + /Email \(DKIM\) \(optional\)\n\s+Type: CNAME\n\s+Host: clk2\._domainkey\.example\.com/, ); expect(output).not.toMatch(/Frontend API \(optional\)/); }); From f8157f3db60bfe40669d9b51490bd02f9075ff58 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 09:02:30 -0700 Subject: [PATCH 08/24] Tell users to choose a consent-screen name, not reuse Clerk's The Google walkthrough's tip ended with the linked Clerk application's name. That name is often a directory-derived slug, so on a screen about what real users see at sign-in it read as a recommendation to reuse it, which is usually the wrong choice. The consent-screen name is free-form and unrelated to Clerk, so the tip now says to pick the name users should see. It also stops naming the screen: "when they sign in with Google" describes the moment, where "prompt" or "form" would make the reader map a word to something they have seen. Removes the application-name argument threaded into the walkthrough, which nothing reads now. --- .changeset/deploy-wizard-copy.md | 2 +- .../src/commands/deploy/index.test.ts | 22 +++++++++++-------- .../cli-core/src/commands/deploy/index.ts | 2 +- .../cli-core/src/commands/deploy/providers.ts | 12 +++++----- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index 934f1061d..dd56b7d06 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -9,7 +9,7 @@ - The confirmation screen lists all five DNS record hosts, including DKIM, and says DNS records will be needed for them once the instance exists. - The DNS check reports records as "not found yet" with a minutes-not-days expectation, tells you what to do based on what's actually pending, and links the Dashboard Domains page for changing the domain. - `clerk auth login` prints the claimed app's Dashboard URL; the wizard prints the new production instance's URL and its next steps say the pulled keys go on the host alongside the other Clerk variables. -- The Google walkthrough adds a tip with the app name to use on the OAuth consent screen. +- The Google walkthrough adds a tip explaining that the OAuth consent screen's app name is what users see when they sign in, and to choose the name they should see. - The DNS check footer points at the "Check again" prompt that follows it instead of telling you to quit and re-run; the closing screen no longer says "Production ready" or "sign up at your domain" when DNS verification was skipped. - Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. - Each DNS record host is named the same way on every screen, the note about what Clerk manages moved off the rows the user has to add, the Domains-page pointer carries its link, and the closing line says what happens next instead of implying that skipping the check finishes the deploy. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4a687303b..73829bdd9 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1286,29 +1286,33 @@ describe("deploy", () => { const err = stripAnsi(captured.err); // Google requires the consent screen before it will create a client, and - // the name entered there is what end users see on the sign-in prompt. - // The fixture's application is named "my-saas-app". - expect(err).toContain("The consent screen's app name is what users see"); - expect(err).toContain('Your Clerk app is named "my-saas-app"'); + // the name entered there is what end users see. + expect(err).toContain( + "The consent screen's app name is what users see when they sign in with Google. Use the name you want them to see.", + ); + // The Clerk app name is often a directory-derived slug, so showing it + // read as a recommendation to reuse it on a user-facing screen. + expect(err).not.toContain('my-saas-app".'); + expect(err).not.toContain("Your Clerk app is named"); // Guidance follows the values to paste and the IMPORTANT note, not before. const tipAt = err.indexOf("The consent screen's app name is what users see"); expect(err.indexOf("Authorized Redirect URI")).toBeLessThan(tipAt); expect(err.indexOf("IMPORTANT")).toBeLessThan(tipAt); }); - test("the consent-screen TIP is Google-only and needs an app name", async () => { + test("the consent-screen TIP is Google-only", async () => { mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); - await showOAuthWalkthrough("github", "example.com", "https://clerk.example.com", "my-app"); + await showOAuthWalkthrough("github", "example.com", "https://clerk.example.com"); const github = stripAnsi(captured.err); captured.clear(); await showOAuthWalkthrough("google", "example.com", "https://clerk.example.com"); - const googleNoName = stripAnsi(captured.err); + const google = stripAnsi(captured.err); expect(github).toContain("Configure your GitHub OAuth app"); expect(github).not.toContain("consent screen's app name"); - expect(googleNoName).not.toContain("consent screen's app name"); - expect(googleNoName).not.toContain("undefined"); + expect(google).toContain("consent screen's app name"); + expect(google).not.toContain("undefined"); }); test("names the Clerk production instance and where it lives once created", async () => { diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index b2fe8cc28..a32151384 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -624,7 +624,7 @@ async function collectAndSaveOAuthCredentials( } if (choice === "walkthrough") { - await showOAuthWalkthrough(descriptor, domain, frontendApiUrl, ctx.appLabel); + await showOAuthWalkthrough(descriptor, domain, frontendApiUrl); choice = await chooseOAuthCredentialAction(descriptor, { includeWalkthrough: false }); if (choice === "skip") { return false; diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index eecd895e8..c5198ac58 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -453,7 +453,6 @@ export async function showOAuthWalkthrough( provider: OAuthProvider | OAuthProviderDescriptor, domain: string, frontendApiUrl?: string, - appName?: string, ): Promise { const descriptor = providerDescriptorFromInput(provider); const slug = descriptor?.provider ?? (provider as OAuthProvider); @@ -474,12 +473,15 @@ export async function showOAuthWalkthrough( log.info(gotcha); } // Google makes you fill in the OAuth consent screen before it will create a - // client, and the app name entered there is what end users see on the - // sign-in prompt. Guidance, not a value to paste, so it follows the values. - if (slug === "google" && appName) { + // client, and the name entered there is what end users see. It is free-form + // and unrelated to the Clerk app's name, so this says to choose rather than + // naming a value: the Clerk name is often a directory-derived slug, and + // showing it read as a recommendation to use it. Guidance, not a value to + // paste, so it follows the values. + if (slug === "google") { log.blank(); log.info( - `${dim(cyan("TIP"))} The consent screen's app name is what users see on Google's sign-in prompt. Your Clerk app is named "${appName}".`, + `${dim(cyan("TIP"))} The consent screen's app name is what users see when they sign in with Google. Use the name you want them to see.`, ); } log.blank(); From c43d72a440dce6960c3848998dc3d263b02bdd07 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 10:12:41 -0700 Subject: [PATCH 09/24] Close a skipped-DNS deploy with "DNS pending", not "Success" The closing word of the wizard summarizes how the run ended. After a skipped DNS check the status row four lines above says "DNS pending", and "Success" beneath it contradicted the headline and step 3 this branch had already corrected on the same screen. The same DNS outcome now drives all three. "Paused" was avoided on purpose: it already closes the OAuth-skip path, which exits as an error with a resume message rather than next steps, so reusing it would have covered two behaviors with one word. Found by running the wizard by hand against a local fake Clerk API. The word is display only; exit code and telemetry are untouched. --- .changeset/deploy-wizard-copy.md | 2 +- packages/cli-core/src/commands/deploy/index.test.ts | 4 ++++ packages/cli-core/src/commands/deploy/index.ts | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index dd56b7d06..e5c2c66aa 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -10,7 +10,7 @@ - The DNS check reports records as "not found yet" with a minutes-not-days expectation, tells you what to do based on what's actually pending, and links the Dashboard Domains page for changing the domain. - `clerk auth login` prints the claimed app's Dashboard URL; the wizard prints the new production instance's URL and its next steps say the pulled keys go on the host alongside the other Clerk variables. - The Google walkthrough adds a tip explaining that the OAuth consent screen's app name is what users see when they sign in, and to choose the name they should see. -- The DNS check footer points at the "Check again" prompt that follows it instead of telling you to quit and re-run; the closing screen no longer says "Production ready" or "sign up at your domain" when DNS verification was skipped. +- The DNS check footer points at the "Check again" prompt that follows it instead of telling you to quit and re-run; the closing screen no longer says "Production ready", "sign up at your domain", or "Success" when DNS verification was skipped. - Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. - Each DNS record host is named the same way on every screen, the note about what Clerk manages moved off the rows the user has to add, the Domains-page pointer carries its link, and the closing line says what happens next instead of implying that skipping the check finishes the deploy. - Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, at `complete` says the production keys still have to reach the host, and names OAuth providers the CLI could not configure so an agent doesn't report OAuth as done. Human-mode `clerk deploy status` prints the pending records, never says "ask the user", and resumes with `clerk deploy` rather than a flag that only affects agents. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 73829bdd9..cdec9ea7f 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -697,6 +697,7 @@ describe("deploy", () => { expect(mockGetApplicationDomainStatus.mock.calls.length).toBeGreaterThanOrEqual(2); expect(err).toContain("DNS verified for example.com"); expect(err).toContain("Production ready at https://example.com"); + expect(err).toMatch(/└\s+Success/); }); test("DNS verification triggers a fresh DNS check before polling status", async () => { @@ -1961,6 +1962,9 @@ describe("deploy", () => { expect(err).toContain("Saved Google OAuth credentials"); expect(err).toContain("Domain DNS pending"); expect(err).not.toContain("Domain Verified"); + // The closing word agrees with the status row instead of contradicting it. + expect(err).toMatch(/└\s+DNS pending/); + expect(err).not.toMatch(/└\s+Success/); expect(mockSelect).toHaveBeenCalledWith({ message: "DNS verification", choices: [ diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index a32151384..1a389979e 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -687,7 +687,10 @@ async function finishDeploy( fallback: bold, body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId, domain, dnsStatus))}\n`, }); - await outro("Success"); + // The closing word summarizes how the run ended. After a skipped DNS check + // the status row four lines up says "DNS pending", and "Success" beneath it + // contradicted that; the same value that drives the headline drives this. + await outro(dnsStatus === "verified" ? "Success" : "DNS pending"); } export function registerDeploy(program: Program): void { From f6a0f3bf831603d26312377b25320bb046c7c4ba Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 10:17:25 -0700 Subject: [PATCH 10/24] Only promise an OAuth step on the DNS screen when one is coming The line closing the DNS screen said "Next you'll set up OAuth, then this command checks these records." On resume, OAuth has already run by the time that screen prints, and the checklist directly above it shows OAuth done. A fresh run with no providers skips OAuth too. The sentence now takes a required flag: the fresh path passes whether any providers exist, the resume path passes false, and the OAuth clause is dropped when nothing is coming. The original sentence had the same assumption. It was carried into the first rewrite on this branch and caught by running the resume path by hand. --- .changeset/deploy-wizard-copy.md | 2 +- .../cli-core/src/commands/deploy/copy.test.ts | 16 +++++++++++++--- packages/cli-core/src/commands/deploy/copy.ts | 14 ++++++++++++-- .../cli-core/src/commands/deploy/index.test.ts | 7 +++++-- packages/cli-core/src/commands/deploy/index.ts | 10 +++++++++- 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index e5c2c66aa..f02a6935f 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -12,5 +12,5 @@ - The Google walkthrough adds a tip explaining that the OAuth consent screen's app name is what users see when they sign in, and to choose the name they should see. - The DNS check footer points at the "Check again" prompt that follows it instead of telling you to quit and re-run; the closing screen no longer says "Production ready", "sign up at your domain", or "Success" when DNS verification was skipped. - Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. -- Each DNS record host is named the same way on every screen, the note about what Clerk manages moved off the rows the user has to add, the Domains-page pointer carries its link, and the closing line says what happens next instead of implying that skipping the check finishes the deploy. +- Each DNS record host is named the same way on every screen, the note about what Clerk manages moved off the rows the user has to add, the Domains-page pointer carries its link, and the closing line says what happens next instead of implying that skipping the check finishes the deploy, and no longer promises an OAuth step on resume when OAuth has already run. - Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, at `complete` says the production keys still have to reach the host, and names OAuth providers the CLI could not configure so an agent doesn't report OAuth as done. Human-mode `clerk deploy status` prints the pending records, never says "ask the user", and resumes with `clerk deploy` rather than a flag that only affects agents. diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 2813b2701..fa73896c9 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -485,19 +485,29 @@ describe("dnsDashboardHandoff", () => { const DOMAINS_URL = "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains"; test("links the Domains page and says how to resume if the check is skipped", () => { - const output = dnsDashboardHandoff("example.com", DOMAINS_URL).join("\n"); + const output = dnsDashboardHandoff("example.com", DOMAINS_URL, { oauthNext: true }).join("\n"); expect(output).toContain(`Clerk Dashboard:\n ${DOMAINS_URL}`); // "wizard" appears nowhere else the user can see, so it isn't introduced here. expect(output).not.toContain("wizard"); - expect(output).toContain("then this command checks these records"); + expect(output).toContain("Next you'll set up OAuth, then this command checks these records."); expect(output).toContain("run `clerk deploy` again later to finish"); // "skip and finish" read as though skipping completed the deploy. expect(output).not.toContain("skip and finish"); }); + test("does not promise an OAuth step when none is coming", () => { + // On resume OAuth already ran, and a fresh run with no providers skips it. + // Under a checklist showing OAuth done, "you'll set up OAuth" was wrong. + const output = dnsDashboardHandoff("example.com", DOMAINS_URL, { oauthNext: false }).join("\n"); + + expect(output).toContain("Next, this command checks these records."); + expect(output).not.toContain("set up OAuth"); + expect(output).toContain("run `clerk deploy` again later to finish"); + }); + test("ends the sentence cleanly when no Dashboard URL is known", () => { - const output = dnsDashboardHandoff("example.com", undefined).join("\n"); + const output = dnsDashboardHandoff("example.com", undefined, { oauthNext: true }).join("\n"); expect(output).toContain("on the Domains page in the Clerk Dashboard."); expect(output).not.toContain("undefined"); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index d74be61c9..25fe23ea9 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -200,14 +200,24 @@ function cnameTargetLabel(host: string): string { } } -export function dnsDashboardHandoff(domain: string, domainsUrl: string | undefined): string[] { +/** + * `oauthNext` is required: on a fresh run with providers, OAuth setup comes + * between this screen and the DNS check; on resume (OAuth already done) and + * on a fresh run with no providers, the check is next. Saying "you'll set up + * OAuth" under a checklist that shows OAuth done was wrong. + */ +export function dnsDashboardHandoff( + domain: string, + domainsUrl: string | undefined, + options: { oauthNext: boolean }, +): string[] { return [ // "this command", not "the wizard": nothing the user sees uses that word. // Skipping the check leaves setup unfinished, so name what resumes it. `Monitor DNS propagation and SSL issuance for ${domain} on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, ...(domainsUrl ? [` ${domainsUrl}`] : []), "", - "Next you'll set up OAuth, then this command checks these records. If they haven't taken effect yet, you can skip the check and run `clerk deploy` again later to finish.", + `${options.oauthNext ? "Next you'll set up OAuth, then this command checks these records." : "Next, this command checks these records."} If they haven't taken effect yet, you can skip the check and run \`clerk deploy\` again later to finish.`, ]; } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index cdec9ea7f..eaca738b4 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1015,7 +1015,7 @@ describe("deploy", () => { expect(err).toContain("Add the following records at your DNS provider"); expect(err).toContain("on the Domains page in the Clerk Dashboard"); expect(err).toContain("propagation and SSL issuance"); - expect(err).toContain("then this command checks these records"); + expect(err).toContain("Next you'll set up OAuth, then this command checks these records"); expect(mockConfirm).toHaveBeenCalledTimes(3); expect(mockConfirm).toHaveBeenCalledWith({ message: "Create production instance?", @@ -1787,6 +1787,9 @@ describe("deploy", () => { expect(err).not.toMatch(/Add the following records at your DNS provider:/); expect(err).toContain("Host: clkmail.example.com"); expect(err).not.toContain("Host: clerk.example.com"); + // OAuth ran before this screen on resume, so it is not "next". + expect(err).toContain("Next, this command checks these records."); + expect(err).not.toContain("set up OAuth"); }); test("resume exports every record to the zone file even when the screen lists only the outstanding ones", async () => { @@ -1992,7 +1995,7 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); expect(err).toContain("on the Domains page in the Clerk Dashboard"); - expect(err).toContain("then this command checks these records"); + expect(err).toContain("Next you'll set up OAuth, then this command checks these records"); expect(err).toContain("Configure Google OAuth for production"); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 1a389979e..634c54012 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -219,10 +219,15 @@ async function startNewDeploy(ctx: DeployContext): Promise { cnameTargets, }; + // OAuth setup only runs when there are providers; with none, the DNS check + // is what comes next. await runDnsRecordHandoff( { ...operationState, pending: { type: "dns" } }, cnameTargets, cnameTargets, + { + oauthNext: oauthProviders.length > 0, + }, ); bar(); @@ -394,7 +399,7 @@ async function runDnsRecordHandoff( state: DeployOperationState, display: readonly CnameTarget[], exportTargets: readonly CnameTarget[], - options: { afterCheck?: boolean } = {}, + options: { afterCheck?: boolean; oauthNext: boolean }, ): Promise { for (const line of dnsIntro(state.domain)) log.info(line); log.blank(); @@ -407,6 +412,7 @@ async function runDnsRecordHandoff( for (const line of dnsDashboardHandoff( state.domain, handoffInstanceId ? domainsDashboardUrl(state.appId, handoffInstanceId) : undefined, + { oauthNext: options.oauthNext }, )) { if (line === "") log.blank(); else log.info(line); @@ -431,8 +437,10 @@ async function runExistingDomainDnsVerification( // On resume some records may already be verified; only the outstanding ones // are records to add, and the user may have added those already. const allTargets = state.cnameTargets ?? []; + // OAuth ran before this on the resume path, so the check is next. await runDnsRecordHandoff(state, pendingCnameTargets(allTargets, componentStatus), allTargets, { afterCheck: true, + oauthNext: false, }); return runDnsVerificationPrompt(ctx, state); } From 704e59030d538b335230ab2750540a6b97f96144 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 10:25:51 -0700 Subject: [PATCH 11/24] Say what the DNS check looks for "This command checks these records" did not say what the check is for. It now reads "checks that these records have taken effect", which is the phrase the next sentence already used, and that sentence shortens to "If they haven't yet" since the referent is established. --- packages/cli-core/src/commands/deploy/copy.test.ts | 6 ++++-- packages/cli-core/src/commands/deploy/copy.ts | 4 +++- packages/cli-core/src/commands/deploy/index.test.ts | 10 +++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index fa73896c9..0d55253c4 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -490,7 +490,9 @@ describe("dnsDashboardHandoff", () => { expect(output).toContain(`Clerk Dashboard:\n ${DOMAINS_URL}`); // "wizard" appears nowhere else the user can see, so it isn't introduced here. expect(output).not.toContain("wizard"); - expect(output).toContain("Next you'll set up OAuth, then this command checks these records."); + expect(output).toContain( + "Next you'll set up OAuth, then this command checks that these records have taken effect.", + ); expect(output).toContain("run `clerk deploy` again later to finish"); // "skip and finish" read as though skipping completed the deploy. expect(output).not.toContain("skip and finish"); @@ -501,7 +503,7 @@ describe("dnsDashboardHandoff", () => { // Under a checklist showing OAuth done, "you'll set up OAuth" was wrong. const output = dnsDashboardHandoff("example.com", DOMAINS_URL, { oauthNext: false }).join("\n"); - expect(output).toContain("Next, this command checks these records."); + expect(output).toContain("Next, this command checks that these records have taken effect."); expect(output).not.toContain("set up OAuth"); expect(output).toContain("run `clerk deploy` again later to finish"); }); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 25fe23ea9..2b5365af8 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -217,7 +217,9 @@ export function dnsDashboardHandoff( `Monitor DNS propagation and SSL issuance for ${domain} on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, ...(domainsUrl ? [` ${domainsUrl}`] : []), "", - `${options.oauthNext ? "Next you'll set up OAuth, then this command checks these records." : "Next, this command checks these records."} If they haven't taken effect yet, you can skip the check and run \`clerk deploy\` again later to finish.`, + // "checks that these records have taken effect": what the check is for. + // "checks these records" said nothing about what it looks for. + `${options.oauthNext ? "Next you'll set up OAuth, then this command checks that these records have taken effect." : "Next, this command checks that these records have taken effect."} If they haven't yet, you can skip the check and run \`clerk deploy\` again later to finish.`, ]; } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index eaca738b4..02cf67c88 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1015,7 +1015,9 @@ describe("deploy", () => { expect(err).toContain("Add the following records at your DNS provider"); expect(err).toContain("on the Domains page in the Clerk Dashboard"); expect(err).toContain("propagation and SSL issuance"); - expect(err).toContain("Next you'll set up OAuth, then this command checks these records"); + expect(err).toContain( + "Next you'll set up OAuth, then this command checks that these records have taken effect", + ); expect(mockConfirm).toHaveBeenCalledTimes(3); expect(mockConfirm).toHaveBeenCalledWith({ message: "Create production instance?", @@ -1788,7 +1790,7 @@ describe("deploy", () => { expect(err).toContain("Host: clkmail.example.com"); expect(err).not.toContain("Host: clerk.example.com"); // OAuth ran before this screen on resume, so it is not "next". - expect(err).toContain("Next, this command checks these records."); + expect(err).toContain("Next, this command checks that these records have taken effect."); expect(err).not.toContain("set up OAuth"); }); @@ -1995,7 +1997,9 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); expect(err).toContain("on the Domains page in the Clerk Dashboard"); - expect(err).toContain("Next you'll set up OAuth, then this command checks these records"); + expect(err).toContain( + "Next you'll set up OAuth, then this command checks that these records have taken effect", + ); expect(err).toContain("Configure Google OAuth for production"); }); From d68c414dc768d603fa9256eb43a409ee9d2ff908 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 10:31:09 -0700 Subject: [PATCH 12/24] Name the DNS provider and both options after a failed check Two changes to the sentence closing the DNS screen. "At your DNS provider" says where the check looks. It matches the records block's own heading a few lines above, so both sentences name the same place the same way. "At" rather than "with": the check looks the records up, it does not contact the provider. "Either wait a few minutes and check again, or skip the check" names both options. Without it a reader could take skipping as the only response to a failed check, which is wrong: "Check again" is the other choice on the prompt that follows, and the sentence now uses that label. --- packages/cli-core/src/commands/deploy/copy.test.ts | 11 +++++++++-- packages/cli-core/src/commands/deploy/copy.ts | 8 +++++--- packages/cli-core/src/commands/deploy/index.test.ts | 8 +++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 0d55253c4..31e2e2670 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -491,7 +491,12 @@ describe("dnsDashboardHandoff", () => { // "wizard" appears nowhere else the user can see, so it isn't introduced here. expect(output).not.toContain("wizard"); expect(output).toContain( - "Next you'll set up OAuth, then this command checks that these records have taken effect.", + "Next you'll set up OAuth, then this command checks that these records have taken effect at your DNS provider.", + ); + // A failed check is not a dead end: "Check again" is the other choice on + // the prompt that follows, and the sentence names it. + expect(output).toContain( + "you can either wait a few minutes and check again, or skip the check", ); expect(output).toContain("run `clerk deploy` again later to finish"); // "skip and finish" read as though skipping completed the deploy. @@ -503,7 +508,9 @@ describe("dnsDashboardHandoff", () => { // Under a checklist showing OAuth done, "you'll set up OAuth" was wrong. const output = dnsDashboardHandoff("example.com", DOMAINS_URL, { oauthNext: false }).join("\n"); - expect(output).toContain("Next, this command checks that these records have taken effect."); + expect(output).toContain( + "Next, this command checks that these records have taken effect at your DNS provider.", + ); expect(output).not.toContain("set up OAuth"); expect(output).toContain("run `clerk deploy` again later to finish"); }); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 2b5365af8..01bfd3705 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -217,9 +217,11 @@ export function dnsDashboardHandoff( `Monitor DNS propagation and SSL issuance for ${domain} on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, ...(domainsUrl ? [` ${domainsUrl}`] : []), "", - // "checks that these records have taken effect": what the check is for. - // "checks these records" said nothing about what it looks for. - `${options.oauthNext ? "Next you'll set up OAuth, then this command checks that these records have taken effect." : "Next, this command checks that these records have taken effect."} If they haven't yet, you can skip the check and run \`clerk deploy\` again later to finish.`, + // "at your DNS provider" matches the records block's own heading, and "at" + // rather than "with": the check looks the records up, it doesn't contact + // the provider. Naming both options matters because a failed check isn't a + // dead end — "Check again" is the other choice on the prompt that follows. + `${options.oauthNext ? "Next you'll set up OAuth, then this command checks that these records have taken effect at your DNS provider." : "Next, this command checks that these records have taken effect at your DNS provider."} If they haven't yet, you can either wait a few minutes and check again, or skip the check and run \`clerk deploy\` again later to finish.`, ]; } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 02cf67c88..28d735466 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1016,7 +1016,7 @@ describe("deploy", () => { expect(err).toContain("on the Domains page in the Clerk Dashboard"); expect(err).toContain("propagation and SSL issuance"); expect(err).toContain( - "Next you'll set up OAuth, then this command checks that these records have taken effect", + "Next you'll set up OAuth, then this command checks that these records have taken effect at your DNS provider", ); expect(mockConfirm).toHaveBeenCalledTimes(3); expect(mockConfirm).toHaveBeenCalledWith({ @@ -1790,7 +1790,9 @@ describe("deploy", () => { expect(err).toContain("Host: clkmail.example.com"); expect(err).not.toContain("Host: clerk.example.com"); // OAuth ran before this screen on resume, so it is not "next". - expect(err).toContain("Next, this command checks that these records have taken effect."); + expect(err).toContain( + "Next, this command checks that these records have taken effect at your DNS provider.", + ); expect(err).not.toContain("set up OAuth"); }); @@ -1998,7 +2000,7 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain("on the Domains page in the Clerk Dashboard"); expect(err).toContain( - "Next you'll set up OAuth, then this command checks that these records have taken effect", + "Next you'll set up OAuth, then this command checks that these records have taken effect at your DNS provider", ); expect(err).toContain("Configure Google OAuth for production"); }); From ddb5fb7e9ff365f5f069007f04b89638892867de Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 11:11:16 -0700 Subject: [PATCH 13/24] Say what is outstanding when the DNS screen has no records to add Rendering every DNS screen for every domain state showed one shape wrong in seven cells: when the list of records to add is empty, the screen still printed "Configure DNS", the propagation note, the proxy tip, and a promise to check "these records". That happens on resume when only the SSL certificate is pending, when everything is verified and Clerk is finalizing, and whenever Clerk returns no record list. On main it was worse, listing already-verified records and telling the user to add them again; the filter earlier on this branch removed those and left the empty frame. The screen now says what is actually outstanding, decided by the classifier the post-check footer already uses so the two can't disagree. Certificate pending: records verified, Clerk issues the certificate, check again in a few minutes. Finalizing: nothing to do, run `clerk deploy` again later, with no "check again" because the check pauses the run instead of prompting. No record list: find them on the Domains page, add them, then check; still names DNS, email DNS, or both. The zone-file export is untouched. The closing screen after a skip stops guessing that DNS is the pending part. "Domain: Not yet verified", "once the domain is verified", and a closing word of "Not verified" are right when DNS is done and the certificate or Clerk is what remains; "DNS pending" was not. Tests cover each state on both the unit and wizard level, and three mutation checks confirm they fail without the fix: forcing the records-present branch, offering a retry on the finalizing lead, and restoring the old closing word. --- .changeset/deploy-wizard-copy.md | 2 +- .../cli-core/src/commands/deploy/copy.test.ts | 78 ++++++++++++++++++- packages/cli-core/src/commands/deploy/copy.ts | 63 ++++++++++++++- .../src/commands/deploy/index.test.ts | 78 ++++++++++++++++++- .../cli-core/src/commands/deploy/index.ts | 51 +++++++----- 5 files changed, 245 insertions(+), 27 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index f02a6935f..1d1162617 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -11,6 +11,6 @@ - `clerk auth login` prints the claimed app's Dashboard URL; the wizard prints the new production instance's URL and its next steps say the pulled keys go on the host alongside the other Clerk variables. - The Google walkthrough adds a tip explaining that the OAuth consent screen's app name is what users see when they sign in, and to choose the name they should see. - The DNS check footer points at the "Check again" prompt that follows it instead of telling you to quit and re-run; the closing screen no longer says "Production ready", "sign up at your domain", or "Success" when DNS verification was skipped. -- Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. +- Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. When none are outstanding, the DNS screen says what is (the SSL certificate, Clerk finalizing, or a record list Clerk didn't return) instead of a "Configure DNS" page with no records, and the closing screen says "Not yet verified" rather than "DNS pending" so it is right when DNS is done and something else is pending. - Each DNS record host is named the same way on every screen, the note about what Clerk manages moved off the rows the user has to add, the Domains-page pointer carries its link, and the closing line says what happens next instead of implying that skipping the check finishes the deploy, and no longer promises an OAuth step on resume when OAuth has already run. - Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, at `complete` says the production keys still have to reach the host, and names OAuth providers the CLI could not configure so an agent doesn't report OAuth as done. Human-mode `clerk deploy status` prints the pending records, never says "ask the user", and resumes with `clerk deploy` rather than a flag that only affects agents. diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 31e2e2670..4d2a37826 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -9,6 +9,7 @@ import { deployStatusRetryMessage, dnsIntro, dnsDashboardHandoff, + dnsHandoffNothingToAdd, dnsRecords, domainAssociationSummary, domainsDashboardUrl, @@ -175,7 +176,9 @@ describe("nextStepsBody", () => { // has to route through the wizard before the sign-up check. const output = nextStepsBody("app_123", "ins_456", "example.com", "pending"); - expect(output).toContain("3. Run `clerk deploy` again once your DNS records are added"); + // "once the domain is verified", not "once your DNS records are added": + // the DNS may already be done with only the certificate outstanding. + expect(output).toContain("3. Run `clerk deploy` again once the domain is verified"); expect(output).toContain("sign up at https://example.com to confirm it works"); expect(output).not.toContain("3. Redeploy your app"); }); @@ -188,7 +191,8 @@ describe("productionSummary", () => { expect(verified).toContain("Production ready at https://example.com"); expect(pending).toContain("Production instance created for https://example.com"); - expect(pending).toContain("Domain DNS pending"); + expect(pending).toContain("Domain Not yet verified"); + expect(pending).not.toContain("DNS pending"); expect(pending).not.toContain("Production ready"); }); }); @@ -481,6 +485,76 @@ describe("dnsRecords", () => { }); }); +describe("dnsHandoffNothingToAdd", () => { + // The DNS screen when the list of records to add is empty. It must say what + // is actually outstanding rather than framing a records task with no + // records, and each state has its own action. + const URL = "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains"; + + test("SSL pending: records are done, the certificate is Clerk's side, check again is offered", () => { + const out = stripAnsi( + dnsHandoffNothingToAdd("example.com", { dns: true, ssl: false, mail: true }, URL).join("\n"), + ); + + expect(out).toContain("Your DNS records for example.com are verified."); + expect(out).toContain("The SSL certificate is still pending; Clerk issues it automatically."); + expect(out).toContain(`Clerk Dashboard:\n ${URL}`); + expect(out).toContain("checks whether the certificate has been issued"); + expect(out).toContain("wait a few minutes and check again"); + // No timing promise the status can't back up. + expect(out).not.toContain("usually takes"); + expect(out).not.toContain("Configure DNS"); + expect(out).not.toContain("these records"); + }); + + test("finalizing: nothing for the user to do, and no in-session retry is promised", () => { + const out = stripAnsi( + dnsHandoffNothingToAdd("example.com", { dns: true, ssl: true, mail: true }, URL).join("\n"), + ); + + expect(out).toContain("Your DNS records and SSL certificate for example.com are verified."); + expect(out).toContain("Clerk is still finalizing production setup."); + // The check pauses the run once everything is verified, so "check again" + // would name an option the prompt never offers. + expect(out).toContain("run `clerk deploy` again in a few minutes"); + expect(out).not.toContain("check again"); + }); + + test.each([ + { + label: "both", + status: { dns: false, ssl: false, mail: false }, + records: "DNS and email DNS", + }, + { label: "email only", status: { dns: true, ssl: false, mail: false }, records: "Email DNS" }, + { label: "DNS only", status: { dns: false, ssl: false, mail: true }, records: "DNS" }, + ])( + "no record list ($label): tells the user to find and add the records", + ({ status, records }) => { + const out = stripAnsi(dnsHandoffNothingToAdd("example.com", status, URL).join("\n")); + + expect(out).toContain( + `${records} records for example.com are not verified yet, but Clerk didn't return the list to add.`, + ); + // An instruction, not a wait: the records still have to be added. + expect(out).toContain("add them at your DNS provider, then choose Check DNS now below"); + expect(out).toContain(`Check DNS now below:\n ${URL}`); + expect(out).toContain("checks that they have taken effect"); + expect(out).not.toContain("Configure DNS"); + }, + ); + + test("ends sentences cleanly with no Dashboard URL", () => { + const out = stripAnsi( + dnsHandoffNothingToAdd("example.com", { dns: true, ssl: false, mail: true }, undefined).join( + "\n", + ), + ); + expect(out).toContain("on the Domains page in the Clerk Dashboard."); + expect(out).not.toContain("undefined"); + }); +}); + describe("dnsDashboardHandoff", () => { const DOMAINS_URL = "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains"; diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 01bfd3705..e9872d8af 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -200,6 +200,62 @@ function cnameTargetLabel(host: string): string { } } +/** + * The DNS screen when there is nothing left to add. Printed instead of + * `dnsIntro` + `dnsDashboardHandoff`: a "Configure DNS" page with an empty + * record list told the user to do work they had already done. What is + * outstanding comes from the same classifier the post-check footer uses, so + * the screen before the check and the footer after it can't disagree. + * `hasPendingRecords` is false by construction here (the display list is + * empty), so `records_available` is unreachable. + */ +export function dnsHandoffNothingToAdd( + domain: string, + status: DeployComponentStatus, + domainsUrl: string | undefined, +): string[] { + const state = classifyDomainPending(status, false); + const url = domainsUrl ? [` ${domainsUrl}`] : []; + const resume = + "If it hasn't yet, you can either wait a few minutes and check again, or skip the check and run `clerk deploy` again later to finish."; + switch (state) { + case "ssl_pending": + return [ + `Your DNS records for ${cyan(domain)} are verified. The SSL certificate is still pending; Clerk issues it automatically.`, + "", + `Monitor SSL issuance on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, + ...url, + "", + `Next, this command checks whether the certificate has been issued. ${resume}`, + ]; + case "finalizing": + // No "check again": once every component is verified, the check pauses + // the run instead of prompting. + return [ + `Your DNS records and SSL certificate for ${cyan(domain)} are verified. Clerk is still finalizing production setup.`, + "", + `Monitor it on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, + ...url, + "", + "Next, this command checks whether Clerk has finished. If it hasn't, run `clerk deploy` again in a few minutes.", + ]; + case "records_available": + case "records_unavailable": { + // Records are needed but Clerk returned no list: the user has to find + // and add them, so this is an instruction, not a wait. + const records = capitalizeFirst(pendingRecordComponents(status)); + return [ + `${records} records for ${cyan(domain)} are not verified yet, but Clerk didn't return the list to add.`, + "", + `Find them on the Domains page in the Clerk Dashboard, add them at your DNS provider, then choose Check DNS now below${domainsUrl ? ":" : "."}`, + ...url, + "", + `Next, this command checks that they have taken effect. ${resume.replace("If it hasn't yet", "If they haven't yet")}`, + ]; + } + } +} + /** * `oauthNext` is required: on a fresh run with providers, OAuth setup comes * between this screen and the DNS check; on resume (OAuth already done) and @@ -386,7 +442,10 @@ export function productionSummary( ? `Production ready at ${cyan(`https://${domain}`)}` : `Production instance created for ${cyan(`https://${domain}`)}`, "", - ` Domain ${domainStatus === "verified" ? "Verified" : "DNS pending"}`, + // "Not yet verified", not "DNS pending": the DNS may be done and only + // the certificate or Clerk's own setup outstanding; the screen above + // said which. + ` Domain ${domainStatus === "verified" ? "Verified" : "Not yet verified"}`, ` OAuth ${completedOAuthProviderLabels.length ? completedOAuthProviderLabels.join(", ") : "Not applicable"}`, ]; } @@ -402,7 +461,7 @@ export function nextStepsBody( const step3 = domainStatus === "verified" ? `Redeploy your app, then sign up at https://${domain} to confirm it works` - : `Run \`clerk deploy\` again once your DNS records are added, then redeploy your app + : `Run \`clerk deploy\` again once the domain is verified, then redeploy your app and sign up at https://${domain} to confirm it works`; return ` 1. Pull production keys into your environment diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 28d735466..b537fe16c 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1847,6 +1847,7 @@ describe("deploy", () => { mockSelect.mockResolvedValueOnce("skip"); await runDeploy({}); + const err = stripAnsi(captured.err); expect(mockConfirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("BIND") }), @@ -1855,6 +1856,71 @@ describe("deploy", () => { String(call[0]).endsWith(".zone"), ); expect(zoneCall).toBeDefined(); + // Nothing left to add: the screen says the certificate is pending + // instead of framing a DNS task around an empty record list. + expect(err).toContain("Your DNS records for example.com are verified."); + expect(err).toContain("The SSL certificate is still pending; Clerk issues it automatically."); + expect(err).not.toContain("Configure DNS for"); + expect(err).not.toContain("these records"); + expect(err).not.toContain("Add the following records"); + // Skipping closes on the domain, not on DNS, which is already done. + expect(err).toContain("Domain Not yet verified"); + expect(err).toMatch(/└\s+Not verified/); + }); + + test("resume while Clerk is finalizing says so and does not promise a retry", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: true, mail: true }), + ); + mockConfirm.mockResolvedValueOnce(false); // BIND export + mockSelect.mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("Clerk is still finalizing production setup."); + expect(err).toContain("run `clerk deploy` again in a few minutes"); + expect(err).not.toContain("check again"); + expect(err).not.toContain("Configure DNS for"); + }); + + test("resume with no record list from Clerk tells the user to find and add the records", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_123" }, + }); + mockIsAgent.mockReturnValue(false); + mockLiveProduction({ + instanceId: "ins_prod_123", + developmentConfig: {}, + productionConfig: {}, + cnameTargets: [], + }); + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: true, ssl: false, mail: false }), + ); + mockSelect.mockResolvedValueOnce("skip"); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain( + "Email DNS records for example.com are not verified yet, but Clerk didn't return the list to add.", + ); + expect(err).toContain("add them at your DNS provider, then choose Check DNS now below"); + expect(err).not.toContain("Add the following records"); + // No records, no export offer. + expect(mockConfirm).not.toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("BIND") }), + ); }); test("DNS verification treats absent components as pending", async () => { @@ -1902,6 +1968,14 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); + // Before the check, the first-run screen already says the list is + // missing and what to do, instead of a "Configure DNS" page with no records. + expect(err).toContain( + "DNS and email DNS records for example.com are not verified yet, but Clerk didn't return the list to add.", + ); + expect(err).toContain("add them at your DNS provider, then choose Check DNS now below"); + expect(err).not.toContain("Configure DNS for"); + // After the check, the footer says the same in its own words. expect(err).toContain("DNS and email DNS records not found yet for example.com."); expect(err).toContain("Clerk didn't return the list of records to add."); expect(err).toContain( @@ -1967,10 +2041,10 @@ describe("deploy", () => { const err = stripAnsi(captured.err); expect(err).toContain("Saved Google OAuth credentials"); - expect(err).toContain("Domain DNS pending"); + expect(err).toContain("Domain Not yet verified"); expect(err).not.toContain("Domain Verified"); // The closing word agrees with the status row instead of contradicting it. - expect(err).toMatch(/└\s+DNS pending/); + expect(err).toMatch(/└\s+Not verified/); expect(err).not.toMatch(/└\s+Success/); expect(mockSelect).toHaveBeenCalledWith({ message: "DNS verification", diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 634c54012..255532c77 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -27,6 +27,7 @@ import { domainAssociationSummary, bindZoneFile, dnsDashboardHandoff, + dnsHandoffNothingToAdd, dnsIntro, dnsRecords, domainsDashboardUrl, @@ -225,9 +226,9 @@ async function startNewDeploy(ctx: DeployContext): Promise { { ...operationState, pending: { type: "dns" } }, cnameTargets, cnameTargets, - { - oauthNext: oauthProviders.length > 0, - }, + // Nothing has been checked yet on a fresh run. + { dns: false, ssl: false, mail: false }, + { oauthNext: oauthProviders.length > 0 }, ); bar(); @@ -399,21 +400,28 @@ async function runDnsRecordHandoff( state: DeployOperationState, display: readonly CnameTarget[], exportTargets: readonly CnameTarget[], + status: DeployComponentStatus, options: { afterCheck?: boolean; oauthNext: boolean }, ): Promise { - for (const line of dnsIntro(state.domain)) log.info(line); - log.blank(); - if (display.length > 0) { - for (const line of dnsRecords(display, options)) log.info(line); - log.blank(); - } - const handoffInstanceId = state.productionInstanceId; - for (const line of dnsDashboardHandoff( - state.domain, - handoffInstanceId ? domainsDashboardUrl(state.appId, handoffInstanceId) : undefined, - { oauthNext: options.oauthNext }, - )) { + const domainsUrl = handoffInstanceId + ? domainsDashboardUrl(state.appId, handoffInstanceId) + : undefined; + + // With no records to add, a "Configure DNS" page is the wrong page: say + // what is actually outstanding instead (certificate, Clerk finalizing, or + // a record list Clerk didn't return). + const lines = + display.length > 0 + ? [ + ...dnsIntro(state.domain), + "", + ...dnsRecords(display, options), + "", + ...dnsDashboardHandoff(state.domain, domainsUrl, { oauthNext: options.oauthNext }), + ] + : dnsHandoffNothingToAdd(state.domain, status, domainsUrl); + for (const line of lines) { if (line === "") log.blank(); else log.info(line); } @@ -438,10 +446,13 @@ async function runExistingDomainDnsVerification( // are records to add, and the user may have added those already. const allTargets = state.cnameTargets ?? []; // OAuth ran before this on the resume path, so the check is next. - await runDnsRecordHandoff(state, pendingCnameTargets(allTargets, componentStatus), allTargets, { - afterCheck: true, - oauthNext: false, - }); + await runDnsRecordHandoff( + state, + pendingCnameTargets(allTargets, componentStatus), + allTargets, + componentStatus, + { afterCheck: true, oauthNext: false }, + ); return runDnsVerificationPrompt(ctx, state); } @@ -698,7 +709,7 @@ async function finishDeploy( // The closing word summarizes how the run ended. After a skipped DNS check // the status row four lines up says "DNS pending", and "Success" beneath it // contradicted that; the same value that drives the headline drives this. - await outro(dnsStatus === "verified" ? "Success" : "DNS pending"); + await outro(dnsStatus === "verified" ? "Success" : "Not verified"); } export function registerDeploy(program: Program): void { From 255ad36aede928f8d0653b7f200b793c9599847a Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 11:19:40 -0700 Subject: [PATCH 14/24] Build the DNS screen's next-step sentence in one place The no-records branch added in the previous commit composed its own "what happens next" sentence and dropped the flag saying whether OAuth setup comes before the DNS check. On a first run with a provider and no record list from Clerk, it told the user to "choose Check DNS now below" and said the check was next, when the next prompt is OAuth. Skipping there pauses the run before the check ever appears. That is the contradiction the flag was introduced to prevent, back on the records-present branch. Both branches now build the sentence through nextStepSentence, which owns the OAuth-first phrasing, and the no-records formatter takes the flag as a required option. With providers, the record-list-missing lead drops the "Check DNS now" clause since that prompt isn't next. The SSL and finalizing leads pass the flag through as well so the formatter is honest for every input, though those states only occur on resume where OAuth has already run. Tests: unit cases for the flag in both directions, the existing first-run missing-list wizard test corrected to expect the OAuth-first wording, and a new first-run test with no providers asserting the direct-check wording. Two mutation checks: forcing the flag false in the caller fails the provider test; making the builder ignore the flag fails five tests across both branches. --- .../cli-core/src/commands/deploy/copy.test.ts | 36 ++++++++++--- packages/cli-core/src/commands/deploy/copy.ts | 50 ++++++++++++++++--- .../src/commands/deploy/index.test.ts | 31 +++++++++++- .../cli-core/src/commands/deploy/index.ts | 2 +- 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 4d2a37826..2f914e7b4 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -493,7 +493,9 @@ describe("dnsHandoffNothingToAdd", () => { test("SSL pending: records are done, the certificate is Clerk's side, check again is offered", () => { const out = stripAnsi( - dnsHandoffNothingToAdd("example.com", { dns: true, ssl: false, mail: true }, URL).join("\n"), + dnsHandoffNothingToAdd("example.com", { dns: true, ssl: false, mail: true }, URL, { + oauthNext: false, + }).join("\n"), ); expect(out).toContain("Your DNS records for example.com are verified."); @@ -509,7 +511,9 @@ describe("dnsHandoffNothingToAdd", () => { test("finalizing: nothing for the user to do, and no in-session retry is promised", () => { const out = stripAnsi( - dnsHandoffNothingToAdd("example.com", { dns: true, ssl: true, mail: true }, URL).join("\n"), + dnsHandoffNothingToAdd("example.com", { dns: true, ssl: true, mail: true }, URL, { + oauthNext: false, + }).join("\n"), ); expect(out).toContain("Your DNS records and SSL certificate for example.com are verified."); @@ -531,7 +535,9 @@ describe("dnsHandoffNothingToAdd", () => { ])( "no record list ($label): tells the user to find and add the records", ({ status, records }) => { - const out = stripAnsi(dnsHandoffNothingToAdd("example.com", status, URL).join("\n")); + const out = stripAnsi( + dnsHandoffNothingToAdd("example.com", status, URL, { oauthNext: false }).join("\n"), + ); expect(out).toContain( `${records} records for example.com are not verified yet, but Clerk didn't return the list to add.`, @@ -544,11 +550,29 @@ describe("dnsHandoffNothingToAdd", () => { }, ); + test("no record list on a first run with providers: OAuth comes before the check", () => { + // The prompt after this screen is OAuth setup, not the DNS check, so the + // screen must not point at a "Check DNS now" that isn't there. + const out = stripAnsi( + dnsHandoffNothingToAdd("example.com", { dns: false, ssl: false, mail: false }, URL, { + oauthNext: true, + }).join("\n"), + ); + + expect(out).toContain( + "Find them on the Domains page in the Clerk Dashboard and add them at your DNS provider:", + ); + expect(out).toContain( + "Next you'll set up OAuth, then this command checks that they have taken effect.", + ); + expect(out).not.toContain("Check DNS now"); + }); + test("ends sentences cleanly with no Dashboard URL", () => { const out = stripAnsi( - dnsHandoffNothingToAdd("example.com", { dns: true, ssl: false, mail: true }, undefined).join( - "\n", - ), + dnsHandoffNothingToAdd("example.com", { dns: true, ssl: false, mail: true }, undefined, { + oauthNext: false, + }).join("\n"), ); expect(out).toContain("on the Domains page in the Clerk Dashboard."); expect(out).not.toContain("undefined"); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index e9872d8af..08c8c15f9 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -200,6 +200,21 @@ function cnameTargetLabel(host: string): string { } } +/** + * The "what happens next" sentence both DNS screens end with. One place for + * the OAuth-first phrasing so the records-present and no-records branches + * can't drift: the first time they were composed separately, the no-records + * branch told the user to "check now" on a run where OAuth setup came first. + * `resume` differs by state (finalizing pauses the run rather than offering + * a retry), so the caller supplies it. + */ +function nextStepSentence(options: { oauthNext: boolean; check: string; resume: string }): string { + const lead = options.oauthNext + ? "Next you'll set up OAuth, then this command " + : "Next, this command "; + return `${lead}${options.check}. ${options.resume}`; +} + /** * The DNS screen when there is nothing left to add. Printed instead of * `dnsIntro` + `dnsDashboardHandoff`: a "Configure DNS" page with an empty @@ -213,6 +228,7 @@ export function dnsHandoffNothingToAdd( domain: string, status: DeployComponentStatus, domainsUrl: string | undefined, + options: { oauthNext: boolean }, ): string[] { const state = classifyDomainPending(status, false); const url = domainsUrl ? [` ${domainsUrl}`] : []; @@ -226,7 +242,11 @@ export function dnsHandoffNothingToAdd( `Monitor SSL issuance on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, ...url, "", - `Next, this command checks whether the certificate has been issued. ${resume}`, + nextStepSentence({ + ...options, + check: "checks whether the certificate has been issued", + resume, + }), ]; case "finalizing": // No "check again": once every component is verified, the check pauses @@ -237,20 +257,33 @@ export function dnsHandoffNothingToAdd( `Monitor it on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, ...url, "", - "Next, this command checks whether Clerk has finished. If it hasn't, run `clerk deploy` again in a few minutes.", + nextStepSentence({ + ...options, + check: "checks whether Clerk has finished", + resume: "If it hasn't, run `clerk deploy` again in a few minutes.", + }), ]; case "records_available": case "records_unavailable": { // Records are needed but Clerk returned no list: the user has to find - // and add them, so this is an instruction, not a wait. + // and add them, so this is an instruction, not a wait. "Then choose + // Check DNS now below" only when that prompt really is next; on a fresh + // run with providers, OAuth setup comes first. const records = capitalizeFirst(pendingRecordComponents(status)); + const find = options.oauthNext + ? `Find them on the Domains page in the Clerk Dashboard and add them at your DNS provider${domainsUrl ? ":" : "."}` + : `Find them on the Domains page in the Clerk Dashboard, add them at your DNS provider, then choose Check DNS now below${domainsUrl ? ":" : "."}`; return [ `${records} records for ${cyan(domain)} are not verified yet, but Clerk didn't return the list to add.`, "", - `Find them on the Domains page in the Clerk Dashboard, add them at your DNS provider, then choose Check DNS now below${domainsUrl ? ":" : "."}`, + find, ...url, "", - `Next, this command checks that they have taken effect. ${resume.replace("If it hasn't yet", "If they haven't yet")}`, + nextStepSentence({ + ...options, + check: "checks that they have taken effect", + resume: resume.replace("If it hasn't yet", "If they haven't yet"), + }), ]; } } @@ -277,7 +310,12 @@ export function dnsDashboardHandoff( // rather than "with": the check looks the records up, it doesn't contact // the provider. Naming both options matters because a failed check isn't a // dead end — "Check again" is the other choice on the prompt that follows. - `${options.oauthNext ? "Next you'll set up OAuth, then this command checks that these records have taken effect at your DNS provider." : "Next, this command checks that these records have taken effect at your DNS provider."} If they haven't yet, you can either wait a few minutes and check again, or skip the check and run \`clerk deploy\` again later to finish.`, + nextStepSentence({ + oauthNext: options.oauthNext, + check: "checks that these records have taken effect at your DNS provider", + resume: + "If they haven't yet, you can either wait a few minutes and check again, or skip the check and run `clerk deploy` again later to finish.", + }), ]; } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index b537fe16c..44a840246 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1946,6 +1946,29 @@ describe("deploy", () => { expect(err).not.toContain("Domain Verified"); }); + test("no record list on a first run without providers points straight at the DNS check", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + // No enabled providers: OAuth setup is skipped, so the check really is next. + mockFetchInstanceConfig.mockResolvedValue({}); + stubCreateProductionInstance({ cnameTargets: [] }); + mockConfirm + .mockResolvedValueOnce(true) // Proceed? + .mockResolvedValueOnce(true); // Create production instance? + mockInput.mockResolvedValueOnce("example.com"); + mockSelect.mockResolvedValueOnce("skip"); // DNS verification + mockGetApplicationDomainStatus.mockResolvedValue( + domainStatus({ status: "incomplete", dns: false, ssl: false, mail: false }), + ); + + await runDeploy({}); + const err = stripAnsi(captured.err); + + expect(err).toContain("add them at your DNS provider, then choose Check DNS now below"); + expect(err).toContain("Next, this command checks that they have taken effect."); + expect(err).not.toContain("set up OAuth"); + }); + test("DNS verification footer says the record list is missing when the API returned no targets", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); @@ -1970,10 +1993,16 @@ describe("deploy", () => { // Before the check, the first-run screen already says the list is // missing and what to do, instead of a "Configure DNS" page with no records. + // This run has a Google provider, so OAuth setup is the next prompt and + // the screen must not point at a "Check DNS now" that isn't there yet. expect(err).toContain( "DNS and email DNS records for example.com are not verified yet, but Clerk didn't return the list to add.", ); - expect(err).toContain("add them at your DNS provider, then choose Check DNS now below"); + expect(err).toContain("and add them at your DNS provider:"); + expect(err).toContain( + "Next you'll set up OAuth, then this command checks that they have taken effect.", + ); + expect(err).not.toContain("Check DNS now below"); expect(err).not.toContain("Configure DNS for"); // After the check, the footer says the same in its own words. expect(err).toContain("DNS and email DNS records not found yet for example.com."); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 255532c77..379857d75 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -420,7 +420,7 @@ async function runDnsRecordHandoff( "", ...dnsDashboardHandoff(state.domain, domainsUrl, { oauthNext: options.oauthNext }), ] - : dnsHandoffNothingToAdd(state.domain, status, domainsUrl); + : dnsHandoffNothingToAdd(state.domain, status, domainsUrl, { oauthNext: options.oauthNext }); for (const line of lines) { if (line === "") log.blank(); else log.info(line); From 53b33a063b1987bdeb097d3688ecd49fa7fb0cc7 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 13:53:55 -0700 Subject: [PATCH 15/24] Mention email records only when one is listed - The sentence under the DNS block saying email records point at Clerk printed even when the filtered list had no email row, which happens on resume or in deploy status once email DNS is verified but the Frontend API record is not. - Print it only when an email record is on screen. Adds a unit test and a human-mode deploy status test for the DNS-only case. --- .../cli-core/src/commands/deploy/copy.test.ts | 14 +++++++ packages/cli-core/src/commands/deploy/copy.ts | 13 ++++-- .../commands/deploy/status-command.test.ts | 42 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 2f914e7b4..0154d65d0 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -468,6 +468,20 @@ describe("dnsRecords", () => { expect(output).not.toMatch(/Email \(DKIM\).*Clerk handles/); }); + test("omits the SPF/DKIM sentence when no email record is listed", () => { + // On resume with email DNS already verified, only the Frontend API + // record is outstanding; a sentence about "the email records" under a + // list with none reads as if rows are missing. + const output = dnsRecords( + [{ host: "clerk.example.com", value: "frontend-api.clerk.services", required: true }], + { afterCheck: true }, + ).join("\n"); + + expect(output).not.toContain("SPF or DKIM"); + expect(output).toContain("Frontend API"); + expect(output).toContain('set them to "DNS only"'); + }); + test("labels the mail host and both DKIM hosts the same way the confirmation screen does", () => { // One label set across screens: a host named two ways reads as two records. const records = dnsRecords([ diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 08c8c15f9..cbfa5affb 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -138,12 +138,17 @@ export function dnsRecords( ` Value: ${target.value}`, ); } - lines.push( - "", + if (targets.some(isMailCnameTarget)) { // These are CNAMEs pointing at Clerk, so the user never generates or // rotates key material and never hand-writes an SPF record. Said once - // here rather than on each email row. - "The email records point at Clerk, so you don't need to create SPF or DKIM values yourself.", + // here rather than on each email row, and only when an email row is on + // screen: a resume where email DNS is already verified lists none. + lines.push( + "", + "The email records point at Clerk, so you don't need to create SPF or DKIM values yourself.", + ); + } + lines.push( "", `${yellow("NOTE")} If your DNS host proxies these records, set them to "DNS only" or verification will fail.`, ); diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 283374fcc..0e5a3e1dd 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -550,6 +550,48 @@ describe("deploy status", () => { expect(output).not.toMatch(/Frontend API \(optional\)/); }); + test("human mode omits the email note when only non-email records are pending", async () => { + setMode("human"); + mockFetchApplication.mockResolvedValue(appWith(true)); + 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 }, + { host: "clkmail.example.com", value: "mail.clerk.services", required: true }, + ], + }, + ], + total_count: 1, + }); + mockOAuthComplete(); + // Email DNS verified, Frontend API not: the filtered list has no email + // row, so a sentence about "the email records" would point at nothing. + const dnsOnly = { + status: "incomplete", + dns: { status: "not_started" }, + ssl: { status: "complete", required: true }, + mail: { status: "complete", required: true }, + }; + mockTriggerApplicationDomainDNSCheck.mockResolvedValue(dnsOnly); + mockGetApplicationDomainStatus.mockResolvedValue(dnsOnly); + + await deployStatus(); + + const output = stripAnsi(captured.err); + expect(output).toContain("Host: clerk.example.com"); + expect(output).not.toContain("Host: clkmail.example.com"); + expect(output).not.toContain("SPF or DKIM"); + }); + test("agent report carries each pending record's required flag", async () => { mockFetchApplication.mockResolvedValue(appWith(true)); mockDomain(); From 241bbd17b8cc11d1c8aabbdc1520d51920cfeb97 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 13:54:12 -0700 Subject: [PATCH 16/24] Write both resume sentences out instead of patching one - The no-records branch turned the shared "If it hasn't yet" sentence into "If they haven't yet" with a string replace; a reword of the original would silently leave it saying "it" about records. - The sentence is now built from its subject, so each branch names the wording it wants and the tests already asserting both keep catching drift. --- packages/cli-core/src/commands/deploy/copy.test.ts | 5 ++++- packages/cli-core/src/commands/deploy/copy.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 0154d65d0..825631409 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -516,7 +516,7 @@ describe("dnsHandoffNothingToAdd", () => { expect(out).toContain("The SSL certificate is still pending; Clerk issues it automatically."); expect(out).toContain(`Clerk Dashboard:\n ${URL}`); expect(out).toContain("checks whether the certificate has been issued"); - expect(out).toContain("wait a few minutes and check again"); + expect(out).toContain("If it hasn't yet, you can either wait a few minutes and check again"); // No timing promise the status can't back up. expect(out).not.toContain("usually takes"); expect(out).not.toContain("Configure DNS"); @@ -560,6 +560,9 @@ describe("dnsHandoffNothingToAdd", () => { expect(out).toContain("add them at your DNS provider, then choose Check DNS now below"); expect(out).toContain(`Check DNS now below:\n ${URL}`); expect(out).toContain("checks that they have taken effect"); + // Plural subject: the sentence is about records, not a certificate. + expect(out).toContain("If they haven't yet, you can either wait a few minutes"); + expect(out).not.toContain("If it hasn't"); expect(out).not.toContain("Configure DNS"); }, ); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index cbfa5affb..c71893dff 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -237,8 +237,10 @@ export function dnsHandoffNothingToAdd( ): string[] { const state = classifyDomainPending(status, false); const url = domainsUrl ? [` ${domainsUrl}`] : []; - const resume = - "If it hasn't yet, you can either wait a few minutes and check again, or skip the check and run `clerk deploy` again later to finish."; + // Written out per subject rather than patched by string replacement, so a + // reword of one can't leave the other reading "it" about records. + const resume = (subject: "it hasn't" | "they haven't") => + `If ${subject} yet, you can either wait a few minutes and check again, or skip the check and run \`clerk deploy\` again later to finish.`; switch (state) { case "ssl_pending": return [ @@ -250,7 +252,7 @@ export function dnsHandoffNothingToAdd( nextStepSentence({ ...options, check: "checks whether the certificate has been issued", - resume, + resume: resume("it hasn't"), }), ]; case "finalizing": @@ -287,7 +289,7 @@ export function dnsHandoffNothingToAdd( nextStepSentence({ ...options, check: "checks that they have taken effect", - resume: resume.replace("If it hasn't yet", "If they haven't yet"), + resume: resume("they haven't"), }), ]; } From 57c189bc3333c913ebc46b8e580f3c4c8d8c0284 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 13:54:58 -0700 Subject: [PATCH 17/24] Name the DNS handoff's two record lists - runDnsRecordHandoff took the records to display and the records for the zone-file export as adjacent positional arrays of the same type, so swapping them at a call site would type-check and ship a partial zone file. - Both now live in the options object under their own names. No behavior change. --- .../cli-core/src/commands/deploy/index.ts | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 379857d75..64d2a1049 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -224,11 +224,13 @@ async function startNewDeploy(ctx: DeployContext): Promise { // is what comes next. await runDnsRecordHandoff( { ...operationState, pending: { type: "dns" } }, - cnameTargets, - cnameTargets, - // Nothing has been checked yet on a fresh run. - { dns: false, ssl: false, mail: false }, - { oauthNext: oauthProviders.length > 0 }, + { + display: cnameTargets, + exportTargets: cnameTargets, + // Nothing has been checked yet on a fresh run. + status: { dns: false, ssl: false, mail: false }, + oauthNext: oauthProviders.length > 0, + }, ); bar(); @@ -398,11 +400,17 @@ async function confirmProductionInstanceCreation(domain: string): Promise { + const { display, exportTargets, status } = options; const handoffInstanceId = state.productionInstanceId; const domainsUrl = handoffInstanceId ? domainsDashboardUrl(state.appId, handoffInstanceId) @@ -416,7 +424,7 @@ async function runDnsRecordHandoff( ? [ ...dnsIntro(state.domain), "", - ...dnsRecords(display, options), + ...dnsRecords(display, { afterCheck: options.afterCheck }), "", ...dnsDashboardHandoff(state.domain, domainsUrl, { oauthNext: options.oauthNext }), ] @@ -446,13 +454,13 @@ async function runExistingDomainDnsVerification( // are records to add, and the user may have added those already. const allTargets = state.cnameTargets ?? []; // OAuth ran before this on the resume path, so the check is next. - await runDnsRecordHandoff( - state, - pendingCnameTargets(allTargets, componentStatus), - allTargets, - componentStatus, - { afterCheck: true, oauthNext: false }, - ); + await runDnsRecordHandoff(state, { + display: pendingCnameTargets(allTargets, componentStatus), + exportTargets: allTargets, + status: componentStatus, + afterCheck: true, + oauthNext: false, + }); return runDnsVerificationPrompt(ctx, state); } From d69e0564d17dc936d822a1a761e0054dcf9efc7a Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 13:54:58 -0700 Subject: [PATCH 18/24] Describe the Google consent-screen tip accurately - The README said the tip names the linked application; the tip deliberately does not, since showing Clerk's app slug read as a recommendation to use it. - Say what the tip actually tells the user. --- packages/cli-core/src/commands/deploy/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 36606bf01..0a88caaf2 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -176,10 +176,10 @@ Most providers ask for `client_id` and `client_secret`. Provider-specific schema The CLI keeps small local overrides for provider setup details that schema does not fully describe: -| Provider | Override | -| -------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| Google | Optional Google Cloud Console JSON import, OAuth consent screen warning, and a tip naming the linked application for the consent screen | -| Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | +| Provider | Override | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Google | Optional Google Cloud Console JSON import, OAuth consent screen warning, and a tip that the consent-screen name is what users see, so choose the name you want them to see | +| Apple | `.p8` file import, production-required `team_id` and `key_id`, native-only field omissions | For Google, the wizard can load `client_id` and `client_secret` from the top-level `web` object in a Google Cloud Console OAuth client JSON file, or from `installed` for desktop-style client downloads. The file contents are used in memory and are not written to CLI config. From 605db2c06b1a31012f16bca49b3fde0a9f2fc58a Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Thu, 17 Sep 2026 15:08:28 -0700 Subject: [PATCH 19/24] Classify and label record hosts from one table - The email-host prefixes were listed twice in copy.ts: once to decide which records are email DNS (filtering and the SPF/DKIM sentence) and once to pick the row label. A new DKIM host added to one list but not the other would be classified as email yet print as a bare CNAME row. - One Map now drives both. The pre-creation confirmation list keeps its own DKIM hosts since it answers a different question. No output change. --- packages/cli-core/src/commands/deploy/copy.ts | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index c71893dff..26e4cef74 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -173,36 +173,45 @@ export function cnameTargetPending(target: CnameTarget, status: DeployComponentS return !status.dns; } +/** + * What each record host is, keyed by its first label (`clk._domainkey` and + * `clk2._domainkey` arrive as "clk"/"clk2"). One table drives both how a + * record is classified (email DNS vs DNS, for filtering and for the SPF/DKIM + * sentence) and how it is labelled on screen, so a new host can't be + * classified as email and still print as an unlabelled "CNAME" row. + * + * "DKIM" stays in the label: it is the standard name for these records and + * already appears in the host. What Clerk manages on the user's behalf is + * said once under the block instead of on each row, where it read as + * "nothing to do here". + * + * `productionDnsHosts` keeps its own list of the DKIM hosts: it answers + * "which records will this domain need" before any exist, not "what is this + * record", so the selector assumption documented there still lives there. + */ +const CNAME_HOSTS = new Map([ + ["clerk", { label: "Frontend API", mail: false }], + ["accounts", { label: "Account portal", mail: false }], + ["clkmail", { label: "Email", mail: true }], + ["clk", { label: "Email (DKIM)", mail: true }], + ["clk2", { label: "Email (DKIM)", mail: true }], +]); + +function cnameHostInfo(host: string): { label: string; mail: boolean } | undefined { + return CNAME_HOSTS.get(host.split(".", 1)[0] ?? ""); +} + function isMailCnameTarget(target: CnameTarget): boolean { - const prefix = target.host.split(".", 1)[0]; - return prefix === "clkmail" || prefix === "clk" || prefix === "clk2"; + return cnameHostInfo(target.host)?.mail ?? false; } /** * Human label for a record host, used by every screen that lists records so * the confirmation screen and the records block can't name the same host two - * ways. "DKIM" stays: it is the standard name for these records and already - * appears in the host (`clk._domainkey`). What Clerk manages on the user's - * behalf is said once under the block instead of on each row, where it read - * as "nothing to do here". + * ways. */ function cnameTargetLabel(host: string): string { - // `host.split(".", 1)[0]` yields only the first label, so DKIM records - // (clk._domainkey, clk2._domainkey) arrive here as "clk"/"clk2". - const prefix = host.split(".", 1)[0]; - switch (prefix) { - case "clerk": - return "Frontend API"; - case "accounts": - return "Account portal"; - case "clkmail": - return "Email"; - case "clk": - case "clk2": - return "Email (DKIM)"; - default: - return "CNAME"; - } + return cnameHostInfo(host)?.label ?? "CNAME"; } /** From bd35679db1cb4473d469ec4df3c016e4d22e9017 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Fri, 18 Sep 2026 08:30:23 -0700 Subject: [PATCH 20/24] Wrap the wizard's new prose to fit an 80-column terminal - The wizard prefixes each printed line with a gutter, added per line the code emits. The sentences this branch added ran to 248 columns, so the terminal's soft wrap put their remainders outside the frame, and wrapped bullets lost their hanging indent. - Adds lib/wrap.ts: word-wrap to 76 visible columns, with the bullet or label prefix carried as a hanging indent and color codes not counted. Every new sentence in the DNS screens, the check footer, and the confirmation lead goes through it; the closing next-steps block is hand-wrapped to match its neighbours. - Tests that assert on a sentence now read it through a helper that folds wrap continuations back together; layout assertions are unchanged. --- .../cli-core/src/commands/deploy/copy.test.ts | 74 ++++++----- packages/cli-core/src/commands/deploy/copy.ts | 120 ++++++++++++------ .../src/commands/deploy/index.test.ts | 52 +++++--- packages/cli-core/src/lib/wrap.test.ts | 51 ++++++++ packages/cli-core/src/lib/wrap.ts | 49 +++++++ 5 files changed, 255 insertions(+), 91 deletions(-) create mode 100644 packages/cli-core/src/lib/wrap.test.ts create mode 100644 packages/cli-core/src/lib/wrap.ts diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 825631409..32ec8498f 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -24,6 +24,14 @@ import type { CnameTarget } from "../../lib/plapi.ts"; const stripAnsi = (value: string): string => value.replace(new RegExp(String.raw`\x1b\[[0-9;]*m`, "g"), ""); +/** + * Lines with wrap continuations folded back into their sentence, for asserting + * on what a sentence says. Assertions about layout (blank lines, a URL on its + * own line, bullets) use the raw join instead. + */ +const flat = (lines: readonly string[] | string): string => + stripAnsi(typeof lines === "string" ? lines : lines.join("\n")).replace(/\n[ \t]*/g, " "); + describe("bindZoneFile", () => { const fixedDate = new Date("2026-05-20T18:30:00.000Z"); @@ -166,7 +174,7 @@ describe("nextStepsBody", () => { test("ends with a real sign-up on the production domain", () => { const output = nextStepsBody("app_123", "ins_456", "example.com", "verified"); - expect(output).toContain( + expect(flat(output)).toContain( "3. Redeploy your app, then sign up at https://example.com to confirm it works", ); }); @@ -213,7 +221,9 @@ describe("domainAssociationSummary", () => { }); test("lead sentence says records are coming, with no record count in it", () => { - const [lead] = domainAssociationSummary("example.com"); + // The lead now wraps onto several lines; read up to the first blank line. + const lines = domainAssociationSummary("example.com"); + const lead = flat(lines.slice(0, lines.indexOf(""))); expect(lead).toContain("Clerk will use these subdomains for"); // Disclose the obligation before the one-way step without demanding @@ -237,7 +247,7 @@ describe("domainAssociationSummary", () => { expect(output).toContain("Account portal accounts.example.com"); // The server omits the Account portal record when the portal is disabled // on the cloned instance, so the lead can't promise one record per row. - expect(output).toContain( + expect(flat(output)).toContain( "You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:", ); expect(output).not.toContain("a DNS record for each"); @@ -250,7 +260,7 @@ describe("dnsIntro", () => { test("sets the propagation expectation as minutes, with 48 hours as the outlier", () => { const output = dnsIntro("example.com").join("\n"); - expect(output).toContain( + expect(flat(output)).toContain( "usually propagate within minutes, but can occasionally take up to 48 hours", ); expect(output).not.toContain("It can take up to 48 hours"); @@ -275,12 +285,14 @@ describe("deployStatusPendingFooter", () => { expect(output).toContain("DNS and email DNS records not found yet for example.com."); // A "Check again" prompt follows this footer, so it points there first and // gives the resume command as the fallback. - expect(output).toContain( + expect(flat(output)).toContain( "Add them at your DNS provider if you haven't already, then choose Check again below.", ); - expect(output).toContain("skip for now and run `clerk deploy` later to resume"); - expect(output).toContain("usually takes minutes, but can occasionally take up to 48 hours"); - expect(output).toContain( + expect(flat(output)).toContain("skip for now and run `clerk deploy` later to resume"); + expect(flat(output)).toContain( + "usually takes minutes, but can occasionally take up to 48 hours", + ); + expect(flat(output)).toContain( "change the domain in the Clerk Dashboard: https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", ); expect(output).not.toContain("still pending"); @@ -319,7 +331,7 @@ describe("deployStatusPendingFooter", () => { expect(output).toContain("DNS and email DNS records not found yet for example.com.\n\n"); expect(output).toContain("Clerk didn't return the list of records to add."); // URL on its own line so terminal autolinkers don't swallow punctuation. - expect(output).toContain( + expect(flat(output)).toContain( "Find them on the Domains page in the Clerk Dashboard, add them, then choose Check again below.", ); expect(output).toContain(`already created.\n ${DOMAINS_URL}`); @@ -342,8 +354,8 @@ describe("deployStatusPendingFooter", () => { ).join("\n"); expect(output).toContain("SSL certificate still pending for example.com."); - expect(output).toContain("choose Check again below in a few minutes"); - expect(output).toContain("run `clerk deploy` later to resume"); + expect(flat(output)).toContain("choose Check again below in a few minutes"); + expect(flat(output)).toContain("run `clerk deploy` later to resume"); // One follow-up line: a blank line and a sentence, not a one-item list. expect(output).toContain("example.com.\n\nClerk issues it"); expect(output).not.toContain(" - "); @@ -381,8 +393,8 @@ describe("deployStatusPendingFooter", () => { const output = deployStatusPendingFooter("example.com", status, DOMAINS_URL, records).join( "\n", ); - expect(output).toMatch(/run `clerk deploy` (again|later).*to resume/i); - expect(output).toMatch(/production instance is already created\./i); + expect(flat(output)).toMatch(/run `clerk deploy` (again|later).*to resume/i); + expect(flat(output)).toMatch(/production instance is already created\./i); }); test("omits the Dashboard URL cleanly when no production instance id is known", () => { @@ -392,7 +404,7 @@ describe("deployStatusPendingFooter", () => { undefined, true, ).join("\n"); - expect(output).toContain("change the domain in the Clerk Dashboard."); + expect(flat(output)).toContain("change the domain in the Clerk Dashboard."); expect(output).not.toContain("undefined"); }); }); @@ -462,7 +474,7 @@ describe("dnsRecords", () => { expect(output).toContain("Email (DKIM)"); expect(output).not.toContain("\n CNAME\n Type:"); // Said once under the block, never on a row the user must act on. - expect(output).toContain( + expect(flat(output)).toContain( "The email records point at Clerk, so you don't need to create SPF or DKIM values yourself.", ); expect(output).not.toMatch(/Email \(DKIM\).*Clerk handles/); @@ -513,10 +525,14 @@ describe("dnsHandoffNothingToAdd", () => { ); expect(out).toContain("Your DNS records for example.com are verified."); - expect(out).toContain("The SSL certificate is still pending; Clerk issues it automatically."); + expect(flat(out)).toContain( + "The SSL certificate is still pending; Clerk issues it automatically.", + ); expect(out).toContain(`Clerk Dashboard:\n ${URL}`); expect(out).toContain("checks whether the certificate has been issued"); - expect(out).toContain("If it hasn't yet, you can either wait a few minutes and check again"); + expect(flat(out)).toContain( + "If it hasn't yet, you can either wait a few minutes and check again", + ); // No timing promise the status can't back up. expect(out).not.toContain("usually takes"); expect(out).not.toContain("Configure DNS"); @@ -531,10 +547,10 @@ describe("dnsHandoffNothingToAdd", () => { ); expect(out).toContain("Your DNS records and SSL certificate for example.com are verified."); - expect(out).toContain("Clerk is still finalizing production setup."); + expect(flat(out)).toContain("Clerk is still finalizing production setup."); // The check pauses the run once everything is verified, so "check again" // would name an option the prompt never offers. - expect(out).toContain("run `clerk deploy` again in a few minutes"); + expect(flat(out)).toContain("run `clerk deploy` again in a few minutes"); expect(out).not.toContain("check again"); }); @@ -553,15 +569,15 @@ describe("dnsHandoffNothingToAdd", () => { dnsHandoffNothingToAdd("example.com", status, URL, { oauthNext: false }).join("\n"), ); - expect(out).toContain( + expect(flat(out)).toContain( `${records} records for example.com are not verified yet, but Clerk didn't return the list to add.`, ); // An instruction, not a wait: the records still have to be added. - expect(out).toContain("add them at your DNS provider, then choose Check DNS now below"); + expect(flat(out)).toContain("add them at your DNS provider, then choose Check DNS now below"); expect(out).toContain(`Check DNS now below:\n ${URL}`); expect(out).toContain("checks that they have taken effect"); // Plural subject: the sentence is about records, not a certificate. - expect(out).toContain("If they haven't yet, you can either wait a few minutes"); + expect(flat(out)).toContain("If they haven't yet, you can either wait a few minutes"); expect(out).not.toContain("If it hasn't"); expect(out).not.toContain("Configure DNS"); }, @@ -576,10 +592,10 @@ describe("dnsHandoffNothingToAdd", () => { }).join("\n"), ); - expect(out).toContain( + expect(flat(out)).toContain( "Find them on the Domains page in the Clerk Dashboard and add them at your DNS provider:", ); - expect(out).toContain( + expect(flat(out)).toContain( "Next you'll set up OAuth, then this command checks that they have taken effect.", ); expect(out).not.toContain("Check DNS now"); @@ -605,15 +621,15 @@ describe("dnsDashboardHandoff", () => { expect(output).toContain(`Clerk Dashboard:\n ${DOMAINS_URL}`); // "wizard" appears nowhere else the user can see, so it isn't introduced here. expect(output).not.toContain("wizard"); - expect(output).toContain( + expect(flat(output)).toContain( "Next you'll set up OAuth, then this command checks that these records have taken effect at your DNS provider.", ); // A failed check is not a dead end: "Check again" is the other choice on // the prompt that follows, and the sentence names it. - expect(output).toContain( + expect(flat(output)).toContain( "you can either wait a few minutes and check again, or skip the check", ); - expect(output).toContain("run `clerk deploy` again later to finish"); + expect(flat(output)).toContain("run `clerk deploy` again later to finish"); // "skip and finish" read as though skipping completed the deploy. expect(output).not.toContain("skip and finish"); }); @@ -623,7 +639,7 @@ describe("dnsDashboardHandoff", () => { // Under a checklist showing OAuth done, "you'll set up OAuth" was wrong. const output = dnsDashboardHandoff("example.com", DOMAINS_URL, { oauthNext: false }).join("\n"); - expect(output).toContain( + expect(flat(output)).toContain( "Next, this command checks that these records have taken effect at your DNS provider.", ); expect(output).not.toContain("set up OAuth"); @@ -633,7 +649,7 @@ describe("dnsDashboardHandoff", () => { test("ends the sentence cleanly when no Dashboard URL is known", () => { const output = dnsDashboardHandoff("example.com", undefined, { oauthNext: true }).join("\n"); - expect(output).toContain("on the Domains page in the Clerk Dashboard."); + expect(flat(output)).toContain("on the Domains page in the Clerk Dashboard."); expect(output).not.toContain("undefined"); }); }); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 26e4cef74..2342b5580 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -1,6 +1,7 @@ import { bold, cyan, dim, green, yellow } from "../../lib/color.ts"; import type { CnameTarget } from "../../lib/plapi.ts"; import { buildDashboardUrl } from "../../lib/environment.ts"; +import { wrap } from "../../lib/wrap.ts"; export type DeployPlanStep = { label: string; @@ -58,7 +59,10 @@ export function dnsIntro(domain: string): string[] { "Clerk uses DNS records to provide session management and emails", "verified from your domain.", "", - `${yellow("NOTE")} DNS records usually propagate within minutes, but can occasionally take up to 48 hours.`, + ...wrap( + `${yellow("NOTE")} DNS records usually propagate within minutes, but can occasionally take up to 48 hours.`, + { hang: 6 }, + ), `${dim(cyan("TIP"))} If you can't add a CNAME for the Frontend API, you can use a proxy:`, dim(" https://clerk.com/docs/guides/dashboard/dns-domains/proxy-fapi"), dim("Reference: https://clerk.com/docs/guides/development/deployment/production#dns-records"), @@ -105,7 +109,9 @@ export function domainAssociationSummary(domain: string): string[] { // "The exact list": the server omits the Account portal record when the // portal is disabled on the instance being cloned, and this screen runs // before the CLI can know that. - `Clerk will use these subdomains for ${cyan(domain)}. You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:`, + ...wrap( + `Clerk will use these subdomains for ${cyan(domain)}. You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:`, + ), "", ...hosts.map((host, i) => ` ${labels[i]!.padEnd(width)} ${host}`), "", @@ -145,12 +151,17 @@ export function dnsRecords( // screen: a resume where email DNS is already verified lists none. lines.push( "", - "The email records point at Clerk, so you don't need to create SPF or DKIM values yourself.", + ...wrap( + "The email records point at Clerk, so you don't need to create SPF or DKIM values yourself.", + ), ); } lines.push( "", - `${yellow("NOTE")} If your DNS host proxies these records, set them to "DNS only" or verification will fail.`, + ...wrap( + `${yellow("NOTE")} If your DNS host proxies these records, set them to "DNS only" or verification will fail.`, + { hang: 6 }, + ), ); return lines; } @@ -253,31 +264,39 @@ export function dnsHandoffNothingToAdd( switch (state) { case "ssl_pending": return [ - `Your DNS records for ${cyan(domain)} are verified. The SSL certificate is still pending; Clerk issues it automatically.`, + ...wrap( + `Your DNS records for ${cyan(domain)} are verified. The SSL certificate is still pending; Clerk issues it automatically.`, + ), "", `Monitor SSL issuance on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, ...url, "", - nextStepSentence({ - ...options, - check: "checks whether the certificate has been issued", - resume: resume("it hasn't"), - }), + ...wrap( + nextStepSentence({ + ...options, + check: "checks whether the certificate has been issued", + resume: resume("it hasn't"), + }), + ), ]; case "finalizing": // No "check again": once every component is verified, the check pauses // the run instead of prompting. return [ - `Your DNS records and SSL certificate for ${cyan(domain)} are verified. Clerk is still finalizing production setup.`, + ...wrap( + `Your DNS records and SSL certificate for ${cyan(domain)} are verified. Clerk is still finalizing production setup.`, + ), "", `Monitor it on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, ...url, "", - nextStepSentence({ - ...options, - check: "checks whether Clerk has finished", - resume: "If it hasn't, run `clerk deploy` again in a few minutes.", - }), + ...wrap( + nextStepSentence({ + ...options, + check: "checks whether Clerk has finished", + resume: "If it hasn't, run `clerk deploy` again in a few minutes.", + }), + ), ]; case "records_available": case "records_unavailable": { @@ -290,16 +309,20 @@ export function dnsHandoffNothingToAdd( ? `Find them on the Domains page in the Clerk Dashboard and add them at your DNS provider${domainsUrl ? ":" : "."}` : `Find them on the Domains page in the Clerk Dashboard, add them at your DNS provider, then choose Check DNS now below${domainsUrl ? ":" : "."}`; return [ - `${records} records for ${cyan(domain)} are not verified yet, but Clerk didn't return the list to add.`, + ...wrap( + `${records} records for ${cyan(domain)} are not verified yet, but Clerk didn't return the list to add.`, + ), "", - find, + ...wrap(find), ...url, "", - nextStepSentence({ - ...options, - check: "checks that they have taken effect", - resume: resume("they haven't"), - }), + ...wrap( + nextStepSentence({ + ...options, + check: "checks that they have taken effect", + resume: resume("they haven't"), + }), + ), ]; } } @@ -319,19 +342,23 @@ export function dnsDashboardHandoff( return [ // "this command", not "the wizard": nothing the user sees uses that word. // Skipping the check leaves setup unfinished, so name what resumes it. - `Monitor DNS propagation and SSL issuance for ${domain} on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, + ...wrap( + `Monitor DNS propagation and SSL issuance for ${domain} on the Domains page in the Clerk Dashboard${domainsUrl ? ":" : "."}`, + ), ...(domainsUrl ? [` ${domainsUrl}`] : []), "", // "at your DNS provider" matches the records block's own heading, and "at" // rather than "with": the check looks the records up, it doesn't contact // the provider. Naming both options matters because a failed check isn't a // dead end — "Check again" is the other choice on the prompt that follows. - nextStepSentence({ - oauthNext: options.oauthNext, - check: "checks that these records have taken effect at your DNS provider", - resume: - "If they haven't yet, you can either wait a few minutes and check again, or skip the check and run `clerk deploy` again later to finish.", - }), + ...wrap( + nextStepSentence({ + oauthNext: options.oauthNext, + check: "checks that these records have taken effect at your DNS provider", + resume: + "If they haven't yet, you can either wait a few minutes and check again, or skip the check and run `clerk deploy` again later to finish.", + }), + ), ]; } @@ -446,9 +473,13 @@ export function deployStatusPendingFooter( if (state === "records_available") { return [ `${records} records not found yet for ${domain}.`, - ` - Add them at your DNS provider if you haven't already, then choose Check again below. ${resume}`, - " - Propagation usually takes minutes, but can occasionally take up to 48 hours.", - ` - If you can't add DNS records for this domain, change the domain in the Clerk Dashboard${domainsUrl ? `: ${domainsUrl}` : "."}`, + ...wrap( + ` - Add them at your DNS provider if you haven't already, then choose Check again below. ${resume}`, + ), + ...wrap(" - Propagation usually takes minutes, but can occasionally take up to 48 hours."), + ...wrap( + ` - If you can't add DNS records for this domain, change the domain in the Clerk Dashboard${domainsUrl ? `: ${domainsUrl}` : "."}`, + ), ]; } if (state === "records_unavailable") { @@ -457,7 +488,9 @@ export function deployStatusPendingFooter( return [ `${records} records not found yet for ${domain}.`, "", - `Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, add them, then choose Check again below. ${resume}`, + ...wrap( + `Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, add them, then choose Check again below. ${resume}`, + ), ...(domainsUrl ? [` ${domainsUrl}`] : []), ]; } @@ -465,13 +498,17 @@ export function deployStatusPendingFooter( return [ `SSL certificate still pending for ${domain}.`, "", - `Clerk issues it automatically now that DNS is verified; choose Check again below in a few minutes. ${resume}`, + ...wrap( + `Clerk issues it automatically now that DNS is verified; choose Check again below in a few minutes. ${resume}`, + ), ]; } return [ `Production setup for ${domain} is still finalizing on Clerk's side.`, "", - "Run `clerk deploy` again in a few minutes to resume. The production instance is already created.", + ...wrap( + "Run `clerk deploy` again in a few minutes to resume. The production instance is already created.", + ), ]; } @@ -514,9 +551,10 @@ export function nextStepsBody( // would send the user to a page that doesn't exist yet. const step3 = domainStatus === "verified" - ? `Redeploy your app, then sign up at https://${domain} to confirm it works` - : `Run \`clerk deploy\` again once the domain is verified, then redeploy your app - and sign up at https://${domain} to confirm it works`; + ? `Redeploy your app, then sign up at https://${domain} to confirm it + works` + : `Run \`clerk deploy\` again once the domain is verified, then redeploy + your app and sign up at https://${domain} to confirm it works`; return ` 1. Pull production keys into your environment clerk env pull --instance prod @@ -544,8 +582,8 @@ export function nextStepsBody( - DNS and SSL status: ${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. +${yellow("NOTE")} Production keys only work on your production domain. They will not + work on localhost. To run your dev environment, keep using your dev keys. ${dim("Reference: https://clerk.com/docs/guides/development/deployment/production#api-keys-and-environment-variables")}`; } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 44a840246..2c413d704 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -77,6 +77,14 @@ function stripAnsi(value: string): string { return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); } +/** + * Captured wizard output with the gutter and wrap continuations folded back + * into sentences, for asserting on what a line says rather than how it wraps. + */ +function flat(value: string): string { + return stripAnsi(value).replace(/\n│?[ \t]*/g, " "); +} + /** What the clack prompt wrappers throw when the user presses Ctrl-C. */ function promptExitError(): Error { return new UserAbortError(); @@ -902,7 +910,7 @@ describe("deploy", () => { expect(err).toContain("[ ] Create production instance"); expect(err).toContain("[ ] Verify DNS records"); expect(err).toContain("[ ] Configure Google OAuth credentials"); - expect(err).toContain("on the Domains page in the Clerk Dashboard"); + expect(flat(err)).toContain("on the Domains page in the Clerk Dashboard"); }); test("asks directly for an owned production domain and accepts short domains", async () => { @@ -983,7 +991,7 @@ describe("deploy", () => { expect(err).toContain("clerk env pull --instance prod"); expect(err).toContain("Update env vars on your hosting provider"); expect(err).toContain("Also copy the other Clerk variables from your env file"); - expect(err).toContain("sign up at https://example.com to confirm it works"); + expect(flat(err)).toContain("sign up at https://example.com to confirm it works"); expect(err).toContain("Manage this instance in the Clerk Dashboard"); expect(err).toContain( "https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", @@ -1003,7 +1011,7 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); - expect(err).toContain( + expect(flat(err)).toContain( "Clerk will use these subdomains for example.com. You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:", ); // A blank line separates the domain the user just typed from this screen. @@ -1013,9 +1021,9 @@ describe("deploy", () => { expect(err).toContain("clkmail.example.com"); expect(err).toContain("This will create a Clerk production instance"); expect(err).toContain("Add the following records at your DNS provider"); - expect(err).toContain("on the Domains page in the Clerk Dashboard"); + expect(flat(err)).toContain("on the Domains page in the Clerk Dashboard"); expect(err).toContain("propagation and SSL issuance"); - expect(err).toContain( + expect(flat(err)).toContain( "Next you'll set up OAuth, then this command checks that these records have taken effect at your DNS provider", ); expect(mockConfirm).toHaveBeenCalledTimes(3); @@ -1049,7 +1057,7 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain( + expect(flat(err)).toContain( "Clerk will use these subdomains for example.com. You'll add DNS records for them after the instance is created. The exact list is printed once the instance exists:", ); expect(err).toContain("No production instance was created."); @@ -1790,7 +1798,7 @@ describe("deploy", () => { expect(err).toContain("Host: clkmail.example.com"); expect(err).not.toContain("Host: clerk.example.com"); // OAuth ran before this screen on resume, so it is not "next". - expect(err).toContain( + expect(flat(err)).toContain( "Next, this command checks that these records have taken effect at your DNS provider.", ); expect(err).not.toContain("set up OAuth"); @@ -1859,7 +1867,9 @@ describe("deploy", () => { // Nothing left to add: the screen says the certificate is pending // instead of framing a DNS task around an empty record list. expect(err).toContain("Your DNS records for example.com are verified."); - expect(err).toContain("The SSL certificate is still pending; Clerk issues it automatically."); + expect(flat(err)).toContain( + "The SSL certificate is still pending; Clerk issues it automatically.", + ); expect(err).not.toContain("Configure DNS for"); expect(err).not.toContain("these records"); expect(err).not.toContain("Add the following records"); @@ -1887,8 +1897,8 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("Clerk is still finalizing production setup."); - expect(err).toContain("run `clerk deploy` again in a few minutes"); + expect(flat(err)).toContain("Clerk is still finalizing production setup."); + expect(flat(err)).toContain("run `clerk deploy` again in a few minutes"); expect(err).not.toContain("check again"); expect(err).not.toContain("Configure DNS for"); }); @@ -1912,10 +1922,10 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain( + expect(flat(err)).toContain( "Email DNS records for example.com are not verified yet, but Clerk didn't return the list to add.", ); - expect(err).toContain("add them at your DNS provider, then choose Check DNS now below"); + expect(flat(err)).toContain("add them at your DNS provider, then choose Check DNS now below"); expect(err).not.toContain("Add the following records"); // No records, no export offer. expect(mockConfirm).not.toHaveBeenCalledWith( @@ -1964,7 +1974,7 @@ describe("deploy", () => { await runDeploy({}); const err = stripAnsi(captured.err); - expect(err).toContain("add them at your DNS provider, then choose Check DNS now below"); + expect(flat(err)).toContain("add them at your DNS provider, then choose Check DNS now below"); expect(err).toContain("Next, this command checks that they have taken effect."); expect(err).not.toContain("set up OAuth"); }); @@ -1995,11 +2005,11 @@ describe("deploy", () => { // missing and what to do, instead of a "Configure DNS" page with no records. // This run has a Google provider, so OAuth setup is the next prompt and // the screen must not point at a "Check DNS now" that isn't there yet. - expect(err).toContain( + expect(flat(err)).toContain( "DNS and email DNS records for example.com are not verified yet, but Clerk didn't return the list to add.", ); - expect(err).toContain("and add them at your DNS provider:"); - expect(err).toContain( + expect(flat(err)).toContain("and add them at your DNS provider:"); + expect(flat(err)).toContain( "Next you'll set up OAuth, then this command checks that they have taken effect.", ); expect(err).not.toContain("Check DNS now below"); @@ -2007,7 +2017,7 @@ describe("deploy", () => { // After the check, the footer says the same in its own words. expect(err).toContain("DNS and email DNS records not found yet for example.com."); expect(err).toContain("Clerk didn't return the list of records to add."); - expect(err).toContain( + expect(flat(err)).toContain( "Find them on the Domains page in the Clerk Dashboard, add them, then choose Check again below.", ); // The URL line belongs to the footer, so it precedes the skip, not the @@ -2101,8 +2111,8 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); - expect(err).toContain("on the Domains page in the Clerk Dashboard"); - expect(err).toContain( + expect(flat(err)).toContain("on the Domains page in the Clerk Dashboard"); + expect(flat(err)).toContain( "Next you'll set up OAuth, then this command checks that these records have taken effect at your DNS provider", ); expect(err).toContain("Configure Google OAuth for production"); @@ -2220,7 +2230,7 @@ describe("deploy", () => { await runDeployUntilPause(); mockLiveProduction(); - expect(stripAnsi(captured.err)).toContain("on the Domains page in the Clerk Dashboard"); + expect(flat(captured.err)).toContain("on the Domains page in the Clerk Dashboard"); expect(stripAnsi(captured.err)).toContain("Configure Google OAuth for production"); captured.clear(); @@ -2436,7 +2446,7 @@ describe("deploy", () => { expect(err).toContain("Propagation usually takes minutes"); expect(err).toContain("DNS and email DNS records not found yet for example.com"); // The footer, not the Next steps block, carries the change-domain URL. - expect(err).toContain( + expect(flat(err)).toContain( "change the domain in the Clerk Dashboard: https://dashboard.clerk.com/apps/app_xyz789/instances/ins_prod_mock/domains", ); expect(err).not.toContain("still pending"); diff --git a/packages/cli-core/src/lib/wrap.test.ts b/packages/cli-core/src/lib/wrap.test.ts new file mode 100644 index 000000000..e5fd2c679 --- /dev/null +++ b/packages/cli-core/src/lib/wrap.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { cyan } from "./color.ts"; +import { visibleWidth, wrap } from "./wrap.ts"; + +describe("wrap", () => { + test("keeps a short line as one line", () => { + expect(wrap("Short enough.")).toEqual(["Short enough."]); + }); + + test("breaks on spaces so no line exceeds the width", () => { + const text = "one two three four five six seven eight nine ten eleven twelve"; + const lines = wrap(text, { width: 20 }); + expect(lines.every((line) => line.length <= 20)).toBe(true); + expect(lines.join(" ")).toBe(text); + }); + + test("gives a bullet a hanging indent on continuation lines", () => { + const lines = wrap(" - Add them at your DNS provider if you haven't already.", { width: 30 }); + expect(lines[0]).toBe(" - Add them at your DNS"); + expect( + lines.slice(1).every((line) => line.startsWith(" ") && !line.startsWith(" ")), + ).toBe(true); + }); + + test("uses an explicit hang for a label prefix", () => { + const lines = wrap("NOTE DNS records usually propagate within minutes.", { + width: 32, + hang: 6, + }); + expect(lines[0]).toBe("NOTE DNS records usually"); + expect(lines[1]).toBe(" propagate within minutes."); + }); + + test("measures width without color codes", () => { + expect(visibleWidth(cyan("example.com"))).toBe("example.com".length); + const lines = wrap( + `Clerk will use these subdomains for ${cyan("example.com")}. More words here.`, + { + width: 50, + }, + ); + expect(lines.every((line) => visibleWidth(line) <= 50)).toBe(true); + expect(lines[0]).toContain(cyan("example.com")); + }); + + test("never splits a token wider than the width", () => { + const url = "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains"; + const lines = wrap(`See ${url} for details.`, { width: 20 }); + expect(lines).toContain(url); + }); +}); diff --git a/packages/cli-core/src/lib/wrap.ts b/packages/cli-core/src/lib/wrap.ts new file mode 100644 index 000000000..1dc3b9353 --- /dev/null +++ b/packages/cli-core/src/lib/wrap.ts @@ -0,0 +1,49 @@ +const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1b\[[0-9;]*m`, "g"); + +/** Columns a string occupies on screen; color escape codes don't count. */ +export function visibleWidth(text: string): number { + return text.replace(ANSI_ESCAPE_PATTERN, "").length; +} + +/** + * Word-wrap one paragraph to `width` visible columns. + * + * The wizard prefixes each line it prints with a gutter, and the terminal's + * own soft wrap doesn't know about it: the wrapped remainder lands at column + * zero and breaks the frame. So prose is wrapped here, before printing. 76 + * leaves room for the gutter on an 80-column terminal, which is what the + * hand-wrapped text in the deploy module already assumes. + * + * Leading indentation and a bullet or number marker (" - ", " 1. ") turn + * into a hanging indent on continuation lines; `hang` overrides that for + * lines led by a label such as "NOTE ". A token wider than the room left, + * such as a URL, is never split; it overflows on its own line instead. + */ +export function wrap(text: string, options: { width?: number; hang?: number } = {}): string[] { + const width = options.width ?? 76; + const lead = /^(\s*)((?:[-*]|\d+\.)\s+)?/.exec(text.replace(ANSI_ESCAPE_PATTERN, ""))?.[0] ?? ""; + const hang = " ".repeat(options.hang ?? lead.length); + + const lines: string[] = []; + let line = ""; + let lineWidth = 0; + let empty = true; + for (const word of text.split(" ")) { + const wordWidth = visibleWidth(word); + if (!empty && lineWidth + 1 + wordWidth > width) { + lines.push(line.trimEnd()); + line = hang; + lineWidth = hang.length; + empty = true; + } + if (!empty) { + line += " "; + lineWidth += 1; + } + line += word; + lineWidth += wordWidth; + empty = false; + } + lines.push(line.trimEnd()); + return lines; +} From b9b06f7cec561a09c15375b0a80b711be7dd0bd0 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Fri, 18 Sep 2026 08:33:53 -0700 Subject: [PATCH 21/24] Render human and agent next steps from one classification - Human-mode deploy status built its sentence by running seven find-and-replace rules over the agent's nextAction. A reword on the agent side could silently leave a person reading agent instructions. - The report is now classified once (deployNextStep) into a small next-step value, and agentNextAction and humanNextAction each render their own sentence from it. Every sentence is byte-identical to before; the existing per-state tests pass unchanged. - The JSON report gains a urls field (instance and Domains page, or null before an instance exists) so the human renderer reads the URL as data instead of extracting it from prose. Additive; nextAction is unchanged. - Adds a per-state test that the human line never contains agent phrasing, and tests for the classifier and the urls field. --- .changeset/deploy-wizard-copy.md | 1 + .../cli-core/src/commands/deploy/README.md | 3 +- .../commands/deploy/status-command.test.ts | 46 ++- .../src/commands/deploy/status-command.ts | 116 ++++--- .../src/commands/deploy/status.test.ts | 122 +++++++- .../cli-core/src/commands/deploy/status.ts | 296 +++++++++++------- 6 files changed, 424 insertions(+), 160 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index 1d1162617..aa6daea8b 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -14,3 +14,4 @@ - Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. When none are outstanding, the DNS screen says what is (the SSL certificate, Clerk finalizing, or a record list Clerk didn't return) instead of a "Configure DNS" page with no records, and the closing screen says "Not yet verified" rather than "DNS pending" so it is right when DNS is done and something else is pending. - Each DNS record host is named the same way on every screen, the note about what Clerk manages moved off the rows the user has to add, the Domains-page pointer carries its link, and the closing line says what happens next instead of implying that skipping the check finishes the deploy, and no longer promises an OAuth step on resume when OAuth has already run. - Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, at `complete` says the production keys still have to reach the host, and names OAuth providers the CLI could not configure so an agent doesn't report OAuth as done. Human-mode `clerk deploy status` prints the pending records, never says "ask the user", and resumes with `clerk deploy` rather than a flag that only affects agents. +- The wizard's new sentences wrap at 76 columns so they stay inside the gutter on an 80-column terminal. The agent JSON report gains a `urls` field with the production instance's Dashboard page and its Domains page; `nextAction` is unchanged. diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 0a88caaf2..00341a4f2 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -51,7 +51,8 @@ In agent mode, `clerk deploy status` emits JSON on stdout with: - `domainStatus`: per-component DNS, SSL, and email DNS status when a domain exists. - `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks, each with `host`, `value`, and Clerk's `required` flag (some targets are optional). - `oauth`: configured, pending, and unsupported provider slugs. -- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints the same sentence reworded for a person: no unsupported-provider clause (the warning row above already says it), no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked. +- `urls`: the production instance's Dashboard page (`instance`) and its Domains page (`domains`), or `null` before a production instance exists. The same URLs appear in `nextAction` prose; this field is the one to read programmatically. +- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints its own sentence, rendered from the same classification of the report (`deployNextStep` in `status.ts`) rather than by rewriting the agent's: no unsupported-provider clause (the warning row above already says it), no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked. Exit codes: diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 0e5a3e1dd..72836d9c3 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -23,7 +23,7 @@ mock.module("../../lib/sleep.ts", () => ({ const { _setConfigDir, setProfile } = await import("../../lib/config.ts"); const { setMode } = await import("../../mode.ts"); const { beginInterrupt, _resetInterruptState } = await import("../../lib/signals.ts"); -const { deployStatus } = await import("./status-command.ts"); +const { deployStatus, humanNextAction } = await import("./status-command.ts"); /** What an in-flight request rejects with once Ctrl-C aborts the shared signal. */ function abortError(): Error { @@ -740,3 +740,47 @@ async function routePlapiFetch( return new Response("Not Found", { status: 404 }); } + +describe("humanNextAction", () => { + // These are the three things that used to leak from the agent sentence into + // the human one. Now that each audience has its own renderer, this pins the + // human side for every state rather than trusting seven replace rules. + const URL = "https://dashboard.clerk.com/apps/app_1/instances/ins_prod"; + const steps = [ + { kind: "not_started" as const }, + { kind: "domain_provisioning" as const, domainsUrl: `${URL}/domains` }, + { kind: "interrupted" as const }, + { kind: "complete" as const, domain: "example.com", oauthUnsupported: ["x"], instanceUrl: URL }, + { kind: "oauth_pending" as const, oauthPending: ["github"], oauthUnsupported: ["x"] }, + ...(["records_available", "records_unavailable", "ssl_pending", "finalizing"] as const).map( + (kind) => ({ kind, domain: "example.com", records: "DNS", domainsUrl: `${URL}/domains` }), + ), + ]; + + test.each(steps.map((step) => ({ kind: step.kind, step })))( + "never speaks to an agent: $kind", + ({ step }) => { + const line = humanNextAction(step); + expect(line).not.toContain("ask the user"); + expect(line).not.toContain("Ask the user"); + expect(line).not.toContain("pendingDnsRecords"); + expect(line).not.toContain("--wait"); + expect(line).not.toContain("human terminal"); + // The warning row above the sentence already names unsupported providers. + expect(line).not.toContain("could not configure them"); + }, + ); + + test("points a person at the wizard to resume, and at the Dashboard to watch", () => { + const line = humanNextAction({ + kind: "records_available", + domain: "example.com", + records: "DNS", + domainsUrl: `${URL}/domains`, + }); + expect(line).toBe( + "DNS records not found yet for example.com. Once they're added, run `clerk deploy` again to resume. " + + `Propagation usually takes minutes. Visit the Clerk Dashboard domains page to monitor its status there: ${URL}/domains`, + ); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index 73954e839..39705c6bd 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -8,11 +8,13 @@ import { deployComponentLabels, dnsRecords, type DeployComponentStatus } from ". import { buildDeployStatusReport, buildInterruptedDeployStatusReport, + deployNextStep, loadProductionDomain, resolveDeployContext, resolveDeployState, triggerDeployStatusCheck, waitForDeployStatus, + type DeployNextStep, type DeployState, type DeployStatusOutcome, type DeployStatusReport, @@ -147,7 +149,7 @@ export function renderHuman(report: DeployStatusReport): void { // is true here. if (report.state === "interrupted") { log.blank(); - log.info(report.nextAction); + log.info(humanNextAction(deployNextStep(report))); log.blank(); return; } @@ -187,52 +189,78 @@ export function renderHuman(report: DeployStatusReport): void { } log.blank(); - log.info(formatHumanNextAction(report.nextAction)); + log.info(humanNextAction(deployNextStep(report))); log.blank(); } /** - * The report's `nextAction` is written for an agent. A person running - * `clerk deploy status` has no JSON and is the user, so the sentences that - * name the JSON field or say "ask the user" are reworded here. Human mode - * already waits, so `--wait` is never suggested; the resume command is the - * wizard, as the wizard's own footer says. + * The line a person sees under `clerk deploy status`. Rendered from the same + * {@link DeployNextStep} as the agent's `nextAction`, not from that sentence: + * the reader is the user (so never "ask the user"), has no JSON (so never + * `pendingDnsRecords`), already waits (so never `--wait`), and resumes setup + * with the wizard. The unsupported-provider warning row above says its piece, + * so it isn't repeated here. Exported so the wording can be tested per state. */ -function formatHumanNextAction(nextAction: string): string { - return ( - nextAction - // The records block printed above already says "add these"; the sentence - // only needs to say what happens next. - .replace( - "Add the records in `pendingDnsRecords` at the domain's DNS provider if you haven't already, then re-run `clerk deploy status --wait`.", - "Once they're added, run `clerk deploy` again to resume.", - ) - .replace( - /but this report has no record list\. Find the records to add on the Domains page in the Clerk Dashboard, then re-run `clerk deploy status --wait`\./, - "but Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, add them, then run `clerk deploy` again to resume.", - ) - // The unsupported-provider warning row above already says this. - .replace( - / These providers are enabled in development but the CLI could not configure them for production: [^.]+\. Configure them in the Clerk Dashboard before going live, or users signing in with them will fail\./, - "", - ) - .replace( - "needs a human terminal, ask the user to run `clerk deploy`, then run `clerk deploy status` to verify.", - "needs a terminal. Run `clerk deploy` to set it up.", - ) - .replace( - "or ask the user to finish `clerk deploy`.", - "or run `clerk deploy` to finish setup.", - ) - .replace( - "Ask the user to finish `clerk deploy`, then run `clerk deploy status`.", - "Run `clerk deploy` to finish setup.", - ) - .replace( - // `https?`: the URL follows CLERK_DASHBOARD_URL, which is plain http for a - // local Dashboard. - /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", - ) - ); +export function humanNextAction(step: DeployNextStep): string { + const domains = (url: string | null): string => + url ? ` Visit the Clerk Dashboard domains page to monitor its status there: ${url}` : ""; + + switch (step.kind) { + case "not_started": + return ( + "No production instance yet. `clerk deploy` configures production interactively and " + + "needs a terminal. Run `clerk deploy` to set it up." + ); + case "domain_provisioning": + return ( + "A production instance exists but its domain is still provisioning. " + + "Run `clerk deploy status` again shortly, or run `clerk deploy` to finish setup." + + domains(step.domainsUrl) + ); + case "interrupted": + return ( + "Interrupted before the deploy status could be read, so nothing is known about this " + + "deploy. Run `clerk deploy status` again to check it." + ); + case "complete": + return ( + `Clerk's production setup for https://${step.domain} is verified. If you haven't already: ` + + `run \`clerk env pull --instance prod\`, set those keys on your host alongside the other ` + + `Clerk variables from your env file, redeploy, then sign up at https://${step.domain} to confirm.` + + (step.instanceUrl + ? ` Manage users, settings, and billing for this instance: ${step.instanceUrl}` + : "") + ); + case "oauth_pending": + return ( + `Domain verified, but these OAuth providers are missing production credentials: ` + + `${step.oauthPending.join(", ")}. Run \`clerk deploy\` to finish setup.` + ); + case "records_available": + // The records block printed above already says "add these"; the + // sentence only needs to say what happens next. + return ( + `${step.records} records not found yet for ${step.domain}. ` + + `Once they're added, run \`clerk deploy\` again to resume. Propagation usually takes minutes.` + + domains(step.domainsUrl) + ); + case "records_unavailable": + return ( + `${step.records} records not found yet for ${step.domain}, but Clerk didn't return the list of records to add. ` + + `Find them on the Domains page in the Clerk Dashboard, add them, then run \`clerk deploy\` again to resume.` + + domains(step.domainsUrl) + ); + case "ssl_pending": + return ( + `SSL certificate still pending for ${step.domain}. Clerk issues it automatically now that ` + + `DNS is verified; re-run \`clerk deploy status\` in a few minutes.` + + domains(step.domainsUrl) + ); + case "finalizing": + return ( + `Production setup for ${step.domain} is still finalizing on Clerk's side. ` + + `Re-run \`clerk deploy status\` in a few minutes.` + + domains(step.domainsUrl) + ); + } } diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 60b1fce8e..6e3b3a31d 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -19,8 +19,14 @@ mock.module("../../lib/plapi.ts", () => ({ mockTriggerApplicationDomainDNSCheck(...args), })); -const { buildDeployStatusReport, resolveDeployState, waitForDeployStatus } = - await import("./status.ts"); +const { + agentNextAction, + buildDeployStatusReport, + buildInterruptedDeployStatusReport, + deployNextStep, + resolveDeployState, + waitForDeployStatus, +} = await import("./status.ts"); const ctx = { profileKey: "/tmp/x", @@ -491,3 +497,115 @@ describe("buildDeployStatusReport", () => { }, ); }); + +describe("report urls", () => { + const snapshot = { + appId: "app_1", + developmentInstanceId: "ins_dev", + productionInstanceId: "ins_prod", + productionDomainId: "dmn_1", + domain: "example.com", + oauthProviders: [], + oauthProviderDescriptors: [], + completedOAuthProviders: [], + cnameTargets: [], + domainComplete: false, + componentStatus: { dns: false, ssl: false, mail: false }, + unsupportedOAuthProviderCount: 0, + unsupportedOAuthProviders: [], + pending: undefined, + } satisfies LiveDeploySnapshot; + + test("carries the instance and Domains page URLs once a production instance exists", () => { + // Agents used to have to pull the URL out of the `nextAction` prose. + const report = buildDeployStatusReport({ kind: "active", snapshot }, null); + expect(report.urls).toEqual({ + instance: "https://dashboard.clerk.com/apps/app_1/instances/ins_prod", + domains: "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + }); + expect( + buildDeployStatusReport( + { kind: "domain_provisioning", appId: "app_1", productionInstanceId: "ins_prod" }, + null, + ).urls, + ).toEqual(report.urls); + }); + + test("is null when there is no production instance to point at", () => { + expect(buildDeployStatusReport({ kind: "not_started" }, null).urls).toBeNull(); + expect(buildInterruptedDeployStatusReport().urls).toBeNull(); + expect( + buildDeployStatusReport( + { kind: "active", snapshot: { ...snapshot, productionInstanceId: undefined } }, + null, + ).urls, + ).toBeNull(); + }); +}); + +describe("deployNextStep", () => { + // The step is derived from the report's own fields, so a report and the + // sentence stored in it can't describe different situations. + const base = { + complete: false, + state: "domain_pending" as const, + domain: "example.com", + productionInstanceId: "ins_prod", + domainStatus: { dns: "pending", ssl: "pending", mail: "pending" }, + pendingDnsRecords: [ + { type: "CNAME" as const, host: "clerk.example.com", value: "v", required: true }, + ], + oauth: { complete: true, configured: [], pending: [], unsupported: [] }, + urls: { + domains: "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains", + instance: "https://dashboard.clerk.com/apps/app_1/instances/ins_prod", + }, + }; + + test.each([ + { + label: "records to add", + domainStatus: { dns: "pending", ssl: "pending", mail: "pending" }, + records: 1, + kind: "records_available", + phrase: "DNS and email DNS", + }, + { + label: "records missing from the report", + domainStatus: { dns: "pending", ssl: "pending", mail: "complete" }, + records: 0, + kind: "records_unavailable", + phrase: "DNS", + }, + { + label: "only SSL pending", + domainStatus: { dns: "complete", ssl: "pending", mail: "complete" }, + records: 0, + kind: "ssl_pending", + phrase: "", + }, + { + label: "everything verified, Clerk finalizing", + domainStatus: { dns: "complete", ssl: "complete", mail: "complete" }, + records: 0, + kind: "finalizing", + phrase: "", + }, + ])("classifies a pending domain: $label", ({ domainStatus, records, kind, phrase }) => { + const step = deployNextStep({ + ...base, + domainStatus, + pendingDnsRecords: base.pendingDnsRecords.slice(0, records), + }); + expect(step.kind).toBe(kind); + if (step.kind === "records_available" || step.kind === "records_unavailable") { + expect(step.records).toBe(phrase); + expect(step.domainsUrl).toBe(base.urls.domains); + } + }); + + test("the agent sentence is rendered from the step the report classifies", () => { + const report = buildDeployStatusReport({ kind: "not_started" }, null); + expect(report.nextAction).toBe(agentNextAction(deployNextStep(report))); + }); +}); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index 3df097464..b3799e922 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -73,9 +73,40 @@ export interface DeployStatusReport { domainStatus: { dns: string; ssl: string; mail: string } | null; pendingDnsRecords: { type: "CNAME"; host: string; value: string; required: boolean }[]; oauth: { complete: boolean; configured: string[]; pending: string[]; unsupported: string[] }; + /** + * Dashboard pages for this deploy: the production instance and its Domains + * page. Null before a production instance exists, and when the run was + * interrupted before the state could be read. + */ + urls: { domains: string; instance: string } | null; nextAction: string; } +/** + * What the report tells its reader to do next, as data. The agent's + * `nextAction` sentence and the human-mode line are both rendered from this, + * so neither audience's wording is derived from the other's: a reword on one + * side can't leak the other side's phrasing. + */ +export type DeployNextStep = + | { kind: "not_started" } + | { kind: "domain_provisioning"; domainsUrl: string } + | { kind: "interrupted" } + | { + kind: "complete"; + domain: string; + oauthUnsupported: readonly string[]; + instanceUrl: string | null; + } + | { kind: "oauth_pending"; oauthPending: readonly string[]; oauthUnsupported: readonly string[] } + | { + kind: "records_available" | "records_unavailable" | "ssl_pending" | "finalizing"; + domain: string; + /** "DNS", "Email DNS", or "DNS and email DNS": the record kinds still unverified. */ + records: string; + domainsUrl: string | null; + }; + export type LiveDeploySnapshot = Omit< DeployOperationState, "pending" | "oauthProviders" | "completedOAuthProviders" @@ -315,6 +346,19 @@ export function buildDeployStatusReport( state: DeployState, outcome: DeployStatusOutcome | null, ): DeployStatusReport { + return withNextAction(buildDeployStatusFacts(state, outcome)); +} + +type DeployStatusFacts = Omit; + +function withNextAction(facts: DeployStatusFacts): DeployStatusReport { + return { ...facts, nextAction: agentNextAction(deployNextStep(facts)) }; +} + +function buildDeployStatusFacts( + state: DeployState, + outcome: DeployStatusOutcome | null, +): DeployStatusFacts { if (state.kind === "not_started") { return { complete: false, @@ -324,16 +368,11 @@ export function buildDeployStatusReport( 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.", + urls: null, }; } if (state.kind === "domain_provisioning") { - const domainsAction = domainSettingsNextAction( - domainsDashboardUrl(state.appId, state.productionInstanceId), - ); return { complete: false, state: "domain_provisioning", @@ -342,10 +381,7 @@ export function buildDeployStatusReport( 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, + urls: dashboardUrls(state.appId, state.productionInstanceId), }; } @@ -385,20 +421,16 @@ export function buildDeployStatusReport( pending: oauthPending, unsupported: [...snapshot.unsupportedOAuthProviders], }, - nextAction: deployNextAction( - reportState, - snapshot.domain, - componentStatus, - pendingDnsRecords.length > 0, - oauthPending, - snapshot.unsupportedOAuthProviders, - snapshot.productionInstanceId - ? { - domains: domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId), - instance: instanceDashboardUrl(snapshot.appId, snapshot.productionInstanceId), - } - : null, - ), + urls: snapshot.productionInstanceId + ? dashboardUrls(snapshot.appId, snapshot.productionInstanceId) + : null, + }; +} + +function dashboardUrls(appId: string, productionInstanceId: string): DeployStatusReport["urls"] { + return { + domains: domainsDashboardUrl(appId, productionInstanceId), + instance: instanceDashboardUrl(appId, productionInstanceId), }; } @@ -412,7 +444,7 @@ export function buildDeployStatusReport( * rather than the empty output this path used to produce. */ export function buildInterruptedDeployStatusReport(): DeployStatusReport { - return { + return withNextAction({ complete: false, state: "interrupted", domain: null, @@ -420,10 +452,8 @@ export function buildInterruptedDeployStatusReport(): DeployStatusReport { domainStatus: null, pendingDnsRecords: [], oauth: { complete: false, configured: [], pending: [], unsupported: [] }, - nextAction: - "Interrupted before the deploy status could be read, so nothing is known about this " + - "deploy. Run `clerk deploy status` again to check it.", - }; + urls: null, + }); } function resolveActiveReportState(domainComplete: boolean, complete: boolean): DeployStatusState { @@ -432,99 +462,141 @@ function resolveActiveReportState(domainComplete: boolean, complete: boolean): D return "oauth_pending"; } -function deployNextAction( - state: DeployStatusState, - domain: string, - componentStatus: DeployComponentStatus, - hasPendingRecords: boolean, - oauthPending: string[], - oauthUnsupported: readonly string[], - urls: { domains: string; instance: string } | null, -): string { - const domainsAction = urls ? ` ${domainSettingsNextAction(urls.domains)}` : ""; +/** + * Classify what the reader should do next from the report's own fields, so + * the human line rendered from a report and the agent sentence stored in it + * always describe the same situation. + */ +export function deployNextStep(report: DeployStatusFacts): DeployNextStep { + switch (report.state) { + case "not_started": + return { kind: "not_started" }; + case "interrupted": + return { kind: "interrupted" }; + case "domain_provisioning": + // Always has a production instance, so always has its URLs. + return { kind: "domain_provisioning", domainsUrl: report.urls?.domains ?? "" }; + case "complete": + return { + kind: "complete", + domain: report.domain ?? "", + oauthUnsupported: report.oauth.unsupported, + instanceUrl: report.urls?.instance ?? null, + }; + case "oauth_pending": + return { + kind: "oauth_pending", + oauthPending: report.oauth.pending, + oauthUnsupported: report.oauth.unsupported, + }; + case "domain_pending": { + // DNS and email DNS are records someone has to add at the registrar; + // SSL is Clerk's side and waits on them. Polling can't move the first + // kind along, so those get "add the records" and only SSL gets "wait". + const status: DeployComponentStatus = { + dns: report.domainStatus?.dns === "complete", + ssl: report.domainStatus?.ssl === "complete", + mail: report.domainStatus?.mail === "complete", + }; + return { + kind: classifyDomainPending(status, report.pendingDnsRecords.length > 0), + domain: report.domain ?? "", + records: capitalizeFirst(pendingRecordComponents(status)), + domainsUrl: report.urls?.domains ?? null, + }; + } + } +} + +/** The `nextAction` sentence: written for an agent that will relay it to a person. */ +export function agentNextAction(step: DeployNextStep): string { // In development Clerk supplies shared OAuth credentials; in production it // doesn't, so a provider the CLI couldn't configure has a sign-in button // that fails for real users. `oauth.complete` only covers what the CLI // manages, so the report has to say this out loud. - const unsupportedAction = - oauthUnsupported.length > 0 + const unsupported = (providers: readonly string[]): string => + providers.length > 0 ? ` These providers are enabled in development but the CLI could not configure them for ` + - `production: ${oauthUnsupported.join(", ")}. Configure them in the Clerk Dashboard before ` + + `production: ${providers.join(", ")}. Configure them in the Clerk Dashboard before ` + `going live, or users signing in with them will fail.` : ""; - - if (state === "complete") { - // Complete on Clerk's side only. The app keeps running on development - // keys until the production keys reach the host, and the report can't - // tell whether that already happened — hence "if you haven't already". - // Nothing is left to monitor on the Domains page here, so the pointer is - // the instance itself (users, settings, billing) rather than the shared - // "visit the domains page" clause every pending state carries. - const instanceAction = urls - ? ` Manage users, settings, and billing for this instance: ${urls.instance}` + const domains = (url: string | null): string => + url + ? ` Ask the user to visit the Clerk Dashboard domains page, or offer to open it: ${url}` : ""; - return ( - `Clerk's production setup for https://${domain} is verified. If you haven't already: ` + - `run \`clerk env pull --instance prod\`, set those keys on your host alongside the other ` + - `Clerk variables from your env file, redeploy, then sign up at https://${domain} to confirm.` + - unsupportedAction + - instanceAction - ); - } - if (state === "oauth_pending") { - // The domain is verified, so there is nothing to monitor on the Domains - // page; the wizard is the only way to supply credentials. - 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\`.` + - unsupportedAction - ); - } - // DNS and email DNS are records someone has to add at the registrar; SSL is - // Clerk's side and waits on them. Polling can't move the first kind along, - // so those get the "add the records" instruction and only SSL keeps "wait". - const pending = classifyDomainPending(componentStatus, hasPendingRecords); - const records = capitalizeFirst(pendingRecordComponents(componentStatus)); - - if (pending === "records_available") { - return ( - `${records} records not found yet for ${domain}. ` + - `Add the records in \`pendingDnsRecords\` at the domain's DNS provider if you haven't already, ` + - `then re-run \`clerk deploy status --wait\`. Propagation usually takes minutes.` + - domainsAction - ); - } - - if (pending === "records_unavailable") { - // The report has nothing to hand over; the Dashboard clause appended below - // carries the URL, so this sentence doesn't repeat it. - return ( - `${records} records not found yet for ${domain}, but this report has no record list. ` + - `Find the records to add on the Domains page in the Clerk Dashboard, then re-run ` + - `\`clerk deploy status --wait\`.` + - domainsAction - ); - } - - if (pending === "ssl_pending") { - // Records are verified; the certificate is Clerk's side and nobody can - // speed it up. Same message the wizard's footer prints for this state. - return ( - `SSL certificate still pending for ${domain}. Clerk issues it automatically now that ` + - `DNS is verified; re-run \`clerk deploy status\` in a few minutes.` + - domainsAction - ); + switch (step.kind) { + case "not_started": + return ( + "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." + ); + case "domain_provisioning": + return ( + "A production instance exists but its domain is still provisioning. " + + "Run `clerk deploy status` again shortly, or ask the user to finish `clerk deploy`." + + domains(step.domainsUrl) + ); + case "interrupted": + return ( + "Interrupted before the deploy status could be read, so nothing is known about this " + + "deploy. Run `clerk deploy status` again to check it." + ); + case "complete": + // Complete on Clerk's side only. The app keeps running on development + // keys until the production keys reach the host, and the report can't + // tell whether that already happened — hence "if you haven't already". + // Nothing is left to monitor on the Domains page here, so the pointer is + // the instance itself (users, settings, billing) rather than the shared + // "visit the domains page" clause every pending state carries. + return ( + `Clerk's production setup for https://${step.domain} is verified. If you haven't already: ` + + `run \`clerk env pull --instance prod\`, set those keys on your host alongside the other ` + + `Clerk variables from your env file, redeploy, then sign up at https://${step.domain} to confirm.` + + unsupported(step.oauthUnsupported) + + (step.instanceUrl + ? ` Manage users, settings, and billing for this instance: ${step.instanceUrl}` + : "") + ); + case "oauth_pending": + // The domain is verified, so there is nothing to monitor on the Domains + // page; the wizard is the only way to supply credentials. + return ( + `Domain verified, but these OAuth providers are missing production credentials: ` + + `${step.oauthPending.join(", ")}. Ask the user to finish \`clerk deploy\`, then run \`clerk deploy status\`.` + + unsupported(step.oauthUnsupported) + ); + case "records_available": + return ( + `${step.records} records not found yet for ${step.domain}. ` + + `Add the records in \`pendingDnsRecords\` at the domain's DNS provider if you haven't already, ` + + `then re-run \`clerk deploy status --wait\`. Propagation usually takes minutes.` + + domains(step.domainsUrl) + ); + case "records_unavailable": + // The report has nothing to hand over; the Dashboard clause carries the + // URL, so this sentence doesn't repeat it. + return ( + `${step.records} records not found yet for ${step.domain}, but this report has no record list. ` + + `Find the records to add on the Domains page in the Clerk Dashboard, then re-run ` + + `\`clerk deploy status --wait\`.` + + domains(step.domainsUrl) + ); + case "ssl_pending": + // Records are verified; the certificate is Clerk's side and nobody can + // speed it up. Same message the wizard's footer prints for this state. + return ( + `SSL certificate still pending for ${step.domain}. Clerk issues it automatically now that ` + + `DNS is verified; re-run \`clerk deploy status\` in a few minutes.` + + domains(step.domainsUrl) + ); + case "finalizing": + return ( + `Production setup for ${step.domain} is still finalizing on Clerk's side. ` + + `Re-run \`clerk deploy status\` in a few minutes.` + + domains(step.domainsUrl) + ); } - - return ( - `Production setup for ${domain} is still finalizing on Clerk's side. ` + - `Re-run \`clerk deploy status\` in a few minutes.${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( From 5cf71d2817e9c811a45aa2c8e11a5bb59c7e3231 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Fri, 18 Sep 2026 09:08:34 -0700 Subject: [PATCH 22/24] Fix remaining overflow in the updated deploy and login messages - The previous wrap commit measured one file. Seven more lines this branch added or rewrote still crossed the wizard's frame on an 80-column terminal: step 3 of the closing screen (hand-wrapped around a short domain), the keys note, the Google consent-screen tip, the two lines ending in a Dashboard URL after login and after instance creation, and the DNS footer's lead sentences. - Step 3, the keys note, the tip, and the footer leads go through wrap(); the two URL lines end at the colon and print the URL on its own line. - Adds a test that renders every wizard screen with a 31-character domain and fails on any line over 76 columns unless it is a lone token or a "Label: token" line, plus raw layout assertions at the tip, instance-created, and login call sites. - Pre-existing overflows (OAuth intro sentences, "Reference: URL" lines) are unchanged and belong to the logger-wrapping follow-up. --- .../cli-core/src/commands/auth/login.test.ts | 5 +- packages/cli-core/src/commands/auth/login.ts | 6 +- .../cli-core/src/commands/deploy/copy.test.ts | 85 +++++++++++++++++++ packages/cli-core/src/commands/deploy/copy.ts | 31 ++++--- .../src/commands/deploy/index.test.ts | 11 ++- .../cli-core/src/commands/deploy/index.ts | 6 +- .../cli-core/src/commands/deploy/providers.ts | 8 +- 7 files changed, 126 insertions(+), 26 deletions(-) diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 8c7837216..6cb844544 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -552,8 +552,9 @@ describe("login", () => { await runLogin(); expect(captured.err).toContain("Claimed and linked application: `bad-agent`"); - expect(captured.err).toContain( - "Your app now lives in your Clerk account: https://dashboard.clerk.com/apps/app_claimed/instances/ins_dev_claimed", + // The URL sits on its own indented line so the sentence fits the frame. + expect(captured.err).toMatch( + /Your app now lives in your Clerk account:\n[^\n]*? {2}https:\/\/dashboard\.clerk\.com\/apps\/app_claimed\/instances\/ins_dev_claimed/, ); }); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 992dc3288..611f545de 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -237,9 +237,9 @@ async function handleAutoclaim(cwd: string): Promise { (instance) => instance.environment_type === "development", ); if (development) { - log.info( - `Your app now lives in your Clerk account: ${buildDashboardUrl(result.app.application_id, development.instance_id)}`, - ); + // URL on its own line: with it, the sentence is wider than the frame. + log.info("Your app now lives in your Clerk account:"); + log.info(` ${buildDashboardUrl(result.app.application_id, development.instance_id)}`); } } diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 32ec8498f..86e3ebe8e 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 { DEPLOY_COMMAND_DESCRIPTION, DEPLOY_COMMAND_SUMMARY, INTRO_PREAMBLE, + OAUTH_SECTION_INTRO, bindZoneFile, deployComponentLabels, deployStatusPendingFooter, @@ -653,3 +654,87 @@ describe("dnsDashboardHandoff", () => { expect(output).not.toContain("undefined"); }); }); + +describe("every wizard screen fits inside the frame", () => { + // The wizard prefixes each printed line with a 3-column gutter, added per + // line the code emits; the terminal's own soft wrap lands outside it. So + // every line a screen emits must fit 76 visible columns. The one allowed + // overflow is a lone unbreakable token, optionally after a "Label:" (a URL + // on its own line, or "Reference: "); a sentence that merely contains + // a URL still has to wrap. + const DOMAIN = "auth.my-long-company-name.co.uk"; + const URL = "https://dashboard.clerk.com/apps/app_1/instances/ins_prod/domains"; + const STATUSES = [ + { dns: false, ssl: false, mail: false }, + { dns: true, ssl: false, mail: false }, + { dns: false, ssl: false, mail: true }, + { dns: true, ssl: false, mail: true }, + { dns: true, ssl: true, mail: true }, + ]; + const targets: CnameTarget[] = [ + { host: `clerk.${DOMAIN}`, value: "frontend-api.clerk.services", required: true }, + { host: `accounts.${DOMAIN}`, value: "accounts.clerk.services", required: false }, + { host: `clk2._domainkey.${DOMAIN}`, value: "dkim2.clerk.services", required: true }, + ]; + const screens: Record = { + INTRO_PREAMBLE: [INTRO_PREAMBLE], + OAUTH_SECTION_INTRO: [OAUTH_SECTION_INTRO], + dnsIntro: dnsIntro(DOMAIN), + domainAssociationSummary: domainAssociationSummary(DOMAIN), + dnsRecords: dnsRecords(targets), + "dnsRecords afterCheck": dnsRecords(targets, { afterCheck: true }), + "productionSummary verified": productionSummary(DOMAIN, ["Google"], "verified"), + "productionSummary pending": productionSummary(DOMAIN, [], "pending"), + "nextStepsBody verified": [nextStepsBody("app_1", "ins_prod", DOMAIN, "verified")], + "nextStepsBody pending": [nextStepsBody("app_1", "ins_prod", DOMAIN, "pending")], + }; + for (const oauthNext of [true, false]) { + screens[`dnsDashboardHandoff oauthNext=${oauthNext}`] = dnsDashboardHandoff(DOMAIN, URL, { + oauthNext, + }); + for (const status of STATUSES) { + const key = JSON.stringify(status); + screens[`dnsHandoffNothingToAdd ${key} oauthNext=${oauthNext}`] = dnsHandoffNothingToAdd( + DOMAIN, + status, + URL, + { oauthNext }, + ); + } + } + for (const status of STATUSES) { + for (const hasRecords of [true, false]) { + screens[`deployStatusPendingFooter ${JSON.stringify(status)} records=${hasRecords}`] = + deployStatusPendingFooter(DOMAIN, status, URL, hasRecords); + } + } + + const loneToken = /^\s*(\S+:\s+)?\S+$/; + + test.each(Object.entries(screens).map(([name, lines]) => ({ name, lines })))( + "$name", + ({ lines }) => { + const tooWide = lines + .join("\n") + .split("\n") + .map((line) => stripAnsi(line)) + .filter((line) => line.length > 76 && !loneToken.test(line)); + expect(tooWide).toEqual([]); + }, + ); + + test("step 3 keeps its numbered indent across wrapped lines", () => { + for (const status of ["verified", "pending"] as const) { + const lines = nextStepsBody("app_1", "ins_prod", DOMAIN, status).split("\n"); + const start = lines.findIndex((line) => line.startsWith(" 3. ")); + expect(start).toBeGreaterThan(0); + // Continuation lines align under the text after "3. ", like steps 1 and 2. + let next = start + 1; + while (lines[next] !== "") { + expect(lines[next]).toMatch(/^ {5}\S/); + next++; + } + expect(next).toBeGreaterThan(start + 1); + } + }); +}); diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index 2342b5580..f1004a542 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -472,7 +472,7 @@ export function deployStatusPendingFooter( "You can also skip for now and run `clerk deploy` later to resume; the production instance is already created."; if (state === "records_available") { return [ - `${records} records not found yet for ${domain}.`, + ...wrap(`${records} records not found yet for ${domain}.`), ...wrap( ` - Add them at your DNS provider if you haven't already, then choose Check again below. ${resume}`, ), @@ -486,7 +486,7 @@ export function deployStatusPendingFooter( // URL on its own line: mid-sentence, terminal autolinkers swallow the // trailing punctuation. return [ - `${records} records not found yet for ${domain}.`, + ...wrap(`${records} records not found yet for ${domain}.`), "", ...wrap( `Clerk didn't return the list of records to add. Find them on the Domains page in the Clerk Dashboard, add them, then choose Check again below. ${resume}`, @@ -496,7 +496,7 @@ export function deployStatusPendingFooter( } if (state === "ssl_pending") { return [ - `SSL certificate still pending for ${domain}.`, + ...wrap(`SSL certificate still pending for ${domain}.`), "", ...wrap( `Clerk issues it automatically now that DNS is verified; choose Check again below in a few minutes. ${resume}`, @@ -504,7 +504,7 @@ export function deployStatusPendingFooter( ]; } return [ - `Production setup for ${domain} is still finalizing on Clerk's side.`, + ...wrap(`Production setup for ${domain} is still finalizing on Clerk's side.`), "", ...wrap( "Run `clerk deploy` again in a few minutes to resume. The production instance is already created.", @@ -549,18 +549,22 @@ export function nextStepsBody( ): string { // Until DNS is verified the domain doesn't resolve, so "sign up there" // would send the user to a page that doesn't exist yet. - const step3 = + // Wrapped here, not by hand: the domain's length moves the break. + const step3 = wrap( domainStatus === "verified" - ? `Redeploy your app, then sign up at https://${domain} to confirm it - works` - : `Run \`clerk deploy\` again once the domain is verified, then redeploy - your app and sign up at https://${domain} to confirm it works`; + ? ` 3. Redeploy your app, then sign up at https://${domain} to confirm it works` + : ` 3. Run \`clerk deploy\` again once the domain is verified, then redeploy your app and sign up at https://${domain} to confirm it works`, + ).join("\n"); + const keysNote = wrap( + `${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.`, + { hang: 6 }, + ).join("\n"); return ` 1. Pull production keys into your environment clerk env pull --instance prod - This writes pk_live_... and sk_live_... to your env file. They replace your - pk_test_... and sk_test_... keys. + This writes pk_live_... and sk_live_... to your env file. They + replace your pk_test_... and sk_test_... keys. 2. Update env vars on your hosting provider Vercel, AWS, GCP, Heroku, Render, etc. all expose env vars in their UI. @@ -568,7 +572,7 @@ export function nextStepsBody( - Also copy the other Clerk variables from your env file, such as NEXT_PUBLIC_CLERK_SIGN_IN_URL. \`env pull\` writes only the two keys. - 3. ${step3} +${step3} 4. (If applicable) Update webhook URLs and signing secrets ${dim("https://clerk.com/docs/guides/development/webhooks/syncing#configure-your-production-instance")} @@ -582,8 +586,7 @@ export function nextStepsBody( - DNS and SSL status: ${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. +${keysNote} ${dim("Reference: https://clerk.com/docs/guides/development/deployment/production#api-keys-and-environment-variables")}`; } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 2c413d704..297907195 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1298,9 +1298,14 @@ describe("deploy", () => { // Google requires the consent screen before it will create a client, and // the name entered there is what end users see. - expect(err).toContain( + expect(flat(err)).toContain( "The consent screen's app name is what users see when they sign in with Google. Use the name you want them to see.", ); + // Wrapped inside the frame, continuation aligned under the text after + // the TIP label (3 + 8 columns), like the NOTE lines elsewhere. + expect(err).toMatch( + /TIP {8}The consent screen's app name is what users see when they sign in\n│ {13}with Google\. Use the name you want them to see\./, + ); // The Clerk app name is often a directory-derived slug, so showing it // read as a recommendation to reuse it on a user-facing screen. expect(err).not.toContain('my-saas-app".'); @@ -1341,8 +1346,10 @@ describe("deploy", () => { // "Clerk production instance", not just "production instance": the user // also has a deployment on their host, and this is the one Clerk manages. + // The URL sits on its own indented line so the sentence fits the frame + // and terminal autolinkers see the whole URL. expect(err).toMatch( - /Clerk production instance created\. Manage it in the Dashboard: https:\/\/dashboard\.clerk\.com\/apps\/app_xyz789\/instances\/ins_\S+/, + /Clerk production instance created\. Manage it in the Dashboard:\n│ {4}https:\/\/dashboard\.clerk\.com\/apps\/app_xyz789\/instances\/ins_\S+/, ); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 64d2a1049..34be656bf 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -190,9 +190,9 @@ async function startNewDeploy(ctx: DeployContext): Promise { await persistProductionInstance(ctx, production.id); // "Clerk production instance", not just "production instance": the user // also has a deployment on their host, and this is the one Clerk manages. - log.success( - `Clerk production instance created. Manage it in the Dashboard: ${instanceDashboardUrl(ctx.appId, production.id)}`, - ); + // URL on its own line: with it, the sentence is wider than the frame. + log.success("Clerk production instance created. Manage it in the Dashboard:"); + log.info(` ${instanceDashboardUrl(ctx.appId, production.id)}`); if (!production.active_domain) { throw new CliError( diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index c5198ac58..52624ef05 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -2,6 +2,7 @@ import { OAUTH_PROVIDERS } from "@clerk/shared/oauth"; import { bold, cyan, dim, yellow } from "../../lib/color.ts"; import { clerkSubdomains } from "./copy.ts"; import { log } from "../../lib/log.ts"; +import { wrap } from "../../lib/wrap.ts"; import { openBrowser } from "../../lib/open.ts"; import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi.ts"; @@ -480,9 +481,12 @@ export async function showOAuthWalkthrough( // paste, so it follows the values. if (slug === "google") { log.blank(); - log.info( + for (const line of wrap( `${dim(cyan("TIP"))} The consent screen's app name is what users see when they sign in with Google. Use the name you want them to see.`, - ); + { hang: 11 }, + )) { + log.info(line); + } } log.blank(); log.info(dim(`Provider guide: ${docsUrl}`)); From 2acf81af90484b1a91c3f14d11f1c630367f4072 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Fri, 18 Sep 2026 09:22:56 -0700 Subject: [PATCH 23/24] Tighten the deploy status report's types - domainStatus was typed as any string but only ever holds "complete" or "pending", and deployNextStep reads it back with === "complete". A third value would have been classified as pending in silence; the narrowed type makes adding one a compile error at every reader. - domain_provisioning's next-step URL is nullable like its siblings, replacing an empty-string fallback that only worked by accident. - nextAction documents that it is derived via withNextAction, never assigned, so a future constructor can't let it drift from the facts. - The width test keeps its own 76 rather than importing the wrap default; sharing it would let a wider default pass while breaking the frame. --- .../cli-core/src/commands/deploy/copy.test.ts | 6 ++++- .../src/commands/deploy/status.test.ts | 10 +++---- .../cli-core/src/commands/deploy/status.ts | 27 +++++++++++++++---- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/copy.test.ts b/packages/cli-core/src/commands/deploy/copy.test.ts index 86e3ebe8e..8d903282d 100644 --- a/packages/cli-core/src/commands/deploy/copy.test.ts +++ b/packages/cli-core/src/commands/deploy/copy.test.ts @@ -710,6 +710,10 @@ describe("every wizard screen fits inside the frame", () => { } const loneToken = /^\s*(\S+:\s+)?\S+$/; + // Deliberately not imported from wrap.ts: this asserts "fits an 80-column + // terminal", and sharing the constant would let a wider default pass the + // test while breaking the frame. + const FRAME_WIDTH = 76; test.each(Object.entries(screens).map(([name, lines]) => ({ name, lines })))( "$name", @@ -718,7 +722,7 @@ describe("every wizard screen fits inside the frame", () => { .join("\n") .split("\n") .map((line) => stripAnsi(line)) - .filter((line) => line.length > 76 && !loneToken.test(line)); + .filter((line) => line.length > FRAME_WIDTH && !loneToken.test(line)); expect(tooWide).toEqual([]); }, ); diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 6e3b3a31d..4325335cf 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -551,7 +551,7 @@ describe("deployNextStep", () => { state: "domain_pending" as const, domain: "example.com", productionInstanceId: "ins_prod", - domainStatus: { dns: "pending", ssl: "pending", mail: "pending" }, + domainStatus: { dns: "pending", ssl: "pending", mail: "pending" } as const, pendingDnsRecords: [ { type: "CNAME" as const, host: "clerk.example.com", value: "v", required: true }, ], @@ -565,28 +565,28 @@ describe("deployNextStep", () => { test.each([ { label: "records to add", - domainStatus: { dns: "pending", ssl: "pending", mail: "pending" }, + domainStatus: { dns: "pending", ssl: "pending", mail: "pending" } as const, records: 1, kind: "records_available", phrase: "DNS and email DNS", }, { label: "records missing from the report", - domainStatus: { dns: "pending", ssl: "pending", mail: "complete" }, + domainStatus: { dns: "pending", ssl: "pending", mail: "complete" } as const, records: 0, kind: "records_unavailable", phrase: "DNS", }, { label: "only SSL pending", - domainStatus: { dns: "complete", ssl: "pending", mail: "complete" }, + domainStatus: { dns: "complete", ssl: "pending", mail: "complete" } as const, records: 0, kind: "ssl_pending", phrase: "", }, { label: "everything verified, Clerk finalizing", - domainStatus: { dns: "complete", ssl: "complete", mail: "complete" }, + domainStatus: { dns: "complete", ssl: "complete", mail: "complete" } as const, records: 0, kind: "finalizing", phrase: "", diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index b3799e922..341a9f430 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -65,12 +65,23 @@ export type DeployStatusState = // buildInterruptedDeployStatusReport. | "interrupted"; +/** + * Two values only. `deployNextStep` reads this back as booleans, so a third + * value would be silently classified as pending; the type makes adding one a + * compile error at every reader instead. + */ +export type DomainComponentState = "complete" | "pending"; + export interface DeployStatusReport { complete: boolean; state: DeployStatusState; domain: string | null; productionInstanceId: string | null; - domainStatus: { dns: string; ssl: string; mail: string } | null; + domainStatus: { + dns: DomainComponentState; + ssl: DomainComponentState; + mail: DomainComponentState; + } | null; pendingDnsRecords: { type: "CNAME"; host: string; value: string; required: boolean }[]; oauth: { complete: boolean; configured: string[]; pending: string[]; unsupported: string[] }; /** @@ -79,6 +90,11 @@ export interface DeployStatusReport { * interrupted before the state could be read. */ urls: { domains: string; instance: string } | null; + /** + * Derived, never written: every constructor goes through `withNextAction`, + * which renders this from `deployNextStep` over the other fields. Assigning + * it directly would let it drift from the facts it describes. + */ nextAction: string; } @@ -90,7 +106,7 @@ export interface DeployStatusReport { */ export type DeployNextStep = | { kind: "not_started" } - | { kind: "domain_provisioning"; domainsUrl: string } + | { kind: "domain_provisioning"; domainsUrl: string | null } | { kind: "interrupted" } | { kind: "complete"; @@ -338,7 +354,7 @@ export function pendingDomainStatus(): DomainStatusResponse { }; } -function domainComponentState(value: boolean): "complete" | "pending" { +function domainComponentState(value: boolean): DomainComponentState { return value ? "complete" : "pending"; } @@ -474,8 +490,9 @@ export function deployNextStep(report: DeployStatusFacts): DeployNextStep { case "interrupted": return { kind: "interrupted" }; case "domain_provisioning": - // Always has a production instance, so always has its URLs. - return { kind: "domain_provisioning", domainsUrl: report.urls?.domains ?? "" }; + // Always has a production instance, so always has its URLs; nullable + // only because the report type can't say so. + return { kind: "domain_provisioning", domainsUrl: report.urls?.domains ?? null }; case "complete": return { kind: "complete", From 869b544992a0759854aad10d908843af94b4e6b7 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Fri, 18 Sep 2026 09:47:14 -0700 Subject: [PATCH 24/24] Correct two stale claims in the deploy docs - The changeset said the agent's nextAction is unchanged two lines after describing how it changed. It meant unchanged by the human/agent renderer split; say that instead. - The README said nextAction carries the Dashboard Domains URL while work remains. Only domain_provisioning and domain_pending do; not_started, interrupted, and oauth_pending carry none, and complete carries the instance root. --- .changeset/deploy-wizard-copy.md | 2 +- packages/cli-core/src/commands/deploy/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/deploy-wizard-copy.md b/.changeset/deploy-wizard-copy.md index aa6daea8b..d4b5d90b7 100644 --- a/.changeset/deploy-wizard-copy.md +++ b/.changeset/deploy-wizard-copy.md @@ -14,4 +14,4 @@ - Resuming the wizard shows only the DNS records still outstanding, not ones Clerk already verified. When none are outstanding, the DNS screen says what is (the SSL certificate, Clerk finalizing, or a record list Clerk didn't return) instead of a "Configure DNS" page with no records, and the closing screen says "Not yet verified" rather than "DNS pending" so it is right when DNS is done and something else is pending. - Each DNS record host is named the same way on every screen, the note about what Clerk manages moved off the rows the user has to add, the Domains-page pointer carries its link, and the closing line says what happens next instead of implying that skipping the check finishes the deploy, and no longer promises an OAuth step on resume when OAuth has already run. - Agent-mode `nextAction` tells the agent to add pending DNS records instead of polling, at `complete` says the production keys still have to reach the host, and names OAuth providers the CLI could not configure so an agent doesn't report OAuth as done. Human-mode `clerk deploy status` prints the pending records, never says "ask the user", and resumes with `clerk deploy` rather than a flag that only affects agents. -- The wizard's new sentences wrap at 76 columns so they stay inside the gutter on an 80-column terminal. The agent JSON report gains a `urls` field with the production instance's Dashboard page and its Domains page; `nextAction` is unchanged. +- The wizard's new sentences wrap at 76 columns so they stay inside the gutter on an 80-column terminal. The agent JSON report gains a `urls` field with the production instance's Dashboard page and its Domains page. Human-mode `deploy status` now renders its sentence from the same classification of the report as `nextAction` rather than by rewriting that sentence; both read the same as before this change. diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 00341a4f2..4c97feea8 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -52,7 +52,7 @@ In agent mode, `clerk deploy status` emits JSON on stdout with: - `pendingDnsRecords`: CNAME records still tied to pending DNS-backed checks, each with `host`, `value`, and Clerk's `required` flag (some targets are optional). - `oauth`: configured, pending, and unsupported provider slugs. - `urls`: the production instance's Dashboard page (`instance`) and its Domains page (`domains`), or `null` before a production instance exists. The same URLs appear in `nextAction` prose; this field is the one to read programmatically. -- `nextAction`: the next step an agent should present to the user. While work remains it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints its own sentence, rendered from the same classification of the report (`deployNextStep` in `status.ts`) rather than by rewriting the agent's: no unsupported-provider clause (the warning row above already says it), no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked. +- `nextAction`: the next step an agent should present to the user. While domain setup remains (`domain_provisioning` and `domain_pending`) it includes the Clerk Dashboard domains URL, and agents should ask whether to open that URL for the user; `not_started`, `interrupted`, and `oauth_pending` carry no URL, and `complete` carries the instance root instead. While DNS or email DNS records are unverified it says to add the records in `pendingDnsRecords` at the domain's DNS provider rather than to keep polling; if that list is empty (the API returned no CNAME targets) it says so and points at the Dashboard Domains page instead; when only SSL is pending it says to wait. At `complete` it says the production keys still have to reach the host (`clerk env pull --instance prod`, alongside the other Clerk variables in the env file) and to sign up on the domain to confirm — "complete" is Clerk's side only — and links the instance root (users, settings, billing) instead of the domains page, since nothing is left to monitor there. At `complete` and `oauth_pending` it also names any providers in `oauth.unsupported`, since `oauth.complete` covers only what the CLI manages and those providers' sign-in fails in production until configured in the Dashboard. `oauth_pending` carries no Domains URL (the domain is verified). Human mode prints its own sentence, rendered from the same classification of the report (`deployNextStep` in `status.ts`) rather than by rewriting the agent's: no unsupported-provider clause (the warning row above already says it), no "ask the user" (the reader is the user), no `--wait` (human mode always waits; the wizard is what resumes setup), and, when records are pending, the records themselves printed first so the sentence only says what happens once they are added. Before a production instance exists, human mode omits the OAuth row rather than printing "pending: none" for something that was never checked. Exit codes: