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