diff --git a/src/commands/domains/utils.ts b/src/commands/domains/utils.ts index 75f3b15b..bef87b3e 100644 --- a/src/commands/domains/utils.ts +++ b/src/commands/domains/utils.ts @@ -1,23 +1,34 @@ import type { DomainRecords } from 'resend'; import { renderTable } from '../../lib/table'; +import { isUnicodeSupported } from '../../lib/tty'; + +const h = isUnicodeSupported ? String.fromCodePoint(0x2500) : '-'; export function renderDnsRecordsTable( records: DomainRecords[], domainName: string, ): string { - const rows = records.map((r) => { + if (records.length === 0) { + return '(no DNS records)'; + } + + const cards = records.map((r) => { const displayName = r.name ? r.name.includes('.') ? r.name : `${r.name}.${domainName}` : domainName; - return [r.type, displayName, r.ttl, r.value]; + + const separator = `${h}${h} ${r.type} ${h.repeat(40)}`; + return [ + separator, + ` Name ${displayName}`, + ` TTL ${r.ttl}`, + ` Value ${r.value}`, + ].join('\n'); }); - return renderTable( - ['Type', 'Name', 'TTL', 'Value'], - rows, - '(no DNS records)', - ); + + return cards.join('\n\n'); } export function renderDomainsTable( diff --git a/src/lib/table.ts b/src/lib/table.ts index 0ee838c1..f6f64aff 100644 --- a/src/lib/table.ts +++ b/src/lib/table.ts @@ -30,6 +30,30 @@ const BOX = isUnicodeSupported mm: '+', }; +function getTerminalWidth(): number | undefined { + return process.stdout.columns; +} + +function renderCards( + headers: string[], + rows: string[][], + termWidth: number, +): string { + const labelWidth = Math.max(...headers.map((h) => h.length)); + const sepWidth = Math.max(20, Math.min(termWidth, 60)); + + return rows + .map((row, idx) => { + const label = String(idx + 1); + const sep = `${BOX.h}${BOX.h} ${label} ${BOX.h.repeat(Math.max(0, sepWidth - label.length - 4))}`; + const fields = headers.map( + (h, i) => ` ${h.padEnd(labelWidth)} ${row[i]}`, + ); + return [sep, ...fields].join('\n'); + }) + .join('\n\n'); +} + export function renderTable( headers: string[], rows: string[][], @@ -41,6 +65,16 @@ export function renderTable( const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)), ); + + const termWidth = getTerminalWidth(); + if (termWidth !== undefined) { + const totalWidth = + widths.reduce((s, w) => s + w, 0) + 3 * widths.length + 1; + if (totalWidth > termWidth) { + return renderCards(headers, rows, termWidth); + } + } + const top = BOX.tl + widths.map((w) => BOX.h.repeat(w + 2)).join(BOX.tm) + BOX.tr; const mid = diff --git a/tests/commands/domains/utils.test.ts b/tests/commands/domains/utils.test.ts new file mode 100644 index 00000000..b81370ad --- /dev/null +++ b/tests/commands/domains/utils.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'vitest'; +import { renderDnsRecordsTable } from '../../../src/commands/domains/utils'; + +describe('renderDnsRecordsTable', () => { + test('returns empty message when no records', () => { + expect(renderDnsRecordsTable([], 'example.com')).toBe('(no DNS records)'); + }); + + test('renders a card per record with type separator', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'SPF', + type: 'MX', + name: 'send', + ttl: 'Auto', + status: 'verified', + value: 'feedback-smtp.us-east-1.amazonses.com', + priority: 10, + }, + ], + 'example.com', + ); + + expect(output).toContain('MX'); + expect(output).toContain('Name send.example.com'); + expect(output).toContain('TTL Auto'); + expect(output).toContain('Value feedback-smtp.us-east-1.amazonses.com'); + }); + + test('expands short name with domain suffix', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'DKIM', + type: 'TXT', + name: 'dkim', + ttl: 'Auto', + status: 'pending', + value: 'p=MIIG...', + priority: 0, + }, + ], + 'send.example.com', + ); + + expect(output).toContain('Name dkim.send.example.com'); + }); + + test('keeps FQDN name as-is', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'SPF', + type: 'TXT', + name: 'send.example.com', + ttl: 'Auto', + status: 'verified', + value: 'v=spf1 include:amazonses.com ~all', + priority: 0, + }, + ], + 'example.com', + ); + + expect(output).toContain('Name send.example.com'); + }); + + test('uses domain name when record name is empty', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'SPF', + type: 'TXT', + name: '', + ttl: 'Auto', + status: 'verified', + value: 'v=spf1', + priority: 0, + }, + ], + 'example.com', + ); + + expect(output).toContain('Name example.com'); + }); + + test('separates multiple records with blank line', () => { + const output = renderDnsRecordsTable( + [ + { + record: 'SPF', + type: 'MX', + name: 'send', + ttl: 'Auto', + status: 'verified', + value: 'mx.example.com', + priority: 10, + }, + { + record: 'DKIM', + type: 'TXT', + name: 'resend._domainkey', + ttl: 'Auto', + status: 'pending', + value: 'p=MIIG...', + priority: 0, + }, + ], + 'example.com', + ); + + const cards = output.split('\n\n'); + expect(cards).toHaveLength(2); + expect(cards[0]).toContain('MX'); + expect(cards[1]).toContain('TXT'); + }); +}); diff --git a/tests/lib/table.test.ts b/tests/lib/table.test.ts index 99568efc..8e34d79a 100644 --- a/tests/lib/table.test.ts +++ b/tests/lib/table.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test } from 'vitest'; import { renderTable } from '../../src/lib/table'; describe('renderTable', () => { @@ -37,7 +37,6 @@ describe('renderTable', () => { ['much longer key', 'v'], ], ); - // All rows should have the same line length const lines = output.split('\n'); const lengths = lines.map((l) => l.length); expect(new Set(lengths).size).toBe(1); @@ -61,3 +60,115 @@ describe('renderTable', () => { expect(output).toContain('┤'); }); }); + +describe('renderTable card layout fallback', () => { + const originalColumns = process.stdout.columns; + + afterEach(() => { + Object.defineProperty(process.stdout, 'columns', { + value: originalColumns, + writable: true, + configurable: true, + }); + }); + + function setTerminalWidth(width: number | undefined) { + Object.defineProperty(process.stdout, 'columns', { + value: width, + writable: true, + configurable: true, + }); + } + + test('renders table at full width when terminal width is undefined', () => { + setTerminalWidth(undefined); + const output = renderTable( + ['Name', 'ID'], + [['a-very-long-domain-name.example.com', 'abc-123-def-456']], + ); + expect(output).toContain('│'); + expect(output).toContain('a-very-long-domain-name.example.com'); + expect(output).toContain('abc-123-def-456'); + }); + + test('renders table when it fits within terminal width', () => { + setTerminalWidth(undefined); + const output = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); + const lineWidth = output.split('\n')[0].length; + + setTerminalWidth(lineWidth + 10); + const output2 = renderTable(['Name', 'ID'], [['Alice', 'abc-123']]); + expect(output2).toContain('│'); + expect(output2).toBe(output); + }); + + test('switches to cards when table overflows terminal', () => { + setTerminalWidth(30); + const output = renderTable( + ['Name', 'ID'], + [['a-very-long-domain-name.example.com', 'abc-123']], + ); + expect(output).not.toContain('│'); + expect(output).toContain('a-very-long-domain-name.example.com'); + expect(output).toContain('abc-123'); + }); + + test('cards include all values untruncated', () => { + setTerminalWidth(30); + const output = renderTable( + ['Name', 'Description', 'ID'], + [['my-widget', 'A very long description of the widget', 'abc-123']], + ); + expect(output).toContain('A very long description of the widget'); + expect(output).toContain('abc-123'); + expect(output).toContain('my-widget'); + }); + + test('cards separate rows with blank lines', () => { + setTerminalWidth(30); + const output = renderTable( + ['Name', 'Description', 'ID'], + [ + ['widget-a', 'First widget description', 'id-111'], + ['widget-b', 'Second widget description', 'id-222'], + ], + ); + const cards = output.split('\n\n'); + expect(cards).toHaveLength(2); + expect(cards[0]).toContain('widget-a'); + expect(cards[1]).toContain('widget-b'); + }); + + test('cards use box-drawing separator with row number', () => { + setTerminalWidth(20); + const output = renderTable( + ['Name', 'ID'], + [ + ['test', 'abc-123-def-456-ghi-789-jkl-012-mno'], + ['test2', 'xyz-789-uvw-456-rst-123-opq-000-aaa'], + ], + ); + expect(output).toContain('── 1 ─'); + expect(output).toContain('── 2 ─'); + }); + + test('wide table with many columns switches to cards', () => { + setTerminalWidth(60); + const output = renderTable( + ['From', 'To', 'Subject', 'Status', 'Created', 'ID'], + [ + [ + 'sender@example.com', + 'recipient@example.com', + 'Hello world', + 'delivered', + '2026-01-15', + 'dca22e90-1693-4b98-a531-ebb9aaf69a2d', + ], + ], + ); + expect(output).not.toContain('│'); + expect(output).toContain('sender@example.com'); + expect(output).toContain('dca22e90-1693-4b98-a531-ebb9aaf69a2d'); + }); +});