diff --git a/README.md b/README.md index f84bd807..33aac73a 100644 --- a/README.md +++ b/README.md @@ -438,7 +438,7 @@ Use `all` with `--events` to subscribe to every event. - `update` - `delete` - `listen` -- `events` (`list`, `get`, `attempts`) +- `events` (`list`, `get`, `attempts`, `replay`) #### Aliases @@ -571,6 +571,7 @@ Inspects what Resend already delivered to a webhook. Use it to debug an endpoint 1. `resend webhooks events list ` — find the event 2. `resend webhooks events get ` — see the payload we sent 3. `resend webhooks events attempts ` — see what your endpoint returned +4. `resend webhooks events replay ` — queue another delivery Running `resend webhooks events ` with no subcommand runs `list`. @@ -581,13 +582,14 @@ Both list subcommands paginate forward only — there is no `--before`. | `--limit ` | Max results to return (`1`–`100`, default `10`) | | `--after ` | Return results after this ID (next page) | -`events list` reports each event's `type`, `status` (`pending`, `attempting`, `success`, `failed`), and `created_at`. `events get` adds `next_attempt_at` and the payload that was sent. `events attempts` reports the `http_status_code` and response body your endpoint returned for each try. +`events list` reports each event's `type`, `status` (`pending`, `attempting`, `success`, `failed`), and `created_at`. `events get` adds `next_attempt_at` and the payload that was sent. `events attempts` reports the `http_status_code` and response body your endpoint returned for each try. `events replay` queues one more delivery attempt and returns `{"object":"webhook_event","id":""}`; it does not schedule automatic retries. ```bash resend webhooks events list wh_abc123 resend webhooks events list wh_abc123 --limit 50 --json resend webhooks events get wh_abc123 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2 resend webhooks events attempts wh_abc123 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2 +resend webhooks events replay wh_abc123 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2 ``` --- diff --git a/package.json b/package.json index d3972599..f595d130 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "resend-cli", - "version": "2.18.0", + "version": "2.19.0", "description": "The official CLI for Resend", "license": "MIT", "repository": { @@ -46,7 +46,7 @@ "esbuild": "0.28.1", "esbuild-wasm": "0.28.0", "picocolors": "1.1.1", - "resend": "6.25.0" + "resend": "6.26.0" }, "pkg": { "scripts": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed9de7e3..8c2a41cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: 1.1.1 version: 1.1.1 resend: - specifier: 6.25.0 - version: 6.25.0 + specifier: 6.26.0 + version: 6.26.0 devDependencies: '@biomejs/biome': specifier: 2.4.11 @@ -1226,8 +1226,8 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - resend@6.25.0: - resolution: {integrity: sha512-iptUEycs+6Hu+W8mExK708LrDiMYOuGgnCP+wTD5L4Zrqqd2B86h1oyAmNe0khZWQw0ccI7jz8OgAaplEsyaIA==} + resend@6.26.0: + resolution: {integrity: sha512-vkrULozV5nGF8o7tyun/t0+qFgtPpn+cyRhVqeorhOsPUNDiSc/9p/pn8AprdHqJpYaTvftpnQgWurW++FkCdw==} engines: {node: '>=20'} peerDependencies: '@react-email/render': '*' @@ -2431,7 +2431,7 @@ snapshots: require-directory@2.1.1: {} - resend@6.25.0: + resend@6.26.0: dependencies: postal-mime: 2.7.5 standardwebhooks: 1.0.0 diff --git a/skills/resend-cli/SKILL.md b/skills/resend-cli/SKILL.md index 0bf661b7..fceb58fa 100644 --- a/skills/resend-cli/SKILL.md +++ b/skills/resend-cli/SKILL.md @@ -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.10.0" + version: "2.11.0" homepage: https://resend.com/docs/cli-agents source: https://github.com/resend/resend-cli openclaw: @@ -145,7 +145,7 @@ Auth resolves: `RESEND_API_KEY` env > config file (`resend login --key`). Use `- | `segments` | create, get, list, update, delete, contacts | | `templates` | create, publish, duplicate, delete, list | | `topics` | create, update, delete, list | -| `webhooks` | create, update, listen, delete, list, events (list, get, attempts) | +| `webhooks` | create, update, listen, delete, list, events (list, get, attempts, replay) | | `auth` | login, logout, switch, rename, remove | | `whoami` / `doctor` / `update` / `open` / `commands` | Utility commands | diff --git a/skills/resend-cli/references/webhooks.md b/skills/resend-cli/references/webhooks.md index c8f92e75..032f156c 100644 --- a/skills/resend-cli/references/webhooks.md +++ b/skills/resend-cli/references/webhooks.md @@ -102,6 +102,21 @@ Lists the delivery attempts for one event, most recent first. Each attempt recor 1. `resend webhooks events list ` — find the event 2. `resend webhooks events get ` — see what we sent 3. `resend webhooks events attempts ` — see what your endpoint returned +4. `resend webhooks events replay ` — queue another delivery + +--- + +## webhooks events replay + +Queues one more delivery of the event. Does not schedule automatic retries. + +**Arguments:** `[webhookId]` `[eventId]` + +The webhook must be enabled — re-enable it first with +`resend webhooks update --status enabled`. + +Returns `{"object":"webhook_event","id":""}` — no `type`, `status`, +or `payload` (use `events get` for those). --- diff --git a/src/commands/webhooks/events/index.ts b/src/commands/webhooks/events/index.ts index f6b43ea3..4bbd1035 100644 --- a/src/commands/webhooks/events/index.ts +++ b/src/commands/webhooks/events/index.ts @@ -3,9 +3,10 @@ import { buildHelpText } from '../../../lib/help-text'; import { listWebhookEventAttemptsCommand } from './attempts'; import { getWebhookEventCommand } from './get'; import { listWebhookEventsCommand } from './list'; +import { replayWebhookEventCommand } from './replay'; export const webhookEventsCommand = new Command('events') - .description('Inspect the events Resend delivered to a webhook') + .description('Inspect or replay the events Resend delivered to a webhook') .addHelpText( 'after', buildHelpText({ @@ -13,6 +14,7 @@ export const webhookEventsCommand = new Command('events') 1. resend webhooks events list (find the event) 2. resend webhooks events get (see the payload we sent) 3. resend webhooks events attempts (see what your endpoint returned) + 4. resend webhooks events replay (queue another delivery) Events are listed most recent first. Both list commands paginate forward only, with --after.`, @@ -20,9 +22,11 @@ with --after.`, 'resend webhooks events list 4dd369bc-aa82-4ff3-97de-514ae3000ee0', 'resend webhooks events get 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2', 'resend webhooks events attempts 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2', + 'resend webhooks events replay 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2', ], }), ) .addCommand(listWebhookEventsCommand, { isDefault: true }) .addCommand(getWebhookEventCommand) - .addCommand(listWebhookEventAttemptsCommand); + .addCommand(listWebhookEventAttemptsCommand) + .addCommand(replayWebhookEventCommand); diff --git a/src/commands/webhooks/events/replay.ts b/src/commands/webhooks/events/replay.ts new file mode 100644 index 00000000..6046abf9 --- /dev/null +++ b/src/commands/webhooks/events/replay.ts @@ -0,0 +1,49 @@ +import { Command } from '@commander-js/extra-typings'; +import { runWrite } 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 { webhookEventPickerConfig } from './utils'; + +export const replayWebhookEventCommand = new Command('replay') + .description('Queue another delivery attempt for a webhook event') + .argument('[webhookId]', 'Webhook ID') + .argument('[eventId]', 'Webhook event ID') + .addHelpText( + 'after', + buildHelpText({ + context: `Queues one more delivery of the event. Does not schedule automatic retries. +The webhook must be enabled — re-enable it first with: resend webhooks update --status enabled`, + output: ` {"object":"webhook_event","id":"msg_..."}`, + errorCodes: ['auth_error', 'replay_error'], + examples: [ + 'resend webhooks events replay 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2', + 'resend webhooks events replay 4dd369bc-aa82-4ff3-97de-514ae3000ee0 msg_1srOrx2ZWZBpBUvZwXKQmoEYga2 --json', + ], + }), + ) + .action(async (webhookIdArg, eventIdArg, _opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const webhookId = await pickId( + webhookIdArg, + webhookPickerConfig, + globalOpts, + ); + const eventId = await pickId( + eventIdArg, + webhookEventPickerConfig(webhookId), + globalOpts, + ); + + await runWrite( + { + loading: 'Replaying webhook event...', + sdkCall: (resend) => + resend.webhooks.events.replay({ webhookId, eventId }), + errorCode: 'replay_error', + successMsg: `Webhook event replay queued: ${eventId}`, + }, + globalOpts, + ); + }); diff --git a/tests/commands/webhooks/events/replay.test.ts b/tests/commands/webhooks/events/replay.test.ts new file mode 100644 index 00000000..e9a2eea7 --- /dev/null +++ b/tests/commands/webhooks/events/replay.test.ts @@ -0,0 +1,107 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest'; +import { replayWebhookEventCommand } from '../../../../src/commands/webhooks/events/replay'; +import { + captureTestEnv, + expectExit1, + mockExitThrow, + mockSdkError, + setNonInteractive, + setupOutputSpies, +} from '../../../helpers'; + +const WEBHOOK_ID = '4dd369bc-aa82-4ff3-97de-514ae3000ee0'; +const EVENT_ID = 'msg_1srOrx2ZWZBpBUvZwXKQmoEYga2'; + +const mockReplay = vi.fn(async () => ({ + data: { + object: 'webhook_event' as const, + id: EVENT_ID, + }, + error: null, +})); + +vi.mock('resend', () => ({ + Resend: class MockResend { + constructor(public key: string) {} + webhooks = { events: { replay: mockReplay } }; + }, +})); + +describe('webhooks events replay command', () => { + const restoreEnv = captureTestEnv(); + let spies: ReturnType | undefined; + let errorSpy: MockInstance | undefined; + let stderrSpy: MockInstance | undefined; + let exitSpy: MockInstance | undefined; + + beforeEach(() => { + process.env.RESEND_API_KEY = 're_test_key'; + mockReplay.mockClear(); + }); + + afterEach(() => { + restoreEnv(); + errorSpy?.mockRestore(); + stderrSpy?.mockRestore(); + exitSpy?.mockRestore(); + spies = undefined; + errorSpy = undefined; + stderrSpy = undefined; + exitSpy = undefined; + }); + + it('maps the two positional args to webhookId and eventId in that order', async () => { + spies = setupOutputSpies(); + + await replayWebhookEventCommand.parseAsync([WEBHOOK_ID, EVENT_ID], { + from: 'user', + }); + + expect(mockReplay).toHaveBeenCalledTimes(1); + const opts = mockReplay.mock.calls[0][0] as Record; + expect(opts.webhookId).toBe(WEBHOOK_ID); + expect(opts.eventId).toBe(EVENT_ID); + }); + + it('outputs the replayed event as JSON when non-interactive', async () => { + spies = setupOutputSpies(); + + await replayWebhookEventCommand.parseAsync([WEBHOOK_ID, EVENT_ID], { + from: 'user', + }); + + const output = spies.logSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + expect(parsed.object).toBe('webhook_event'); + expect(parsed.id).toBe(EVENT_ID); + }); + + it('errors with replay_error when the SDK returns an error', async () => { + setNonInteractive(); + mockReplay.mockResolvedValueOnce( + mockSdkError('Webhook is disabled', 'validation_error'), + ); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + + await expectExit1(() => + replayWebhookEventCommand.parseAsync([WEBHOOK_ID, EVENT_ID], { + from: 'user', + }), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('replay_error'); + }); +});