Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions src/commands/domains/utils.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
34 changes: 34 additions & 0 deletions src/lib/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[][],
Expand All @@ -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 =
Expand Down
118 changes: 118 additions & 0 deletions tests/commands/domains/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
115 changes: 113 additions & 2 deletions tests/lib/table.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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');
});
});