Skip to content
Draft
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
3 changes: 2 additions & 1 deletion src/commands/contacts/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { runCreate } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { cancelAndExit, requireText } from '../../lib/prompts';
import { safeTerminalText } from '../../lib/safe-terminal-text';
import { isInteractive } from '../../lib/tty';
import { parsePropertiesJson } from './utils';

Expand Down Expand Up @@ -105,7 +106,7 @@ Unsubscribed: setting --unsubscribed is a team-wide opt-out from all broadcasts,
}),
}),
onInteractive: (data) => {
console.log(`Contact created: ${data.id}`);
console.log(`Contact created: ${safeTerminalText(data.id)}`);
},
},
globalOpts,
Expand Down
20 changes: 13 additions & 7 deletions src/commands/contacts/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { runGet } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { pickId } from '../../lib/prompts';
import { safeTerminalText } from '../../lib/safe-terminal-text';
import { contactPickerConfig } from './utils';

export const getContactCommand = new Command('get')
Expand Down Expand Up @@ -32,18 +33,23 @@ export const getContactCommand = new Command('get')
sdkCall: (resend) => resend.contacts.get(id),
onInteractive: (data) => {
const name = [data.first_name, data.last_name]
.filter(Boolean)
.filter((s): s is string => Boolean(s))
.map(safeTerminalText)
.join(' ');
console.log(`${data.email}${name ? ` (${name})` : ''}`);
console.log(`ID: ${data.id}`);
console.log(`Created: ${data.created_at}`);
console.log(
`${safeTerminalText(data.email)}${name ? ` (${name})` : ''}`,
);
console.log(`ID: ${safeTerminalText(data.id)}`);
console.log(`Created: ${safeTerminalText(data.created_at)}`);
console.log(`Unsubscribed: ${data.unsubscribed ? 'yes' : 'no'}`);
const propEntries = Object.entries(data.properties ?? {});
if (propEntries.length > 0) {
console.log('Properties:');
for (const [key, val] of propEntries) {
console.log(` ${key}: ${val.value}`);
}
propEntries.map(([key, val]) =>
console.log(
` ${safeTerminalText(key)}: ${safeTerminalText(String(val.value))}`,
),
);
}
},
},
Expand Down
5 changes: 3 additions & 2 deletions src/commands/segments/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { runCreate } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { requireText } from '../../lib/prompts';
import { safeTerminalText } from '../../lib/safe-terminal-text';

export const createSegmentCommand = new Command('create')
.description('Create a new segment')
Expand Down Expand Up @@ -37,8 +38,8 @@ Non-interactive: --name is required.`,
loading: 'Creating segment...',
sdkCall: (resend) => resend.segments.create({ name }),
onInteractive: (data) => {
console.log(`Segment created: ${data.id}`);
console.log(`Name: ${data.name}`);
console.log(`Segment created: ${safeTerminalText(data.id)}`);
console.log(`Name: ${safeTerminalText(data.name)}`);
},
},
globalOpts,
Expand Down
7 changes: 4 additions & 3 deletions src/commands/segments/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { runGet } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { pickId } from '../../lib/prompts';
import { safeTerminalText } from '../../lib/safe-terminal-text';
import { segmentPickerConfig } from './utils';

export const getSegmentCommand = new Command('get')
Expand All @@ -27,9 +28,9 @@ export const getSegmentCommand = new Command('get')
loading: 'Fetching segment...',
sdkCall: (resend) => resend.segments.get(id),
onInteractive: (data) => {
console.log(`${data.name}`);
console.log(`ID: ${data.id}`);
console.log(`Created: ${data.created_at}`);
console.log(safeTerminalText(data.name));
console.log(`ID: ${safeTerminalText(data.id)}`);
console.log(`Created: ${safeTerminalText(data.created_at)}`);
},
},
globalOpts,
Expand Down
3 changes: 2 additions & 1 deletion src/commands/topics/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { runCreate } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { requireText } from '../../lib/prompts';
import { safeTerminalText } from '../../lib/safe-terminal-text';

export const createTopicCommand = new Command('create')
.description('Create a new topic for subscription management')
Expand Down Expand Up @@ -61,7 +62,7 @@ Non-interactive: --name is required.`,
...(opts.description && { description: opts.description }),
}),
onInteractive: (data) => {
console.log(`Topic created: ${data.id}`);
console.log(`Topic created: ${safeTerminalText(data.id)}`);
},
},
globalOpts,
Expand Down
13 changes: 8 additions & 5 deletions src/commands/topics/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { runGet } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { pickId } from '../../lib/prompts';
import { safeTerminalText } from '../../lib/safe-terminal-text';
import { topicPickerConfig } from './utils';

export const getTopicCommand = new Command('get')
Expand All @@ -27,13 +28,15 @@ export const getTopicCommand = new Command('get')
loading: 'Fetching topic...',
sdkCall: (resend) => resend.topics.get(id),
onInteractive: (data) => {
console.log(`${data.name}`);
console.log(`ID: ${data.id}`);
console.log(safeTerminalText(data.name));
console.log(`ID: ${safeTerminalText(data.id)}`);
if (data.description) {
console.log(`Description: ${data.description}`);
console.log(`Description: ${safeTerminalText(data.description)}`);
}
console.log(`Default subscription: ${data.default_subscription}`);
console.log(`Created: ${data.created_at}`);
console.log(
`Default subscription: ${safeTerminalText(data.default_subscription)}`,
);
console.log(`Created: ${safeTerminalText(data.created_at)}`);
},
},
globalOpts,
Expand Down
3 changes: 2 additions & 1 deletion src/lib/output.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pc from 'picocolors';
import { safeTerminalText } from './safe-terminal-text';

export function errorMessage(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback;
Expand Down Expand Up @@ -49,7 +50,7 @@ export function outputError(
),
);
} else {
console.error(`${pc.red('Error:')} ${error.message}`);
console.error(`${pc.red('Error:')} ${safeTerminalText(error.message)}`);
}

process.exit(exitCode);
Expand Down
11 changes: 11 additions & 0 deletions src/lib/safe-terminal-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// biome-ignore lint/complexity/useRegexLiterals: literal form triggers noControlCharactersInRegex
const CONTROL_CHARS = new RegExp(
'[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]',
'g',
);

export const safeTerminalText = (value: string): string =>
value.replace(
CONTROL_CHARS,
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`,
);
8 changes: 5 additions & 3 deletions src/lib/table.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { safeTerminalText } from './safe-terminal-text';
import { isUnicodeSupported } from './tty';

// All box-drawing characters generated via String.fromCodePoint() — never literal
Expand Down Expand Up @@ -62,16 +63,17 @@ export function renderTable(
if (rows.length === 0) {
return emptyMessage;
}
const sanitizedRows = rows.map((r) => r.map(safeTerminalText));
const widths = headers.map((h, i) =>
Math.max(h.length, ...rows.map((r) => r[i].length)),
Math.max(h.length, ...sanitizedRows.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);
return renderCards(headers, sanitizedRows, termWidth);
}
}

Expand All @@ -87,5 +89,5 @@ export function renderTable(
cells.map((c, i) => c.padEnd(widths[i])).join(` ${BOX.v} `) +
' ' +
BOX.v;
return [top, row(headers), mid, ...rows.map(row), bot].join('\n');
return [top, row(headers), mid, ...sanitizedRows.map(row), bot].join('\n');
}
57 changes: 57 additions & 0 deletions tests/lib/safe-terminal-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { safeTerminalText } from '../../src/lib/safe-terminal-text';

describe('safeTerminalText', () => {
it('returns plain text unchanged', () => {
expect(safeTerminalText('hello world')).toBe('hello world');
});

it('preserves newlines and tabs', () => {
expect(safeTerminalText('line1\nline2\ttab')).toBe('line1\nline2\ttab');
});

it('escapes ANSI escape sequences', () => {
expect(safeTerminalText('\u001b[2Jspoofed')).toBe('\\u001b[2Jspoofed');
});

it('escapes CSI cursor movement sequences', () => {
expect(safeTerminalText('\u001b[Hspoofed')).toBe('\\u001b[Hspoofed');
});

it('escapes null bytes', () => {
expect(safeTerminalText('before\u0000after')).toBe('before\\u0000after');
});

it('escapes C1 control characters', () => {
expect(safeTerminalText('a\u008db')).toBe('a\\u008db');
});

it('escapes DEL character', () => {
expect(safeTerminalText('a\u007fb')).toBe('a\\u007fb');
});

it('escapes multiple control characters in one string', () => {
expect(safeTerminalText('\u001b[31mred\u001b[0m')).toBe(
'\\u001b[31mred\\u001b[0m',
);
});

it('does not escape carriage return', () => {
expect(safeTerminalText('line\r\n')).toBe('line\r\n');
});

it('handles empty strings', () => {
expect(safeTerminalText('')).toBe('');
});

it('escapes OSC 52 clipboard payloads', () => {
const osc52 = '\u001b]52;c;SGVsbG8=\u0007';
const result = safeTerminalText(osc52);
expect(result).not.toContain('\u001b');
expect(result).not.toContain('\u0007');
});

it('preserves unicode text beyond control range', () => {
expect(safeTerminalText('héllo wörld 日本語')).toBe('héllo wörld 日本語');
});
});
Loading