Skip to content
18 changes: 1 addition & 17 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<boolean> {
return new Promise((resolve) => {
// `start` on Windows is a shell built-in, not an executable.
// Must invoke via `cmd.exe /c start <url>`.
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 <key>', 'API key to store (required in non-interactive mode)')
Expand Down
4 changes: 4 additions & 0 deletions src/commands/broadcasts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 })
Expand Down
27 changes: 27 additions & 0 deletions src/commands/broadcasts/open.ts
Original file line number Diff line number Diff line change
@@ -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);
});
19 changes: 5 additions & 14 deletions src/commands/open.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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);
});
4 changes: 4 additions & 0 deletions src/commands/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions src/commands/templates/open.ts
Original file line number Diff line number Diff line change
@@ -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);
});
66 changes: 66 additions & 0 deletions src/lib/browser.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<void> {
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;
41 changes: 41 additions & 0 deletions tests/commands/broadcasts/open.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
});
});
23 changes: 23 additions & 0 deletions tests/commands/open.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
});
});
41 changes: 41 additions & 0 deletions tests/commands/templates/open.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
});
});