Skip to content

Commit 9111d0d

Browse files
committed
feat(deploy): offer BIND zone export after DNS records
After the DNS records block in both runDnsSetup and runExistingDomainDnsVerification, prompt the user (default: no) to export the records as a clerk-<domain>.zone BIND zone file.
1 parent 289b4a1 commit 9111d0d

3 files changed

Lines changed: 153 additions & 5 deletions

File tree

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

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ describe("deploy", () => {
243243
domainId?: string;
244244
productionConfig?: Record<string, unknown>;
245245
developmentConfig?: Record<string, unknown>;
246+
cnameTargets?: readonly { host: string; value: string; required: boolean }[];
246247
} = {},
247248
) {
248249
const instanceId = options.instanceId ?? "ins_prod_mock";
@@ -254,6 +255,9 @@ describe("deploy", () => {
254255
const productionConfig = options.productionConfig ?? {
255256
connection_oauth_google: { enabled: false, client_id: "", client_secret: "" },
256257
};
258+
const cnameTargets = options.cnameTargets ?? [
259+
{ host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true },
260+
];
257261

258262
mockFetchApplication.mockResolvedValue({
259263
application_id: "app_xyz789",
@@ -282,9 +286,7 @@ describe("deploy", () => {
282286
frontend_api_url: `https://clerk.${domain}`,
283287
accounts_portal_url: `https://accounts.${domain}`,
284288
development_origin: "",
285-
cname_targets: [
286-
{ host: `clerk.${domain}`, value: "frontend-api.clerk.services", required: true },
287-
],
289+
cname_targets: cnameTargets,
288290
created_at: "2026-05-06T00:00:00Z",
289291
updated_at: "2026-05-06T00:00:00Z",
290292
},
@@ -647,7 +649,10 @@ describe("deploy", () => {
647649
test("DNS setup prints dashboard handoff and asks about verifying DNS", async () => {
648650
await linkedProject();
649651
mockIsAgent.mockReturnValue(false);
650-
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
652+
mockConfirm
653+
.mockResolvedValueOnce(true) // Proceed?
654+
.mockResolvedValueOnce(true) // Create production instance?
655+
.mockResolvedValueOnce(false); // Export DNS records as a BIND zone file?
651656
mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("skip");
652657
mockInput.mockResolvedValueOnce("example.com");
653658

@@ -663,11 +668,15 @@ describe("deploy", () => {
663668
expect(err).toContain("propagation and SSL issuance");
664669
expect(err).toContain("DNS propagation can take time");
665670
expect(err).toContain("Skipping DNS verification for now.");
666-
expect(mockConfirm).toHaveBeenCalledTimes(2);
671+
expect(mockConfirm).toHaveBeenCalledTimes(3);
667672
expect(mockConfirm).toHaveBeenCalledWith({
668673
message: "Create production instance?",
669674
default: true,
670675
});
676+
expect(mockConfirm).toHaveBeenCalledWith({
677+
message: "Export DNS records as a BIND zone file?",
678+
default: false,
679+
});
671680
expect(mockConfirm).not.toHaveBeenCalledWith({
672681
message: "Continue to OAuth setup?",
673682
default: true,
@@ -937,6 +946,119 @@ describe("deploy", () => {
937946
expect(recordsIdx).toBeLessThan(promptIdx);
938947
});
939948

949+
test("BIND export prompt writes the zone file when the user accepts", async () => {
950+
await linkedProject({
951+
instances: { development: "ins_dev_123", production: "ins_prod_123" },
952+
});
953+
mockIsAgent.mockReturnValue(false);
954+
mockLiveProduction({
955+
instanceId: "ins_prod_123",
956+
productionConfig: {},
957+
});
958+
mockGetDeployStatus.mockResolvedValue({
959+
status: "incomplete",
960+
dns_ok: false,
961+
ssl_ok: false,
962+
mail_ok: false,
963+
});
964+
mockConfirm.mockResolvedValueOnce(true); // BIND export prompt: yes
965+
mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials");
966+
mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
967+
mockPassword.mockResolvedValueOnce("google-secret");
968+
mockPatchInstanceConfig.mockResolvedValueOnce({});
969+
970+
const writeSpy = spyOn(Bun, "write").mockResolvedValue(0);
971+
try {
972+
await runDeploy({});
973+
const err = stripAnsi(captured.err);
974+
975+
const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone"));
976+
expect(zoneCall).toBeDefined();
977+
const pathArg = zoneCall![0];
978+
const contentArg = zoneCall![1];
979+
expect(String(pathArg)).toMatch(/clerk-example\.com\.zone$/);
980+
expect(String(contentArg)).toContain("$ORIGIN example.com.");
981+
expect(String(contentArg)).toContain("$TTL 300");
982+
expect(String(contentArg)).toContain("IN\tCNAME");
983+
expect(err).toContain("Wrote ");
984+
expect(err).toContain("clerk-example.com.zone");
985+
} finally {
986+
writeSpy.mockRestore();
987+
}
988+
});
989+
990+
test("BIND export prompt writes no file when the user declines", async () => {
991+
await linkedProject({
992+
instances: { development: "ins_dev_123", production: "ins_prod_123" },
993+
});
994+
mockIsAgent.mockReturnValue(false);
995+
mockLiveProduction({
996+
instanceId: "ins_prod_123",
997+
productionConfig: {},
998+
});
999+
mockGetDeployStatus.mockResolvedValue({
1000+
status: "incomplete",
1001+
dns_ok: false,
1002+
ssl_ok: false,
1003+
mail_ok: false,
1004+
});
1005+
mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: no
1006+
mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials");
1007+
mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
1008+
mockPassword.mockResolvedValueOnce("google-secret");
1009+
mockPatchInstanceConfig.mockResolvedValueOnce({});
1010+
1011+
const writeSpy = spyOn(Bun, "write").mockResolvedValue(0);
1012+
try {
1013+
await runDeploy({});
1014+
const err = stripAnsi(captured.err);
1015+
1016+
const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone"));
1017+
expect(zoneCall).toBeUndefined();
1018+
expect(err).not.toContain("Wrote ");
1019+
} finally {
1020+
writeSpy.mockRestore();
1021+
}
1022+
});
1023+
1024+
test("BIND export prompt is skipped when cnameTargets is empty", async () => {
1025+
await linkedProject({
1026+
instances: { development: "ins_dev_123", production: "ins_prod_123" },
1027+
});
1028+
mockIsAgent.mockReturnValue(false);
1029+
mockLiveProduction({
1030+
instanceId: "ins_prod_123",
1031+
productionConfig: {},
1032+
cnameTargets: [], // override: domain has no CNAME targets
1033+
});
1034+
mockGetDeployStatus.mockResolvedValue({
1035+
status: "incomplete",
1036+
dns_ok: false,
1037+
ssl_ok: false,
1038+
mail_ok: false,
1039+
});
1040+
mockSelect.mockResolvedValueOnce("skip").mockResolvedValueOnce("have-credentials");
1041+
mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
1042+
mockPassword.mockResolvedValueOnce("google-secret");
1043+
mockPatchInstanceConfig.mockResolvedValueOnce({});
1044+
1045+
const writeSpy = spyOn(Bun, "write").mockResolvedValue(0);
1046+
try {
1047+
await runDeploy({});
1048+
1049+
// confirm() was never called for the BIND prompt in this run.
1050+
const bindPromptCalls = mockConfirm.mock.calls.filter((call) => {
1051+
const arg = call[0] as { message?: string } | undefined;
1052+
return typeof arg?.message === "string" && arg.message.includes("BIND zone file");
1053+
});
1054+
expect(bindPromptCalls.length).toBe(0);
1055+
const zoneCall = writeSpy.mock.calls.find((call) => String(call[0]).endsWith(".zone"));
1056+
expect(zoneCall).toBeUndefined();
1057+
} finally {
1058+
writeSpy.mockRestore();
1059+
}
1060+
});
1061+
9401062
test("DNS verification timeout names the specific pending components from deploy_status", async () => {
9411063
await linkedProject({
9421064
instances: { development: "ins_dev_123", production: "ins_prod_123" },

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
deployComponentStatus,
3131
deployStatusPendingFooter,
3232
domainAssociationSummary,
33+
bindZoneFile,
3334
dnsDashboardHandoff,
3435
dnsIntro,
3536
dnsRecords,
@@ -52,6 +53,7 @@ import {
5253
collectCustomDomain,
5354
collectOAuthCredentials,
5455
confirmCreateProductionInstance,
56+
confirmExportBindZone,
5557
confirmProceed,
5658
} from "./prompts.ts";
5759
import {
@@ -543,6 +545,8 @@ async function runDnsSetup(
543545

544546
for (const line of dnsDashboardHandoff(state.domain)) log.info(line);
545547
log.blank();
548+
await offerBindZoneExport(state.domain, cnameTargets);
549+
log.blank();
546550
try {
547551
const action = await chooseDnsVerificationAction();
548552
if (action === "skip") {
@@ -576,6 +580,8 @@ async function runExistingDomainDnsVerification(
576580
}
577581
for (const line of dnsDashboardHandoff(state.domain)) log.info(line);
578582
log.blank();
583+
await offerBindZoneExport(state.domain, state.cnameTargets);
584+
log.blank();
579585

580586
try {
581587
const action = await chooseDnsVerificationAction();
@@ -688,6 +694,19 @@ async function pollDeployStatus(
688694
return { verified: true, status };
689695
}
690696

697+
async function offerBindZoneExport(
698+
domain: string,
699+
cnameTargets: readonly CnameTarget[] | undefined,
700+
): Promise<void> {
701+
if (!cnameTargets || cnameTargets.length === 0) return;
702+
const accepted = await confirmExportBindZone();
703+
if (!accepted) return;
704+
const contents = bindZoneFile(domain, cnameTargets, new Date());
705+
const filePath = `${process.cwd()}/clerk-${domain}.zone`;
706+
await Bun.write(filePath, contents);
707+
log.success(`Wrote ${filePath}`);
708+
}
709+
691710
async function requestDomainDnsCheck(appId: string, domainIdOrName: string): Promise<void> {
692711
try {
693712
await triggerDomainDnsCheck(appId, domainIdOrName);

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ export async function chooseDnsVerificationAction(): Promise<DnsVerificationActi
6666
});
6767
}
6868

69+
export async function confirmExportBindZone(): Promise<boolean> {
70+
return confirm({
71+
message: "Export DNS records as a BIND zone file?",
72+
default: false,
73+
});
74+
}
75+
6976
export async function chooseOAuthCredentialAction(
7077
provider: OAuthProvider,
7178
): Promise<OAuthCredentialAction> {

0 commit comments

Comments
 (0)