From d0a8084d20726c3a31121b74cb9bc06d61134f54 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:55:24 -0600 Subject: [PATCH 1/7] refactor(deploy): construct simulated PlapiError via fromBody --- packages/cli-core/src/commands/deploy/mock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/deploy/mock.ts b/packages/cli-core/src/commands/deploy/mock.ts index f0d58036..78c40866 100644 --- a/packages/cli-core/src/commands/deploy/mock.ts +++ b/packages/cli-core/src/commands/deploy/mock.ts @@ -63,7 +63,7 @@ export function resolveTestDeployFlags(options: { } export function simulatedDeployApiFailure(step: string): PlapiError { - return new PlapiError( + return PlapiError.fromBody( 500, JSON.stringify({ errors: [{ message: `Simulated deploy failure: ${step}.` }] }), "clerk deploy test flag", From 387f28136af4536e2d12804a01e3d15686ded39f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:55:42 -0600 Subject: [PATCH 2/7] refactor(plapi): drop unused is_secondary from CreateProductionInstanceParams --- packages/cli-core/src/lib/plapi.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 5d355876..74e86c2a 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -183,7 +183,6 @@ export type ProductionInstanceResponse = { export type CreateProductionInstanceParams = { home_url: string; clone_instance_id?: string; - is_secondary?: boolean; }; export type ValidateCloningParams = { From 20bd3d303c5887d80b8ad559f2a67d7c56e630e7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:57:54 -0600 Subject: [PATCH 3/7] feat(deploy/mock): inject production_instance_exists and unsupported plan features --- .../cli-core/src/commands/deploy/mock.test.ts | 46 +++++++++++++++++-- packages/cli-core/src/commands/deploy/mock.ts | 37 +++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/mock.test.ts b/packages/cli-core/src/commands/deploy/mock.test.ts index a4a711f2..03383449 100644 --- a/packages/cli-core/src/commands/deploy/mock.test.ts +++ b/packages/cli-core/src/commands/deploy/mock.test.ts @@ -5,6 +5,9 @@ import { resolveTestDeployFlags, withMockProductionInstance, withTestFailureAfterApiCall, + mockDeployApi, + configureMockDeployApi, + _resetDeployStatusMock, } from "./mock.ts"; describe("resolveTestDeployFlags", () => { @@ -14,7 +17,9 @@ describe("resolveTestDeployFlags", () => { testFailProductionInstanceCheck: false, testFailDomainLookup: false, testFailValidateCloning: false, + testFailValidateCloningUnsupportedFeatures: undefined, testFailCreateProductionInstance: false, + testFailCreateProductionInstanceExists: false, testFailDnsVerification: false, testFailOAuthSave: false, }); @@ -31,7 +36,9 @@ describe("resolveTestDeployFlags", () => { testFailProductionInstanceCheck: false, testFailDomainLookup: false, testFailValidateCloning: false, + testFailValidateCloningUnsupportedFeatures: undefined, testFailCreateProductionInstance: false, + testFailCreateProductionInstanceExists: false, testFailDnsVerification: true, testFailOAuthSave: false, }); @@ -156,8 +163,41 @@ describe("withMockProductionInstance", () => { const result = withMockProductionInstance(stagingOnly); expect(result.instances).toHaveLength(2); - expect( - result.instances.some((instance) => instance.environment_type === "production"), - ).toBe(true); + expect(result.instances.some((instance) => instance.environment_type === "production")).toBe( + true, + ); + }); +}); + +describe("mockDeployApi failure injection — specific error codes", () => { + test("failCreateProductionInstanceExists throws PlapiError with code production_instance_exists", async () => { + _resetDeployStatusMock(); + configureMockDeployApi({ failCreateProductionInstanceExists: true }); + let thrown: unknown; + try { + await mockDeployApi.createProductionInstance("app_x", { home_url: "https://example.com" }); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(PlapiError); + const err = thrown as PlapiError; + expect(err.status).toBe(400); + expect(err.code).toBe("production_instance_exists"); + }); + + test("failValidateCloningUnsupportedFeatures throws 402 with meta.unsupported_features", async () => { + _resetDeployStatusMock(); + configureMockDeployApi({ failValidateCloningUnsupportedFeatures: ["saml", "mfa"] }); + let thrown: unknown; + try { + await mockDeployApi.validateCloning("app_x", { clone_instance_id: "ins_dev" }); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(PlapiError); + const err = thrown as PlapiError; + expect(err.status).toBe(402); + expect(err.code).toBe("unsupported_subscription_plan_features"); + expect(err.meta).toEqual({ unsupported_features: ["saml", "mfa"] }); }); }); diff --git a/packages/cli-core/src/commands/deploy/mock.ts b/packages/cli-core/src/commands/deploy/mock.ts index 78c40866..a051eebf 100644 --- a/packages/cli-core/src/commands/deploy/mock.ts +++ b/packages/cli-core/src/commands/deploy/mock.ts @@ -21,7 +21,11 @@ const MOCK_INCOMPLETE_POLLS = 2; export type DeployApiMockOptions = { failValidateCloning?: boolean; + /** Simulates HTTP 402 unsupported_subscription_plan_features with this feature list. */ + failValidateCloningUnsupportedFeatures?: string[]; failCreateProductionInstance?: boolean; + /** Simulates HTTP 400 production_instance_exists. */ + failCreateProductionInstanceExists?: boolean; failDnsVerification?: boolean; failOAuthSave?: boolean; }; @@ -31,7 +35,9 @@ export type DeployTestFlags = { testFailProductionInstanceCheck?: boolean; testFailDomainLookup?: boolean; testFailValidateCloning?: boolean; + testFailValidateCloningUnsupportedFeatures?: string[]; testFailCreateProductionInstance?: boolean; + testFailCreateProductionInstanceExists?: boolean; testFailDnsVerification?: boolean; testFailOAuthSave?: boolean; }; @@ -47,7 +53,9 @@ export function resolveTestDeployFlags(options: { testFailProductionInstanceCheck?: boolean; testFailDomainLookup?: boolean; testFailValidateCloning?: boolean; + testFailValidateCloningUnsupportedFeatures?: string[]; testFailCreateProductionInstance?: boolean; + testFailCreateProductionInstanceExists?: boolean; testFailDnsVerification?: boolean; testFailOAuthSave?: boolean; }): DeployTestFlags { @@ -56,7 +64,9 @@ export function resolveTestDeployFlags(options: { testFailProductionInstanceCheck: options.testFailProductionInstanceCheck === true, testFailDomainLookup: options.testFailDomainLookup === true, testFailValidateCloning: options.testFailValidateCloning === true, + testFailValidateCloningUnsupportedFeatures: options.testFailValidateCloningUnsupportedFeatures, testFailCreateProductionInstance: options.testFailCreateProductionInstance === true, + testFailCreateProductionInstanceExists: options.testFailCreateProductionInstanceExists === true, testFailDnsVerification: options.testFailDnsVerification === true, testFailOAuthSave: options.testFailOAuthSave === true, }; @@ -105,12 +115,31 @@ export function _resetDeployStatusMock(): void { configureMockDeployApi(); } +function simulatedSpecificFailure( + status: number, + code: string, + message: string, + meta?: Record, +): PlapiError { + const body = JSON.stringify({ + errors: [{ code, message, ...(meta ? { meta } : {}) }], + }); + return PlapiError.fromBody(status, body, "clerk deploy mock"); +} + export const mockDeployApi: DeployApi = { async createProductionInstance(_applicationId, params) { await simulateServerLatency(); if (mockOptions.failCreateProductionInstance) { throw simulatedDeployApiFailure("production instance creation"); } + if (mockOptions.failCreateProductionInstanceExists) { + throw simulatedSpecificFailure( + 400, + "production_instance_exists", + "You can only have one production instance.", + ); + } return { instance_id: MOCK_PRODUCTION_INSTANCE_ID, environment_type: "production", @@ -129,6 +158,14 @@ export const mockDeployApi: DeployApi = { if (mockOptions.failValidateCloning) { throw simulatedDeployApiFailure("cloning validation"); } + if (mockOptions.failValidateCloningUnsupportedFeatures) { + throw simulatedSpecificFailure( + 402, + "unsupported_subscription_plan_features", + "Unsupported plan features", + { unsupported_features: mockOptions.failValidateCloningUnsupportedFeatures }, + ); + } }, async getDeployStatus(applicationId, envOrInsId) { From 77b96c6b24a7ec40599ad6433a203ccc6ba6c788 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 21:02:23 -0600 Subject: [PATCH 4/7] feat(deploy): recover from production_instance_exists by resuming live state --- .../src/commands/deploy/index.test.ts | 92 ++++++++++++++++++- .../cli-core/src/commands/deploy/index.ts | 47 +++++++++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index bf9af46d..3899b4f8 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; import { captureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts"; -import { CliError, EXIT_CODE, UserAbortError } from "../../lib/errors.ts"; +import { CliError, EXIT_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -40,6 +40,8 @@ type DeployApiMockOptions = { failCreateProductionInstance?: boolean; failDnsVerification?: boolean; failOAuthSave?: boolean; + failValidateCloningUnsupportedFeatures?: string[]; + failCreateProductionInstanceExists?: boolean; }; let mockDeployApiOptions: DeployApiMockOptions = {}; @@ -53,6 +55,18 @@ function simulatedDeployApiFailure(step: string): Error { return new Error(`Simulated deploy failure: ${step}.`); } +function simulatedSpecificFailure( + status: number, + code: string, + message: string, + meta?: Record, +): PlapiError { + const body = JSON.stringify({ + errors: [{ code, message, ...(meta ? { meta } : {}) }], + }); + return PlapiError.fromBody(status, body, "clerk deploy test mock"); +} + mock.module("@inquirer/prompts", () => ({ ...promptsStubs, select: (...args: unknown[]) => mockSelect(...args), @@ -85,6 +99,13 @@ mock.module("../../lib/plapi.ts", () => ({ mock.module("./api.ts", () => ({ configureMockDeployApi, createProductionInstance: (...args: unknown[]) => { + if (mockDeployApiOptions.failCreateProductionInstanceExists) { + throw simulatedSpecificFailure( + 400, + "production_instance_exists", + "You can only have one production instance.", + ); + } const result = mockCreateProductionInstance(...args); if (mockDeployApiOptions.failCreateProductionInstance) { throw simulatedDeployApiFailure("production instance creation"); @@ -92,6 +113,14 @@ mock.module("./api.ts", () => ({ return result; }, validateCloning: (...args: unknown[]) => { + if (mockDeployApiOptions.failValidateCloningUnsupportedFeatures) { + throw simulatedSpecificFailure( + 402, + "unsupported_subscription_plan_features", + "Unsupported plan features", + { unsupported_features: mockDeployApiOptions.failValidateCloningUnsupportedFeatures }, + ); + } const result = mockValidateCloning(...args); if (mockDeployApiOptions.failValidateCloning) { throw simulatedDeployApiFailure("cloning validation"); @@ -1446,5 +1475,66 @@ describe("deploy", () => { expect(err).toContain("facebook"); expect(err).toContain("Configure them from the Clerk Dashboard before going live"); }); + + test("recovers from production_instance_exists by resuming reconcileExistingDeploy", async () => { + _modeOverride = "human"; + await linkedProject({ + appId: "app_test", + appName: "Test App", + instances: { development: "ins_dev" }, + }); + // First call (resolveDeployContext): no production instance yet. + // Second call (reloadProductionState after recovery): production instance exists. + mockFetchApplication + .mockResolvedValueOnce({ + application_id: "app_test", + name: "Test App", + instances: [ + { instance_id: "ins_dev", environment_type: "development", publishable_key: "pk_test" }, + ], + }) + .mockResolvedValueOnce({ + application_id: "app_test", + name: "Test App", + instances: [ + { instance_id: "ins_dev", environment_type: "development", publishable_key: "pk_test" }, + { + instance_id: "ins_prod_existing", + environment_type: "production", + publishable_key: "pk_live", + }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_existing", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + development_origin: "", + cname_targets: [], + created_at: "2026-05-12T00:00:00Z", + updated_at: "2026-05-12T00:00:00Z", + }, + ], + total_count: 1, + }); + mockGetDeployStatus.mockResolvedValue({ status: "complete" }); + mockFetchInstanceConfig.mockResolvedValue({}); + mockConfirm.mockResolvedValue(true); + mockInput.mockResolvedValueOnce("example.com"); + + captured = captureLog(); + await runDeploy({ testFailCreateProductionInstanceExists: true }); + + expect(captured.err).toContain( + "A production instance already exists for this application. Resuming", + ); + // Confirm reconcile path ran (plan renders "Use production domain"). + expect(captured.err).toContain("Use production domain example.com"); + }); }); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 0b110655..87a4e189 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -2,7 +2,13 @@ import { isAgent } from "../../mode.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { sleep } from "../../lib/sleep.ts"; import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; -import { UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; +import { + CliError, + PlapiError, + UserAbortError, + isPromptExitError, + throwUsageError, +} from "../../lib/errors.ts"; import { resolveProfile, setProfile } from "../../lib/config.ts"; import { fetchApplication, @@ -74,6 +80,7 @@ type DeployOptions = { testFailDomainLookup?: boolean; testFailValidateCloning?: boolean; testFailCreateProductionInstance?: boolean; + testFailCreateProductionInstanceExists?: boolean; testFailDnsVerification?: boolean; testFailOAuthSave?: boolean; }; @@ -169,6 +176,7 @@ function configureDeployApiMocks(testFlags: DeployTestFlags): void { configureMockDeployApi({ failValidateCloning: testFlags.testFailValidateCloning, failCreateProductionInstance: testFlags.testFailCreateProductionInstance, + failCreateProductionInstanceExists: testFlags.testFailCreateProductionInstanceExists, failDnsVerification: testFlags.testFailDnsVerification, failOAuthSave: testFlags.testFailOAuthSave, }); @@ -255,7 +263,18 @@ async function startNewDeploy(ctx: DeployContext): Promise { ); if (!shouldCreateProductionInstance) return; - const production = await createProductionInstance(ctx, domain); + let production: ProductionInstanceResponse; + try { + production = await createProductionInstance(ctx, domain); + } catch (error) { + if (error instanceof PlapiError && error.code === "production_instance_exists") { + log.info("A production instance already exists for this application. Resuming…"); + const reconciledCtx = await reloadProductionState(ctx); + await reconcileExistingDeploy(reconciledCtx); + return; + } + throw error; + } await persistProductionInstance(ctx, production.instance_id); log.blank(); @@ -759,6 +778,30 @@ async function collectAndSaveOAuthCredentials( return true; } +/** + * Refresh the deploy context from the server after a recovery branch. + * + * Used when the server tells us a production instance exists but our local + * context doesn't know about it yet (e.g. state was lost between runs). Pulls + * the application down again, finds the production instance, and persists the + * resolved ID so subsequent `clerk deploy` invocations short-circuit to + * `reconcileExistingDeploy` directly. + */ +async function reloadProductionState(ctx: DeployContext): Promise { + const app = await fetchApplication(ctx.appId); + const production = app.instances.find((entry) => entry.environment_type === "production"); + if (!production) { + throw new CliError( + "Server reports a production instance exists but did not return one when refetching the application.", + ); + } + await persistProductionInstance(ctx, production.instance_id); + return { + ...ctx, + productionInstanceId: production.instance_id, + }; +} + async function persistProductionInstance(ctx: DeployContext, productionInstanceId: string) { await setProfile(ctx.profileKey, { ...ctx.profile, From 05eff4ca26762e35647fd7ef1a53ce77e2e7ab98 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 21:04:23 -0600 Subject: [PATCH 5/7] feat(deploy): friendlier error for unsupported subscription plan features When validateCloning returns HTTP 402 with code unsupported_subscription_plan_features, rethrow a CliError whose message lists the unsupported features from meta.unsupported_features and includes a docs URL hint pointing at clerk.com/docs/billing/plans. --- .../src/commands/deploy/index.test.ts | 17 +++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 19 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 3899b4f8..0898f649 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -987,6 +987,23 @@ describe("deploy", () => { expect(mockCreateProductionInstance).not.toHaveBeenCalled(); }); + test("rethrows unsupported_subscription_plan_features as a CliError with feature list", async () => { + await linkedProject(); + mockIsAgent.mockReturnValue(false); + + let thrown: unknown; + try { + await runDeploy({ testFailValidateCloningUnsupportedFeatures: ["saml", "mfa"] }); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(CliError); + const err = thrown as CliError; + expect(err.message).toContain("saml"); + expect(err.message).toContain("mfa"); + expect(err.docsUrl).toContain("clerk.com/docs"); + }); + test("--test-fail-create-production-instance simulates production creation failure", 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 87a4e189..b21373bd 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -81,6 +81,7 @@ type DeployOptions = { testFailValidateCloning?: boolean; testFailCreateProductionInstance?: boolean; testFailCreateProductionInstanceExists?: boolean; + testFailValidateCloningUnsupportedFeatures?: string[]; testFailDnsVerification?: boolean; testFailOAuthSave?: boolean; }; @@ -177,6 +178,7 @@ function configureDeployApiMocks(testFlags: DeployTestFlags): void { failValidateCloning: testFlags.testFailValidateCloning, failCreateProductionInstance: testFlags.testFailCreateProductionInstance, failCreateProductionInstanceExists: testFlags.testFailCreateProductionInstanceExists, + failValidateCloningUnsupportedFeatures: testFlags.testFailValidateCloningUnsupportedFeatures, failDnsVerification: testFlags.testFailDnsVerification, failOAuthSave: testFlags.testFailOAuthSave, }); @@ -532,7 +534,22 @@ function discoverEnabledOAuthProviders(config: Record): Discove async function runValidateCloning(ctx: DeployContext): Promise { await withSpinner("Validating subscription compatibility...", async () => { - await validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }); + try { + await validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }); + } catch (error) { + if (error instanceof PlapiError && error.code === "unsupported_subscription_plan_features") { + const features = Array.isArray(error.meta?.unsupported_features) + ? (error.meta.unsupported_features as string[]) + : []; + const featureList = features.length > 0 ? features.join(", ") : "this plan"; + throw new CliError( + `Your subscription plan doesn't support: ${featureList}.\n` + + "Upgrade your plan or disable these features in development before deploying.", + { docsUrl: "https://clerk.com/docs/billing/plans" }, + ); + } + throw error; + } }); } From 0200995adc6385c1dc57e7d540c478104d89bee7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 21:06:07 -0600 Subject: [PATCH 6/7] refactor(plapi): allow null active_domain and guard in deploy wizard --- .../cli-core/src/commands/deploy/api.test.ts | 2 +- .../src/commands/deploy/index.test.ts | 37 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 8 ++++ packages/cli-core/src/lib/plapi.ts | 3 +- 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/api.test.ts b/packages/cli-core/src/commands/deploy/api.test.ts index fb9cc7f0..1da85baa 100644 --- a/packages/cli-core/src/commands/deploy/api.test.ts +++ b/packages/cli-core/src/commands/deploy/api.test.ts @@ -63,7 +63,7 @@ describe("deploy api adapter", () => { }); expect(production.instance_id).toBe("MOCKED_NOT_REAL_FIXME"); - expect(production.active_domain.name).toBe("example.com"); + expect(production.active_domain?.name).toBe("example.com"); expect(production.cname_targets).toHaveLength(3); expect(mockPlapiCreateProductionInstance).not.toHaveBeenCalled(); expect(mockPlapiValidateCloning).not.toHaveBeenCalled(); diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 0898f649..831751c9 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1493,6 +1493,43 @@ describe("deploy", () => { expect(err).toContain("Configure them from the Clerk Dashboard before going live"); }); + test("throws a CliError when createProductionInstance returns no active_domain", async () => { + _modeOverride = "human"; + await linkedProject({ + appId: "app_test", + appName: "Test App", + instances: { development: "ins_dev" }, + }); + mockFetchApplication.mockResolvedValue({ + application_id: "app_test", + name: "Test App", + instances: [ + { instance_id: "ins_dev", environment_type: "development", publishable_key: "pk_test" }, + ], + }); + mockFetchInstanceConfig.mockResolvedValue({}); + mockConfirm.mockResolvedValue(true); + mockInput.mockResolvedValue("https://example.com"); + + mockCreateProductionInstance.mockResolvedValue({ + instance_id: "ins_prod", + environment_type: "production", + active_domain: null, + secret_key: "sk_live_x", + publishable_key: "pk_live_x", + cname_targets: [], + }); + + let thrown: unknown; + try { + await runDeploy({}); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(CliError); + expect((thrown as CliError).message).toContain("did not return a domain"); + }); + test("recovers from production_instance_exists by resuming reconcileExistingDeploy", async () => { _modeOverride = "human"; await linkedProject({ diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index b21373bd..7ca06ad5 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -278,6 +278,14 @@ async function startNewDeploy(ctx: DeployContext): Promise { throw error; } await persistProductionInstance(ctx, production.instance_id); + + if (!production.active_domain) { + throw new CliError( + "Production instance was created but Clerk did not return a domain. " + + "Run `clerk deploy` again to retry domain provisioning.", + ); + } + log.blank(); const productionDomain = production.active_domain.name; diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 74e86c2a..b6d93fa4 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -174,7 +174,8 @@ export type ListApplicationDomainsResponse = { export type ProductionInstanceResponse = { instance_id: string; environment_type: "production"; - active_domain: DomainSummary; + /** Server contract permits null; CLI currently throws if the server omits it. */ + active_domain: DomainSummary | null; secret_key?: string; publishable_key: string; cname_targets: CnameTarget[]; From ce49854d0616b101b1b1216962b5dfd5fc495a17 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 21:08:41 -0600 Subject: [PATCH 7/7] docs(deploy): document live-error recovery paths and add changeset --- .changeset/deploy-error-recovery.md | 5 +++++ packages/cli-core/src/commands/deploy/README.md | 9 +++++++++ 2 files changed, 14 insertions(+) create mode 100644 .changeset/deploy-error-recovery.md diff --git a/.changeset/deploy-error-recovery.md b/.changeset/deploy-error-recovery.md new file mode 100644 index 00000000..e6050582 --- /dev/null +++ b/.changeset/deploy-error-recovery.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Surface Clerk API error codes and metadata as structured fields on `PlapiError` / `BapiError` / `FapiError`, and use them to add two recovery paths in `clerk deploy`: resume from server state when a production instance already exists, and present a friendly upgrade hint when the development instance uses features the current subscription plan doesn't allow. diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 235d8c2b..2ea84c6e 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -176,3 +176,12 @@ When the user chooses the guided walkthrough, these values are derived from thei | ----------------------------- | --------------------------------------------- | | Authorized JavaScript origins | `https://{domain}`, `https://www.{domain}` | | Authorized redirect URI | `https://accounts.{domain}/v1/oauth_callback` | + +### Recovery paths + +- **`production_instance_exists`** (HTTP 400) on production-instance creation — + the wizard refetches the application, persists the existing instance ID, and + jumps into the reconcile flow as if local state had been intact. +- **`unsupported_subscription_plan_features`** (HTTP 402) on cloning validation — + the wizard rethrows a `CliError` listing the unsupported features and pointing + at `https://clerk.com/docs/billing/plans`.