From f477b7363960cff1059033fb91477417cf56defa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 3 Jun 2026 15:49:48 +0800 Subject: [PATCH 1/6] feat(cli): simplify domain add/status output for HTTP-DCV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom-domain certificate validation now uses HTTP-DCV at Cloudflare's edge, so there are no user-facing `_acme-challenge` SSL-validation records and renewals are fully automatic. - `domain add` now prints two sections — traffic CNAME + ownership TXT — and drops the "TXT (SSL validation)" section plus its deadline-miss fallback hint (both dead/misleading under HTTP-DCV). Summary updated to "Add both records above". - `domain status` only surfaces the ownership TXT while it is still outstanding; the SSL-DCV record block is removed (issuance and renewals never surface user records). `computeRollup` is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/domain.ts | 74 ++------ .../void/test/unit/domain-command.test.ts | 160 ++---------------- 2 files changed, 27 insertions(+), 207 deletions(-) diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index e592e3b0f..106991433 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -55,11 +55,11 @@ async function domainAdd( const domain = await client.addDomain(projectId, hostname); log.success(`Domain ${hostname} added (status: ${domain.status})`); - // Named locals make the section order explicit (CNAME -> ownership -> SSL), + // Named locals make the section order explicit (CNAME -> ownership), // rather than relying on the order of conditional pushes onto an array. + // Cert DCV uses HTTP-DCV, so no user-facing SSL-validation records exist. let cnameSection: string | undefined; let ownershipSection: string | undefined; - let sslSection: string | undefined; if (domain.cname_target) { cnameSection = `CNAME (traffic)\n ${hostname} → ${domain.cname_target}`; @@ -69,25 +69,9 @@ async function domainAdd( ownershipSection = `TXT (ownership verification)\n ${domain.ownership_verification.name} → ${domain.ownership_verification.value}`; } - const sslRecords = (domain.ssl_validation_records ?? []).filter( - (r): r is { txt_name: string; txt_value: string; status?: string } => - Boolean(r.txt_name) && Boolean(r.txt_value), - ); - if (sslRecords.length > 0) { - const lines = sslRecords.map((r) => ` ${r.txt_name} → ${r.txt_value}`).join('\n'); - sslSection = `TXT (SSL validation)\n${lines}`; - } else { - // Deadline-miss path: API short-poll didn't see SSL records before timeout. - // Tell the user they'll surface in `domain status` instead of silently - // omitting the section. - sslSection = `SSL validation records will appear in \`void domain status\` shortly.`; - } - - const summary = `Add the records above to your DNS, then run \`void domain status ${hostname}\` to check progress.`; + const summary = `Add both records above to your DNS, then run \`void domain status ${hostname}\` to check progress.`; - const sections = [cnameSection, ownershipSection, sslSection, summary].filter( - (s) => s !== undefined, - ); + const sections = [cnameSection, ownershipSection, summary].filter((s) => s !== undefined); if (sections.length > 0) { note(sections.join('\n\n'), 'DNS Setup'); @@ -177,7 +161,8 @@ export function computeRollup(status: DomainStatusResponse): RolledUpStatus { ) { return { state: 'issuing_cert', - diagnostic: 'Cloudflare is issuing the SSL certificate (typically <5 minutes).', + diagnostic: + 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes). No action needed.', }; } @@ -238,43 +223,14 @@ async function domainStatus( note(rollupLines.join('\n'), 'Domain Status'); - // Gate on actual validation states, not the aggregate `status.status`. - // Once the DB row is flipped to `active`, the API keeps returning - // `status: 'active'` even if CF later reports SSL/ownership re-validation - // (cert renewal, ownership re-check). Keying off the per-field states - // ensures pending records still surface in that window. CF stops returning - // `ownership_verification` once ownership is verified, so its presence is - // itself the pending signal. - const ownershipPending = Boolean(status.ownership_verification); - const sslPending = status.ssl_status !== 'active'; - - if (ownershipPending || sslPending) { - const pendingSections: Array = []; - - if (ownershipPending && status.ownership_verification) { - pendingSections.push( - `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`, - ); - } - - if (sslPending) { - // Defensive: drop any per-record entries CF has already marked active. - // CF normally clears `ssl.validation_records` once SSL is active, but - // during cert renewals or partial states a stale `active` row can sit - // alongside a pending one — we only want to surface what the user still - // needs to add. - const sslRecords = (status.ssl_validation_records ?? []).filter( - (r): r is { txt_name: string; txt_value: string; status?: string } => - Boolean(r.txt_name) && Boolean(r.txt_value) && r.status !== 'active', - ); - if (sslRecords.length > 0) { - const lines = sslRecords.map((r) => ` ${r.txt_name} → ${r.txt_value}`).join('\n'); - pendingSections.push(`TXT (SSL validation)\n${lines}`); - } - } - - if (pendingSections.length > 0) { - note(pendingSections.join('\n\n'), 'Add these DNS records to your provider'); - } + // Surface the ownership TXT while it's still outstanding. Cert DCV uses + // HTTP-DCV, so issuance and renewals are automatic at CF's edge and never + // surface user-facing records. CF stops returning `ownership_verification` + // once ownership is verified, so its presence is itself the pending signal. + if (status.ownership_verification) { + note( + `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`, + 'Add this DNS record to your provider', + ); } } diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts index e00503ed7..91ecd8c62 100644 --- a/packages/void/test/unit/domain-command.test.ts +++ b/packages/void/test/unit/domain-command.test.ts @@ -134,7 +134,7 @@ describe('runDomainCommand top-level guards', () => { }); describe('domainAdd', () => { - it('prints CNAME + ownership TXT + SSL TXT when all three records returned', async () => { + it('prints CNAME + ownership TXT when both records returned', async () => { mockAddDomain.mockResolvedValue({ id: 'dom_1', project_id: 'proj_default', @@ -144,7 +144,6 @@ describe('domainAdd', () => { created_at: 'now', cname_target: 'foo.example.com.cdn.cloudflare.net', ownership_verification: { type: 'txt', name: '_cf-custom-hostname.foo', value: 'own-token' }, - ssl_validation_records: [{ txt_name: '_acme-challenge.foo', txt_value: 'ssl-token' }], }); await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); @@ -155,32 +154,8 @@ describe('domainAdd', () => { expect(content).toContain('foo.example.com → foo.example.com.cdn.cloudflare.net'); expect(content).toContain('TXT (ownership verification)'); expect(content).toContain('_cf-custom-hostname.foo → own-token'); - expect(content).toContain('TXT (SSL validation)'); - expect(content).toContain('_acme-challenge.foo → ssl-token'); - }); - - it('prints multiple SSL records as separate lines under one section', async () => { - mockAddDomain.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - cname_target: 'cname.example', - ssl_validation_records: [ - { txt_name: 'a', txt_value: 'b' }, - { txt_name: 'c', txt_value: 'd' }, - ], - }); - - await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); - - const content = mockNote.mock.calls[0][0] as string; - expect(content).toContain('a → b'); - expect(content).toContain('c → d'); - // Only one SSL section header, despite two records - expect(content.match(/TXT \(SSL validation\)/g)).toHaveLength(1); + // HTTP-DCV: no user-facing SSL-validation records. + expect(content).not.toContain('TXT (SSL validation)'); }); it('skips ownership section when ownership_verification is missing', async () => { @@ -192,38 +167,14 @@ describe('domainAdd', () => { status: 'pending', created_at: 'now', cname_target: 'cname.example', - ssl_validation_records: [{ txt_name: 'a', txt_value: 'b' }], }); await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); const content = mockNote.mock.calls[0][0] as string; expect(content).toContain('CNAME (traffic)'); - expect(content).toContain('TXT (SSL validation)'); expect(content).not.toContain('TXT (ownership verification)'); - }); - - it('shows hint when SSL records missing (deadline-miss path)', async () => { - mockAddDomain.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - cname_target: 'cname.example', - ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'a' }], - }); - - await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); - - const content = mockNote.mock.calls[0][0] as string; - expect(content).toContain('CNAME (traffic)'); expect(content).not.toContain('TXT (SSL validation)'); - expect(content).toContain( - 'SSL validation records will appear in `void domain status` shortly.', - ); }); it('appends the summary line to the DNS Setup block', async () => { @@ -236,18 +187,17 @@ describe('domainAdd', () => { created_at: 'now', cname_target: 'cname.example', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); const content = mockNote.mock.calls[0][0] as string; expect(content).toContain( - 'Add the records above to your DNS, then run `void domain status foo.example.com` to check progress.', + 'Add both records above to your DNS, then run `void domain status foo.example.com` to check progress.', ); }); - it('orders sections CNAME -> ownership -> SSL', async () => { + it('orders sections CNAME -> ownership', async () => { mockAddDomain.mockResolvedValue({ id: 'dom_1', project_id: 'proj_default', @@ -257,7 +207,6 @@ describe('domainAdd', () => { created_at: 'now', cname_target: 'cname.example', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); @@ -265,10 +214,8 @@ describe('domainAdd', () => { const content = mockNote.mock.calls[0][0] as string; const cnameIdx = content.indexOf('CNAME (traffic)'); const ownershipIdx = content.indexOf('TXT (ownership verification)'); - const sslIdx = content.indexOf('TXT (SSL validation)'); expect(cnameIdx).toBeGreaterThanOrEqual(0); expect(ownershipIdx).toBeGreaterThan(cnameIdx); - expect(sslIdx).toBeGreaterThan(ownershipIdx); }); }); @@ -650,7 +597,7 @@ describe('domainStatus --verbose', () => { }); describe('domainStatus pending records block', () => { - it('shows pending ownership record ONLY when ownership is pending', async () => { + it('shows the pending ownership record while ownership_verification is present', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, ]); @@ -661,7 +608,7 @@ describe('domainStatus pending records block', () => { cf_hostname_id: 'cf_1', status: 'pending', created_at: 'now', - ssl_status: 'active', + ssl_status: 'pending_validation', cf_status: 'pending', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, }); @@ -672,10 +619,12 @@ describe('domainStatus pending records block', () => { const pendingNote = mockNote.mock.calls[1][0] as string; expect(pendingNote).toContain('TXT (ownership verification)'); expect(pendingNote).toContain('own-name → own-value'); + // HTTP-DCV: no SSL-validation records ever surface to the user. expect(pendingNote).not.toContain('TXT (SSL validation)'); + expect(mockNote.mock.calls[1][1]).toBe('Add this DNS record to your provider'); }); - it('shows pending SSL record ONLY when SSL is pending and ownership is verified', async () => { + it('omits the pending block when ownership_verification is absent (SSL is automatic under HTTP-DCV)', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, ]); @@ -688,43 +637,12 @@ describe('domainStatus pending records block', () => { created_at: 'now', ssl_status: 'pending_validation', cf_status: 'pending_validation', - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - expect(mockNote).toHaveBeenCalledTimes(2); - const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('TXT (SSL validation)'); - expect(pendingNote).toContain('ssl-name → ssl-value'); - expect(pendingNote).not.toContain('TXT (ownership verification)'); - }); - - it('shows both records when both pending', async () => { - mockListDomains.mockResolvedValue([ - { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, - ]); - mockGetDomainStatus.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - ssl_status: 'pending_validation', - cf_status: 'pending', - ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], - }); - - await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - - expect(mockNote).toHaveBeenCalledTimes(2); - const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('TXT (ownership verification)'); - expect(pendingNote).toContain('own-name → own-value'); - expect(pendingNote).toContain('TXT (SSL validation)'); - expect(pendingNote).toContain('ssl-name → ssl-value'); + expect(mockNote).toHaveBeenCalledTimes(1); + expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); }); it('skips the pending block when status is fully verified', async () => { @@ -749,59 +667,6 @@ describe('domainStatus pending records block', () => { expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); }); - it('shows multiple SSL records under one section in pending note', async () => { - mockListDomains.mockResolvedValue([ - { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, - ]); - mockGetDomainStatus.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - ssl_status: 'pending_validation', - cf_status: 'pending_validation', - ssl_validation_records: [ - { txt_name: 'a', txt_value: 'b' }, - { txt_name: 'c', txt_value: 'd' }, - ], - }); - - await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - - const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('a → b'); - expect(pendingNote).toContain('c → d'); - expect(pendingNote.match(/TXT \(SSL validation\)/g)).toHaveLength(1); - }); - - it('drops per-record SSL entries already marked active', async () => { - mockListDomains.mockResolvedValue([ - { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, - ]); - mockGetDomainStatus.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - ssl_status: 'pending_validation', - cf_status: 'pending_validation', - ssl_validation_records: [ - { txt_name: 'done', txt_value: 'done-val', status: 'active' }, - { txt_name: 'pending', txt_value: 'pending-val', status: 'pending_validation' }, - ], - }); - - await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - - const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('pending → pending-val'); - expect(pendingNote).not.toContain('done → done-val'); - }); - it('exit code stays 0 even when pending', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, @@ -816,7 +681,6 @@ describe('domainStatus pending records block', () => { ssl_status: 'pending_validation', cf_status: 'pending', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); From 22eb5f435636cbc0829df902788d6481ee05900e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 3 Jun 2026 21:48:09 +0800 Subject: [PATCH 2/6] fix(cli): derive domain add DNS summary from rendered records The `void domain add` summary hardcoded "Add both records above", but the CNAME and ownership-TXT sections are each conditional. When the API returns fewer than two records the wording was misleading (and in the zero-record case pointed at records that weren't displayed). Build the record sections first and derive the summary from them: singular "the record above" for one, plural "the records above" for two, and render nothing when there are none. Addresses inline review on voidzero-dev/void#214. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/domain.ts | 10 +++++----- packages/void/test/unit/domain-command.test.ts | 7 ++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index 106991433..15b19bf06 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -69,12 +69,12 @@ async function domainAdd( ownershipSection = `TXT (ownership verification)\n ${domain.ownership_verification.name} → ${domain.ownership_verification.value}`; } - const summary = `Add both records above to your DNS, then run \`void domain status ${hostname}\` to check progress.`; + const recordSections = [cnameSection, ownershipSection].filter((s) => s !== undefined); - const sections = [cnameSection, ownershipSection, summary].filter((s) => s !== undefined); - - if (sections.length > 0) { - note(sections.join('\n\n'), 'DNS Setup'); + if (recordSections.length > 0) { + const recordWord = recordSections.length === 1 ? 'the record above' : 'the records above'; + const summary = `Add ${recordWord} to your DNS, then run \`void domain status ${hostname}\` to check progress.`; + note([...recordSections, summary].join('\n\n'), 'DNS Setup'); } } diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts index 91ecd8c62..cd59727d7 100644 --- a/packages/void/test/unit/domain-command.test.ts +++ b/packages/void/test/unit/domain-command.test.ts @@ -175,6 +175,11 @@ describe('domainAdd', () => { expect(content).toContain('CNAME (traffic)'); expect(content).not.toContain('TXT (ownership verification)'); expect(content).not.toContain('TXT (SSL validation)'); + // Only one record rendered: summary is singular and must not claim "both". + expect(content).toContain( + 'Add the record above to your DNS, then run `void domain status foo.example.com` to check progress.', + ); + expect(content).not.toContain('both'); }); it('appends the summary line to the DNS Setup block', async () => { @@ -193,7 +198,7 @@ describe('domainAdd', () => { const content = mockNote.mock.calls[0][0] as string; expect(content).toContain( - 'Add both records above to your DNS, then run `void domain status foo.example.com` to check progress.', + 'Add the records above to your DNS, then run `void domain status foo.example.com` to check progress.', ); }); From 5b3bfec5d33d54a375afcc7401292fda01b077ee Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 3 Jun 2026 22:22:09 +0800 Subject: [PATCH 3/6] fix(cli): surface traffic CNAME in domain status while cert is validating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `void domain status` only printed a DNS record while the ownership TXT was outstanding, so once ownership verified but HTTP-DCV was still pending it showed "issuing cert... No action needed" with no CNAME — even though a missing/misconfigured traffic CNAME is exactly what blocks HTTP-DCV (the hostname must proxy through Cloudflare). Now surface the CNAME whenever `ssl_status !== 'active'` (suppressed once live), alongside the ownership TXT, with a count-aware title; and soften the issuing_cert diagnostic to point at the CNAME. Uses the `cname_target` the status endpoint now returns; degrades gracefully (renders nothing) against an older API that omits it. Addresses inline review on voidzero-dev/void#214. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/domain.ts | 30 +++++-- .../void/test/unit/domain-command.test.ts | 85 +++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index 15b19bf06..fced88c9a 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -162,7 +162,7 @@ export function computeRollup(status: DomainStatusResponse): RolledUpStatus { return { state: 'issuing_cert', diagnostic: - 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes). No action needed.', + 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes). If this persists, confirm the traffic CNAME below is published.', }; } @@ -223,14 +223,30 @@ async function domainStatus( note(rollupLines.join('\n'), 'Domain Status'); - // Surface the ownership TXT while it's still outstanding. Cert DCV uses - // HTTP-DCV, so issuance and renewals are automatic at CF's edge and never - // surface user-facing records. CF stops returning `ownership_verification` - // once ownership is verified, so its presence is itself the pending signal. + // Surface the DNS records the user may still need to publish. The ownership + // TXT shows while `ownership_verification` is present (CF drops it once + // verified). The traffic CNAME shows whenever the cert is not yet active — + // HTTP-DCV cannot validate until the hostname proxies through Cloudflare, so + // a missing/misconfigured CNAME silently blocks issuance even after ownership + // is verified. Once `ssl_status === 'active'` the domain is live and both + // records are correct, so neither is shown. + let cnameSection: string | undefined; + let ownershipSection: string | undefined; + + if (status.cname_target && status.ssl_status !== 'active') { + cnameSection = `CNAME (traffic)\n ${status.hostname} → ${status.cname_target}`; + } if (status.ownership_verification) { + ownershipSection = `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`; + } + + const pendingRecords = [cnameSection, ownershipSection].filter((s) => s !== undefined); + if (pendingRecords.length > 0) { note( - `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`, - 'Add this DNS record to your provider', + pendingRecords.join('\n\n'), + pendingRecords.length === 1 + ? 'Add this DNS record to your provider' + : 'Add these DNS records to your provider', ); } } diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts index cd59727d7..571a14f6e 100644 --- a/packages/void/test/unit/domain-command.test.ts +++ b/packages/void/test/unit/domain-command.test.ts @@ -650,6 +650,91 @@ describe('domainStatus pending records block', () => { expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); }); + it('shows the traffic CNAME while the cert is still pending (ownership already verified)', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'pending_validation', + cf_status: 'pending_issuance', + cname_target: 'cname.void.app', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockNote).toHaveBeenCalledTimes(2); + const pendingNote = mockNote.mock.calls[1][0] as string; + expect(pendingNote).toContain('CNAME (traffic)'); + expect(pendingNote).toContain('foo.example.com → cname.void.app'); + // Ownership already verified — CF dropped the TXT, so it must not appear. + expect(pendingNote).not.toContain('TXT (ownership verification)'); + // One record only: singular title. + expect(mockNote.mock.calls[1][1]).toBe('Add this DNS record to your provider'); + }); + + it('does NOT show the traffic CNAME once the domain is fully active', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'active' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'active', + created_at: 'now', + ssl_status: 'active', + cf_status: 'active', + cname_target: 'cname.void.app', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + // Only the Domain Status note — the CNAME is correct by definition when live. + expect(mockNote).toHaveBeenCalledTimes(1); + expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); + expect(mockNote.mock.calls[0][0] as string).not.toContain('CNAME (traffic)'); + }); + + it('shows both records (CNAME before TXT) with a plural title before activation', async () => { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + ssl_status: 'pending_validation', + cf_status: 'pending', + cname_target: 'cname.void.app', + ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockNote).toHaveBeenCalledTimes(2); + const pendingNote = mockNote.mock.calls[1][0] as string; + expect(pendingNote).toContain('CNAME (traffic)'); + expect(pendingNote).toContain('foo.example.com → cname.void.app'); + expect(pendingNote).toContain('TXT (ownership verification)'); + expect(pendingNote).toContain('own-name → own-value'); + // CNAME ordered before ownership, matching `domain add`. + expect(pendingNote.indexOf('CNAME (traffic)')).toBeLessThan( + pendingNote.indexOf('TXT (ownership verification)'), + ); + // Two records: plural title. + expect(mockNote.mock.calls[1][1]).toBe('Add these DNS records to your provider'); + }); + it('skips the pending block when status is fully verified', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'active' }, From 07de52e191c41a046592fa9875933fac69d0ce07 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 4 Jun 2026 15:57:56 +0800 Subject: [PATCH 4/6] docs(cli): describe HTTP-DCV two-record flow for domain add/status `void domain add` now prints the traffic CNAME + non-rotating `_cf-custom-hostname` ownership TXT (no `_acme-challenge`, auto-renew); `void domain status` surfaces both records while the cert is validating and notes that activation is automatic via the reconcile cron. Adds a wildcard-not-supported note. Documentation-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/cli.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 912025d57..09b44bed5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -572,7 +572,9 @@ See [Environment Variables](../guide/env-vars.md) for the full guide. void domain add [--project ] ``` -Add a custom domain to a project. Prints the CNAME target you need to add in your DNS provider. +Add a custom domain to a project. Prints the two DNS records to add at your DNS provider: a traffic **CNAME** (`` → `cname.void.app`) and a non-rotating `_cf-custom-hostname` ownership **TXT**. Certificates are validated over HTTP at Cloudflare's edge and renew automatically — there are no `_acme-challenge` records to publish, at first issuance or ever. After adding the records the domain activates automatically (no polling required); run `void domain status ` to check progress. + +> Wildcard custom hostnames (`*.example.com`) are not supported — register each subdomain individually. ### `void domain delete` @@ -596,7 +598,7 @@ List all custom domains and their status (active/pending). void domain status [--project ] [--verbose] ``` -Check verification and SSL status for a specific domain. Prints a rolled-up state (`awaiting_dns`, `verifying_dns`, `issuing_cert`, `deploying_cert`, `awaiting_deployment`, `active`, `error`, or `pending`) with a one-line diagnostic explaining what Cloudflare is doing and any user action required. When DNS records are still pending, the command also prints the exact TXT records to add at your DNS provider. +Check verification and SSL status for a specific domain. Prints a rolled-up state (`awaiting_dns`, `verifying_dns`, `issuing_cert`, `deploying_cert`, `awaiting_deployment`, `active`, `error`, or `pending`) with a one-line diagnostic explaining what Cloudflare is doing and any user action required. While the certificate is not yet active, the command also surfaces the records to configure — the traffic **CNAME** and the `_cf-custom-hostname` ownership **TXT** — so you can verify them. Activation is automatic: a background job reconciles pending domains (about every 2 minutes for the first 30 minutes after adding, then hourly), so this command is for instant feedback rather than required polling. Pass `--verbose` to additionally print the raw multi-line status breakdown (DB status, SSL status, ownership state, verification errors) underneath the rollup. From 9976da50c241546d6e3896134d534bb72081a476 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 4 Jun 2026 17:24:27 +0800 Subject: [PATCH 5/6] fix(cli): gate issuing_cert CNAME hint on cname_target presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `issuing_cert` rollup diagnostic cited "confirm the traffic CNAME below," but the CNAME block only renders when `status.cname_target` is set — so a target-less status response (e.g. an older API during the deploy window) showed the hint with no CNAME block. Gate the hint on `status.cname_target` so it matches exactly when the block renders (`ssl_status !== 'active'` always holds in this branch), and add unit tests for both branches. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/domain.ts | 10 +++- .../void/test/unit/domain-command.test.ts | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index fced88c9a..6d32cc5a8 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -161,8 +161,14 @@ export function computeRollup(status: DomainStatusResponse): RolledUpStatus { ) { return { state: 'issuing_cert', - diagnostic: - 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes). If this persists, confirm the traffic CNAME below is published.', + // Only cite the CNAME "below" when it will actually render. The CNAME block + // shows iff `status.cname_target` is set (ssl_status !== 'active' always + // holds in this branch), so a status response without a target (e.g. an + // older API during the deploy window) no longer points at a record we + // don't display. + diagnostic: status.cname_target + ? 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes). If this persists, confirm the traffic CNAME below is published.' + : 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes).', }; } diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts index 571a14f6e..edbe29dd0 100644 --- a/packages/void/test/unit/domain-command.test.ts +++ b/packages/void/test/unit/domain-command.test.ts @@ -391,6 +391,56 @@ describe('domainStatus rollup', () => { expect(statusNote).toContain('Status: issuing_cert —'); }); + it('issuing_cert cites the traffic CNAME and renders it when cname_target is set', async () => { + setupDomain(); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cf_status: 'pending_issuance', + ssl_status: 'pending_validation', + cname_target: 'cname.void.app', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: issuing_cert —'); + expect(statusNote).toContain('confirm the traffic CNAME below is published'); + // The CNAME block it cites is actually rendered. + const allNotes = mockNote.mock.calls.map((c) => c[0] as string).join('\n'); + expect(allNotes).toContain('CNAME (traffic)'); + expect(allNotes).toContain('foo.example.com → cname.void.app'); + }); + + it('issuing_cert omits the CNAME citation when cname_target is absent', async () => { + setupDomain(); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cf_status: 'pending_issuance', + ssl_status: 'pending_validation', + // no cname_target (e.g. an older API during the deploy window) + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: issuing_cert —'); + expect(statusNote).toContain('Cloudflare is issuing the SSL certificate'); + // No dangling reference to a CNAME block that isn't shown. + const allNotes = mockNote.mock.calls.map((c) => c[0] as string).join('\n'); + expect(allNotes).not.toContain('confirm the traffic CNAME below'); + expect(allNotes).not.toContain('CNAME (traffic)'); + }); + it('renders deploying_cert state when cf_status=pending_deployment', async () => { setupDomain(); mockGetDomainStatus.mockResolvedValue({ From 206a0737804b583ca860f810c2623195906c92cf Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 4 Jun 2026 17:35:35 +0800 Subject: [PATCH 6/6] docs(cli): don't hard-code the CNAME target in domain add reference The custom-domain CNAME target is API-provided and varies by environment (e.g. cname.staging.void.app on staging). The reference now points users at the target shown in `void domain add` output instead of a fixed `cname.void.app`, matching what domainAdd() actually prints. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 09b44bed5..aeb75ee2e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -572,7 +572,7 @@ See [Environment Variables](../guide/env-vars.md) for the full guide. void domain add [--project ] ``` -Add a custom domain to a project. Prints the two DNS records to add at your DNS provider: a traffic **CNAME** (`` → `cname.void.app`) and a non-rotating `_cf-custom-hostname` ownership **TXT**. Certificates are validated over HTTP at Cloudflare's edge and renew automatically — there are no `_acme-challenge` records to publish, at first issuance or ever. After adding the records the domain activates automatically (no polling required); run `void domain status ` to check progress. +Add a custom domain to a project. Prints the two DNS records to add at your DNS provider: a traffic **CNAME** pointing `` at the CNAME target shown in the command output, and a non-rotating `_cf-custom-hostname` ownership **TXT**. Certificates are validated over HTTP at Cloudflare's edge and renew automatically — there are no `_acme-challenge` records to publish, at first issuance or ever. After adding the records the domain activates automatically (no polling required); run `void domain status ` to check progress. > Wildcard custom hostnames (`*.example.com`) are not supported — register each subdomain individually.