Skip to content

Commit 77b96c6

Browse files
committed
feat(deploy): recover from production_instance_exists by resuming live state
1 parent 20bd3d3 commit 77b96c6

2 files changed

Lines changed: 136 additions & 3 deletions

File tree

packages/cli-core/src/commands/deploy/index.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
33
import { join, relative } from "node:path";
44
import { tmpdir } from "node:os";
55
import { captureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts";
6-
import { CliError, EXIT_CODE, UserAbortError } from "../../lib/errors.ts";
6+
import { CliError, EXIT_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts";
77

88
const mockIsAgent = mock();
99
let _modeOverride: string | undefined;
@@ -40,6 +40,8 @@ type DeployApiMockOptions = {
4040
failCreateProductionInstance?: boolean;
4141
failDnsVerification?: boolean;
4242
failOAuthSave?: boolean;
43+
failValidateCloningUnsupportedFeatures?: string[];
44+
failCreateProductionInstanceExists?: boolean;
4345
};
4446

4547
let mockDeployApiOptions: DeployApiMockOptions = {};
@@ -53,6 +55,18 @@ function simulatedDeployApiFailure(step: string): Error {
5355
return new Error(`Simulated deploy failure: ${step}.`);
5456
}
5557

58+
function simulatedSpecificFailure(
59+
status: number,
60+
code: string,
61+
message: string,
62+
meta?: Record<string, unknown>,
63+
): PlapiError {
64+
const body = JSON.stringify({
65+
errors: [{ code, message, ...(meta ? { meta } : {}) }],
66+
});
67+
return PlapiError.fromBody(status, body, "clerk deploy test mock");
68+
}
69+
5670
mock.module("@inquirer/prompts", () => ({
5771
...promptsStubs,
5872
select: (...args: unknown[]) => mockSelect(...args),
@@ -85,13 +99,28 @@ mock.module("../../lib/plapi.ts", () => ({
8599
mock.module("./api.ts", () => ({
86100
configureMockDeployApi,
87101
createProductionInstance: (...args: unknown[]) => {
102+
if (mockDeployApiOptions.failCreateProductionInstanceExists) {
103+
throw simulatedSpecificFailure(
104+
400,
105+
"production_instance_exists",
106+
"You can only have one production instance.",
107+
);
108+
}
88109
const result = mockCreateProductionInstance(...args);
89110
if (mockDeployApiOptions.failCreateProductionInstance) {
90111
throw simulatedDeployApiFailure("production instance creation");
91112
}
92113
return result;
93114
},
94115
validateCloning: (...args: unknown[]) => {
116+
if (mockDeployApiOptions.failValidateCloningUnsupportedFeatures) {
117+
throw simulatedSpecificFailure(
118+
402,
119+
"unsupported_subscription_plan_features",
120+
"Unsupported plan features",
121+
{ unsupported_features: mockDeployApiOptions.failValidateCloningUnsupportedFeatures },
122+
);
123+
}
95124
const result = mockValidateCloning(...args);
96125
if (mockDeployApiOptions.failValidateCloning) {
97126
throw simulatedDeployApiFailure("cloning validation");
@@ -1446,5 +1475,66 @@ describe("deploy", () => {
14461475
expect(err).toContain("facebook");
14471476
expect(err).toContain("Configure them from the Clerk Dashboard before going live");
14481477
});
1478+
1479+
test("recovers from production_instance_exists by resuming reconcileExistingDeploy", async () => {
1480+
_modeOverride = "human";
1481+
await linkedProject({
1482+
appId: "app_test",
1483+
appName: "Test App",
1484+
instances: { development: "ins_dev" },
1485+
});
1486+
// First call (resolveDeployContext): no production instance yet.
1487+
// Second call (reloadProductionState after recovery): production instance exists.
1488+
mockFetchApplication
1489+
.mockResolvedValueOnce({
1490+
application_id: "app_test",
1491+
name: "Test App",
1492+
instances: [
1493+
{ instance_id: "ins_dev", environment_type: "development", publishable_key: "pk_test" },
1494+
],
1495+
})
1496+
.mockResolvedValueOnce({
1497+
application_id: "app_test",
1498+
name: "Test App",
1499+
instances: [
1500+
{ instance_id: "ins_dev", environment_type: "development", publishable_key: "pk_test" },
1501+
{
1502+
instance_id: "ins_prod_existing",
1503+
environment_type: "production",
1504+
publishable_key: "pk_live",
1505+
},
1506+
],
1507+
});
1508+
mockListApplicationDomains.mockResolvedValue({
1509+
data: [
1510+
{
1511+
object: "domain",
1512+
id: "dmn_existing",
1513+
name: "example.com",
1514+
is_satellite: false,
1515+
is_provider_domain: false,
1516+
frontend_api_url: "https://clerk.example.com",
1517+
development_origin: "",
1518+
cname_targets: [],
1519+
created_at: "2026-05-12T00:00:00Z",
1520+
updated_at: "2026-05-12T00:00:00Z",
1521+
},
1522+
],
1523+
total_count: 1,
1524+
});
1525+
mockGetDeployStatus.mockResolvedValue({ status: "complete" });
1526+
mockFetchInstanceConfig.mockResolvedValue({});
1527+
mockConfirm.mockResolvedValue(true);
1528+
mockInput.mockResolvedValueOnce("example.com");
1529+
1530+
captured = captureLog();
1531+
await runDeploy({ testFailCreateProductionInstanceExists: true });
1532+
1533+
expect(captured.err).toContain(
1534+
"A production instance already exists for this application. Resuming",
1535+
);
1536+
// Confirm reconcile path ran (plan renders "Use production domain").
1537+
expect(captured.err).toContain("Use production domain example.com");
1538+
});
14491539
});
14501540
});

packages/cli-core/src/commands/deploy/index.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { isAgent } from "../../mode.ts";
22
import { isInsideGutter, log } from "../../lib/log.ts";
33
import { sleep } from "../../lib/sleep.ts";
44
import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts";
5-
import { UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts";
5+
import {
6+
CliError,
7+
PlapiError,
8+
UserAbortError,
9+
isPromptExitError,
10+
throwUsageError,
11+
} from "../../lib/errors.ts";
612
import { resolveProfile, setProfile } from "../../lib/config.ts";
713
import {
814
fetchApplication,
@@ -74,6 +80,7 @@ type DeployOptions = {
7480
testFailDomainLookup?: boolean;
7581
testFailValidateCloning?: boolean;
7682
testFailCreateProductionInstance?: boolean;
83+
testFailCreateProductionInstanceExists?: boolean;
7784
testFailDnsVerification?: boolean;
7885
testFailOAuthSave?: boolean;
7986
};
@@ -169,6 +176,7 @@ function configureDeployApiMocks(testFlags: DeployTestFlags): void {
169176
configureMockDeployApi({
170177
failValidateCloning: testFlags.testFailValidateCloning,
171178
failCreateProductionInstance: testFlags.testFailCreateProductionInstance,
179+
failCreateProductionInstanceExists: testFlags.testFailCreateProductionInstanceExists,
172180
failDnsVerification: testFlags.testFailDnsVerification,
173181
failOAuthSave: testFlags.testFailOAuthSave,
174182
});
@@ -255,7 +263,18 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
255263
);
256264
if (!shouldCreateProductionInstance) return;
257265

258-
const production = await createProductionInstance(ctx, domain);
266+
let production: ProductionInstanceResponse;
267+
try {
268+
production = await createProductionInstance(ctx, domain);
269+
} catch (error) {
270+
if (error instanceof PlapiError && error.code === "production_instance_exists") {
271+
log.info("A production instance already exists for this application. Resuming…");
272+
const reconciledCtx = await reloadProductionState(ctx);
273+
await reconcileExistingDeploy(reconciledCtx);
274+
return;
275+
}
276+
throw error;
277+
}
259278
await persistProductionInstance(ctx, production.instance_id);
260279
log.blank();
261280

@@ -759,6 +778,30 @@ async function collectAndSaveOAuthCredentials(
759778
return true;
760779
}
761780

781+
/**
782+
* Refresh the deploy context from the server after a recovery branch.
783+
*
784+
* Used when the server tells us a production instance exists but our local
785+
* context doesn't know about it yet (e.g. state was lost between runs). Pulls
786+
* the application down again, finds the production instance, and persists the
787+
* resolved ID so subsequent `clerk deploy` invocations short-circuit to
788+
* `reconcileExistingDeploy` directly.
789+
*/
790+
async function reloadProductionState(ctx: DeployContext): Promise<DeployContext> {
791+
const app = await fetchApplication(ctx.appId);
792+
const production = app.instances.find((entry) => entry.environment_type === "production");
793+
if (!production) {
794+
throw new CliError(
795+
"Server reports a production instance exists but did not return one when refetching the application.",
796+
);
797+
}
798+
await persistProductionInstance(ctx, production.instance_id);
799+
return {
800+
...ctx,
801+
productionInstanceId: production.instance_id,
802+
};
803+
}
804+
762805
async function persistProductionInstance(ctx: DeployContext, productionInstanceId: string) {
763806
await setProfile(ctx.profileKey, {
764807
...ctx.profile,

0 commit comments

Comments
 (0)