@hogsend/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ import { parseArgs } from "node:util";
2
+ import { isHttpError } from "../lib/http.js";
3
+ import { color } from "../lib/output.js";
4
+ import type { Command, CommandContext } from "./types.js";
5
+
6
+ /**
7
+ * The 12-event outbound catalog, VENDORED from the engine's
8
+ * `WEBHOOK_EVENT_TYPES` (lib/webhook-signing.ts). The CLI cannot import the
9
+ * engine, so the tuple is re-declared here; a drift test asserts equality. The
10
+ * `webhook.test` sentinel is NOT a member (out-of-band).
11
+ */
12
+ const WEBHOOK_EVENT_TYPES = [
13
+ "contact.created",
14
+ "contact.updated",
15
+ "contact.deleted",
16
+ "contact.unsubscribed",
17
+ "email.sent",
18
+ "email.delivered",
19
+ "email.opened",
20
+ "email.clicked",
21
+ "email.bounced",
22
+ "journey.completed",
23
+ "bucket.entered",
24
+ "bucket.left",
25
+ ] as const;
26
+
27
+ type OutboundEventType = (typeof WEBHOOK_EVENT_TYPES)[number];
28
+
29
+ const usage = `hogsend webhooks <subcommand> [options]
30
+
31
+ Manage outbound webhook endpoints — the Svix-style signed event stream Hogsend
32
+ emits to your URLs. Wraps the admin routes (/v1/admin/webhooks), so this command
33
+ REQUIRES an admin key (--admin-key / HOGSEND_ADMIN_KEY), not the data key.
34
+
35
+ Subcommands:
36
+ list List endpoints.
37
+ get <id> Show one endpoint.
38
+ create Register an endpoint (prints the secret ONCE).
39
+ update <id> Patch an endpoint.
40
+ delete <id> Hard-delete an endpoint (drops its deliveries).
41
+ rotate-secret <id> Issue a new signing secret (prints it ONCE).
42
+ test <id> Enqueue an out-of-band webhook.test delivery.
43
+
44
+ list options:
45
+ --include-disabled Include disabled endpoints.
46
+ --limit <n> Page size.
47
+ --offset <n> Page offset.
48
+
49
+ create options (--url required, plus at least one event):
50
+ --url <url> Destination URL (required).
51
+ --event <type> Subscribe to an event; repeatable.
52
+ --all-events Subscribe to all 12 event types.
53
+ --description <text> Human label.
54
+ --disabled Create the endpoint disabled.
55
+
56
+ update options (only the provided fields change):
57
+ --url <url> New destination URL.
58
+ --event <type> Replace the subscribed events (repeatable).
59
+ --all-events Subscribe to all 12 event types.
60
+ --description <text> New description.
61
+ --disabled / --enabled Disable or enable the endpoint.
62
+
63
+ Event types:
64
+ ${WEBHOOK_EVENT_TYPES.join(", ")}
65
+
66
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
67
+ -h/--help.
68
+
69
+ Examples:
70
+ hogsend webhooks create --url https://x.com/hook --event contact.created --event email.sent
71
+ hogsend webhooks create --url https://x.com/hook --all-events --json
72
+ hogsend webhooks list --include-disabled
73
+ hogsend webhooks rotate-secret we_123
74
+ hogsend webhooks test we_123`;
75
+
76
+ const badge = `${color.bgMagenta(color.black(" hogsend "))} webhooks`;
77
+
78
+ interface WebhookEndpoint {
79
+ id: string;
80
+ url: string;
81
+ description: string | null;
82
+ eventTypes: OutboundEventType[];
83
+ secretPrefix: string;
84
+ status: "enabled" | "disabled";
85
+ organizationId: string | null;
86
+ lastDeliveryAt: string | null;
87
+ createdAt: string;
88
+ updatedAt: string;
89
+ }
90
+
91
+ type CreatedWebhookEndpoint = WebhookEndpoint & { secret: string };
92
+
93
+ interface ListResponse {
94
+ endpoints: WebhookEndpoint[];
95
+ total: number;
96
+ limit: number;
97
+ offset: number;
98
+ }
99
+
100
+ interface RotateResponse {
101
+ id: string;
102
+ secret: string;
103
+ secretPrefix: string;
104
+ }
105
+
106
+ /** Run an admin HTTP call, mapping HttpError to a clean ctx.out.fail message. */
107
+ async function fetchOrFail<T>(
108
+ ctx: CommandContext,
109
+ label: string,
110
+ fn: () => Promise<T>,
111
+ ): Promise<T> {
112
+ try {
113
+ return await ctx.out.step(label, fn);
114
+ } catch (err) {
115
+ if (isHttpError(err)) {
116
+ if (err.status === 404) {
117
+ ctx.out.fail(err.message || "webhook endpoint not found");
118
+ }
119
+ ctx.out.fail(err.message);
120
+ }
121
+ throw err;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Resolve the subscribed event set from `--all-events` and/or repeatable
127
+ * `--event <type>` flags, validating each against the vendored catalog. Returns
128
+ * undefined when neither was passed (so `update` can leave events unchanged).
129
+ */
130
+ function resolveEvents(
131
+ ctx: CommandContext,
132
+ allEvents: boolean | undefined,
133
+ events: string[] | undefined,
134
+ ): OutboundEventType[] | undefined {
135
+ if (allEvents) {
136
+ return [...WEBHOOK_EVENT_TYPES];
137
+ }
138
+ if (!events || events.length === 0) {
139
+ return undefined;
140
+ }
141
+ const valid = new Set<string>(WEBHOOK_EVENT_TYPES);
142
+ const out: OutboundEventType[] = [];
143
+ for (const ev of events) {
144
+ if (!valid.has(ev)) {
145
+ ctx.out.fail(
146
+ `unknown event type "${ev}" — expected one of: ${WEBHOOK_EVENT_TYPES.join(", ")}`,
147
+ );
148
+ }
149
+ if (!out.includes(ev as OutboundEventType)) {
150
+ out.push(ev as OutboundEventType);
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /** Print a created/rotated secret once, with a loud yellow warning. */
157
+ function printSecretOnce(ctx: CommandContext, secret: string): void {
158
+ ctx.out.note(
159
+ `${color.yellow("Store this signing secret now — it is shown only once and cannot be recovered.")}\n\n${color.bold(secret)}`,
160
+ color.yellow("Signing secret"),
161
+ );
162
+ }
163
+
164
+ async function runList(ctx: CommandContext, argv: string[]): Promise<void> {
165
+ const { values } = parseArgs({
166
+ args: argv,
167
+ allowPositionals: true,
168
+ options: {
169
+ "include-disabled": { type: "boolean", default: false },
170
+ limit: { type: "string" },
171
+ offset: { type: "string" },
172
+ help: { type: "boolean", short: "h", default: false },
173
+ },
174
+ });
175
+
176
+ if (values.help) {
177
+ ctx.out.log(usage);
178
+ return;
179
+ }
180
+
181
+ const query = {
182
+ includeDisabled: values["include-disabled"] ? "true" : undefined,
183
+ limit: values.limit,
184
+ offset: values.offset,
185
+ };
186
+
187
+ if (!ctx.json) ctx.out.intro(`${badge} list`);
188
+
189
+ const res = await fetchOrFail<ListResponse>(ctx, "Fetching webhooks", () =>
190
+ ctx.http.get<ListResponse>("/v1/admin/webhooks", query),
191
+ );
192
+
193
+ if (ctx.json) {
194
+ ctx.out.json(res);
195
+ return;
196
+ }
197
+
198
+ ctx.out.table(
199
+ res.endpoints.map((ep) => ({
200
+ id: ep.id,
201
+ url: ep.url,
202
+ status:
203
+ ep.status === "enabled"
204
+ ? color.green(ep.status)
205
+ : color.yellow(ep.status),
206
+ events: ep.eventTypes.length,
207
+ lastDeliveryAt: ep.lastDeliveryAt ?? color.dim("(never)"),
208
+ })),
209
+ ["id", "url", "status", "events", "lastDeliveryAt"],
210
+ );
211
+ ctx.out.outro(
212
+ `${res.endpoints.length} of ${res.total} endpoint(s) — offset ${res.offset}, limit ${res.limit}`,
213
+ );
214
+ }
215
+
216
+ function renderEndpoint(
217
+ ctx: CommandContext,
218
+ ep: WebhookEndpoint,
219
+ title: string,
220
+ ): void {
221
+ ctx.out.kv(
222
+ {
223
+ id: ep.id,
224
+ url: ep.url,
225
+ description: ep.description ?? color.dim("(none)"),
226
+ status:
227
+ ep.status === "enabled"
228
+ ? color.green(ep.status)
229
+ : color.yellow(ep.status),
230
+ eventTypes: ep.eventTypes,
231
+ secretPrefix: ep.secretPrefix,
232
+ lastDeliveryAt: ep.lastDeliveryAt ?? color.dim("(never)"),
233
+ createdAt: ep.createdAt,
234
+ updatedAt: ep.updatedAt,
235
+ },
236
+ title,
237
+ );
238
+ }
239
+
240
+ async function runGet(ctx: CommandContext, argv: string[]): Promise<void> {
241
+ const { values, positionals } = parseArgs({
242
+ args: argv,
243
+ allowPositionals: true,
244
+ options: { help: { type: "boolean", short: "h", default: false } },
245
+ });
246
+
247
+ if (values.help) {
248
+ ctx.out.log(usage);
249
+ return;
250
+ }
251
+
252
+ const id = positionals[0];
253
+ if (!id) {
254
+ ctx.out.fail(
255
+ "webhooks get requires an endpoint id, e.g. hogsend webhooks get we_123",
256
+ );
257
+ }
258
+
259
+ if (!ctx.json) ctx.out.intro(`${badge} get`);
260
+
261
+ const res = await fetchOrFail<WebhookEndpoint>(ctx, "Fetching webhook", () =>
262
+ ctx.http.get<WebhookEndpoint>(
263
+ `/v1/admin/webhooks/${encodeURIComponent(id)}`,
264
+ ),
265
+ );
266
+
267
+ if (ctx.json) {
268
+ ctx.out.json(res);
269
+ return;
270
+ }
271
+
272
+ renderEndpoint(ctx, res, "Endpoint");
273
+ ctx.out.outro(`${res.url} → ${res.status}`);
274
+ }
275
+
276
+ async function runCreate(ctx: CommandContext, argv: string[]): Promise<void> {
277
+ const { values } = parseArgs({
278
+ args: argv,
279
+ allowPositionals: true,
280
+ options: {
281
+ url: { type: "string" },
282
+ event: { type: "string", multiple: true },
283
+ "all-events": { type: "boolean", default: false },
284
+ description: { type: "string" },
285
+ disabled: { type: "boolean", default: false },
286
+ help: { type: "boolean", short: "h", default: false },
287
+ },
288
+ });
289
+
290
+ if (values.help) {
291
+ ctx.out.log(usage);
292
+ return;
293
+ }
294
+
295
+ const url = values.url;
296
+ if (!url) {
297
+ ctx.out.fail(
298
+ "webhooks create requires --url, e.g. hogsend webhooks create --url https://x.com/hook --all-events",
299
+ );
300
+ }
301
+
302
+ const eventTypes = resolveEvents(ctx, values["all-events"], values.event);
303
+ if (!eventTypes || eventTypes.length === 0) {
304
+ ctx.out.fail(
305
+ "webhooks create requires at least one --event <type> (or --all-events)",
306
+ );
307
+ }
308
+
309
+ const body: {
310
+ url: string;
311
+ eventTypes: OutboundEventType[];
312
+ description?: string;
313
+ disabled?: boolean;
314
+ } = { url, eventTypes };
315
+ if (values.description !== undefined) body.description = values.description;
316
+ if (values.disabled) body.disabled = true;
317
+
318
+ if (!ctx.json) ctx.out.intro(`${badge} create`);
319
+
320
+ const res = await fetchOrFail<CreatedWebhookEndpoint>(
321
+ ctx,
322
+ "Creating webhook",
323
+ () => ctx.http.post<CreatedWebhookEndpoint>("/v1/admin/webhooks", body),
324
+ );
325
+
326
+ if (ctx.json) {
327
+ ctx.out.json(res);
328
+ return;
329
+ }
330
+
331
+ const { secret, ...endpoint } = res;
332
+ renderEndpoint(ctx, endpoint, "Endpoint created");
333
+ printSecretOnce(ctx, secret);
334
+ ctx.out.outro(`${color.green("Created")} ${res.id} → ${res.url}`);
335
+ }
336
+
337
+ async function runUpdate(ctx: CommandContext, argv: string[]): Promise<void> {
338
+ const { values, positionals } = parseArgs({
339
+ args: argv,
340
+ allowPositionals: true,
341
+ options: {
342
+ url: { type: "string" },
343
+ event: { type: "string", multiple: true },
344
+ "all-events": { type: "boolean", default: false },
345
+ description: { type: "string" },
346
+ disabled: { type: "boolean", default: false },
347
+ enabled: { type: "boolean", default: false },
348
+ help: { type: "boolean", short: "h", default: false },
349
+ },
350
+ });
351
+
352
+ if (values.help) {
353
+ ctx.out.log(usage);
354
+ return;
355
+ }
356
+
357
+ const id = positionals[0];
358
+ if (!id) {
359
+ ctx.out.fail(
360
+ "webhooks update requires an endpoint id, e.g. hogsend webhooks update we_123 --enabled",
361
+ );
362
+ }
363
+
364
+ if (values.disabled && values.enabled) {
365
+ ctx.out.fail("webhooks update: pass at most one of --disabled / --enabled");
366
+ }
367
+
368
+ const eventTypes = resolveEvents(ctx, values["all-events"], values.event);
369
+
370
+ const body: {
371
+ url?: string;
372
+ eventTypes?: OutboundEventType[];
373
+ description?: string;
374
+ disabled?: boolean;
375
+ } = {};
376
+ if (values.url !== undefined) body.url = values.url;
377
+ if (eventTypes !== undefined) body.eventTypes = eventTypes;
378
+ if (values.description !== undefined) body.description = values.description;
379
+ if (values.disabled) body.disabled = true;
380
+ if (values.enabled) body.disabled = false;
381
+
382
+ if (Object.keys(body).length === 0) {
383
+ ctx.out.fail(
384
+ "webhooks update: nothing to change — pass --url / --event / --description / --disabled / --enabled",
385
+ );
386
+ }
387
+
388
+ if (!ctx.json) ctx.out.intro(`${badge} update`);
389
+
390
+ const res = await fetchOrFail<WebhookEndpoint>(ctx, "Updating webhook", () =>
391
+ ctx.http.patch<WebhookEndpoint>(
392
+ `/v1/admin/webhooks/${encodeURIComponent(id)}`,
393
+ body,
394
+ ),
395
+ );
396
+
397
+ if (ctx.json) {
398
+ ctx.out.json(res);
399
+ return;
400
+ }
401
+
402
+ renderEndpoint(ctx, res, "Endpoint updated");
403
+ ctx.out.outro(`${color.green("Updated")} ${res.id} → ${res.status}`);
404
+ }
405
+
406
+ async function runDelete(ctx: CommandContext, argv: string[]): Promise<void> {
407
+ const { values, positionals } = parseArgs({
408
+ args: argv,
409
+ allowPositionals: true,
410
+ options: { help: { type: "boolean", short: "h", default: false } },
411
+ });
412
+
413
+ if (values.help) {
414
+ ctx.out.log(usage);
415
+ return;
416
+ }
417
+
418
+ const id = positionals[0];
419
+ if (!id) {
420
+ ctx.out.fail(
421
+ "webhooks delete requires an endpoint id, e.g. hogsend webhooks delete we_123",
422
+ );
423
+ }
424
+
425
+ if (!ctx.json) ctx.out.intro(`${badge} delete`);
426
+
427
+ const res = await fetchOrFail<{ deleted: boolean }>(
428
+ ctx,
429
+ "Deleting webhook",
430
+ () =>
431
+ ctx.http.del<{ deleted: boolean }>(
432
+ `/v1/admin/webhooks/${encodeURIComponent(id)}`,
433
+ ),
434
+ );
435
+
436
+ if (ctx.json) {
437
+ ctx.out.json(res);
438
+ return;
439
+ }
440
+
441
+ ctx.out.outro(`${color.green("Deleted")} ${id}`);
442
+ }
443
+
444
+ async function runRotate(ctx: CommandContext, argv: string[]): Promise<void> {
445
+ const { values, positionals } = parseArgs({
446
+ args: argv,
447
+ allowPositionals: true,
448
+ options: { help: { type: "boolean", short: "h", default: false } },
449
+ });
450
+
451
+ if (values.help) {
452
+ ctx.out.log(usage);
453
+ return;
454
+ }
455
+
456
+ const id = positionals[0];
457
+ if (!id) {
458
+ ctx.out.fail(
459
+ "webhooks rotate-secret requires an endpoint id, e.g. hogsend webhooks rotate-secret we_123",
460
+ );
461
+ }
462
+
463
+ if (!ctx.json) ctx.out.intro(`${badge} rotate-secret`);
464
+
465
+ const res = await fetchOrFail<RotateResponse>(
466
+ ctx,
467
+ "Rotating signing secret",
468
+ () =>
469
+ ctx.http.post<RotateResponse>(
470
+ `/v1/admin/webhooks/${encodeURIComponent(id)}/rotate-secret`,
471
+ {},
472
+ ),
473
+ );
474
+
475
+ if (ctx.json) {
476
+ ctx.out.json(res);
477
+ return;
478
+ }
479
+
480
+ ctx.out.kv({ id: res.id, secretPrefix: res.secretPrefix }, "Secret rotated");
481
+ printSecretOnce(ctx, res.secret);
482
+ ctx.out.outro(
483
+ `${color.green("Rotated")} — the old secret is now invalid. Update every subscriber.`,
484
+ );
485
+ }
486
+
487
+ async function runTest(ctx: CommandContext, argv: string[]): Promise<void> {
488
+ const { values, positionals } = parseArgs({
489
+ args: argv,
490
+ allowPositionals: true,
491
+ options: { help: { type: "boolean", short: "h", default: false } },
492
+ });
493
+
494
+ if (values.help) {
495
+ ctx.out.log(usage);
496
+ return;
497
+ }
498
+
499
+ const id = positionals[0];
500
+ if (!id) {
501
+ ctx.out.fail(
502
+ "webhooks test requires an endpoint id, e.g. hogsend webhooks test we_123",
503
+ );
504
+ }
505
+
506
+ if (!ctx.json) ctx.out.intro(`${badge} test`);
507
+
508
+ const res = await fetchOrFail<{
509
+ enqueued: boolean;
510
+ eventType: "webhook.test";
511
+ }>(ctx, "Enqueuing test delivery", () =>
512
+ ctx.http.post<{ enqueued: boolean; eventType: "webhook.test" }>(
513
+ `/v1/admin/webhooks/${encodeURIComponent(id)}/test`,
514
+ {},
515
+ ),
516
+ );
517
+
518
+ if (ctx.json) {
519
+ ctx.out.json(res);
520
+ return;
521
+ }
522
+
523
+ ctx.out.outro(
524
+ `${color.green("Enqueued")} a ${color.cyan(res.eventType)} delivery to ${id}.`,
525
+ );
526
+ }
527
+
528
+ async function run(ctx: CommandContext): Promise<void> {
529
+ const sub = ctx.argv[0];
530
+
531
+ switch (sub) {
532
+ case "list":
533
+ return runList(ctx, ctx.argv.slice(1));
534
+ case "get":
535
+ return runGet(ctx, ctx.argv.slice(1));
536
+ case "create":
537
+ return runCreate(ctx, ctx.argv.slice(1));
538
+ case "update":
539
+ return runUpdate(ctx, ctx.argv.slice(1));
540
+ case "delete":
541
+ return runDelete(ctx, ctx.argv.slice(1));
542
+ case "rotate-secret":
543
+ return runRotate(ctx, ctx.argv.slice(1));
544
+ case "test":
545
+ return runTest(ctx, ctx.argv.slice(1));
546
+ case undefined:
547
+ ctx.out.fail(
548
+ "webhooks requires a subcommand: list | get | create | update | delete | rotate-secret | test (see hogsend webhooks --help)",
549
+ );
550
+ break;
551
+ default:
552
+ ctx.out.fail(
553
+ `unknown webhooks subcommand "${sub}" — expected one of list | get | create | update | delete | rotate-secret | test`,
554
+ );
555
+ }
556
+ }
557
+
558
+ export const webhooksCommand: Command = {
559
+ name: "webhooks",
560
+ summary: "Manage outbound webhook endpoints (create, rotate, test)",
561
+ usage,
562
+ run,
563
+ };
package/src/lib/http.ts CHANGED
@@ -28,6 +28,7 @@ export interface AdminClient {
28
28
  ): Promise<T>;
29
29
  patch<T = unknown>(path: string, body: unknown): Promise<T>;
30
30
  post<T = unknown>(path: string, body: unknown): Promise<T>;
31
+ del<T = unknown>(path: string, body?: unknown): Promise<T>;
31
32
  /** The resolved config this client is bound to (for messages/JSON output). */
32
33
  readonly cfg: ResolvedConfig;
33
34
  }
@@ -164,6 +165,11 @@ export function createAdminClient(cfg: ResolvedConfig): AdminClient {
164
165
  body,
165
166
  auth: true,
166
167
  }),
168
+ del: <T>(path: string, body?: unknown) =>
169
+ request<T>(cfg.baseUrl, cfg.adminKey, missing, "DELETE", path, {
170
+ body,
171
+ auth: true,
172
+ }),
167
173
  };
168
174
  }
169
175