From 95bf898efc1430621cac4fecd34f1fb6d3587733 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 14 May 2026 22:47:45 +0800 Subject: [PATCH 1/5] feat: Display ownership and SSL TXT records during domain setup. `void domain add` and `void domain status` now print the CNAME record plus both Cloudflare-issued TXT records (ownership verification + SSL validation) so users can complete domain binding from the CLI. Multiple SSL validation records (e.g. wildcard certs) are emitted as separate lines rather than collapsed to the first. The pending-records display in `void domain status` keys off per-field validation state instead of the aggregate `status` so re-validation cycles still surface the records the user needs to add. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/client.ts | 11 ++++++ packages/void/src/cli/domain.ts | 63 ++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 3263419ae..75faf637f 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -165,6 +165,17 @@ export type DomainResponse = { status: string; created_at: string; cname_target?: string; + ownership_verification?: { + type: 'txt'; + name: string; + value: string; + status?: string; + }; + ssl_validation_records?: Array<{ + txt_name?: string; + txt_value?: string; + status?: string; + }>; }; export type DomainStatusResponse = DomainResponse & { diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index f2d139e10..2e3c6c922 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -54,7 +54,32 @@ async function domainAdd( ): Promise { const domain = await client.addDomain(projectId, hostname); log.success(`Domain ${hostname} added (status: ${domain.status})`); - note(`Add a CNAME record:\n ${hostname} → ${domain.cname_target}`, 'DNS Setup'); + + const sections: Array = []; + + if (domain.cname_target) { + sections.push(`CNAME (traffic)\n ${hostname} → ${domain.cname_target}`); + } + + if (domain.ownership_verification) { + sections.push( + `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'); + sections.push(`TXT (SSL validation)\n${lines}`); + } + + if (sections.length > 0) { + note(sections.join('\n\n'), 'DNS Setup'); + } + log.info('Run `void domain status ' + hostname + '` to check verification progress.'); } @@ -107,8 +132,44 @@ async function domainStatus( `Status: ${status.status}`, `SSL Status: ${status.ssl_status}`, ]; + if (status.ownership_verification?.status) { + lines.push(`Ownership: ${status.ownership_verification.status}`); + } if (status.verification_errors && status.verification_errors.length > 0) { lines.push(`Errors: ${status.verification_errors.join(', ')}`); } note(lines.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. + const ownershipPending = + status.ownership_verification?.status !== undefined && + status.ownership_verification.status !== 'verified'; + const sslPending = status.ssl_status !== 'active'; + + if (ownershipPending || sslPending) { + const pendingSections: Array = []; + + if (status.ownership_verification) { + pendingSections.push( + `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`, + ); + } + + 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), + ); + 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'); + } + } } From 685a474167a0142db2f06c48d9d212f800289141 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 14 May 2026 23:14:07 +0800 Subject: [PATCH 2/5] fix: Derive ownership-pending state from field presence. The previous logic keyed off `ownership_verification.status`, but the Cloudflare Custom Hostnames API does not return a `status` field inside `ownership_verification` (it returns `{ name, type, value }` only). The derivation also leaked verified records into the pending-DNS-records display when only SSL was outstanding. Use the presence of the `ownership_verification` object as the ownership-pending signal (CF drops the object once ownership clears), gate each inner section in the pending block on its own pending state, and remove the invented `status?: string` from the `DomainResponse` type so it matches the live CF contract. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/client.ts | 1 - packages/void/src/cli/domain.ts | 30 ++++++++++++++++-------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 75faf637f..404baeed9 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -169,7 +169,6 @@ export type DomainResponse = { type: 'txt'; name: string; value: string; - status?: string; }; ssl_validation_records?: Array<{ txt_name?: string; diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index 2e3c6c922..e4e25634a 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -132,8 +132,8 @@ async function domainStatus( `Status: ${status.status}`, `SSL Status: ${status.ssl_status}`, ]; - if (status.ownership_verification?.status) { - lines.push(`Ownership: ${status.ownership_verification.status}`); + if (status.ownership_verification) { + lines.push(`Ownership: pending`); } if (status.verification_errors && status.verification_errors.length > 0) { lines.push(`Errors: ${status.verification_errors.join(', ')}`); @@ -144,28 +144,30 @@ async function domainStatus( // 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. - const ownershipPending = - status.ownership_verification?.status !== undefined && - status.ownership_verification.status !== 'verified'; + // 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 (status.ownership_verification) { + if (ownershipPending && status.ownership_verification) { pendingSections.push( `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`, ); } - 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), - ); - 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 (sslPending) { + 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), + ); + 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) { From 59a689c5e473c92fd28a3f0fa9e4338bab61e20e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 14 May 2026 23:18:48 +0800 Subject: [PATCH 3/5] test: Add unit tests for the void domain CLI commands. Covers the runDomainCommand top-level guards (token + project resolution), domainAdd output (three-record block, multi-SSL, graceful skip when the API omits the newer fields), domainStatus presence-based ownership state and per-state pending-section gating, plus the trivial domainList/domainRemove not-found paths. 20 tests, all pass. Co-Authored-By: Claude Opus 4.7 --- .../void/test/unit/domain-command.test.ts | 460 ++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 packages/void/test/unit/domain-command.test.ts diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts new file mode 100644 index 000000000..ad82f6328 --- /dev/null +++ b/packages/void/test/unit/domain-command.test.ts @@ -0,0 +1,460 @@ +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { DomainArgs } from '../../src/cli/domain-args.ts'; +import { runDomainCommand } from '../../src/cli/domain.ts'; + +const mockLogError = vi.fn(); +const mockLogSuccess = vi.fn(); +const mockLogInfo = vi.fn(); +const mockNote = vi.fn(); +class ProcessExitError extends Error { + constructor(public code: number | undefined) { + super(`process.exit(${code})`); + } +} +const mockExit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ProcessExitError(code); +}) as never); + +async function runExpectingExit(rt: string, a: DomainArgs): Promise { + await expect(runDomainCommand(rt, a)).rejects.toBeInstanceOf(ProcessExitError); +} + +const mockGetToken = vi.fn(); +const mockReadProjectConfig = vi.fn(); +const mockListProjects = vi.fn(); +const mockAddDomain = vi.fn(); +const mockListDomains = vi.fn(); +const mockGetDomainStatus = vi.fn(); +const mockRemoveDomain = vi.fn(); + +vi.mock('@clack/prompts', () => ({ + log: { + error: (...args: Array) => mockLogError(...args), + success: (...args: Array) => mockLogSuccess(...args), + info: (...args: Array) => mockLogInfo(...args), + }, + note: (...args: Array) => mockNote(...args), +})); + +vi.mock('../../src/cli/auth', () => ({ + getToken: (...args: Array) => mockGetToken(...args), +})); + +vi.mock('../../src/cli/project', () => ({ + readProjectConfig: (...args: Array) => mockReadProjectConfig(...args), +})); + +vi.mock('../../src/cli/client', () => ({ + PlatformClient: class { + listProjects(...args: Array) { + return mockListProjects(...args); + } + addDomain(...args: Array) { + return mockAddDomain(...args); + } + listDomains(...args: Array) { + return mockListDomains(...args); + } + getDomainStatus(...args: Array) { + return mockGetDomainStatus(...args); + } + removeDomain(...args: Array) { + return mockRemoveDomain(...args); + } + }, +})); + +const root = '/project'; + +function args(overrides: Partial & Pick): DomainArgs { + return { ...overrides }; +} + +beforeEach(() => { + mockLogError.mockReset(); + mockLogSuccess.mockReset(); + mockLogInfo.mockReset(); + mockNote.mockReset(); + mockExit.mockClear(); + + mockGetToken.mockReset(); + mockReadProjectConfig.mockReset(); + mockListProjects.mockReset(); + mockAddDomain.mockReset(); + mockListDomains.mockReset(); + mockGetDomainStatus.mockReset(); + mockRemoveDomain.mockReset(); + + mockGetToken.mockReturnValue('test-token'); + mockReadProjectConfig.mockReturnValue({ projectId: 'proj_default', slug: 'default' }); + mockListProjects.mockResolvedValue([]); + mockListDomains.mockResolvedValue([]); + delete process.env.VOID_PROJECT; +}); + +describe('runDomainCommand top-level guards', () => { + it('fails when no token is found', async () => { + mockGetToken.mockReturnValue(null); + + await runExpectingExit(root, args({ subcommand: 'list' })); + + expect(mockLogError).toHaveBeenCalledWith('Not logged in. Run `void auth login` first.'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('resolves project from --project flag', async () => { + mockListProjects.mockResolvedValue([{ slug: 'foo', id: 'proj_foo' }]); + mockListDomains.mockResolvedValue([]); + + await runDomainCommand(root, args({ subcommand: 'list', projectSlug: 'foo' })); + + expect(mockListDomains).toHaveBeenCalledWith('proj_foo'); + }); + + it('resolves project from readProjectConfig when no slug', async () => { + mockReadProjectConfig.mockReturnValue({ projectId: 'proj_linked', slug: 'linked' }); + mockListDomains.mockResolvedValue([]); + + await runDomainCommand(root, args({ subcommand: 'list' })); + + expect(mockListDomains).toHaveBeenCalledWith('proj_linked'); + }); + + it('errors when no project resolvable', async () => { + mockReadProjectConfig.mockReturnValue(null); + + await runExpectingExit(root, args({ subcommand: 'list' })); + + expect(mockLogError).toHaveBeenCalledWith( + 'No linked project. Run `void project link` or pass --project .', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); +}); + +describe('domainAdd', () => { + it('prints CNAME + ownership TXT + SSL TXT when all three records returned', 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: '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' })); + + expect(mockNote).toHaveBeenCalledTimes(1); + const content = mockNote.mock.calls[0][0] as string; + expect(content).toContain('CNAME (traffic)'); + 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); + }); + + it('skips ownership section when ownership_verification is missing', 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' }], + }); + + 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('skips SSL section when no record has both txt_name and txt_value', 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' }], + }); + + 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)'); + }); + + it('always prints the `void domain status …` info line', 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', + }); + + await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); + + expect(mockLogInfo).toHaveBeenCalledWith( + 'Run `void domain status foo.example.com` to check verification progress.', + ); + }); +}); + +describe('domainStatus', () => { + it('errors when hostname not found on project', async () => { + mockListDomains.mockResolvedValue([]); + + await runExpectingExit(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockLogError).toHaveBeenCalledWith( + "domain: Domain 'foo.example.com' not found on this project.", + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('prints `Ownership: pending` when CF returns ownership_verification', 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', + ownership_verification: { type: 'txt', name: '_cf-custom-hostname.foo', value: 'own-token' }, + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Ownership: pending'); + }); + + it('omits the Ownership line when CF does not return ownership_verification', 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', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).not.toContain('Ownership:'); + }); + + it('shows pending ownership record ONLY when ownership is 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: 'active', + 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('TXT (ownership verification)'); + expect(pendingNote).toContain('own-name → own-value'); + expect(pendingNote).not.toContain('TXT (SSL validation)'); + }); + + it('shows pending SSL record ONLY when SSL is pending and ownership is 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', + 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', + 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'); + }); + + it('skips the pending block when status is fully verified', 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', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + expect(mockNote).toHaveBeenCalledTimes(1); + // The single note call is the Domain Status block; no pending block follows. + 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', + 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('exit code stays 0 even when 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', + 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(mockExit).not.toHaveBeenCalled(); + }); +}); + +describe('domainList', () => { + it("prints 'No custom domains configured.' info line for empty list", async () => { + mockListDomains.mockResolvedValue([]); + + await runDomainCommand(root, args({ subcommand: 'list' })); + + expect(mockLogInfo).toHaveBeenCalledWith('No custom domains configured.'); + }); +}); + +describe('domainRemove', () => { + it('errors with not-found message when hostname not in list', async () => { + mockListDomains.mockResolvedValue([]); + + await runExpectingExit(root, args({ subcommand: 'delete', hostname: 'foo.example.com' })); + + expect(mockLogError).toHaveBeenCalledWith( + "domain: Domain 'foo.example.com' not found on this project.", + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); +}); From 6152253a254c0a046f87321773d3a6348c319d4b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 15 May 2026 09:20:46 +0800 Subject: [PATCH 4/5] fix: Drop SSL validation records CF reports as 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. Filter on per-record status so the CLI only surfaces records the user still needs to add. Co-Authored-By: Claude Opus 4.7 --- packages/void/src/cli/domain.ts | 7 +++++- .../void/test/unit/domain-command.test.ts | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index e4e25634a..bb12cc98a 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -160,9 +160,14 @@ async function domainStatus( } 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), + 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'); diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts index ad82f6328..54295fdfa 100644 --- a/packages/void/test/unit/domain-command.test.ts +++ b/packages/void/test/unit/domain-command.test.ts @@ -414,6 +414,31 @@ describe('domainStatus', () => { 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', + 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' }, From c50ce7765806f37ee3066daa2ac172f52eb96877 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 15 May 2026 16:40:16 +0800 Subject: [PATCH 5/5] fix: Add /__void/ready handler to framework-mode wrapper. --- packages/void/src/cli/wrapper.ts | 9 +++++++ packages/void/test/unit/wrapper.test.ts | 36 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index 44a5f2032..6d6f38754 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -250,6 +250,15 @@ function generateWrapperSource( lines.push('async function wrappedFetch(request, env, ctx) {'); lines.push(' const url = new URL(request.url);'); + // Readiness probe — platform polls this to confirm the worker is live + // after WfP upload. Handled here (before original.fetch) so the user's + // framework handler doesn't route it to env.ASSETS, which returns CF's + // "Assets have not yet been deployed" stub during asset propagation + // and would trip the platform's probe. + lines.push(' if (request.method === "GET" && url.pathname === "/__void/ready") {'); + lines.push(' return Response.json({ ready: true });'); + lines.push(' }'); + // Migration handler if (migrations && migrations.length > 0) { lines.push(' if (request.method === "POST" && url.pathname === "/__void/migrate") {'); diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index a38ec4f7d..52e81dddc 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -153,6 +153,42 @@ describe('generateWrapper with migrations', () => { expect(existsSync(join(workerDir, '__void_migration_handler.mjs'))).toBe(true); }); + it('intercepts GET /__void/ready with {ready:true} when wrapper is generated', async () => { + // Platform's deploy-time readiness probe (packages/api/src/routes/deploy.ts) + // expects a JSON 200 from GET /__void/ready before dispatching migrations. + // Without this, the request falls through to the user's framework handler + // (e.g. SvelteKit -> env.ASSETS), which returns CF's "Assets have not yet + // been deployed to this script." stub during WfP propagation, causing the + // probe to retry until exhaustion. + mkdirSync(workerDir, { recursive: true }); + writeFileSync( + join(workerDir, 'index.mjs'), + 'export default { fetch() { return new Response("ok"); } };', + ); + + mkdirSync(join(projectRoot, 'db', 'migrations'), { recursive: true }); + writeFileSync( + join(projectRoot, 'db', 'migrations', '001_test.sql'), + 'CREATE TABLE test (id INTEGER PRIMARY KEY);', + ); + + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + hasMigrations: true, + migrationDialect: 'sqlite', + }); + + const wrapper = readFileSync(join(workerDir, 'index.mjs'), 'utf-8'); + expect(wrapper).toContain('/__void/ready'); + expect(wrapper).toMatch(/Response\.json\(\s*\{\s*ready:\s*true\s*\}\s*\)/); + // Must be checked before delegating to original.fetch, otherwise the + // user's framework handler will see the request. + const readyIdx = wrapper.indexOf('/__void/ready'); + const originalIdx = wrapper.indexOf('original.fetch'); + expect(readyIdx).toBeGreaterThanOrEqual(0); + expect(originalIdx).toBeGreaterThan(readyIdx); + }); + it('does not generate migration handler when hasMigrations is false', async () => { mkdirSync(workerDir, { recursive: true }); writeFileSync(