diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 6b1dc8e..8c5a97c 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,7 +1,7 @@ -import { execFile } from 'node:child_process'; import * as p from '@clack/prompts'; import { Command } from '@commander-js/extra-typings'; import { Resend } from 'resend'; +import { openInBrowser } from '../../lib/browser'; import type { GlobalOpts } from '../../lib/client'; import { listProfiles, @@ -18,22 +18,6 @@ import { isInteractive } from '../../lib/tty'; const RESEND_API_KEYS_URL = 'https://resend.com/api-keys?new=true'; -function openInBrowser(url: string): Promise { - return new Promise((resolve) => { - // `start` on Windows is a shell built-in, not an executable. - // Must invoke via `cmd.exe /c start `. - const cmd = - process.platform === 'win32' - ? 'cmd.exe' - : process.platform === 'darwin' - ? 'open' - : 'xdg-open'; - const args = - process.platform === 'win32' ? ['/c', 'start', '""', url] : [url]; - execFile(cmd, args, { timeout: 5000 }, (err) => resolve(!err)); - }); -} - export const loginCommand = new Command('login') .description('Save a Resend API key') .option('--key ', 'API key to store (required in non-interactive mode)') diff --git a/src/commands/broadcasts/index.ts b/src/commands/broadcasts/index.ts index f9b0fb1..d7137f1 100644 --- a/src/commands/broadcasts/index.ts +++ b/src/commands/broadcasts/index.ts @@ -4,6 +4,7 @@ import { createBroadcastCommand } from './create'; import { deleteBroadcastCommand } from './delete'; import { getBroadcastCommand } from './get'; import { listBroadcastsCommand } from './list'; +import { openBroadcastCommand } from './open'; import { sendBroadcastCommand } from './send'; import { updateBroadcastCommand } from './update'; @@ -32,10 +33,13 @@ Scheduling: 'resend broadcasts get d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', 'resend broadcasts update d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --subject "Updated Subject"', 'resend broadcasts delete d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --yes', + 'resend broadcasts open', + 'resend broadcasts open d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', ], }), ) .addCommand(createBroadcastCommand) + .addCommand(openBroadcastCommand) .addCommand(sendBroadcastCommand) .addCommand(getBroadcastCommand) .addCommand(listBroadcastsCommand, { isDefault: true }) diff --git a/src/commands/broadcasts/open.ts b/src/commands/broadcasts/open.ts new file mode 100644 index 0000000..6962516 --- /dev/null +++ b/src/commands/broadcasts/open.ts @@ -0,0 +1,27 @@ +import { Command } from '@commander-js/extra-typings'; +import { openInBrowserOrLog, RESEND_URLS } from '../../lib/browser'; +import type { GlobalOpts } from '../../lib/client'; +import { buildHelpText } from '../../lib/help-text'; + +export const openBroadcastCommand = new Command('open') + .description( + 'Open a broadcast or the broadcasts list in the Resend dashboard', + ) + .argument('[id]', 'Broadcast ID — omit to open the broadcasts list') + .addHelpText( + 'after', + buildHelpText({ + context: `Opens the Resend dashboard in your default browser. + With an ID: opens that broadcast's page for viewing or editing. + Without an ID: opens the broadcasts list.`, + examples: [ + 'resend broadcasts open', + 'resend broadcasts open d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', + ], + }), + ) + .action(async (id: string | undefined, _opts, cmd) => { + const url = id ? RESEND_URLS.broadcast(id) : RESEND_URLS.broadcasts; + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + await openInBrowserOrLog(url, globalOpts); + }); diff --git a/src/commands/open.ts b/src/commands/open.ts index ea7a253..f475d83 100644 --- a/src/commands/open.ts +++ b/src/commands/open.ts @@ -1,5 +1,6 @@ -import { spawn } from 'node:child_process'; import { Command } from '@commander-js/extra-typings'; +import { openInBrowserOrLog, RESEND_URLS } from '../lib/browser'; +import type { GlobalOpts } from '../lib/client'; import { buildHelpText } from '../lib/help-text'; export const openCommand = new Command('open') @@ -11,17 +12,7 @@ export const openCommand = new Command('open') examples: ['resend open'], }), ) - .action(async () => { - const url = 'https://resend.com/emails'; - const { platform } = process; - const args = - platform === 'darwin' - ? ['open', url] - : platform === 'win32' - ? ['cmd', '/c', 'start', url] - : ['xdg-open', url]; - - spawn(args[0], args.slice(1), { stdio: 'ignore', detached: true }) - .on('error', () => {}) - .unref(); + .action(async (_opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + await openInBrowserOrLog(RESEND_URLS.emails, globalOpts); }); diff --git a/src/commands/templates/index.ts b/src/commands/templates/index.ts index 188d56c..87a4976 100644 --- a/src/commands/templates/index.ts +++ b/src/commands/templates/index.ts @@ -5,6 +5,7 @@ import { deleteTemplateCommand } from './delete'; import { duplicateTemplateCommand } from './duplicate'; import { getTemplateCommand } from './get'; import { listTemplatesCommand } from './list'; +import { openTemplateCommand } from './open'; import { publishTemplateCommand } from './publish'; import { updateTemplateCommand } from './update'; @@ -36,11 +37,14 @@ Template variables: 'resend templates publish 78261eea-8f8b-4381-83c6-79fa7120f1cf', 'resend templates duplicate 78261eea-8f8b-4381-83c6-79fa7120f1cf', 'resend templates delete 78261eea-8f8b-4381-83c6-79fa7120f1cf --yes', + 'resend templates open', + 'resend templates open 78261eea-8f8b-4381-83c6-79fa7120f1cf', ], }), ) .addCommand(createTemplateCommand) .addCommand(getTemplateCommand) + .addCommand(openTemplateCommand) .addCommand(listTemplatesCommand, { isDefault: true }) .addCommand(updateTemplateCommand) .addCommand(deleteTemplateCommand) diff --git a/src/commands/templates/open.ts b/src/commands/templates/open.ts new file mode 100644 index 0000000..8893baa --- /dev/null +++ b/src/commands/templates/open.ts @@ -0,0 +1,25 @@ +import { Command } from '@commander-js/extra-typings'; +import { openInBrowserOrLog, RESEND_URLS } from '../../lib/browser'; +import type { GlobalOpts } from '../../lib/client'; +import { buildHelpText } from '../../lib/help-text'; + +export const openTemplateCommand = new Command('open') + .description('Open a template or the templates list in the Resend dashboard') + .argument('[id]', 'Template ID — omit to open the templates list') + .addHelpText( + 'after', + buildHelpText({ + context: `Opens the Resend dashboard in your default browser. + With an ID: opens that template's page for editing or viewing. + Without an ID: opens the templates list.`, + examples: [ + 'resend templates open', + 'resend templates open 78261eea-8f8b-4381-83c6-79fa7120f1cf', + ], + }), + ) + .action(async (id: string | undefined, _opts, cmd) => { + const url = id ? RESEND_URLS.template(id) : RESEND_URLS.templates; + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + await openInBrowserOrLog(url, globalOpts); + }); diff --git a/src/lib/browser.ts b/src/lib/browser.ts new file mode 100644 index 0000000..7d216be --- /dev/null +++ b/src/lib/browser.ts @@ -0,0 +1,66 @@ +import { execFile } from 'node:child_process'; +import pc from 'picocolors'; + +const RESEND_BASE = 'https://resend.com'; + +/** + * Try to open a URL in the user's default browser. Returns true if the open + * succeeded, false on error or when the terminal has no browser (e.g. SSH). + */ +export function openInBrowser(url: string): Promise { + return new Promise((resolve) => { + const cmd = + process.platform === 'win32' + ? 'cmd.exe' + : process.platform === 'darwin' + ? 'open' + : 'xdg-open'; + const safeUrl = url.replaceAll('"', ''); + const args = + process.platform === 'win32' + ? ['/c', 'start', '""', `"${safeUrl}"`] + : [url]; + execFile( + cmd, + args, + { timeout: 5000, windowsVerbatimArguments: true }, + (err) => resolve(!err), + ); + }); +} + +export type OpenInBrowserOrLogOpts = { + json?: boolean; + quiet?: boolean; +}; + +/** + * Opens the URL in the browser and logs the outcome: success with link, or + * warning with link to copy when the browser could not be opened. No output + * when opts.json or opts.quiet. + */ +export async function openInBrowserOrLog( + url: string, + opts?: OpenInBrowserOrLogOpts, +): Promise { + const opened = await openInBrowser(url); + if (opts?.json || opts?.quiet) { + return; + } + if (opened) { + console.log(pc.dim('Opened'), pc.blue(url)); + } else { + console.warn( + pc.yellow('Could not open browser. Visit this link:'), + pc.blue(url), + ); + } +} + +export const RESEND_URLS = { + emails: `${RESEND_BASE}/emails`, + templates: `${RESEND_BASE}/templates`, + template: (id: string) => `${RESEND_BASE}/templates/${id}`, + broadcasts: `${RESEND_BASE}/broadcasts`, + broadcast: (id: string) => `${RESEND_BASE}/broadcasts/${id}`, +} as const; diff --git a/tests/commands/broadcasts/open.test.ts b/tests/commands/broadcasts/open.test.ts new file mode 100644 index 0000000..5363f45 --- /dev/null +++ b/tests/commands/broadcasts/open.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as browser from '../../../src/lib/browser'; + +describe('broadcasts open command', () => { + beforeEach(() => { + vi.spyOn(browser, 'openInBrowserOrLog').mockResolvedValue(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('with no args opens broadcasts list', async () => { + const { openBroadcastCommand } = await import( + '../../../src/commands/broadcasts/open' + ); + await openBroadcastCommand.parseAsync([], { from: 'user' }); + + expect(browser.openInBrowserOrLog).toHaveBeenCalledTimes(1); + expect(browser.openInBrowserOrLog).toHaveBeenCalledWith( + browser.RESEND_URLS.broadcasts, + expect.any(Object), + ); + }); + + test('with id opens broadcast URL', async () => { + const { openBroadcastCommand } = await import( + '../../../src/commands/broadcasts/open' + ); + await openBroadcastCommand.parseAsync( + ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'], + { from: 'user' }, + ); + + expect(browser.openInBrowserOrLog).toHaveBeenCalledTimes(1); + expect(browser.openInBrowserOrLog).toHaveBeenCalledWith( + browser.RESEND_URLS.broadcast('d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'), + expect.any(Object), + ); + }); +}); diff --git a/tests/commands/open.test.ts b/tests/commands/open.test.ts new file mode 100644 index 0000000..83b517f --- /dev/null +++ b/tests/commands/open.test.ts @@ -0,0 +1,23 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as browser from '../../src/lib/browser'; + +describe('resend open command', () => { + beforeEach(() => { + vi.spyOn(browser, 'openInBrowserOrLog').mockResolvedValue(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('opens emails URL in browser', async () => { + const { openCommand } = await import('../../src/commands/open'); + await openCommand.parseAsync([], { from: 'user' }); + + expect(browser.openInBrowserOrLog).toHaveBeenCalledTimes(1); + expect(browser.openInBrowserOrLog).toHaveBeenCalledWith( + browser.RESEND_URLS.emails, + expect.any(Object), + ); + }); +}); diff --git a/tests/commands/templates/open.test.ts b/tests/commands/templates/open.test.ts new file mode 100644 index 0000000..5562fac --- /dev/null +++ b/tests/commands/templates/open.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as browser from '../../../src/lib/browser'; + +describe('templates open command', () => { + beforeEach(() => { + vi.spyOn(browser, 'openInBrowserOrLog').mockResolvedValue(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('with no args opens templates list', async () => { + const { openTemplateCommand } = await import( + '../../../src/commands/templates/open' + ); + await openTemplateCommand.parseAsync([], { from: 'user' }); + + expect(browser.openInBrowserOrLog).toHaveBeenCalledTimes(1); + expect(browser.openInBrowserOrLog).toHaveBeenCalledWith( + browser.RESEND_URLS.templates, + expect.any(Object), + ); + }); + + test('with id opens template URL', async () => { + const { openTemplateCommand } = await import( + '../../../src/commands/templates/open' + ); + await openTemplateCommand.parseAsync( + ['78261eea-8f8b-4381-83c6-79fa7120f1cf'], + { from: 'user' }, + ); + + expect(browser.openInBrowserOrLog).toHaveBeenCalledTimes(1); + expect(browser.openInBrowserOrLog).toHaveBeenCalledWith( + browser.RESEND_URLS.template('78261eea-8f8b-4381-83c6-79fa7120f1cf'), + expect.any(Object), + ); + }); +});