diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 79715c395..be773d207 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -539,10 +539,12 @@ List all custom domains and their status (active/pending). ### `void domain status` ``` -void domain status [--project ] +void domain status [--project ] [--verbose] ``` -Check verification and SSL status for a specific domain. Shows hostname, status, SSL status, and any verification errors. +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. + +Pass `--verbose` to additionally print the raw multi-line status breakdown (DB status, SSL status, ownership state, verification errors) underneath the rollup. Project resolution for domain commands follows the same order as deploy (`--project`, `VOID_PROJECT`, linked project). diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 404baeed9..0bfcb5ce1 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -180,6 +180,8 @@ export type DomainResponse = { export type DomainStatusResponse = DomainResponse & { ssl_status: string; verification_errors?: Array; + cf_status?: string; + awaiting_deployment?: boolean; }; /** diff --git a/packages/void/src/cli/domain-args.ts b/packages/void/src/cli/domain-args.ts index 3a8f98575..ab90c3241 100644 --- a/packages/void/src/cli/domain-args.ts +++ b/packages/void/src/cli/domain-args.ts @@ -11,6 +11,7 @@ export type DomainArgs = { subcommand: 'add' | 'delete' | 'list' | 'status'; hostname?: string; projectSlug?: string; + verbose?: boolean; }; export function parseDomainArgs(args: Array): DomainArgs { @@ -23,11 +24,14 @@ export function parseDomainArgs(args: Array): DomainArgs { let hostname: string | undefined; let projectSlug: string | undefined; + let verbose = false; const rest = args.slice(1); for (let i = 0; i < rest.length; i++) { if (rest[i] === '--project' && rest[i + 1]) { projectSlug = rest[++i]; + } else if (rest[i] === '--verbose') { + verbose = true; } else if (!hostname) { hostname = rest[i]; } @@ -37,5 +41,5 @@ export function parseDomainArgs(args: Array): DomainArgs { throw new Error(`domain: Usage: void domain ${subcommand} [--project ].`); } - return { subcommand, hostname, projectSlug }; + return { subcommand, hostname, projectSlug, verbose }; } diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index bb12cc98a..e592e3b0f 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -1,6 +1,6 @@ import { log, note } from '@clack/prompts'; import { getToken } from './auth.ts'; -import { PlatformClient } from './client.ts'; +import { type DomainStatusResponse, PlatformClient } from './client.ts'; import type { DomainArgs } from './domain-args.ts'; import { pc } from './output.ts'; import { readProjectConfig } from './project.ts'; @@ -43,7 +43,7 @@ export async function runDomainCommand(root: string, args: DomainArgs): Promise< case 'list': return domainList(client, projectId!); case 'status': - return domainStatus(client, projectId!, args.hostname!); + return domainStatus(client, projectId!, args.hostname!, args.verbose === true); } } @@ -55,16 +55,18 @@ async function domainAdd( const domain = await client.addDomain(projectId, hostname); log.success(`Domain ${hostname} added (status: ${domain.status})`); - const sections: Array = []; + // Named locals make the section order explicit (CNAME -> ownership -> SSL), + // rather than relying on the order of conditional pushes onto an array. + let cnameSection: string | undefined; + let ownershipSection: string | undefined; + let sslSection: string | undefined; if (domain.cname_target) { - sections.push(`CNAME (traffic)\n ${hostname} → ${domain.cname_target}`); + cnameSection = `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}`, - ); + ownershipSection = `TXT (ownership verification)\n ${domain.ownership_verification.name} → ${domain.ownership_verification.value}`; } const sslRecords = (domain.ssl_validation_records ?? []).filter( @@ -73,14 +75,23 @@ async function domainAdd( ); if (sslRecords.length > 0) { const lines = sslRecords.map((r) => ` ${r.txt_name} → ${r.txt_value}`).join('\n'); - sections.push(`TXT (SSL validation)\n${lines}`); + 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 sections = [cnameSection, ownershipSection, sslSection, summary].filter( + (s) => s !== undefined, + ); + if (sections.length > 0) { note(sections.join('\n\n'), 'DNS Setup'); } - - log.info('Run `void domain status ' + hostname + '` to check verification progress.'); } async function domainRemove( @@ -114,10 +125,86 @@ async function domainList(client: PlatformClient, projectId: string): Promise 0) { + return { + state: 'error', + diagnostic: `Verification failed: ${status.verification_errors[0]}`, + }; + } + + if (status.awaiting_deployment === true) { + return { + state: 'awaiting_deployment', + diagnostic: + 'Your project has no live deployment yet. Run `void deploy` to bring the domain online.', + }; + } + + if (status.cf_status === 'pending' && status.ownership_verification) { + return { + state: 'awaiting_dns', + diagnostic: + 'Cloudflare has not yet observed your _cf-custom-hostname TXT record. Confirm it is published at the host shown below.', + }; + } + + if (status.cf_status === 'pending_validation') { + return { + state: 'verifying_dns', + diagnostic: 'DNS records found, Cloudflare is validating ownership.', + }; + } + + if ( + status.cf_status === 'pending_issuance' || + (status.cf_status === 'active' && status.ssl_status !== 'active') + ) { + return { + state: 'issuing_cert', + diagnostic: 'Cloudflare is issuing the SSL certificate (typically <5 minutes).', + }; + } + + if (status.cf_status === 'pending_deployment') { + return { + state: 'deploying_cert', + diagnostic: 'SSL certificate issued; Cloudflare is propagating it to its edge.', + }; + } + + // `active` is keyed off the DB status, but only reached after the per-field + // signals above have already surfaced cert-renewal / re-validation windows + // (e.g. `issuing_cert` catches `cf_status === 'active' && ssl_status !== 'active'` + // even when the DB row says active). Reaching this branch means CF and SSL + // are both healthy. + if (status.status === 'active' && status.ssl_status === 'active') { + return { state: 'active', diagnostic: 'Live.' }; + } + + return { state: 'pending', diagnostic: 'Verification pending. Cloudflare is processing.' }; +} + async function domainStatus( client: PlatformClient, projectId: string, hostname: string, + verbose: boolean, ): Promise { const domains = await client.listDomains(projectId); const domain = domains.find((d) => d.hostname === hostname); @@ -127,18 +214,29 @@ async function domainStatus( } const status = await client.getDomainStatus(projectId, domain.id); - const lines = [ - `Hostname: ${status.hostname}`, - `Status: ${status.status}`, - `SSL Status: ${status.ssl_status}`, + const rollup = computeRollup(status); + + const rollupLines = [ + `Hostname: ${status.hostname}`, + `Status: ${rollup.state} — ${rollup.diagnostic}`, ]; - if (status.ownership_verification) { - lines.push(`Ownership: pending`); - } - if (status.verification_errors && status.verification_errors.length > 0) { - lines.push(`Errors: ${status.verification_errors.join(', ')}`); + + if (verbose) { + // Visual separator: blank line + "Details:" header so the raw breakdown + // reads as a distinct section under the rollup, not just a continuation. + // `Hostname:` is already shown by the rollup above, so it's omitted here. + rollupLines.push('', 'Details:'); + rollupLines.push(` Status: ${status.status}`); + rollupLines.push(` SSL Status: ${status.ssl_status}`); + if (status.ownership_verification) { + rollupLines.push(` Ownership: pending`); + } + if (status.verification_errors && status.verification_errors.length > 0) { + rollupLines.push(` Errors: ${status.verification_errors.join(', ')}`); + } } - note(lines.join('\n'), 'Domain Status'); + + 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 diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 0170a3430..5189df64f 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -172,10 +172,18 @@ const HELP_TREE: HelpTree = { 'void domain delete [options]', ), list: withProjectOption('List custom domains and status.', 'void domain list [options]'), - status: withProjectOption( - 'Check domain verification status.', - 'void domain status [options]', - ), + status: { + summary: + 'Check domain verification status. Prints a rolled-up state with a one-line diagnostic.', + usage: ['void domain status [options]'], + options: [ + { command: '--project ', description: 'Project name override' }, + { + command: '--verbose', + description: 'Also print the raw multi-line status breakdown', + }, + ], + }, }, }, db: { diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts index 54295fdfa..e00503ed7 100644 --- a/packages/void/test/unit/domain-command.test.ts +++ b/packages/void/test/unit/domain-command.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { DomainStatusResponse } from '../../src/cli/client.ts'; import type { DomainArgs } from '../../src/cli/domain-args.ts'; -import { runDomainCommand } from '../../src/cli/domain.ts'; +import { computeRollup, runDomainCommand } from '../../src/cli/domain.ts'; const mockLogError = vi.fn(); const mockLogSuccess = vi.fn(); @@ -202,7 +203,7 @@ describe('domainAdd', () => { expect(content).not.toContain('TXT (ownership verification)'); }); - it('skips SSL section when no record has both txt_name and txt_value', async () => { + it('shows hint when SSL records missing (deadline-miss path)', async () => { mockAddDomain.mockResolvedValue({ id: 'dom_1', project_id: 'proj_default', @@ -211,6 +212,7 @@ describe('domainAdd', () => { 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' }], }); @@ -219,9 +221,12 @@ describe('domainAdd', () => { 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('always prints the `void domain status …` info line', async () => { + it('appends the summary line to the DNS Setup block', async () => { mockAddDomain.mockResolvedValue({ id: 'dom_1', project_id: 'proj_default', @@ -230,17 +235,50 @@ describe('domainAdd', () => { status: 'pending', 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' })); - expect(mockLogInfo).toHaveBeenCalledWith( - 'Run `void domain status foo.example.com` to check verification progress.', + 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.', ); }); + + it('orders sections CNAME -> ownership -> SSL', 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: '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; + 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); + }); }); -describe('domainStatus', () => { +describe('domainStatus rollup', () => { + function setupDomain(): void { + mockListDomains.mockResolvedValue([ + { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + ]); + } + it('errors when hostname not found on project', async () => { mockListDomains.mockResolvedValue([]); @@ -252,10 +290,157 @@ describe('domainStatus', () => { expect(mockExit).toHaveBeenCalledWith(1); }); - it('prints `Ownership: pending` when CF returns ownership_verification', async () => { + it('renders error state when verification_errors present', 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', + ssl_status: 'pending_validation', + cf_status: 'pending_validation', + verification_errors: ['DNS validation timed out', 'second error'], + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: error — Verification failed: DNS validation timed out'); + // Only the first error surfaces in the rollup line. + expect(statusNote).not.toContain('second error'); + }); + + it('renders awaiting_deployment state when bit is true', 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', + ssl_status: 'active', + cf_status: 'active', + awaiting_deployment: true, + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: awaiting_deployment —'); + expect(statusNote).toContain( + 'Your project has no live deployment yet. Run `void deploy` to bring the domain online.', + ); + }); + + it('renders awaiting_dns state when cf_status=pending and ownership_verification present', 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', + ssl_status: 'pending_validation', + cf_status: 'pending', + 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('Status: awaiting_dns —'); + expect(statusNote).toContain( + 'Cloudflare has not yet observed your _cf-custom-hostname TXT record.', + ); + }); + + it('renders verifying_dns state when cf_status=pending_validation', 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', + ssl_status: 'pending_validation', + cf_status: 'pending_validation', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: verifying_dns —'); + expect(statusNote).toContain('DNS records found, Cloudflare is validating ownership.'); + }); + + it('renders issuing_cert state when cf_status=pending_issuance', 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', + ssl_status: 'pending_validation', + cf_status: 'pending_issuance', + }); + + 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'); + }); + + it("computes 'issuing_cert' for the cert-renewal window (CF active + SSL not active)", 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: 'active', + ssl_status: 'pending_validation', + }); + + 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'); + }); + + it("computes 'issuing_cert' over 'active' when DB is active but SSL is re-validating", async () => { mockListDomains.mockResolvedValue([ - { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + { 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', + cf_status: 'active', + ssl_status: 'pending_validation', + }); + + 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 —'); + }); + + it('renders deploying_cert state when cf_status=pending_deployment', async () => { + setupDomain(); mockGetDomainStatus.mockResolvedValue({ id: 'dom_1', project_id: 'proj_default', @@ -264,16 +449,19 @@ describe('domainStatus', () => { status: 'pending', created_at: 'now', ssl_status: 'pending_validation', - ownership_verification: { type: 'txt', name: '_cf-custom-hostname.foo', value: 'own-token' }, + cf_status: 'pending_deployment', }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); const statusNote = mockNote.mock.calls[0][0] as string; - expect(statusNote).toContain('Ownership: pending'); + expect(statusNote).toContain('Status: deploying_cert —'); + expect(statusNote).toContain( + 'SSL certificate issued; Cloudflare is propagating it to its edge.', + ); }); - it('omits the Ownership line when CF does not return ownership_verification', async () => { + it('renders active state when DB status is active', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'active' }, ]); @@ -285,14 +473,183 @@ describe('domainStatus', () => { status: 'active', created_at: 'now', ssl_status: 'active', + cf_status: 'active', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: active — Live.'); + }); + + it('falls back to pending when cf_status and awaiting_deployment are absent (old API)', 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', + ssl_status: 'pending_validation', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain( + 'Status: pending — Verification pending. Cloudflare is processing.', + ); + }); + + it('error state takes priority over awaiting_deployment and cf_status states', 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', + ssl_status: 'pending_validation', + cf_status: 'pending_validation', + awaiting_deployment: true, + verification_errors: ['some failure'], }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); const statusNote = mockNote.mock.calls[0][0] as string; - expect(statusNote).not.toContain('Ownership:'); + expect(statusNote).toContain('Status: error —'); }); +}); + +describe('computeRollup', () => { + // Direct table-driven test for the rollup state machine, exercising + // `computeRollup` without going through `runDomainCommand`. The + // `domainStatus rollup` block above covers the rendering side + // (rollup -> note() lines). + const baseStatus: DomainStatusResponse = { + id: 'dom_1', + project_id: 'p', + hostname: 'h', + cf_hostname_id: 'cf', + created_at: 'now', + status: 'pending', + ssl_status: 'pending', + }; + + const cases: Array<[name: string, input: Partial, expectedState: string]> = + [ + [ + 'error wins over everything', + { verification_errors: ['boom'], cf_status: 'active', awaiting_deployment: true }, + 'error', + ], + [ + 'awaiting_deployment beats CF states', + { awaiting_deployment: true, cf_status: 'pending' }, + 'awaiting_deployment', + ], + [ + 'awaiting_dns on pending + ownership', + { + cf_status: 'pending', + ownership_verification: { type: 'txt', name: 'n', value: 'v' }, + }, + 'awaiting_dns', + ], + ['verifying_dns', { cf_status: 'pending_validation' }, 'verifying_dns'], + ['issuing_cert on pending_issuance', { cf_status: 'pending_issuance' }, 'issuing_cert'], + [ + 'issuing_cert on cert-renewal (cf active + ssl pending)', + { cf_status: 'active', ssl_status: 'pending_validation' }, + 'issuing_cert', + ], + ['deploying_cert', { cf_status: 'pending_deployment' }, 'deploying_cert'], + [ + 'active when DB active and CF/ssl both active', + { status: 'active', cf_status: 'active', ssl_status: 'active' }, + 'active', + ], + ['pending fallback when no signals', {}, 'pending'], + [ + 'active gated on ssl_status — pre-rollout platform during cert renewal', + { status: 'active', ssl_status: 'pending_validation' }, + 'pending', + ], + [ + 'empty verification_errors does NOT trigger error', + { verification_errors: [], cf_status: 'pending_validation' }, + 'verifying_dns', + ], + ]; + + for (const [name, input, expectedState] of cases) { + it(name, () => { + const rollup = computeRollup({ ...baseStatus, ...input }); + expect(rollup.state).toBe(expectedState); + }); + } +}); + +describe('domainStatus --verbose', () => { + it('renders both rolled-up state and multi-line breakdown', 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', + ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, + verification_errors: ['boom'], + }); + + await runDomainCommand( + root, + args({ subcommand: 'status', hostname: 'foo.example.com', verbose: true }), + ); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: error —'); + expect(statusNote).toContain('Details:'); + expect(statusNote).toContain(' Status: pending'); + expect(statusNote).toContain(' SSL Status: pending_validation'); + expect(statusNote).toContain(' Ownership: pending'); + expect(statusNote).toContain(' Errors: boom'); + }); + + it('omits the multi-line breakdown when --verbose is not set', 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', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).not.toContain('SSL Status:'); + expect(statusNote).not.toContain('Details:'); + expect(statusNote).not.toContain(' Status: pending'); + }); +}); +describe('domainStatus pending records block', () => { it('shows pending ownership record ONLY when ownership is pending', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, @@ -305,6 +662,7 @@ describe('domainStatus', () => { status: 'pending', created_at: 'now', ssl_status: 'active', + cf_status: 'pending', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, }); @@ -329,6 +687,7 @@ describe('domainStatus', () => { status: 'pending', created_at: 'now', ssl_status: 'pending_validation', + cf_status: 'pending_validation', ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); @@ -353,6 +712,7 @@ describe('domainStatus', () => { 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' }], }); @@ -379,6 +739,7 @@ describe('domainStatus', () => { status: 'active', created_at: 'now', ssl_status: 'active', + cf_status: 'active', }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); @@ -400,6 +761,7 @@ describe('domainStatus', () => { 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' }, @@ -426,6 +788,7 @@ describe('domainStatus', () => { 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' }, @@ -451,6 +814,7 @@ describe('domainStatus', () => { 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' }], });