Skip to content
5 changes: 5 additions & 0 deletions .changeset/deploy-error-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/cli-core/src/commands/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/deploy/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
146 changes: 145 additions & 1 deletion packages/cli-core/src/commands/deploy/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,6 +40,8 @@ type DeployApiMockOptions = {
failCreateProductionInstance?: boolean;
failDnsVerification?: boolean;
failOAuthSave?: boolean;
failValidateCloningUnsupportedFeatures?: string[];
failCreateProductionInstanceExists?: boolean;
};

let mockDeployApiOptions: DeployApiMockOptions = {};
Expand All @@ -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<string, unknown>,
): 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),
Expand Down Expand Up @@ -85,13 +99,28 @@ 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");
}
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");
Expand Down Expand Up @@ -958,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);
Expand Down Expand Up @@ -1446,5 +1492,103 @@ describe("deploy", () => {
expect(err).toContain("facebook");
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({
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");
});
});
});
74 changes: 71 additions & 3 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -74,6 +80,8 @@ type DeployOptions = {
testFailDomainLookup?: boolean;
testFailValidateCloning?: boolean;
testFailCreateProductionInstance?: boolean;
testFailCreateProductionInstanceExists?: boolean;
testFailValidateCloningUnsupportedFeatures?: string[];
testFailDnsVerification?: boolean;
testFailOAuthSave?: boolean;
};
Expand Down Expand Up @@ -169,6 +177,8 @@ function configureDeployApiMocks(testFlags: DeployTestFlags): void {
configureMockDeployApi({
failValidateCloning: testFlags.testFailValidateCloning,
failCreateProductionInstance: testFlags.testFailCreateProductionInstance,
failCreateProductionInstanceExists: testFlags.testFailCreateProductionInstanceExists,
failValidateCloningUnsupportedFeatures: testFlags.testFailValidateCloningUnsupportedFeatures,
failDnsVerification: testFlags.testFailDnsVerification,
failOAuthSave: testFlags.testFailOAuthSave,
});
Expand Down Expand Up @@ -255,8 +265,27 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
);
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);

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;
Expand Down Expand Up @@ -513,7 +542,22 @@ function discoverEnabledOAuthProviders(config: Record<string, unknown>): Discove

async function runValidateCloning(ctx: DeployContext): Promise<void> {
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;
}
});
}

Expand Down Expand Up @@ -759,6 +803,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<DeployContext> {
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,
Expand Down
Loading