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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "resend-cli",
"version": "2.20.1",
"version": "2.21.0",
"description": "The official CLI for Resend",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -46,7 +46,7 @@
"esbuild": "0.28.1",
"esbuild-wasm": "0.28.0",
"picocolors": "1.1.1",
"resend": "6.27.0"
"resend": "6.28.0"
},
"pkg": {
"scripts": [
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions skills/resend-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ metadata:
author: resend
# Skill version is independent from the CLI/package.json version —
# bump it on skill content changes, not CLI releases.
version: "2.12.1"
version: "2.13.0"
homepage: https://resend.com/docs/cli-agents
source: https://github.com/resend/resend-cli
openclaw:
Expand Down Expand Up @@ -139,7 +139,7 @@ Auth resolves: `RESEND_API_KEY` env > config file (`resend login --key`). Use `-
| `api-keys` | create, list, update, delete |
| `automations` | create, get, list, update, delete, duplicate, stop, open, runs |
| `events` | create, get, list, update, delete, send, open |
| `broadcasts` | create, send, get, update, delete, list, cancel, open, clicked-links, recipients |
| `broadcasts` | create, send, get, update, delete, list, cancel, duplicate, open, clicked-links, recipients |
| `contacts` | create, update, delete, segments, topics, imports |
| `contact-properties` | create, update, delete, list |
| `segments` | create, get, list, update, delete, contacts |
Expand Down
8 changes: 8 additions & 0 deletions skills/resend-cli/references/broadcasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ Cancelling a queued broadcast stops it mid-send — emails already sent are not

---

## broadcasts duplicate

**Argument:** `<id>` — Broadcast ID

The copy is a draft named `<name> (copy)`, truncated to 70 characters, with the same segment, topic, from, subject, reply-to, preview text, and content. Any broadcast can be duplicated, including sent ones.

---

## broadcasts delete

**Argument:** `<id>` — Broadcast ID
Expand Down
38 changes: 38 additions & 0 deletions src/commands/broadcasts/duplicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Command } from '@commander-js/extra-typings';
import { runCreate } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import { pickId } from '../../lib/prompts';
import { broadcastPickerConfig } from './utils';

export const duplicateBroadcastCommand = new Command('duplicate')
.description('Duplicate a broadcast')
.argument('[id]', 'Broadcast ID')
.addHelpText(
'after',
buildHelpText({
context: `Creates a copy of an existing broadcast and returns the new broadcast ID.
The copy is a draft named after the original with " (copy)" appended, truncated to 70 characters.
Segment, topic, from, subject, reply-to, preview text, and content are copied. Any broadcast can be duplicated, including sent ones.`,
output: ` {"object":"broadcast","id":"<new-broadcast-id>"}`,
errorCodes: ['auth_error', 'create_error'],
examples: [
'resend broadcasts duplicate d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
'resend broadcasts duplicate d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --json',
],
}),
)
.action(async (idArg, _opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
const id = await pickId(idArg, broadcastPickerConfig, globalOpts);
await runCreate(
{
loading: 'Duplicating broadcast...',
sdkCall: (resend) => resend.broadcasts.duplicate(id),
onInteractive: (d) => {
console.log(`Broadcast duplicated: ${d.id}`);
},
},
globalOpts,
);
});
3 changes: 3 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 { cancelBroadcastCommand } from './cancel';
import { clickedLinksBroadcastCommand } from './clicked-links';
import { createBroadcastCommand } from './create';
import { deleteBroadcastCommand } from './delete';
import { duplicateBroadcastCommand } from './duplicate';
import { getBroadcastCommand } from './get';
import { listBroadcastsCommand } from './list';
import { openBroadcastCommand } from './open';
Expand Down Expand Up @@ -40,6 +41,7 @@ Scheduling:
'resend broadcasts recipients d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --type opened',
'resend broadcasts update d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --subject "Updated Subject"',
'resend broadcasts cancel d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
'resend broadcasts duplicate d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
'resend broadcasts delete d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --yes',
'resend broadcasts open',
'resend broadcasts open d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
Expand All @@ -55,5 +57,6 @@ Scheduling:
.addCommand(recipientsBroadcastCommand)
.addCommand(updateBroadcastCommand)
.addCommand(cancelBroadcastCommand)
.addCommand(duplicateBroadcastCommand)
.addCommand(deleteBroadcastCommand)
.addCommand(clickedLinksBroadcastCommand);
113 changes: 113 additions & 0 deletions tests/commands/broadcasts/duplicate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from 'vitest';
import { duplicateBroadcastCommand } from '../../../src/commands/broadcasts/duplicate';
import {
captureTestEnv,
expectExit1,
mockExitThrow,
mockSdkError,
setNonInteractive,
setupOutputSpies,
} from '../../helpers';

const mockDuplicate = vi.fn(async () => ({
data: { object: 'broadcast', id: 'f0e1d2c3-b4a5-6978-8a9b-0c1d2e3f4a5b' },
error: null,
}));

vi.mock('resend', () => ({
Resend: class MockResend {
constructor(public key: string) {}
broadcasts = { duplicate: mockDuplicate };
},
}));

describe('broadcasts duplicate command', () => {
const restoreEnv = captureTestEnv();
let spies: ReturnType<typeof setupOutputSpies> | undefined;
let errorSpy: MockInstance | undefined;
let stderrSpy: MockInstance | undefined;
let exitSpy: MockInstance | undefined;

beforeEach(() => {
process.env.RESEND_API_KEY = 're_test_key';
mockDuplicate.mockClear();
});

afterEach(() => {
restoreEnv();
errorSpy?.mockRestore();
stderrSpy?.mockRestore();
exitSpy?.mockRestore();
spies = undefined;
errorSpy = undefined;
stderrSpy = undefined;
exitSpy = undefined;
});

it('duplicates the given broadcast and prints the new id as JSON', async () => {
spies = setupOutputSpies();

await duplicateBroadcastCommand.parseAsync(
['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
{ from: 'user' },
);

expect(mockDuplicate).toHaveBeenCalledTimes(1);
expect(mockDuplicate.mock.calls[0][0]).toBe(
'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
);

const output = spies.logSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.id).toBe('f0e1d2c3-b4a5-6978-8a9b-0c1d2e3f4a5b');
expect(parsed.object).toBe('broadcast');
});

it('errors with auth_error when no API key', async () => {
setNonInteractive();
delete process.env.RESEND_API_KEY;
process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
exitSpy = mockExitThrow();

await expectExit1(() =>
duplicateBroadcastCommand.parseAsync(
['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
{ from: 'user' },
),
);

const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
expect(output).toContain('auth_error');
});

it('errors with create_error when SDK returns an error', async () => {
setNonInteractive();
mockDuplicate.mockResolvedValueOnce(
mockSdkError('Broadcast not found', 'not_found'),
);
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
exitSpy = mockExitThrow();

await expectExit1(() =>
duplicateBroadcastCommand.parseAsync(
['00000000-0000-0000-0000-00000000bad0'],
{ from: 'user' },
),
);

const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
expect(output).toContain('create_error');
});
});