From 7fe6981ed44d564f7058fcd03971d2a2b17d29a7 Mon Sep 17 00:00:00 2001 From: Danilo Woznica Date: Wed, 1 Jul 2026 13:47:23 +0100 Subject: [PATCH] feat: rebrand --- .gitignore | 1 + README.md | 19 ++++-- src/cli.ts | 6 +- src/commands/auth/login.ts | 12 +++- src/commands/automations/runs/get.ts | 3 +- src/commands/automations/runs/list.ts | 48 ++++++++++++++- src/commands/automations/utils.ts | 29 ++++++++- src/commands/broadcasts/utils.ts | 34 +++++++++-- src/commands/contacts/imports/get.ts | 4 +- src/commands/contacts/imports/utils.ts | 46 +++++++++++++- src/commands/domains/utils.ts | 47 ++++++++++++--- src/commands/emails/get.ts | 4 +- src/commands/emails/list.ts | 13 ++-- src/commands/emails/utils.ts | 51 +++++++++++++++- src/commands/logs/utils.ts | 37 +++++++++++- src/commands/templates/get.ts | 4 +- src/commands/templates/utils.ts | 38 +++++++++++- src/commands/webhooks/get.ts | 4 +- src/commands/webhooks/utils.ts | 38 +++++++++++- src/lib/brand.ts | 19 ++++++ src/lib/help-text.ts | 14 +++-- src/lib/logo.ts | 77 +++++++++++++++++++++--- src/lib/spinner.ts | 7 +-- src/lib/table.ts | 83 ++++++++++++++++++++++---- tests/lib/logo.test.ts | 12 ++-- 25 files changed, 562 insertions(+), 88 deletions(-) create mode 100644 src/lib/brand.ts diff --git a/.gitignore b/.gitignore index c79f29ac..f484ff46 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist/ # OS .DS_Store Thumbs.db +\~/.volta # Environment .env diff --git a/README.md b/README.md index a0e56657..15c7ea61 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,19 @@ The official CLI for [Resend](https://resend.com). Built for humans, AI agents, and CI/CD pipelines. ``` -██████╗ ███████╗███████╗███████╗███╗ ██╗██████╗ -██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔══██╗ -██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║██║ ██║ -██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║██║ ██║ -██║ ██║███████╗███████║███████╗██║ ╚████║██████╔╝ -╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═══╝╚═════╝ ++++++++++++++++++++++ +++++ ++++++++++++++++++++++++ +++++ + ++++++++++ +++++ + +++++++++ +++++ +++++++ +++++ ++++ +++ +++++ + +++++++++ +++++++++++++ ++++++++++++++ +++++++++++++ ++++++++++++++++ ++++++++++++++++ + +++++++++++ ++++++++++++++++ ++++++++++++++++ ++++++++++++++++ ++++++++++++++++++ ++++++++++++++++++ + +++++++++++++++ +++++++ +++++++ +++++++ ++++++ ++++++ +++++++ +++++++ ++++++++ ++++++++ +++++++ + +++++++++ +++++++++++++++++++ ++++++++++++++ +++++++++++++++++++ ++++++ ++++++ ++++++ +++++ + ++++++++++ +++++++++++++++++++ +++++++++++++++ +++++++++++++++++++ ++++++ +++++++++++++ +++++ + ++++++++++ ++++++ ++++++++++++ ++++++ ++++++ ++++++ ++++++ ++++++ + +++++++++++ +++++++ +++++++ +++++++ ++++++ +++++++ +++++++ ++++++ ++++++ ++++++++ ++++++++ + ++++++++++ ++++++++++++++++ ++++++++++++++++ +++++++++++++++ ++++++ ++++++ ++++++++++++++++++ + ++++++++++ +++++++++++ +++++++++++ +++++++++++ ++++++ ++++++ +++++++++ +++++ ``` ## Install diff --git a/src/cli.ts b/src/cli.ts index f7cebc0a..7270c6ee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,7 +26,7 @@ import { webhooksCommand } from './commands/webhooks/index'; import { whoamiCommand } from './commands/whoami'; import { setupCliExitHandler } from './lib/cli-exit'; import { installCommandSuggestions } from './lib/command-suggestions'; -import { printBannerPlain } from './lib/logo'; +import { printBanner } from './lib/logo'; import { errorMessage, outputError } from './lib/output'; import { trackCommand } from './lib/telemetry'; import { checkForUpdates } from './lib/update-check'; @@ -108,11 +108,13 @@ ${pc.gray('Examples:')} - Send an email ${pc.blue('$ resend emails send')} + +${pc.dim('Run resend --help for details on a specific command.')} `, ) .action(() => { if (process.stdout.isTTY) { - printBannerPlain(); + printBanner(); } const opts = program.opts(); if (opts.apiKey) { diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 782399e0..841cda1b 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,6 +1,8 @@ import * as p from '@clack/prompts'; import { Command } from '@commander-js/extra-typings'; +import pc from 'picocolors'; import { Resend } from 'resend'; +import { wordmark } from '../../lib/brand'; import { openInBrowser } from '../../lib/browser'; import type { GlobalOpts } from '../../lib/client'; import { @@ -12,6 +14,7 @@ import { validateProfileName, } from '../../lib/config'; import { buildHelpText } from '../../lib/help-text'; +import { printBanner } from '../../lib/logo'; import { errorMessage, outputError, outputResult } from '../../lib/output'; import { cancelAndExit, promptRenameIfInvalid } from '../../lib/prompts'; import { createSpinner } from '../../lib/spinner'; @@ -60,7 +63,10 @@ export const loginCommand = new Command('login') ); } - p.intro('Resend Authentication'); + if (process.stdout.isTTY) { + printBanner(); + } + p.intro('Resend authentication'); p.log.info( `Use a full access API key for complete CLI access.\n${SENDING_KEY_MESSAGE}`, ); @@ -264,7 +270,9 @@ export const loginCommand = new Command('login') : `in ${backend.name}`; const msg = `API key stored for profile '${profileLabel}' ${storageInfo}`; if (isInteractive()) { - p.outro(msg); + p.outro( + `${wordmark()} is ready — API key stored for profile '${pc.bold(profileLabel)}' ${storageInfo}`, + ); } else { console.log(msg); } diff --git a/src/commands/automations/runs/get.ts b/src/commands/automations/runs/get.ts index dcabc771..bb7bf96c 100644 --- a/src/commands/automations/runs/get.ts +++ b/src/commands/automations/runs/get.ts @@ -3,6 +3,7 @@ import { runGet } from '../../../lib/actions'; import type { GlobalOpts } from '../../../lib/client'; import { buildHelpText } from '../../../lib/help-text'; import { requireText } from '../../../lib/prompts'; +import { runStatusIndicator } from './list'; export const getAutomationRunCommand = new Command('get') .description('Retrieve details of a specific automation run') @@ -52,7 +53,7 @@ export const getAutomationRunCommand = new Command('get') resend.automations.runs.get({ automationId, runId }), onInteractive: (r) => { console.log(`Run: ${r.id}`); - console.log(`Status: ${r.status}`); + console.log(`Status: ${runStatusIndicator(r.status)}`); if (r.started_at) { console.log(`Started: ${r.started_at}`); } diff --git a/src/commands/automations/runs/list.ts b/src/commands/automations/runs/list.ts index b48185b4..25f4fa84 100644 --- a/src/commands/automations/runs/list.ts +++ b/src/commands/automations/runs/list.ts @@ -8,9 +8,47 @@ import { printPaginationHint, } from '../../../lib/pagination'; import { pickId } from '../../../lib/prompts'; -import { renderTable } from '../../../lib/table'; +import { renderTable, type StatusTone } from '../../../lib/table'; +import { isUnicodeSupported } from '../../../lib/tty'; import { automationPickerConfig } from '../utils'; +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const HOURGLASS = isUnicodeSupported ? String.fromCodePoint(0x23f3) : '~'; // ⏳ +const CIRCLE = isUnicodeSupported ? String.fromCodePoint(0x25cb) : 'o'; // ○ +const CROSS_MARK = isUnicodeSupported ? String.fromCodePoint(0x2717) : 'x'; // ✗ + +function runStatusTone(status: string): StatusTone { + switch (status) { + case 'completed': + return 'success'; + case 'running': + return 'pending'; + case 'failed': + return 'failure'; + case 'cancelled': + return 'neutral'; + default: + return 'neutral'; + } +} + +export function runStatusIndicator(status: string): string { + switch (status) { + case 'completed': + return `${CHECK} Completed`; + case 'running': + return `${HOURGLASS} Running`; + case 'failed': + return `${CROSS_MARK} Failed`; + case 'cancelled': + return `${CIRCLE} Cancelled`; + default: + return status; + } +} + export const listAutomationRunsCommand = new Command('list') .alias('ls') .description('List runs for an automation') @@ -67,7 +105,7 @@ export const listAutomationRunsCommand = new Command('list') onInteractive: (list) => { const rows = list.data.map((r) => [ r.id, - r.status, + runStatusIndicator(r.status), r.started_at ?? '-', r.completed_at ?? '-', ]); @@ -76,6 +114,12 @@ export const listAutomationRunsCommand = new Command('list') ['ID', 'Status', 'Started', 'Completed'], rows, '(no runs)', + { + statusColumn: { + index: 1, + tones: list.data.map((r) => runStatusTone(r.status)), + }, + }, ), ); printPaginationHint(list, `automations runs list ${automationId}`, { diff --git a/src/commands/automations/utils.ts b/src/commands/automations/utils.ts index c4456fa5..9bbe71a4 100644 --- a/src/commands/automations/utils.ts +++ b/src/commands/automations/utils.ts @@ -1,5 +1,11 @@ import type { PickerConfig } from '../../lib/prompts'; -import { renderTable } from '../../lib/table'; +import { renderTable, type StatusTone } from '../../lib/table'; +import { isUnicodeSupported } from '../../lib/tty'; + +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const CIRCLE = isUnicodeSupported ? String.fromCodePoint(0x25cb) : 'o'; // ○ export function renderAutomationsTable( automations: Array<{ @@ -19,20 +25,37 @@ export function renderAutomationsTable( ['Name', 'Status', 'Created', 'ID'], rows, '(no automations)', + { + statusColumn: { + index: 1, + tones: automations.map((a) => automationStatusTone(a.status)), + }, + }, ); } export function statusIndicator(status: string): string { switch (status) { case 'enabled': - return '✓ Enabled'; + return `${CHECK} Enabled`; case 'disabled': - return '○ Disabled'; + return `${CIRCLE} Disabled`; default: return status; } } +function automationStatusTone(status: string): StatusTone { + switch (status) { + case 'enabled': + return 'success'; + case 'disabled': + return 'neutral'; + default: + return 'neutral'; + } +} + export const automationPickerConfig: PickerConfig<{ id: string; name: string; diff --git a/src/commands/broadcasts/utils.ts b/src/commands/broadcasts/utils.ts index 476ae149..a5043185 100644 --- a/src/commands/broadcasts/utils.ts +++ b/src/commands/broadcasts/utils.ts @@ -1,19 +1,39 @@ import type { PickerConfig } from '../../lib/prompts'; -import { renderTable } from '../../lib/table'; +import { renderTable, type StatusTone } from '../../lib/table'; +import { isUnicodeSupported } from '../../lib/tty'; + +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const HOURGLASS = isUnicodeSupported ? String.fromCodePoint(0x23f3) : '~'; // ⏳ +const CIRCLE = isUnicodeSupported ? String.fromCodePoint(0x25cb) : 'o'; // ○ export function broadcastStatusIndicator(status: string): string { switch (status) { case 'draft': - return '○ Draft'; + return `${CIRCLE} Draft`; case 'queued': - return '⏳ Queued'; + return `${HOURGLASS} Queued`; case 'sent': - return '✓ Sent'; + return `${CHECK} Sent`; default: return status; } } +function broadcastStatusTone(status: string): StatusTone { + switch (status) { + case 'sent': + return 'success'; + case 'queued': + return 'pending'; + case 'draft': + return 'neutral'; + default: + return 'neutral'; + } +} + export const broadcastPickerConfig: PickerConfig<{ id: string; name: string | null; @@ -59,5 +79,11 @@ export function renderBroadcastsTable( ['Name', 'Status', 'Created', 'ID'], rows, '(no broadcasts)', + { + statusColumn: { + index: 1, + tones: broadcasts.map((b) => broadcastStatusTone(b.status)), + }, + }, ); } diff --git a/src/commands/contacts/imports/get.ts b/src/commands/contacts/imports/get.ts index a94c47fe..a0eb128a 100644 --- a/src/commands/contacts/imports/get.ts +++ b/src/commands/contacts/imports/get.ts @@ -4,7 +4,7 @@ import { runGet } from '../../../lib/actions'; import type { GlobalOpts } from '../../../lib/client'; import { buildHelpText } from '../../../lib/help-text'; import { pickId } from '../../../lib/prompts'; -import { contactImportPickerConfig } from './utils'; +import { contactImportPickerConfig, importStatusIndicator } from './utils'; export const getContactImportCommand = new Command('get') .description('Retrieve a contact import by ID') @@ -28,7 +28,7 @@ export const getContactImportCommand = new Command('get') loading: 'Fetching contact import...', sdkCall: (resend) => resend.contacts.imports.get(id), onInteractive: (imp) => { - console.log(`${imp.id} - ${imp.status}`); + console.log(`${imp.id} - ${importStatusIndicator(imp.status)}`); console.log(`Created: ${imp.created_at}`); if (imp.completed_at) { console.log(`Completed: ${imp.completed_at}`); diff --git a/src/commands/contacts/imports/utils.ts b/src/commands/contacts/imports/utils.ts index 6cd7ab66..6b8e71ea 100644 --- a/src/commands/contacts/imports/utils.ts +++ b/src/commands/contacts/imports/utils.ts @@ -2,13 +2,49 @@ import type { ContactImport, ContactImportColumnMap } from 'resend'; import type { GlobalOpts } from '../../../lib/client'; import { outputError } from '../../../lib/output'; import type { PickerConfig } from '../../../lib/prompts'; -import { renderTable } from '../../../lib/table'; +import { renderTable, type StatusTone } from '../../../lib/table'; +import { isUnicodeSupported } from '../../../lib/tty'; // ─── Table renderer ──────────────────────────────────────────────────────── +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const HOURGLASS = isUnicodeSupported ? String.fromCodePoint(0x23f3) : '~'; // ⏳ +const CROSS_MARK = isUnicodeSupported ? String.fromCodePoint(0x2717) : 'x'; // ✗ + +function importStatusTone(status: string): StatusTone { + switch (status) { + case 'completed': + return 'success'; + case 'queued': + case 'in_progress': + return 'pending'; + case 'failed': + return 'failure'; + default: + return 'neutral'; + } +} + +export function importStatusIndicator(status: string): string { + switch (status) { + case 'completed': + return `${CHECK} Completed`; + case 'queued': + return `${HOURGLASS} Queued`; + case 'in_progress': + return `${HOURGLASS} In progress`; + case 'failed': + return `${CROSS_MARK} Failed`; + default: + return status; + } +} + export function renderContactImportsTable(imports: ContactImport[]): string { const rows = imports.map((imp) => [ - imp.status, + importStatusIndicator(imp.status), String(imp.counts.total), String(imp.counts.created), String(imp.counts.updated), @@ -30,6 +66,12 @@ export function renderContactImportsTable(imports: ContactImport[]): string { ], rows, '(no contact imports)', + { + statusColumn: { + index: 0, + tones: imports.map((imp) => importStatusTone(imp.status)), + }, + }, ); } diff --git a/src/commands/domains/utils.ts b/src/commands/domains/utils.ts index f117f67e..c51a8f1f 100644 --- a/src/commands/domains/utils.ts +++ b/src/commands/domains/utils.ts @@ -1,10 +1,36 @@ import type { DomainRecords } from 'resend'; import type { PickerConfig } from '../../lib/prompts'; -import { renderTable } from '../../lib/table'; +import { renderTable, type StatusTone } from '../../lib/table'; import { isUnicodeSupported } from '../../lib/tty'; const h = isUnicodeSupported ? String.fromCodePoint(0x2500) : '-'; +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const HOURGLASS = isUnicodeSupported ? String.fromCodePoint(0x23f3) : '~'; // ⏳ +const CIRCLE = isUnicodeSupported ? String.fromCodePoint(0x25cb) : 'o'; // ○ +const HALF_CIRCLE = isUnicodeSupported ? String.fromCodePoint(0x25d0) : '~'; // ◐ +const CROSS_MARK = isUnicodeSupported ? String.fromCodePoint(0x2717) : 'x'; // ✗ + +function domainStatusTone(status: string): StatusTone { + switch (status) { + case 'verified': + return 'success'; + case 'pending': + case 'partially_verified': + case 'partially_failed': + return 'pending'; + case 'not_started': + return 'neutral'; + case 'failed': + case 'temporary_failure': + return 'failure'; + default: + return 'neutral'; + } +} + export function renderDnsRecordsTable( records: DomainRecords[], domainName: string, @@ -36,24 +62,29 @@ export function renderDomainsTable( domains: Array<{ id: string; name: string; status: string; region: string }>, ): string { const rows = domains.map((d) => [d.name, d.status, d.region, d.id]); - return renderTable(['Name', 'Status', 'Region', 'ID'], rows, '(no domains)'); + return renderTable(['Name', 'Status', 'Region', 'ID'], rows, '(no domains)', { + statusColumn: { + index: 1, + tones: domains.map((d) => domainStatusTone(d.status)), + }, + }); } export function statusIndicator(status: string): string { switch (status) { case 'verified': - return '✓ Verified'; + return `${CHECK} Verified`; case 'pending': - return '⏳ Pending'; + return `${HOURGLASS} Pending`; case 'not_started': - return '○ Not started'; + return `${CIRCLE} Not started`; case 'partially_verified': - return '◐ Partially verified'; + return `${HALF_CIRCLE} Partially verified`; case 'partially_failed': - return '◐ Partially failed'; + return `${HALF_CIRCLE} Partially failed`; case 'failed': case 'temporary_failure': - return '✗ Failed'; + return `${CROSS_MARK} Failed`; default: return status; } diff --git a/src/commands/emails/get.ts b/src/commands/emails/get.ts index 897882aa..2b190249 100644 --- a/src/commands/emails/get.ts +++ b/src/commands/emails/get.ts @@ -3,7 +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 { emailPickerConfig } from './utils'; +import { emailPickerConfig, lastEventIndicator } from './utils'; export const getEmailCommand = new Command('get') .description('Retrieve a sent email by ID') @@ -31,7 +31,7 @@ export const getEmailCommand = new Command('get') console.log(`From: ${data.from}`); console.log(`To: ${data.to.join(', ')}`); console.log(`Subject: ${data.subject}`); - console.log(`Status: ${data.last_event}`); + console.log(`Status: ${lastEventIndicator(data.last_event)}`); console.log(`Date: ${data.created_at}`); if (data.scheduled_at) { console.log(`Scheduled: ${data.scheduled_at}`); diff --git a/src/commands/emails/list.ts b/src/commands/emails/list.ts index 641a06da..2d53d6fe 100644 --- a/src/commands/emails/list.ts +++ b/src/commands/emails/list.ts @@ -8,6 +8,7 @@ import { printPaginationHint, } from '../../lib/pagination'; import { renderTable } from '../../lib/table'; +import { lastEventIndicator, lastEventTone } from './utils'; type SentEmail = { id: string; @@ -21,16 +22,20 @@ type SentEmail = { function renderSentEmailsTable(emails: SentEmail[]): string { const rows = emails.map((e) => { - const to = e.to.join(', '); - const toStr = to.length > 40 ? `${to.slice(0, 37)}...` : to; const subject = e.subject.length > 50 ? `${e.subject.slice(0, 47)}...` : e.subject; - return [e.from, toStr, subject, e.last_event ?? '—', e.created_at, e.id]; + return [subject, lastEventIndicator(e.last_event), e.created_at, e.id]; }); return renderTable( - ['From', 'To', 'Subject', 'Status', 'Created', 'ID'], + ['Subject', 'Status', 'Created', 'ID'], rows, '(no sent emails)', + { + statusColumn: { + index: 1, + tones: emails.map((e) => lastEventTone(e.last_event)), + }, + }, ); } diff --git a/src/commands/emails/utils.ts b/src/commands/emails/utils.ts index 556e6233..c7188640 100644 --- a/src/commands/emails/utils.ts +++ b/src/commands/emails/utils.ts @@ -1,6 +1,55 @@ import type { ListAttachmentsResponseSuccess } from 'resend'; import type { PickerConfig } from '../../lib/prompts'; -import { renderTable } from '../../lib/table'; +import { renderTable, type StatusTone } from '../../lib/table'; +import { isUnicodeSupported } from '../../lib/tty'; + +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const HOURGLASS = isUnicodeSupported ? String.fromCodePoint(0x23f3) : '~'; // ⏳ +const CIRCLE = isUnicodeSupported ? String.fromCodePoint(0x25cb) : 'o'; // ○ +const CROSS_MARK = isUnicodeSupported ? String.fromCodePoint(0x2717) : 'x'; // ✗ + +export function lastEventTone(lastEvent: string | null): StatusTone { + switch (lastEvent) { + case 'delivered': + case 'opened': + case 'clicked': + return 'success'; + case 'sent': + case 'queued': + case 'delivery_delayed': + return 'pending'; + case 'bounced': + case 'complained': + return 'failure'; + default: + return 'neutral'; + } +} + +export function lastEventIndicator(lastEvent: string | null): string { + switch (lastEvent) { + case 'delivered': + return `${CHECK} Delivered`; + case 'opened': + return `${CHECK} Opened`; + case 'clicked': + return `${CHECK} Clicked`; + case 'sent': + return `${HOURGLASS} Sent`; + case 'queued': + return `${HOURGLASS} Queued`; + case 'delivery_delayed': + return `${HOURGLASS} Delayed`; + case 'bounced': + return `${CROSS_MARK} Bounced`; + case 'complained': + return `${CROSS_MARK} Complained`; + default: + return `${CIRCLE} —`; + } +} export const emailPickerConfig: PickerConfig<{ id: string; diff --git a/src/commands/logs/utils.ts b/src/commands/logs/utils.ts index b81f2e89..f1c15beb 100644 --- a/src/commands/logs/utils.ts +++ b/src/commands/logs/utils.ts @@ -1,5 +1,32 @@ import type { PickerConfig } from '../../lib/prompts'; -import { renderTable } from '../../lib/table'; +import { renderTable, type StatusTone } from '../../lib/table'; +import { isUnicodeSupported } from '../../lib/tty'; + +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const HOURGLASS = isUnicodeSupported ? String.fromCodePoint(0x23f3) : '~'; // ⏳ +const CROSS_MARK = isUnicodeSupported ? String.fromCodePoint(0x2717) : 'x'; // ✗ + +function httpStatusTone(status: number): StatusTone { + if (status < 300) { + return 'success'; + } + if (status < 400) { + return 'pending'; + } + return 'failure'; +} + +function httpStatusIndicator(status: number): string { + if (status < 300) { + return `${CHECK} ${status}`; + } + if (status < 400) { + return `${HOURGLASS} ${status}`; + } + return `${CROSS_MARK} ${status}`; +} export function renderLogsTable( logs: Array<{ @@ -14,7 +41,7 @@ export function renderLogsTable( const rows = logs.map((l) => [ l.method, l.endpoint, - String(l.response_status), + httpStatusIndicator(l.response_status), l.created_at, l.id, ]); @@ -22,6 +49,12 @@ export function renderLogsTable( ['Method', 'Endpoint', 'Status', 'Created', 'ID'], rows, '(no logs)', + { + statusColumn: { + index: 2, + tones: logs.map((l) => httpStatusTone(l.response_status)), + }, + }, ); } diff --git a/src/commands/templates/get.ts b/src/commands/templates/get.ts index fd943a37..fcad36ab 100644 --- a/src/commands/templates/get.ts +++ b/src/commands/templates/get.ts @@ -3,7 +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 { templatePickerConfig } from './utils'; +import { templatePickerConfig, templateStatusIndicator } from './utils'; export const getTemplateCommand = new Command('get') .description('Retrieve a template by ID or alias') @@ -31,7 +31,7 @@ export const getTemplateCommand = new Command('get') onInteractive: (data) => { console.log(`${data.name}`); console.log(`ID: ${data.id}`); - console.log(`Status: ${data.status}`); + console.log(`Status: ${templateStatusIndicator(data.status)}`); if (data.alias) { console.log(`Alias: ${data.alias}`); } diff --git a/src/commands/templates/utils.ts b/src/commands/templates/utils.ts index 33bdfc86..14a930a3 100644 --- a/src/commands/templates/utils.ts +++ b/src/commands/templates/utils.ts @@ -8,7 +8,13 @@ type TemplateVariableCreationOptions = NonNullable< CreateTemplateOptions['variables'] >[number]; -import { renderTable } from '../../lib/table'; +import { renderTable, type StatusTone } from '../../lib/table'; +import { isUnicodeSupported } from '../../lib/tty'; + +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const CIRCLE = isUnicodeSupported ? String.fromCodePoint(0x25cb) : 'o'; // ○ /** * Parse `--var` values like "name:string" or "count:number:42" into SDK options. @@ -65,12 +71,34 @@ export const templatePickerConfig: PickerConfig<{ display: (t) => ({ label: t.name, hint: t.id }), }; +function templateStatusTone(status: string): StatusTone { + switch (status) { + case 'published': + return 'success'; + case 'draft': + return 'neutral'; + default: + return 'neutral'; + } +} + +export function templateStatusIndicator(status: string): string { + switch (status) { + case 'published': + return `${CHECK} Published`; + case 'draft': + return `${CIRCLE} Draft`; + default: + return status; + } +} + export function renderTemplatesTable( templates: ListTemplatesResponseSuccess['data'], ): string { const rows = templates.map((t) => [ t.name, - t.status, + templateStatusIndicator(t.status), t.alias ?? '', t.id, t.created_at, @@ -79,5 +107,11 @@ export function renderTemplatesTable( ['Name', 'Status', 'Alias', 'ID', 'Created'], rows, '(no templates)', + { + statusColumn: { + index: 1, + tones: templates.map((t) => templateStatusTone(t.status)), + }, + }, ); } diff --git a/src/commands/webhooks/get.ts b/src/commands/webhooks/get.ts index 57fe13e6..65259a57 100644 --- a/src/commands/webhooks/get.ts +++ b/src/commands/webhooks/get.ts @@ -3,7 +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 { webhookPickerConfig } from './utils'; +import { webhookPickerConfig, webhookStatusIndicator } from './utils'; export const getWebhookCommand = new Command('get') .description('Retrieve a webhook endpoint configuration by ID') @@ -31,7 +31,7 @@ To rotate secrets, delete the webhook and recreate it.`, onInteractive: (d) => { console.log(`${d.endpoint}`); console.log(`ID: ${d.id}`); - console.log(`Status: ${d.status}`); + console.log(`Status: ${webhookStatusIndicator(d.status)}`); console.log(`Events: ${(d.events ?? []).join(', ') || '(none)'}`); console.log(`Created: ${d.created_at}`); }, diff --git a/src/commands/webhooks/utils.ts b/src/commands/webhooks/utils.ts index 57cb315d..9dca981c 100644 --- a/src/commands/webhooks/utils.ts +++ b/src/commands/webhooks/utils.ts @@ -1,6 +1,34 @@ import type { Webhook, WebhookEvent } from 'resend'; import type { PickerConfig } from '../../lib/prompts'; -import { renderTable } from '../../lib/table'; +import { renderTable, type StatusTone } from '../../lib/table'; +import { isUnicodeSupported } from '../../lib/tty'; + +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +const CHECK = isUnicodeSupported ? String.fromCodePoint(0x2713) : 'v'; // ✓ +const CIRCLE = isUnicodeSupported ? String.fromCodePoint(0x25cb) : 'o'; // ○ + +function webhookStatusTone(status: string): StatusTone { + switch (status) { + case 'enabled': + return 'success'; + case 'disabled': + return 'neutral'; + default: + return 'neutral'; + } +} + +export function webhookStatusIndicator(status: string): string { + switch (status) { + case 'enabled': + return `${CHECK} Enabled`; + case 'disabled': + return `${CIRCLE} Disabled`; + default: + return status; + } +} export const ALL_WEBHOOK_EVENTS: WebhookEvent[] = [ 'email.sent', @@ -45,11 +73,17 @@ export function renderWebhooksTable(webhooks: Webhook[]): string { const eventsStr = (w.events ?? []).join(', '); const events = eventsStr.length > 60 ? `${eventsStr.slice(0, 57)}...` : eventsStr; - return [w.endpoint, events, w.status, w.id]; + return [w.endpoint, events, webhookStatusIndicator(w.status), w.id]; }); return renderTable( ['Endpoint', 'Events', 'Status', 'ID'], rows, '(no webhooks)', + { + statusColumn: { + index: 2, + tones: webhooks.map((w) => webhookStatusTone(w.status)), + }, + }, ); } diff --git a/src/lib/brand.ts b/src/lib/brand.ts new file mode 100644 index 00000000..f6e63556 --- /dev/null +++ b/src/lib/brand.ts @@ -0,0 +1,19 @@ +import pc from 'picocolors'; +import { isUnicodeSupported } from './tty'; + +export const TAGLINE = 'Email for developers'; + +// Status symbols generated via String.fromCodePoint() — never literal Unicode in +// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. +export const TICK = isUnicodeSupported ? String.fromCodePoint(0x2714) : 'v'; // ✔ +export const WARN = isUnicodeSupported ? String.fromCodePoint(0x26a0) : '!'; // ⚠ +export const CROSS = isUnicodeSupported ? String.fromCodePoint(0x2717) : 'x'; // ✗ + +export function wordmark(bold = true): string { + return bold ? pc.bold('Resend') : 'Resend'; +} + +export function divider(width = 40): string { + const char = isUnicodeSupported ? String.fromCodePoint(0x2500) : '-'; + return pc.dim(char.repeat(width)); +} diff --git a/src/lib/help-text.ts b/src/lib/help-text.ts index 0e76a529..b815637f 100644 --- a/src/lib/help-text.ts +++ b/src/lib/help-text.ts @@ -1,3 +1,5 @@ +import pc from 'picocolors'; + export interface HelpTextOptions { context?: string; // All text before Global options (includes Non-interactive line if needed) output?: string; // Content after "Output (--json or piped):" header (raw string, may be multi-line) @@ -6,13 +8,13 @@ export interface HelpTextOptions { setup?: boolean; // true = --json only (no --api-key); default false = full API variant } -const GLOBAL_OPTS_FULL = `Global options: +const GLOBAL_OPTS_FULL = `${pc.gray('Global options:')} --api-key API key (or set RESEND_API_KEY env var) -p, --profile Profile to use (overrides RESEND_PROFILE) --json Force JSON output (also auto-enabled when stdout is piped) -q, --quiet Suppress spinners and status output (implies --json)`; -const GLOBAL_OPTS_SETUP = `Global options: +const GLOBAL_OPTS_SETUP = `${pc.gray('Global options:')} -p, --profile Profile to use --json Force JSON output -q, --quiet Suppress spinners and status output (implies --json)`; @@ -26,13 +28,15 @@ export function buildHelpText(opts: HelpTextOptions): string { } parts.push(opts.setup ? GLOBAL_OPTS_SETUP : GLOBAL_OPTS_FULL); if (opts.output != null) { - parts.push(`Output (--json or piped):\n${opts.output}`); + parts.push(`${pc.gray('Output (--json or piped):')}\n${opts.output}`); } if (opts.errorCodes != null) { parts.push( - `Errors (exit code 1, JSON on stderr when using --json or non-TTY):\n${ERROR_ENVELOPE}\n Codes: ${opts.errorCodes.join(' | ')}`, + `${pc.gray('Errors (exit code 1, JSON on stderr when using --json or non-TTY):')}\n${ERROR_ENVELOPE}\n Codes: ${opts.errorCodes.join(' | ')}`, ); } - parts.push(`Examples:\n${opts.examples.map((e) => ` $ ${e}`).join('\n')}`); + parts.push( + `${pc.gray('Examples:')}\n${opts.examples.map((e) => ` ${pc.blue(`$ ${e}`)}`).join('\n')}`, + ); return `\n${parts.join('\n\n')}`; } diff --git a/src/lib/logo.ts b/src/lib/logo.ts index ee96ee94..f46232b6 100644 --- a/src/lib/logo.ts +++ b/src/lib/logo.ts @@ -1,16 +1,75 @@ +import pc from 'picocolors'; +import { TAGLINE, wordmark } from './brand'; + const LOGO_LINES = [ - ' ██████╗ ███████╗███████╗███████╗███╗ ██╗██████╗ ', - ' ██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔══██╗', - ' ██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║██║ ██║', - ' ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║██║ ██║', - ' ██║ ██║███████╗███████║███████╗██║ ╚████║██████╔╝', - ' ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═══╝╚═════╝ ', + '+++++++++++++++++++++ +++++', + '+++++++++++++++++++++++ +++++', + ' ++++++++++ +++++', + ' +++++++++ +++++ +++++++ +++++ ++++ +++ +++++', + ' +++++++++ +++++++++++++ ++++++++++++++ +++++++++++++ ++++++++++++++++ ++++++++++++++++', + ' +++++++++++ ++++++++++++++++ ++++++++++++++++ ++++++++++++++++ ++++++++++++++++++ ++++++++++++++++++', + ' +++++++++++++++ +++++++ +++++++ +++++++ ++++++ ++++++ +++++++ +++++++ ++++++++ ++++++++ +++++++', + ' +++++++++ +++++++++++++++++++ ++++++++++++++ +++++++++++++++++++ ++++++ ++++++ ++++++ +++++', + ' ++++++++++ +++++++++++++++++++ +++++++++++++++ +++++++++++++++++++ ++++++ +++++++++++++ +++++', + ' ++++++++++ ++++++ ++++++++++++ ++++++ ++++++ ++++++ ++++++ ++++++', + ' +++++++++++ +++++++ +++++++ +++++++ ++++++ +++++++ +++++++ ++++++ ++++++ ++++++++ ++++++++', + ' ++++++++++ ++++++++++++++++ ++++++++++++++++ +++++++++++++++ ++++++ ++++++ ++++++++++++++++++', + ' ++++++++++ +++++++++++ +++++++++++ +++++++++++ ++++++ ++++++ +++++++++ +++++', +]; + +// The icon mark — a compact variation of the full wordmark for narrower terminals. +const ICON_LINES = [ + '▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + '▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + '▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', + ' ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓', ]; -export function printBannerPlain(): void { +function maxLineWidth(lines: string[]): number { + return Math.max(...lines.map((l) => l.length)); +} + +const LOGO_WIDTH = maxLineWidth(LOGO_LINES); +const ICON_WIDTH = maxLineWidth(ICON_LINES); + +// Picks the widest logo that still fits the terminal — full wordmark art, +// the icon mark (narrower still), or plain text. +function selectLogoLines(): string[] | null { + const width = process.stdout.columns; + if (width === undefined || width >= LOGO_WIDTH) { + return LOGO_LINES; + } + if (width >= ICON_WIDTH) { + return ICON_LINES; + } + return null; +} + +export function printBanner(): void { process.stdout.write('\n'); - for (const line of LOGO_LINES) { - process.stdout.write(`${line}\n`); + const lines = selectLogoLines(); + if (lines) { + for (const line of lines) { + process.stdout.write(`${pc.bold(line)}\n`); + } + } else { + process.stdout.write(` ${wordmark()}\n`); } + process.stdout.write(` ${pc.dim(TAGLINE)}\n`); process.stdout.write('\n'); } diff --git a/src/lib/spinner.ts b/src/lib/spinner.ts index a0b45b35..72a4a1e9 100644 --- a/src/lib/spinner.ts +++ b/src/lib/spinner.ts @@ -1,16 +1,11 @@ import pc from 'picocolors'; +import { CROSS, TICK, WARN } from './brand'; import type { GlobalOpts } from './client'; import { errorMessage, outputError } from './output'; import { isInteractive, isUnicodeSupported } from './tty'; import { type SdkResponse, withRetry } from './with-retry'; import { REQUEST_TIMEOUT_MS, withTimeout } from './with-timeout'; -// Status symbols generated via String.fromCodePoint() — never literal Unicode in -// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled. -const TICK = isUnicodeSupported ? String.fromCodePoint(0x2714) : 'v'; // ✔ -const WARN = isUnicodeSupported ? String.fromCodePoint(0x26a0) : '!'; // ⚠ -const CROSS = isUnicodeSupported ? String.fromCodePoint(0x2717) : 'x'; // ✗ - // Braille spinner: cycle through U+2800-block dot patterns. const SPINNER_FRAMES = [ '\u2839', diff --git a/src/lib/table.ts b/src/lib/table.ts index 2f079cb9..27f83cbf 100644 --- a/src/lib/table.ts +++ b/src/lib/table.ts @@ -1,6 +1,37 @@ +import pc from 'picocolors'; import { safeTerminalText } from './safe-terminal-text'; import { isUnicodeSupported } from './tty'; +export type StatusTone = 'success' | 'pending' | 'failure' | 'neutral'; + +export interface RenderTableOptions { + // Colors the cell at `index` for each row per `tones[rowIndex]` (undefined = no color). + statusColumn?: { + index: number; + tones: (StatusTone | undefined)[]; + }; +} + +const TONE_COLOR: Record string> = { + success: pc.green, + pending: pc.yellow, + failure: pc.red, + neutral: pc.dim, +}; + +function colorizeStatusCell( + cell: string, + rowIndex: number, + colIndex: number, + statusColumn: RenderTableOptions['statusColumn'], +): string { + if (!statusColumn || colIndex !== statusColumn.index) { + return cell; + } + const tone = statusColumn.tones[rowIndex]; + return tone ? TONE_COLOR[tone](cell) : cell; +} + // All box-drawing characters generated via String.fromCodePoint() — never literal // Unicode in source — to prevent UTF-8 → Latin-1 corruption in npm bundles. const BOX = isUnicodeSupported @@ -39,6 +70,7 @@ function renderCards( headers: string[], rows: string[][], termWidth: number, + statusColumn: RenderTableOptions['statusColumn'], ): string { const labelWidth = Math.max(...headers.map((h) => h.length)); const sepWidth = Math.max(20, Math.min(termWidth, 60)); @@ -46,10 +78,13 @@ function renderCards( 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]}`, + const sep = pc.dim( + `${BOX.h}${BOX.h} ${label} ${BOX.h.repeat(Math.max(0, sepWidth - label.length - 4))}`, ); + const fields = headers.map((h, i) => { + const value = colorizeStatusCell(row[i], idx, i, statusColumn); + return ` ${h.padEnd(labelWidth)} ${value}`; + }); return [sep, ...fields].join('\n'); }) .join('\n\n'); @@ -59,6 +94,7 @@ export function renderTable( headers: string[], rows: string[][], emptyMessage = '(no results)', + options: RenderTableOptions = {}, ): string { if (rows.length === 0) { return emptyMessage; @@ -73,21 +109,42 @@ export function renderTable( const totalWidth = widths.reduce((s, w) => s + w, 0) + 3 * widths.length + 1; if (totalWidth > termWidth) { - return renderCards(headers, sanitizedRows, termWidth); + return renderCards( + headers, + sanitizedRows, + termWidth, + options.statusColumn, + ); } } - const top = - BOX.tl + widths.map((w) => BOX.h.repeat(w + 2)).join(BOX.tm) + BOX.tr; - const mid = - BOX.lm + widths.map((w) => BOX.h.repeat(w + 2)).join(BOX.mm) + BOX.rm; - const bot = - BOX.bl + widths.map((w) => BOX.h.repeat(w + 2)).join(BOX.bm) + BOX.br; - const row = (cells: string[]) => + const top = pc.dim( + BOX.tl + widths.map((w) => BOX.h.repeat(w + 2)).join(BOX.tm) + BOX.tr, + ); + const mid = pc.dim( + BOX.lm + widths.map((w) => BOX.h.repeat(w + 2)).join(BOX.mm) + BOX.rm, + ); + const bot = pc.dim( + BOX.bl + widths.map((w) => BOX.h.repeat(w + 2)).join(BOX.bm) + BOX.br, + ); + const row = (cells: string[], rowIndex?: number) => BOX.v + ' ' + - cells.map((c, i) => c.padEnd(widths[i])).join(` ${BOX.v} `) + + cells + .map((c, i) => { + const padded = c.padEnd(widths[i]); + return rowIndex === undefined + ? padded + : colorizeStatusCell(padded, rowIndex, i, options.statusColumn); + }) + .join(` ${BOX.v} `) + ' ' + BOX.v; - return [top, row(headers), mid, ...sanitizedRows.map(row), bot].join('\n'); + return [ + top, + pc.bold(row(headers)), + mid, + ...sanitizedRows.map((r, idx) => row(r, idx)), + bot, + ].join('\n'); } diff --git a/tests/lib/logo.test.ts b/tests/lib/logo.test.ts index 5f851048..cae7b17e 100644 --- a/tests/lib/logo.test.ts +++ b/tests/lib/logo.test.ts @@ -1,14 +1,14 @@ import { afterEach, describe, expect, it, type MockInstance, vi } from 'vitest'; -import { printBannerPlain } from '../../src/lib/logo'; +import { printBanner } from '../../src/lib/logo'; -describe('printBannerPlain', () => { +describe('printBanner', () => { let writeSpy: MockInstance; afterEach(() => { writeSpy?.mockRestore(); }); - it('writes ASCII logo to stdout', () => { + it('writes ASCII logo and tagline to stdout', () => { const chunks: string[] = []; writeSpy = vi .spyOn(process.stdout, 'write') @@ -21,10 +21,10 @@ describe('printBannerPlain', () => { return true; }); - printBannerPlain(); + printBanner(); const out = chunks.join(''); - expect(out).toContain('██████╗'); - expect(out).toContain('█'); + expect(out).toContain('+++++'); + expect(out).toContain('Email for developers'); }); });