Skip to content

Commit 5f2ceba

Browse files
committed
feat(deploy): sequence DNS verification feedback per component
Replace the single-spinner pollDeployStatus loop with a chained mail/dns/ssl spinner sequence that emits a per-component success line as each boolean flips true. Add a defensive status === "complete" check after all three components succeed so the proxy_ok server-side case fails closed rather than reporting verified. When all DNS components are resolved but the server has not yet marked the deployment complete, exit the verification path without reaching finishDeploy.
1 parent f3f9f84 commit 5f2ceba

2 files changed

Lines changed: 121 additions & 14 deletions

File tree

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,80 @@ describe("deploy", () => {
477477
expect(err).toContain("Production ready at https://example.com");
478478
});
479479

480+
test("DNS verification emits per-component spinner labels in mail/dns/ssl order", async () => {
481+
await linkedProject();
482+
mockIsAgent.mockReturnValue(false);
483+
mockConfirm
484+
.mockResolvedValueOnce(true) // Proceed?
485+
.mockResolvedValueOnce(true) // Create production instance?
486+
.mockResolvedValueOnce(false); // Export BIND zone file? (wired in Task 5; harmless when not yet consumed)
487+
mockInput
488+
.mockResolvedValueOnce("example.com")
489+
.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
490+
mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("have-credentials");
491+
mockPassword.mockResolvedValueOnce("google-secret");
492+
mockGetDeployStatus
493+
.mockResolvedValueOnce({
494+
status: "incomplete",
495+
dns_ok: false,
496+
ssl_ok: false,
497+
mail_ok: false,
498+
})
499+
.mockResolvedValueOnce({
500+
status: "incomplete",
501+
dns_ok: false,
502+
ssl_ok: false,
503+
mail_ok: true,
504+
})
505+
.mockResolvedValueOnce({
506+
status: "incomplete",
507+
dns_ok: true,
508+
ssl_ok: false,
509+
mail_ok: true,
510+
})
511+
.mockResolvedValueOnce({ status: "complete", dns_ok: true, ssl_ok: true, mail_ok: true });
512+
mockPatchInstanceConfig.mockResolvedValueOnce({});
513+
514+
await runDeploy({});
515+
const err = stripAnsi(captured.err);
516+
517+
const mailIdx = err.indexOf("Mail sender verified");
518+
const dnsIdx = err.indexOf("DNS verified for example.com");
519+
const sslIdx = err.indexOf("SSL certificate issued for example.com");
520+
expect(mailIdx).toBeGreaterThan(-1);
521+
expect(dnsIdx).toBeGreaterThan(-1);
522+
expect(sslIdx).toBeGreaterThan(-1);
523+
expect(mailIdx).toBeLessThan(dnsIdx);
524+
expect(dnsIdx).toBeLessThan(sslIdx);
525+
});
526+
527+
test("DNS verification fails closed when status stays incomplete despite all exposed booleans true (proxy_ok case)", async () => {
528+
await linkedProject({
529+
instances: { development: "ins_dev_123", production: "ins_prod_123" },
530+
});
531+
mockIsAgent.mockReturnValue(false);
532+
mockLiveProduction({
533+
instanceId: "ins_prod_123",
534+
developmentConfig: {},
535+
productionConfig: {},
536+
});
537+
// Every poll returns dns/ssl/mail all true but status incomplete (proxy_ok = false on server).
538+
mockGetDeployStatus.mockResolvedValue({
539+
status: "incomplete",
540+
dns_ok: true,
541+
ssl_ok: true,
542+
mail_ok: true,
543+
});
544+
mockConfirm.mockResolvedValueOnce(false); // BIND export prompt: skip (wired in Task 5)
545+
mockSelect.mockResolvedValueOnce("check").mockResolvedValueOnce("skip");
546+
547+
await runDeploy({});
548+
const err = stripAnsi(captured.err);
549+
550+
expect(err).toContain("Production setup for example.com is still finalizing.");
551+
expect(err).not.toContain("Production ready at");
552+
});
553+
480554
test("uses existing wizard framing and concise plan confirmation", async () => {
481555
await linkedProject();
482556
mockHumanFlow();

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

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ import {
2525
OAUTH_SECTION_INTRO,
2626
type DeployComponentStatus,
2727
type DeployPlanStep,
28+
DEPLOY_COMPONENT_ORDER,
29+
deployComponentLabels,
2830
deployComponentStatus,
2931
deployStatusPendingFooter,
3032
domainAssociationSummary,
3133
dnsDashboardHandoff,
3234
dnsIntro,
3335
dnsRecords,
34-
dnsVerified,
3536
pausedOperationNotice,
3637
printPlan,
3738
productionSummary,
@@ -581,7 +582,7 @@ async function runExistingDomainDnsVerification(
581582
async function runDnsVerification(
582583
ctx: DeployContext,
583584
state: DeployOperationState,
584-
): Promise<DnsVerificationResult> {
585+
): Promise<DnsVerificationResult | false> {
585586
const productionInstanceId =
586587
state.productionInstanceId ?? ctx.productionInstanceId ?? ctx.profile.instances.production;
587588
if (!productionInstanceId) {
@@ -592,13 +593,10 @@ async function runDnsVerification(
592593

593594
await requestDomainDnsCheck(ctx.appId, state.productionDomainId ?? state.domain);
594595

595-
const outcome = await withSpinner(`Verifying production setup for ${state.domain}...`, () =>
596-
pollDeployStatus(ctx.appId, productionInstanceId),
597-
);
596+
const outcome = await pollDeployStatus(ctx.appId, productionInstanceId, state.domain);
598597

599598
if (outcome.verified) {
600599
log.blank();
601-
for (const line of dnsVerified(state.domain)) log.success(line);
602600
log.info(deployComponentStatus(outcome.status));
603601
return "verified";
604602
}
@@ -609,6 +607,15 @@ async function runDnsVerification(
609607
for (const line of deployStatusPendingFooter(state.domain, outcome.status)) {
610608
log.warn(line);
611609
}
610+
611+
// When all DNS components are verified but the server has not yet marked the
612+
// deployment complete (proxy_ok or another server-side gate is still pending),
613+
// do not offer a retry — the user cannot influence the remaining wait. Fail
614+
// closed so the caller exits without reaching finishDeploy.
615+
if (outcome.status.dns && outcome.status.ssl && outcome.status.mail) {
616+
return false;
617+
}
618+
612619
if (state.cnameTargets && state.cnameTargets.length > 0) {
613620
log.blank();
614621
for (const line of dnsRecords(state.cnameTargets)) log.info(line);
@@ -630,15 +637,41 @@ type DeployStatusOutcome =
630637
async function pollDeployStatus(
631638
appId: string,
632639
productionInstanceId: string,
640+
domain: string,
633641
): Promise<DeployStatusOutcome> {
634-
let status: DeployComponentStatus = { dns: false, ssl: false, mail: false };
635-
for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) {
636-
const result = await mapDeployError(getDeployStatus(appId, productionInstanceId));
637-
status = { dns: result.dns_ok, ssl: result.ssl_ok, mail: result.mail_ok };
638-
if (result.status === "complete") return { verified: true, status };
639-
await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS);
640-
}
641-
return { verified: false, status };
642+
let response = await mapDeployError(getDeployStatus(appId, productionInstanceId));
643+
let status: DeployComponentStatus = {
644+
dns: response.dns_ok,
645+
ssl: response.ssl_ok,
646+
mail: response.mail_ok,
647+
};
648+
let pollsRemaining = DEPLOY_STATUS_MAX_POLLS - 1;
649+
650+
for (const component of DEPLOY_COMPONENT_ORDER) {
651+
const labels = deployComponentLabels(component, domain);
652+
const flipped = await withSpinner(labels.progress, async () => {
653+
if (status[component]) return true;
654+
while (pollsRemaining > 0) {
655+
await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS);
656+
pollsRemaining--;
657+
response = await mapDeployError(getDeployStatus(appId, productionInstanceId));
658+
status = {
659+
dns: response.dns_ok,
660+
ssl: response.ssl_ok,
661+
mail: response.mail_ok,
662+
};
663+
if (status[component]) return true;
664+
}
665+
return false;
666+
});
667+
if (!flipped) return { verified: false, status };
668+
log.success(labels.done);
669+
}
670+
671+
if (response.status !== "complete") {
672+
return { verified: false, status };
673+
}
674+
return { verified: true, status };
642675
}
643676

644677
async function requestDomainDnsCheck(appId: string, domainIdOrName: string): Promise<void> {

0 commit comments

Comments
 (0)