@better-webhook/cli 0.2.0 → 0.3.1

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.
package/dist/index.js CHANGED
@@ -1,18 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { Command as Command4 } from "commander";
5
+
6
+ // src/commands/webhooks.ts
4
7
  import { Command } from "commander";
5
- import {
6
- readdirSync,
7
- statSync,
8
- mkdirSync,
9
- writeFileSync,
10
- existsSync
11
- } from "fs";
12
- import { join, resolve, basename, extname } from "path";
8
+ import { join as join2, resolve as resolve2, basename } from "path";
9
+ import { statSync as statSync2 } from "fs";
13
10
 
14
- // src/loader.ts
15
- import { readFileSync } from "fs";
11
+ // src/utils/index.ts
12
+ import { readdirSync, readFileSync, statSync } from "fs";
13
+ import { join, resolve, extname } from "path";
16
14
 
17
15
  // src/schema.ts
18
16
  import { z } from "zod";
@@ -48,24 +46,7 @@ ${issues}`);
48
46
  return parsed.data;
49
47
  }
50
48
 
51
- // src/loader.ts
52
- function loadWebhookFile(path) {
53
- let rawContent;
54
- try {
55
- rawContent = readFileSync(path, "utf8");
56
- } catch (e) {
57
- throw new Error(`Failed to read file ${path}: ${e.message}`);
58
- }
59
- let json;
60
- try {
61
- json = JSON.parse(rawContent);
62
- } catch (e) {
63
- throw new Error(`Invalid JSON in file ${path}: ${e.message}`);
64
- }
65
- return validateWebhookJSON(json, path);
66
- }
67
-
68
- // src/http.ts
49
+ // src/utils/http.ts
69
50
  import { request } from "undici";
70
51
  async function executeWebhook(def) {
71
52
  const headerMap = {};
@@ -101,14 +82,17 @@ async function executeWebhook(def) {
101
82
  };
102
83
  }
103
84
 
104
- // src/index.ts
105
- import { request as request2 } from "undici";
106
- var program = new Command();
107
- program.name("better-webhook").description("CLI for listing, downloading and executing predefined webhooks").version("0.2.0");
85
+ // src/utils/index.ts
108
86
  function findWebhooksDir(cwd) {
109
87
  return resolve(cwd, ".webhooks");
110
88
  }
111
89
  function listWebhookFiles(dir) {
90
+ return listJsonFiles(dir);
91
+ }
92
+ function findCapturesDir(cwd) {
93
+ return resolve(cwd, ".webhook-captures");
94
+ }
95
+ function listJsonFiles(dir) {
112
96
  try {
113
97
  const entries = readdirSync(dir);
114
98
  return entries.filter(
@@ -118,11 +102,212 @@ function listWebhookFiles(dir) {
118
102
  return [];
119
103
  }
120
104
  }
105
+ function loadWebhookFile(path) {
106
+ let rawContent;
107
+ try {
108
+ rawContent = readFileSync(path, "utf8");
109
+ } catch (e) {
110
+ throw new Error(`Failed to read file ${path}: ${e.message}`);
111
+ }
112
+ let json;
113
+ try {
114
+ json = JSON.parse(rawContent);
115
+ } catch (e) {
116
+ throw new Error(`Invalid JSON in file ${path}: ${e.message}`);
117
+ }
118
+ return validateWebhookJSON(json, path);
119
+ }
120
+
121
+ // src/commands/webhooks.ts
122
+ import { mkdirSync, writeFileSync, existsSync } from "fs";
123
+ import { request as request2 } from "undici";
124
+
125
+ // src/config.ts
121
126
  var TEMPLATE_REPO_BASE = "https://raw.githubusercontent.com/endalk200/better-webhook/main";
122
127
  var TEMPLATES = {
123
128
  "stripe-invoice.payment_succeeded": "templates/stripe-invoice.payment_succeeded.json"
124
129
  };
125
- program.command("download [name]").description(
130
+
131
+ // src/commands/webhooks.ts
132
+ import prompts from "prompts";
133
+ import ora from "ora";
134
+ function statExists(p) {
135
+ try {
136
+ statSync2(p);
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+ var listCommand = new Command().name("list").description("List available webhook JSON definitions in .webhooks directory").action(() => {
143
+ const cwd = process.cwd();
144
+ const dir = findWebhooksDir(cwd);
145
+ const files = listWebhookFiles(dir);
146
+ if (!files.length) {
147
+ console.log("\u{1F4ED} No webhook definitions found in .webhooks directory.");
148
+ console.log("\u{1F4A1} Create webhook files or download templates with:");
149
+ console.log(" better-webhook webhooks download --all");
150
+ return;
151
+ }
152
+ console.log("Available webhook definitions:");
153
+ files.forEach((f) => console.log(` \u2022 ${basename(f, ".json")}`));
154
+ });
155
+ var runCommand = new Command().name("run").argument(
156
+ "[nameOrPath]",
157
+ "Webhook name (in .webhooks) or path to JSON file (optional - will prompt if not provided)"
158
+ ).description(
159
+ "Run a webhook by name (in .webhooks) or by providing a path to a JSON file"
160
+ ).option("-u, --url <url>", "Override destination URL").option("-m, --method <method>", "Override HTTP method").action(async (nameOrPath, options = {}) => {
161
+ const cwd = process.cwd();
162
+ const webhooksDir = findWebhooksDir(cwd);
163
+ let selectedNameOrPath = nameOrPath;
164
+ if (!selectedNameOrPath) {
165
+ const spinner = ora("Loading available webhooks...").start();
166
+ const localFiles = listWebhookFiles(webhooksDir);
167
+ const templates = Object.keys(TEMPLATES);
168
+ spinner.stop();
169
+ if (localFiles.length === 0 && templates.length === 0) {
170
+ console.log("\u{1F4ED} No webhook definitions found.");
171
+ console.log(
172
+ "\u{1F4A1} Create webhook files in .webhooks directory or download templates with:"
173
+ );
174
+ console.log(" better-webhook webhooks download --all");
175
+ return;
176
+ }
177
+ const choices = [];
178
+ if (localFiles.length > 0) {
179
+ choices.push(
180
+ {
181
+ title: "--- Local Webhooks (.webhooks) ---",
182
+ description: "",
183
+ value: "",
184
+ type: "separator"
185
+ },
186
+ ...localFiles.map((file) => ({
187
+ title: basename(file, ".json"),
188
+ description: `Local file: ${file}`,
189
+ value: basename(file, ".json"),
190
+ type: "local"
191
+ }))
192
+ );
193
+ }
194
+ if (templates.length > 0) {
195
+ choices.push(
196
+ {
197
+ title: "--- Available Templates ---",
198
+ description: "",
199
+ value: "",
200
+ type: "separator"
201
+ },
202
+ ...templates.map((template) => ({
203
+ title: template,
204
+ description: `Template: ${TEMPLATES[template]}`,
205
+ value: template,
206
+ type: "template"
207
+ }))
208
+ );
209
+ }
210
+ const response = await prompts({
211
+ type: "select",
212
+ name: "webhook",
213
+ message: "Select a webhook to run:",
214
+ choices: choices.filter((choice) => choice.value !== ""),
215
+ // Remove separators for selection
216
+ initial: 0
217
+ });
218
+ if (!response.webhook) {
219
+ console.log("\u274C No webhook selected. Exiting.");
220
+ process.exitCode = 1;
221
+ return;
222
+ }
223
+ selectedNameOrPath = response.webhook;
224
+ if (selectedNameOrPath && templates.includes(selectedNameOrPath)) {
225
+ const downloadSpinner = ora(
226
+ `Downloading template: ${selectedNameOrPath}...`
227
+ ).start();
228
+ try {
229
+ const rel = TEMPLATES[selectedNameOrPath];
230
+ const rawUrl = `${TEMPLATE_REPO_BASE}/${rel}`;
231
+ const { statusCode, body } = await request2(rawUrl);
232
+ if (statusCode !== 200) {
233
+ throw new Error(`HTTP ${statusCode}`);
234
+ }
235
+ const text = await body.text();
236
+ const json = JSON.parse(text);
237
+ validateWebhookJSON(json, rawUrl);
238
+ mkdirSync(webhooksDir, { recursive: true });
239
+ const fileName = basename(rel);
240
+ const destPath = join2(webhooksDir, fileName);
241
+ writeFileSync(destPath, JSON.stringify(json, null, 2));
242
+ selectedNameOrPath = basename(fileName, ".json");
243
+ downloadSpinner.succeed(`Downloaded template: ${selectedNameOrPath}`);
244
+ } catch (error) {
245
+ downloadSpinner.fail(`Failed to download template: ${error.message}`);
246
+ process.exitCode = 1;
247
+ return;
248
+ }
249
+ }
250
+ }
251
+ if (!selectedNameOrPath) {
252
+ console.error("\u274C No webhook selected.");
253
+ process.exitCode = 1;
254
+ return;
255
+ }
256
+ let filePath;
257
+ if (selectedNameOrPath.endsWith(".json") && !selectedNameOrPath.includes("/") && !selectedNameOrPath.startsWith(".")) {
258
+ filePath = join2(webhooksDir, selectedNameOrPath);
259
+ } else {
260
+ const candidate = join2(
261
+ webhooksDir,
262
+ selectedNameOrPath + (selectedNameOrPath.endsWith(".json") ? "" : ".json")
263
+ );
264
+ if (statExists(candidate)) {
265
+ filePath = candidate;
266
+ } else {
267
+ filePath = resolve2(cwd, selectedNameOrPath);
268
+ }
269
+ }
270
+ if (!statExists(filePath)) {
271
+ console.error(`Webhook file not found: ${filePath}`);
272
+ process.exitCode = 1;
273
+ return;
274
+ }
275
+ let def;
276
+ try {
277
+ def = loadWebhookFile(filePath);
278
+ } catch (err) {
279
+ console.error(err.message);
280
+ process.exitCode = 1;
281
+ return;
282
+ }
283
+ if (options.url) def = { ...def, url: options.url };
284
+ if (options.method)
285
+ def = { ...def, method: options.method.toUpperCase() };
286
+ const executeSpinner = ora(
287
+ `Executing webhook: ${basename(filePath, ".json")}...`
288
+ ).start();
289
+ try {
290
+ const result = await executeWebhook(def);
291
+ executeSpinner.succeed("Webhook executed successfully!");
292
+ console.log("Status:", result.status);
293
+ console.log("Headers:");
294
+ for (const [k, v] of Object.entries(result.headers)) {
295
+ console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
296
+ }
297
+ if (result.json !== void 0) {
298
+ console.log("Response JSON:");
299
+ console.log(JSON.stringify(result.json, null, 2));
300
+ } else {
301
+ console.log("Response Body:");
302
+ console.log(result.bodyText);
303
+ }
304
+ } catch (err) {
305
+ executeSpinner.fail("Request failed");
306
+ console.error("Error:", err.message);
307
+ process.exitCode = 1;
308
+ }
309
+ });
310
+ var downloadCommand = new Command().name("download").argument("[name]", "Template name to download").description(
126
311
  "Download official webhook template(s) into the .webhooks directory. If no name is provided, prints available templates."
127
312
  ).option("-a, --all", "Download all available templates").option("-f, --force", "Overwrite existing files if they exist").action(
128
313
  async (name, opts) => {
@@ -138,7 +323,7 @@ program.command("download [name]").description(
138
323
  if (!toDownload.length) {
139
324
  console.log("Available templates:");
140
325
  for (const key of Object.keys(TEMPLATES)) console.log(` - ${key}`);
141
- console.log("Use: better-webhook download <name> OR --all");
326
+ console.log("Use: better-webhook webhooks download <name> OR --all");
142
327
  return;
143
328
  }
144
329
  for (const templateName of toDownload) {
@@ -175,7 +360,7 @@ program.command("download [name]").description(
175
360
  continue;
176
361
  }
177
362
  const fileName = basename(rel);
178
- const destPath = join(dir, fileName);
363
+ const destPath = join2(dir, fileName);
179
364
  if (existsSync(destPath) && !opts.force) {
180
365
  console.log(
181
366
  `Skipping existing file ${fileName} (use --force to overwrite)`
@@ -190,75 +375,519 @@ program.command("download [name]").description(
190
375
  }
191
376
  }
192
377
  );
193
- program.command("list").description("List available webhook JSON definitions in .webhooks directory").action(() => {
194
- const cwd = process.cwd();
195
- const dir = findWebhooksDir(cwd);
196
- const files = listWebhookFiles(dir);
197
- if (!files.length) {
198
- console.log("No webhook definitions found in .webhooks");
199
- return;
378
+ var webhooks = new Command().name("webhooks").description("Manage and execute webhook definitions").addCommand(listCommand).addCommand(runCommand).addCommand(downloadCommand);
379
+
380
+ // src/commands/capture.ts
381
+ import { Command as Command2 } from "commander";
382
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
383
+ import { join as join5, resolve as resolve3 } from "path";
384
+
385
+ // src/capture.ts
386
+ import { createServer } from "http";
387
+ import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
388
+ import { join as join3 } from "path";
389
+ import { randomUUID } from "crypto";
390
+ var WebhookCaptureServer = class {
391
+ server = null;
392
+ capturesDir;
393
+ constructor(capturesDir) {
394
+ this.capturesDir = capturesDir;
395
+ if (!existsSync2(capturesDir)) {
396
+ mkdirSync2(capturesDir, { recursive: true });
397
+ }
200
398
  }
201
- files.forEach((f) => console.log(basename(f, ".json")));
202
- });
203
- program.command("run <nameOrPath>").description(
204
- "Run a webhook by name (in .webhooks) or by providing a path to a JSON file"
205
- ).option("-u, --url <url>", "Override destination URL").option("-m, --method <method>", "Override HTTP method").action(async (nameOrPath, options) => {
206
- const cwd = process.cwd();
207
- let filePath;
208
- if (nameOrPath.endsWith(".json") && !nameOrPath.includes("/") && !nameOrPath.startsWith(".")) {
209
- filePath = join(findWebhooksDir(cwd), nameOrPath);
210
- } else {
211
- const candidate = join(
212
- findWebhooksDir(cwd),
213
- nameOrPath + (nameOrPath.endsWith(".json") ? "" : ".json")
399
+ start(startPort = 3001, maxAttempts = 20) {
400
+ return new Promise((resolve4, reject) => {
401
+ if (!Number.isInteger(startPort) || startPort < 0 || startPort > 65535) {
402
+ startPort = 3001;
403
+ }
404
+ let attempt = 0;
405
+ const tryListen = (portToTry) => {
406
+ this.server = createServer(
407
+ async (req, res) => {
408
+ try {
409
+ await this.handleRequest(req, res);
410
+ } catch (error) {
411
+ console.error("Error handling request:", error);
412
+ res.statusCode = 500;
413
+ res.end("Internal Server Error");
414
+ }
415
+ }
416
+ );
417
+ const onError = (err) => {
418
+ this.server?.off("error", onError);
419
+ this.server?.off("listening", onListening);
420
+ if (err && err.code === "EADDRINUSE") {
421
+ attempt += 1;
422
+ if (startPort === 0) {
423
+ reject(new Error("Failed to bind to an ephemeral port."));
424
+ return;
425
+ }
426
+ if (attempt >= maxAttempts) {
427
+ reject(
428
+ new Error(
429
+ `All ${maxAttempts} port attempts starting at ${startPort} are in use.`
430
+ )
431
+ );
432
+ return;
433
+ }
434
+ const nextPort = startPort + attempt;
435
+ tryListen(nextPort);
436
+ } else {
437
+ reject(err);
438
+ }
439
+ };
440
+ const onListening = () => {
441
+ this.server?.off("error", onError);
442
+ this.server?.off("listening", onListening);
443
+ const address = this.server?.address();
444
+ const actualPort = address?.port ?? portToTry;
445
+ console.log(
446
+ `\u{1F3A3} Webhook capture server running on http://localhost:${actualPort}`
447
+ );
448
+ console.log(
449
+ `\u{1F4C1} Captured webhooks will be saved to: ${this.capturesDir}`
450
+ );
451
+ console.log(
452
+ "\u{1F4A1} Send webhooks to any path on this server to capture them"
453
+ );
454
+ console.log("\u23F9\uFE0F Press Ctrl+C to stop the server");
455
+ resolve4(actualPort);
456
+ };
457
+ this.server.on("error", onError);
458
+ this.server.on("listening", onListening);
459
+ this.server.listen(portToTry);
460
+ };
461
+ tryListen(startPort === 0 ? 0 : startPort);
462
+ });
463
+ }
464
+ stop() {
465
+ return new Promise((resolve4) => {
466
+ if (this.server) {
467
+ this.server.close(() => {
468
+ console.log("\u{1F4F4} Webhook capture server stopped");
469
+ resolve4();
470
+ });
471
+ } else {
472
+ resolve4();
473
+ }
474
+ });
475
+ }
476
+ async handleRequest(req, res) {
477
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
478
+ const id = this.generateId();
479
+ const url = req.url || "/";
480
+ const urlParts = new URL(url, `http://${req.headers.host || "localhost"}`);
481
+ const query = {};
482
+ for (const [key, value] of urlParts.searchParams.entries()) {
483
+ if (query[key]) {
484
+ if (Array.isArray(query[key])) {
485
+ query[key].push(value);
486
+ } else {
487
+ query[key] = [query[key], value];
488
+ }
489
+ } else {
490
+ query[key] = value;
491
+ }
492
+ }
493
+ const chunks = [];
494
+ req.on("data", (chunk) => chunks.push(chunk));
495
+ await new Promise((resolve4) => {
496
+ req.on("end", () => resolve4());
497
+ });
498
+ const rawBody = Buffer.concat(chunks).toString("utf8");
499
+ let parsedBody;
500
+ try {
501
+ parsedBody = rawBody ? JSON.parse(rawBody) : null;
502
+ } catch {
503
+ parsedBody = rawBody || null;
504
+ }
505
+ const captured = {
506
+ id,
507
+ timestamp,
508
+ method: req.method || "GET",
509
+ url: urlParts.pathname,
510
+ headers: req.headers,
511
+ body: parsedBody,
512
+ rawBody,
513
+ query
514
+ };
515
+ const filename = `${timestamp.replace(/[:.]/g, "-")}_${id}.json`;
516
+ const filepath = join3(this.capturesDir, filename);
517
+ try {
518
+ writeFileSync2(filepath, JSON.stringify(captured, null, 2));
519
+ console.log(
520
+ `\u{1F4E6} Captured ${req.method} ${urlParts.pathname} -> ${filename}`
521
+ );
522
+ } catch (error) {
523
+ console.error(`\u274C Failed to save capture: ${error}`);
524
+ }
525
+ res.statusCode = 200;
526
+ res.setHeader("Content-Type", "application/json");
527
+ res.end(
528
+ JSON.stringify(
529
+ {
530
+ message: "Webhook captured successfully",
531
+ id,
532
+ timestamp,
533
+ file: filename
534
+ },
535
+ null,
536
+ 2
537
+ )
214
538
  );
215
- if (statExists(candidate)) {
216
- filePath = candidate;
539
+ }
540
+ generateId() {
541
+ try {
542
+ return randomUUID();
543
+ } catch {
544
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
545
+ }
546
+ }
547
+ };
548
+
549
+ // src/replay.ts
550
+ import { readFileSync as readFileSync2 } from "fs";
551
+ import { join as join4 } from "path";
552
+ var WebhookReplayer = class {
553
+ constructor(capturesDir) {
554
+ this.capturesDir = capturesDir;
555
+ }
556
+ /**
557
+ * List all captured webhooks
558
+ */
559
+ listCaptured() {
560
+ try {
561
+ const files = listJsonFiles(this.capturesDir).sort().reverse();
562
+ return files.map((file) => ({
563
+ file,
564
+ capture: this.loadCapture(join4(this.capturesDir, file))
565
+ }));
566
+ } catch {
567
+ return [];
568
+ }
569
+ }
570
+ /**
571
+ * Load a specific captured webhook by filename or ID
572
+ */
573
+ loadCapture(filePathOrId) {
574
+ let filepath;
575
+ if (filePathOrId.includes("/") || filePathOrId.endsWith(".json")) {
576
+ filepath = filePathOrId;
217
577
  } else {
218
- filePath = resolve(cwd, nameOrPath);
578
+ const files = listJsonFiles(this.capturesDir).filter(
579
+ (f) => f.includes(filePathOrId)
580
+ );
581
+ if (files.length === 0) {
582
+ throw new Error(`No capture found with ID: ${filePathOrId}`);
583
+ }
584
+ if (files.length > 1) {
585
+ throw new Error(
586
+ `Multiple captures found with ID ${filePathOrId}: ${files.join(", ")}`
587
+ );
588
+ }
589
+ filepath = join4(this.capturesDir, files[0] ?? "");
590
+ }
591
+ try {
592
+ const content = readFileSync2(filepath, "utf8");
593
+ return JSON.parse(content);
594
+ } catch (error) {
595
+ throw new Error(
596
+ `Failed to load capture from ${filepath}: ${error.message}`
597
+ );
219
598
  }
220
599
  }
221
- if (!statExists(filePath)) {
222
- console.error(`Webhook file not found: ${filePath}`);
600
+ /**
601
+ * Replay a captured webhook to a target URL
602
+ */
603
+ async replay(captureId, targetUrl, options = {}) {
604
+ const capture2 = this.loadCapture(captureId);
605
+ const webhookDef = {
606
+ url: options.url || targetUrl,
607
+ method: options.method || capture2.method,
608
+ headers: options.headers || this.convertHeaders(capture2.headers),
609
+ body: capture2.body
610
+ };
611
+ console.log(`\u{1F504} Replaying webhook ${capture2.id} (${capture2.timestamp})`);
612
+ console.log(` Method: ${webhookDef.method}`);
613
+ console.log(` URL: ${webhookDef.url}`);
614
+ console.log(` Original: ${capture2.method} ${capture2.url}`);
615
+ try {
616
+ const result = await executeWebhook(webhookDef);
617
+ return result;
618
+ } catch (error) {
619
+ throw new Error(`Replay failed: ${error.message}`);
620
+ }
621
+ }
622
+ /**
623
+ * Convert captured webhook to template format
624
+ */
625
+ captureToTemplate(captureId, templateUrl) {
626
+ const capture2 = this.loadCapture(captureId);
627
+ return {
628
+ url: templateUrl || "http://localhost:3000/webhook",
629
+ method: capture2.method,
630
+ headers: this.convertHeaders(capture2.headers),
631
+ body: capture2.body
632
+ };
633
+ }
634
+ /**
635
+ * Convert captured headers to template format
636
+ */
637
+ convertHeaders(headers) {
638
+ const result = [];
639
+ for (const [key, value] of Object.entries(headers)) {
640
+ const skipHeaders = [
641
+ "host",
642
+ "content-length",
643
+ "connection",
644
+ "accept-encoding",
645
+ "user-agent",
646
+ "x-forwarded-for",
647
+ "x-forwarded-proto"
648
+ ];
649
+ if (skipHeaders.includes(key.toLowerCase())) {
650
+ continue;
651
+ }
652
+ if (Array.isArray(value)) {
653
+ if (value.length === 1) {
654
+ result.push({ key, value: value[0] ?? "" });
655
+ } else {
656
+ result.push({ key, value: value.join(", ") });
657
+ }
658
+ } else {
659
+ result.push({ key, value });
660
+ }
661
+ }
662
+ return result;
663
+ }
664
+ /**
665
+ * Get summary information about a capture
666
+ */
667
+ getCaptureSummary(captureId) {
668
+ const capture2 = this.loadCapture(captureId);
669
+ const date = new Date(capture2.timestamp);
670
+ const bodySize = capture2.rawBody ? capture2.rawBody.length : 0;
671
+ const headerCount = Object.keys(capture2.headers).length;
672
+ return [
673
+ `ID: ${capture2.id}`,
674
+ `Date: ${date.toLocaleString()}`,
675
+ `Method: ${capture2.method}`,
676
+ `Path: ${capture2.url}`,
677
+ `Headers: ${headerCount}`,
678
+ `Body Size: ${bodySize} bytes`,
679
+ `Query Params: ${Object.keys(capture2.query).length}`
680
+ ].join("\n");
681
+ }
682
+ };
683
+
684
+ // src/commands/capture.ts
685
+ var listCommand2 = new Command2().name("list").description("List captured webhook requests").option("-l, --limit <limit>", "Maximum number of captures to show", "10").action((options) => {
686
+ const cwd = process.cwd();
687
+ const capturesDir = findCapturesDir(cwd);
688
+ const replayer = new WebhookReplayer(capturesDir);
689
+ const captures = replayer.listCaptured();
690
+ const limit = parseInt(options.limit, 10);
691
+ if (Number.isNaN(limit) || limit <= 0) {
692
+ console.error("Invalid --limit: must be a positive integer.");
223
693
  process.exitCode = 1;
224
694
  return;
225
695
  }
226
- let def;
696
+ if (captures.length === 0) {
697
+ console.log("No webhook captures found.");
698
+ console.log(`Run 'better-webhook capture' to start capturing webhooks.`);
699
+ return;
700
+ }
701
+ console.log(
702
+ `\u{1F4CB} Found ${captures.length} captured webhooks (showing ${Math.min(limit, captures.length)}):
703
+ `
704
+ );
705
+ captures.slice(0, limit).forEach(({ file, capture: capture2 }) => {
706
+ const date = new Date(capture2.timestamp).toLocaleString();
707
+ const bodySize = capture2.rawBody ? capture2.rawBody.length : 0;
708
+ console.log(`\u{1F194} ${capture2.id} | \u{1F4C5} ${date}`);
709
+ console.log(` ${capture2.method} ${capture2.url} | ${bodySize} bytes`);
710
+ console.log(` \u{1F4C4} ${file}
711
+ `);
712
+ });
713
+ if (captures.length > limit) {
714
+ console.log(
715
+ `... and ${captures.length - limit} more. Use --limit to show more.`
716
+ );
717
+ }
718
+ });
719
+ var templateCommand = new Command2().name("template").argument("<captureId>", "ID of the captured webhook to create template from").argument("[templateName]", "Name for the generated template").description("Generate a webhook template from a captured request").option(
720
+ "-u, --url <url>",
721
+ "Template URL (default: http://localhost:3000/webhook)"
722
+ ).option("-o, --output-dir <dir>", "Output directory (default: .webhooks)").action(
723
+ async (captureId, templateName, options) => {
724
+ const cwd = process.cwd();
725
+ const capturesDir = findCapturesDir(cwd);
726
+ const replayer = new WebhookReplayer(capturesDir);
727
+ try {
728
+ const template = replayer.captureToTemplate(captureId, options?.url);
729
+ if (!templateName) {
730
+ const capture2 = replayer.loadCapture(captureId);
731
+ const date = new Date(capture2.timestamp).toISOString().split("T")[0];
732
+ const pathPart = capture2.url.replace(/[^a-zA-Z0-9]/g, "_").substring(1) || "webhook";
733
+ templateName = `captured_${date}_${pathPart}_${capture2.id}`;
734
+ }
735
+ const outputDir = options?.outputDir ? resolve3(cwd, options.outputDir) : findWebhooksDir(cwd);
736
+ mkdirSync3(outputDir, { recursive: true });
737
+ const templatePath = join5(outputDir, `${templateName}.json`);
738
+ writeFileSync3(templatePath, JSON.stringify(template, null, 2));
739
+ console.log(`\u2705 Template created: ${templatePath}`);
740
+ console.log(
741
+ `\u{1F504} Run it with: better-webhook webhooks run ${templateName}`
742
+ );
743
+ console.log("\n\u{1F4CA} Template Summary:");
744
+ console.log(replayer.getCaptureSummary(captureId));
745
+ } catch (error) {
746
+ console.error("\u274C Template generation failed:", error.message);
747
+ process.exitCode = 1;
748
+ }
749
+ }
750
+ );
751
+ var capture = new Command2().name("capture").description(
752
+ "Start a server to capture incoming webhook requests, or list captured webhooks"
753
+ ).option("-p, --port <port>", "Port to listen on", "3001").action(async (options) => {
754
+ const cwd = process.cwd();
755
+ const capturesDir = findCapturesDir(cwd);
756
+ const server = new WebhookCaptureServer(capturesDir);
757
+ let actualPort;
227
758
  try {
228
- def = loadWebhookFile(filePath);
229
- } catch (err) {
230
- console.error(err.message);
759
+ actualPort = await server.start(parseInt(options.port));
760
+ } catch (error) {
761
+ console.error("Failed to start capture server:", error.message);
231
762
  process.exitCode = 1;
232
763
  return;
233
764
  }
234
- if (options.url) def = { ...def, url: options.url };
235
- if (options.method)
236
- def = { ...def, method: options.method.toUpperCase() };
237
- try {
238
- const result = await executeWebhook(def);
239
- console.log("Status:", result.status);
240
- console.log("Headers:");
241
- for (const [k, v] of Object.entries(result.headers)) {
242
- console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
765
+ const shutdown = async () => {
766
+ console.log("\n\u{1F6D1} Shutting down server...");
767
+ await server.stop();
768
+ process.exit(0);
769
+ };
770
+ process.on("SIGINT", shutdown);
771
+ process.on("SIGTERM", shutdown);
772
+ }).addCommand(listCommand2).addCommand(templateCommand);
773
+
774
+ // src/commands/replay.ts
775
+ import { Command as Command3 } from "commander";
776
+ import prompts2 from "prompts";
777
+ import ora2 from "ora";
778
+ var replay = new Command3().name("replay").argument(
779
+ "[captureId]",
780
+ "ID of the captured webhook to replay (optional - will prompt if not provided)"
781
+ ).argument(
782
+ "[targetUrl]",
783
+ "Target URL to replay the webhook to (optional - will prompt if not provided)"
784
+ ).description("Replay a captured webhook to a target URL").option("-m, --method <method>", "Override HTTP method").option(
785
+ "-H, --header <header>",
786
+ "Add custom header (format: key:value)",
787
+ (value, previous) => {
788
+ const [key, ...valueParts] = value.split(":");
789
+ const headerValue = valueParts.join(":");
790
+ if (!key || !headerValue) {
791
+ throw new Error("Header format should be key:value");
243
792
  }
244
- if (result.json !== void 0) {
245
- console.log("Response JSON:");
246
- console.log(JSON.stringify(result.json, null, 2));
247
- } else {
248
- console.log("Response Body:");
249
- console.log(result.bodyText);
793
+ return (previous || []).concat([
794
+ { key: key.trim(), value: headerValue.trim() }
795
+ ]);
796
+ },
797
+ []
798
+ ).action(
799
+ async (captureId, targetUrl, options = {}) => {
800
+ const cwd = process.cwd();
801
+ const capturesDir = findCapturesDir(cwd);
802
+ const replayer = new WebhookReplayer(capturesDir);
803
+ const spinner = ora2("Loading captured webhooks...").start();
804
+ const captured = replayer.listCaptured();
805
+ spinner.stop();
806
+ if (captured.length === 0) {
807
+ console.log("\u{1F4ED} No captured webhooks found.");
808
+ console.log(
809
+ "\u{1F4A1} Run 'better-webhook capture' to start capturing webhooks first."
810
+ );
811
+ return;
812
+ }
813
+ let selectedCaptureId = captureId;
814
+ let selectedTargetUrl = targetUrl;
815
+ if (!selectedCaptureId) {
816
+ const choices = captured.map((c) => {
817
+ const date = new Date(c.capture.timestamp).toLocaleString();
818
+ const bodySize = c.capture.rawBody?.length ?? 0;
819
+ return {
820
+ title: `${c.capture.id} - ${c.capture.method} ${c.capture.url}`,
821
+ description: `${date} | Body: ${bodySize} bytes`,
822
+ value: c.capture.id
823
+ };
824
+ });
825
+ const response = await prompts2({
826
+ type: "select",
827
+ name: "captureId",
828
+ message: "Select a captured webhook to replay:",
829
+ choices,
830
+ initial: 0
831
+ });
832
+ if (!response.captureId) {
833
+ console.log("\u274C No webhook selected. Exiting.");
834
+ process.exitCode = 1;
835
+ return;
836
+ }
837
+ selectedCaptureId = response.captureId;
838
+ }
839
+ if (!selectedTargetUrl) {
840
+ const response = await prompts2({
841
+ type: "text",
842
+ name: "targetUrl",
843
+ message: "Enter the target URL to replay to:",
844
+ initial: "http://localhost:3000/webhook",
845
+ validate: (value) => {
846
+ try {
847
+ new URL(value);
848
+ return true;
849
+ } catch {
850
+ return "Please enter a valid URL";
851
+ }
852
+ }
853
+ });
854
+ if (!response.targetUrl) {
855
+ console.log("\u274C No target URL provided. Exiting.");
856
+ process.exitCode = 1;
857
+ return;
858
+ }
859
+ selectedTargetUrl = response.targetUrl;
860
+ }
861
+ try {
862
+ const result = await replayer.replay(
863
+ selectedCaptureId,
864
+ selectedTargetUrl,
865
+ {
866
+ method: options.method,
867
+ headers: options.header
868
+ }
869
+ );
870
+ console.log("\u2705 Replay completed successfully!");
871
+ console.log("Status:", result.status);
872
+ console.log("Headers:");
873
+ for (const [k, v] of Object.entries(result.headers)) {
874
+ console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
875
+ }
876
+ if (result.json !== void 0) {
877
+ console.log("Response JSON:");
878
+ console.log(JSON.stringify(result.json, null, 2));
879
+ } else {
880
+ console.log("Response Body:");
881
+ console.log(result.bodyText);
882
+ }
883
+ } catch (error) {
884
+ console.error("\u274C Replay failed:", error.message);
885
+ process.exitCode = 1;
250
886
  }
251
- } catch (err) {
252
- console.error("Request failed:", err.message);
253
- process.exitCode = 1;
254
- }
255
- });
256
- function statExists(p) {
257
- try {
258
- statSync(p);
259
- return true;
260
- } catch {
261
- return false;
262
887
  }
263
- }
888
+ );
889
+
890
+ // src/index.ts
891
+ var program = new Command4().name("better-webhook").description("CLI for listing, downloading and executing predefined webhooks").version("0.2.0");
892
+ program.addCommand(webhooks).addCommand(capture).addCommand(replay);
264
893
  program.parseAsync(process.argv);