@better-webhook/cli 0.2.0 → 0.3.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.
- package/README.md +250 -86
- package/dist/index.cjs +539 -82
- package/dist/index.js +534 -83
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
import { join as join2, resolve as resolve2, basename } from "path";
|
|
9
|
+
import { statSync as statSync2 } from "fs";
|
|
10
|
+
|
|
11
|
+
// src/utils/index.ts
|
|
12
|
+
import { readdirSync, statSync } from "fs";
|
|
13
|
+
import { join, resolve, extname } from "path";
|
|
14
|
+
function findWebhooksDir(cwd) {
|
|
15
|
+
return resolve(cwd, ".webhooks");
|
|
16
|
+
}
|
|
17
|
+
function listWebhookFiles(dir) {
|
|
18
|
+
return listJsonFiles(dir);
|
|
19
|
+
}
|
|
20
|
+
function findCapturesDir(cwd) {
|
|
21
|
+
return resolve(cwd, ".webhook-captures");
|
|
22
|
+
}
|
|
23
|
+
function listJsonFiles(dir) {
|
|
24
|
+
try {
|
|
25
|
+
const entries = readdirSync(dir);
|
|
26
|
+
return entries.filter(
|
|
27
|
+
(e) => statSync(join(dir, e)).isFile() && extname(e) === ".json"
|
|
28
|
+
);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
13
33
|
|
|
14
34
|
// src/loader.ts
|
|
15
35
|
import { readFileSync } from "fs";
|
|
@@ -101,28 +121,85 @@ async function executeWebhook(def) {
|
|
|
101
121
|
};
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
// src/
|
|
124
|
+
// src/commands/webhooks.ts
|
|
125
|
+
import { mkdirSync, writeFileSync, existsSync } from "fs";
|
|
105
126
|
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");
|
|
108
|
-
function findWebhooksDir(cwd) {
|
|
109
|
-
return resolve(cwd, ".webhooks");
|
|
110
|
-
}
|
|
111
|
-
function listWebhookFiles(dir) {
|
|
112
|
-
try {
|
|
113
|
-
const entries = readdirSync(dir);
|
|
114
|
-
return entries.filter(
|
|
115
|
-
(e) => statSync(join(dir, e)).isFile() && extname(e) === ".json"
|
|
116
|
-
);
|
|
117
|
-
} catch {
|
|
118
|
-
return [];
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
127
|
var TEMPLATE_REPO_BASE = "https://raw.githubusercontent.com/endalk200/better-webhook/main";
|
|
122
128
|
var TEMPLATES = {
|
|
123
129
|
"stripe-invoice.payment_succeeded": "templates/stripe-invoice.payment_succeeded.json"
|
|
124
130
|
};
|
|
125
|
-
|
|
131
|
+
function statExists(p) {
|
|
132
|
+
try {
|
|
133
|
+
statSync2(p);
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
var listCommand = new Command().name("list").description("List available webhook JSON definitions in .webhooks directory").action(() => {
|
|
140
|
+
const cwd = process.cwd();
|
|
141
|
+
const dir = findWebhooksDir(cwd);
|
|
142
|
+
const files = listWebhookFiles(dir);
|
|
143
|
+
if (!files.length) {
|
|
144
|
+
console.log("No webhook definitions found in .webhooks");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
files.forEach((f) => console.log(basename(f, ".json")));
|
|
148
|
+
});
|
|
149
|
+
var runCommand = new Command().name("run").argument("<nameOrPath>", "Webhook name (in .webhooks) or path to JSON file").description(
|
|
150
|
+
"Run a webhook by name (in .webhooks) or by providing a path to a JSON file"
|
|
151
|
+
).option("-u, --url <url>", "Override destination URL").option("-m, --method <method>", "Override HTTP method").action(async (nameOrPath, options) => {
|
|
152
|
+
const cwd = process.cwd();
|
|
153
|
+
let filePath;
|
|
154
|
+
if (nameOrPath.endsWith(".json") && !nameOrPath.includes("/") && !nameOrPath.startsWith(".")) {
|
|
155
|
+
filePath = join2(findWebhooksDir(cwd), nameOrPath);
|
|
156
|
+
} else {
|
|
157
|
+
const candidate = join2(
|
|
158
|
+
findWebhooksDir(cwd),
|
|
159
|
+
nameOrPath + (nameOrPath.endsWith(".json") ? "" : ".json")
|
|
160
|
+
);
|
|
161
|
+
if (statExists(candidate)) {
|
|
162
|
+
filePath = candidate;
|
|
163
|
+
} else {
|
|
164
|
+
filePath = resolve2(cwd, nameOrPath);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!statExists(filePath)) {
|
|
168
|
+
console.error(`Webhook file not found: ${filePath}`);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
let def;
|
|
173
|
+
try {
|
|
174
|
+
def = loadWebhookFile(filePath);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(err.message);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (options.url) def = { ...def, url: options.url };
|
|
181
|
+
if (options.method)
|
|
182
|
+
def = { ...def, method: options.method.toUpperCase() };
|
|
183
|
+
try {
|
|
184
|
+
const result = await executeWebhook(def);
|
|
185
|
+
console.log("Status:", result.status);
|
|
186
|
+
console.log("Headers:");
|
|
187
|
+
for (const [k, v] of Object.entries(result.headers)) {
|
|
188
|
+
console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
|
|
189
|
+
}
|
|
190
|
+
if (result.json !== void 0) {
|
|
191
|
+
console.log("Response JSON:");
|
|
192
|
+
console.log(JSON.stringify(result.json, null, 2));
|
|
193
|
+
} else {
|
|
194
|
+
console.log("Response Body:");
|
|
195
|
+
console.log(result.bodyText);
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error("Request failed:", err.message);
|
|
199
|
+
process.exitCode = 1;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
var downloadCommand = new Command().name("download").argument("[name]", "Template name to download").description(
|
|
126
203
|
"Download official webhook template(s) into the .webhooks directory. If no name is provided, prints available templates."
|
|
127
204
|
).option("-a, --all", "Download all available templates").option("-f, --force", "Overwrite existing files if they exist").action(
|
|
128
205
|
async (name, opts) => {
|
|
@@ -138,7 +215,7 @@ program.command("download [name]").description(
|
|
|
138
215
|
if (!toDownload.length) {
|
|
139
216
|
console.log("Available templates:");
|
|
140
217
|
for (const key of Object.keys(TEMPLATES)) console.log(` - ${key}`);
|
|
141
|
-
console.log("Use: better-webhook download <name> OR --all");
|
|
218
|
+
console.log("Use: better-webhook webhooks download <name> OR --all");
|
|
142
219
|
return;
|
|
143
220
|
}
|
|
144
221
|
for (const templateName of toDownload) {
|
|
@@ -175,7 +252,7 @@ program.command("download [name]").description(
|
|
|
175
252
|
continue;
|
|
176
253
|
}
|
|
177
254
|
const fileName = basename(rel);
|
|
178
|
-
const destPath =
|
|
255
|
+
const destPath = join2(dir, fileName);
|
|
179
256
|
if (existsSync(destPath) && !opts.force) {
|
|
180
257
|
console.log(
|
|
181
258
|
`Skipping existing file ${fileName} (use --force to overwrite)`
|
|
@@ -190,75 +267,449 @@ program.command("download [name]").description(
|
|
|
190
267
|
}
|
|
191
268
|
}
|
|
192
269
|
);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
270
|
+
var webhooks = new Command().name("webhooks").description("Manage and execute webhook definitions").addCommand(listCommand).addCommand(runCommand).addCommand(downloadCommand);
|
|
271
|
+
|
|
272
|
+
// src/commands/capture.ts
|
|
273
|
+
import { Command as Command2 } from "commander";
|
|
274
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
275
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
276
|
+
|
|
277
|
+
// src/capture.ts
|
|
278
|
+
import { createServer } from "http";
|
|
279
|
+
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
280
|
+
import { join as join3 } from "path";
|
|
281
|
+
import { randomUUID } from "crypto";
|
|
282
|
+
var WebhookCaptureServer = class {
|
|
283
|
+
server = null;
|
|
284
|
+
capturesDir;
|
|
285
|
+
constructor(capturesDir) {
|
|
286
|
+
this.capturesDir = capturesDir;
|
|
287
|
+
if (!existsSync2(capturesDir)) {
|
|
288
|
+
mkdirSync2(capturesDir, { recursive: true });
|
|
289
|
+
}
|
|
200
290
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
291
|
+
start(startPort = 3001, maxAttempts = 20) {
|
|
292
|
+
return new Promise((resolve4, reject) => {
|
|
293
|
+
if (!Number.isInteger(startPort) || startPort < 0 || startPort > 65535) {
|
|
294
|
+
startPort = 3001;
|
|
295
|
+
}
|
|
296
|
+
let attempt = 0;
|
|
297
|
+
const tryListen = (portToTry) => {
|
|
298
|
+
this.server = createServer(
|
|
299
|
+
async (req, res) => {
|
|
300
|
+
try {
|
|
301
|
+
await this.handleRequest(req, res);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error("Error handling request:", error);
|
|
304
|
+
res.statusCode = 500;
|
|
305
|
+
res.end("Internal Server Error");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
const onError = (err) => {
|
|
310
|
+
this.server?.off("error", onError);
|
|
311
|
+
this.server?.off("listening", onListening);
|
|
312
|
+
if (err && err.code === "EADDRINUSE") {
|
|
313
|
+
attempt += 1;
|
|
314
|
+
if (startPort === 0) {
|
|
315
|
+
reject(new Error("Failed to bind to an ephemeral port."));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (attempt >= maxAttempts) {
|
|
319
|
+
reject(
|
|
320
|
+
new Error(
|
|
321
|
+
`All ${maxAttempts} port attempts starting at ${startPort} are in use.`
|
|
322
|
+
)
|
|
323
|
+
);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const nextPort = startPort + attempt;
|
|
327
|
+
tryListen(nextPort);
|
|
328
|
+
} else {
|
|
329
|
+
reject(err);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const onListening = () => {
|
|
333
|
+
this.server?.off("error", onError);
|
|
334
|
+
this.server?.off("listening", onListening);
|
|
335
|
+
const address = this.server?.address();
|
|
336
|
+
const actualPort = address?.port ?? portToTry;
|
|
337
|
+
console.log(
|
|
338
|
+
`\u{1F3A3} Webhook capture server running on http://localhost:${actualPort}`
|
|
339
|
+
);
|
|
340
|
+
console.log(
|
|
341
|
+
`\u{1F4C1} Captured webhooks will be saved to: ${this.capturesDir}`
|
|
342
|
+
);
|
|
343
|
+
console.log(
|
|
344
|
+
"\u{1F4A1} Send webhooks to any path on this server to capture them"
|
|
345
|
+
);
|
|
346
|
+
console.log("\u23F9\uFE0F Press Ctrl+C to stop the server");
|
|
347
|
+
resolve4(actualPort);
|
|
348
|
+
};
|
|
349
|
+
this.server.on("error", onError);
|
|
350
|
+
this.server.on("listening", onListening);
|
|
351
|
+
this.server.listen(portToTry);
|
|
352
|
+
};
|
|
353
|
+
tryListen(startPort === 0 ? 0 : startPort);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
stop() {
|
|
357
|
+
return new Promise((resolve4) => {
|
|
358
|
+
if (this.server) {
|
|
359
|
+
this.server.close(() => {
|
|
360
|
+
console.log("\u{1F4F4} Webhook capture server stopped");
|
|
361
|
+
resolve4();
|
|
362
|
+
});
|
|
363
|
+
} else {
|
|
364
|
+
resolve4();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
async handleRequest(req, res) {
|
|
369
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
370
|
+
const id = this.generateId();
|
|
371
|
+
const url = req.url || "/";
|
|
372
|
+
const urlParts = new URL(url, `http://${req.headers.host || "localhost"}`);
|
|
373
|
+
const query = {};
|
|
374
|
+
for (const [key, value] of urlParts.searchParams.entries()) {
|
|
375
|
+
if (query[key]) {
|
|
376
|
+
if (Array.isArray(query[key])) {
|
|
377
|
+
query[key].push(value);
|
|
378
|
+
} else {
|
|
379
|
+
query[key] = [query[key], value];
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
query[key] = value;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const chunks = [];
|
|
386
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
387
|
+
await new Promise((resolve4) => {
|
|
388
|
+
req.on("end", () => resolve4());
|
|
389
|
+
});
|
|
390
|
+
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
391
|
+
let parsedBody;
|
|
392
|
+
try {
|
|
393
|
+
parsedBody = rawBody ? JSON.parse(rawBody) : null;
|
|
394
|
+
} catch {
|
|
395
|
+
parsedBody = rawBody || null;
|
|
396
|
+
}
|
|
397
|
+
const captured = {
|
|
398
|
+
id,
|
|
399
|
+
timestamp,
|
|
400
|
+
method: req.method || "GET",
|
|
401
|
+
url: urlParts.pathname,
|
|
402
|
+
headers: req.headers,
|
|
403
|
+
body: parsedBody,
|
|
404
|
+
rawBody,
|
|
405
|
+
query
|
|
406
|
+
};
|
|
407
|
+
const filename = `${timestamp.replace(/[:.]/g, "-")}_${id}.json`;
|
|
408
|
+
const filepath = join3(this.capturesDir, filename);
|
|
409
|
+
try {
|
|
410
|
+
writeFileSync2(filepath, JSON.stringify(captured, null, 2));
|
|
411
|
+
console.log(
|
|
412
|
+
`\u{1F4E6} Captured ${req.method} ${urlParts.pathname} -> ${filename}`
|
|
413
|
+
);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error(`\u274C Failed to save capture: ${error}`);
|
|
416
|
+
}
|
|
417
|
+
res.statusCode = 200;
|
|
418
|
+
res.setHeader("Content-Type", "application/json");
|
|
419
|
+
res.end(
|
|
420
|
+
JSON.stringify(
|
|
421
|
+
{
|
|
422
|
+
message: "Webhook captured successfully",
|
|
423
|
+
id,
|
|
424
|
+
timestamp,
|
|
425
|
+
file: filename
|
|
426
|
+
},
|
|
427
|
+
null,
|
|
428
|
+
2
|
|
429
|
+
)
|
|
214
430
|
);
|
|
215
|
-
|
|
216
|
-
|
|
431
|
+
}
|
|
432
|
+
generateId() {
|
|
433
|
+
try {
|
|
434
|
+
return randomUUID();
|
|
435
|
+
} catch {
|
|
436
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// src/replay.ts
|
|
442
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
443
|
+
import { join as join4 } from "path";
|
|
444
|
+
var WebhookReplayer = class {
|
|
445
|
+
constructor(capturesDir) {
|
|
446
|
+
this.capturesDir = capturesDir;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* List all captured webhooks
|
|
450
|
+
*/
|
|
451
|
+
listCaptured() {
|
|
452
|
+
try {
|
|
453
|
+
const files = listJsonFiles(this.capturesDir).sort().reverse();
|
|
454
|
+
return files.map((file) => ({
|
|
455
|
+
file,
|
|
456
|
+
capture: this.loadCapture(join4(this.capturesDir, file))
|
|
457
|
+
}));
|
|
458
|
+
} catch {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Load a specific captured webhook by filename or ID
|
|
464
|
+
*/
|
|
465
|
+
loadCapture(filePathOrId) {
|
|
466
|
+
let filepath;
|
|
467
|
+
if (filePathOrId.includes("/") || filePathOrId.endsWith(".json")) {
|
|
468
|
+
filepath = filePathOrId;
|
|
217
469
|
} else {
|
|
218
|
-
|
|
470
|
+
const files = listJsonFiles(this.capturesDir).filter(
|
|
471
|
+
(f) => f.includes(filePathOrId)
|
|
472
|
+
);
|
|
473
|
+
if (files.length === 0) {
|
|
474
|
+
throw new Error(`No capture found with ID: ${filePathOrId}`);
|
|
475
|
+
}
|
|
476
|
+
if (files.length > 1) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Multiple captures found with ID ${filePathOrId}: ${files.join(", ")}`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
filepath = join4(this.capturesDir, files[0] ?? "");
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const content = readFileSync2(filepath, "utf8");
|
|
485
|
+
return JSON.parse(content);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`Failed to load capture from ${filepath}: ${error.message}`
|
|
489
|
+
);
|
|
219
490
|
}
|
|
220
491
|
}
|
|
221
|
-
|
|
222
|
-
|
|
492
|
+
/**
|
|
493
|
+
* Replay a captured webhook to a target URL
|
|
494
|
+
*/
|
|
495
|
+
async replay(captureId, targetUrl, options = {}) {
|
|
496
|
+
const capture2 = this.loadCapture(captureId);
|
|
497
|
+
const webhookDef = {
|
|
498
|
+
url: options.url || targetUrl,
|
|
499
|
+
method: options.method || capture2.method,
|
|
500
|
+
headers: options.headers || this.convertHeaders(capture2.headers),
|
|
501
|
+
body: capture2.body
|
|
502
|
+
};
|
|
503
|
+
console.log(`\u{1F504} Replaying webhook ${capture2.id} (${capture2.timestamp})`);
|
|
504
|
+
console.log(` Method: ${webhookDef.method}`);
|
|
505
|
+
console.log(` URL: ${webhookDef.url}`);
|
|
506
|
+
console.log(` Original: ${capture2.method} ${capture2.url}`);
|
|
507
|
+
try {
|
|
508
|
+
const result = await executeWebhook(webhookDef);
|
|
509
|
+
return result;
|
|
510
|
+
} catch (error) {
|
|
511
|
+
throw new Error(`Replay failed: ${error.message}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Convert captured webhook to template format
|
|
516
|
+
*/
|
|
517
|
+
captureToTemplate(captureId, templateUrl) {
|
|
518
|
+
const capture2 = this.loadCapture(captureId);
|
|
519
|
+
return {
|
|
520
|
+
url: templateUrl || "http://localhost:3000/webhook",
|
|
521
|
+
method: capture2.method,
|
|
522
|
+
headers: this.convertHeaders(capture2.headers),
|
|
523
|
+
body: capture2.body
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Convert captured headers to template format
|
|
528
|
+
*/
|
|
529
|
+
convertHeaders(headers) {
|
|
530
|
+
const result = [];
|
|
531
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
532
|
+
const skipHeaders = [
|
|
533
|
+
"host",
|
|
534
|
+
"content-length",
|
|
535
|
+
"connection",
|
|
536
|
+
"accept-encoding",
|
|
537
|
+
"user-agent",
|
|
538
|
+
"x-forwarded-for",
|
|
539
|
+
"x-forwarded-proto"
|
|
540
|
+
];
|
|
541
|
+
if (skipHeaders.includes(key.toLowerCase())) {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (Array.isArray(value)) {
|
|
545
|
+
if (value.length === 1) {
|
|
546
|
+
result.push({ key, value: value[0] ?? "" });
|
|
547
|
+
} else {
|
|
548
|
+
result.push({ key, value: value.join(", ") });
|
|
549
|
+
}
|
|
550
|
+
} else {
|
|
551
|
+
result.push({ key, value });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Get summary information about a capture
|
|
558
|
+
*/
|
|
559
|
+
getCaptureSummary(captureId) {
|
|
560
|
+
const capture2 = this.loadCapture(captureId);
|
|
561
|
+
const date = new Date(capture2.timestamp);
|
|
562
|
+
const bodySize = capture2.rawBody ? capture2.rawBody.length : 0;
|
|
563
|
+
const headerCount = Object.keys(capture2.headers).length;
|
|
564
|
+
return [
|
|
565
|
+
`ID: ${capture2.id}`,
|
|
566
|
+
`Date: ${date.toLocaleString()}`,
|
|
567
|
+
`Method: ${capture2.method}`,
|
|
568
|
+
`Path: ${capture2.url}`,
|
|
569
|
+
`Headers: ${headerCount}`,
|
|
570
|
+
`Body Size: ${bodySize} bytes`,
|
|
571
|
+
`Query Params: ${Object.keys(capture2.query).length}`
|
|
572
|
+
].join("\n");
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// src/commands/capture.ts
|
|
577
|
+
var listCommand2 = new Command2().name("list").description("List captured webhook requests").option("-l, --limit <limit>", "Maximum number of captures to show", "10").action((options) => {
|
|
578
|
+
const cwd = process.cwd();
|
|
579
|
+
const capturesDir = findCapturesDir(cwd);
|
|
580
|
+
const replayer = new WebhookReplayer(capturesDir);
|
|
581
|
+
const captures = replayer.listCaptured();
|
|
582
|
+
const limit = parseInt(options.limit, 10);
|
|
583
|
+
if (Number.isNaN(limit) || limit <= 0) {
|
|
584
|
+
console.error("Invalid --limit: must be a positive integer.");
|
|
223
585
|
process.exitCode = 1;
|
|
224
586
|
return;
|
|
225
587
|
}
|
|
226
|
-
|
|
588
|
+
if (captures.length === 0) {
|
|
589
|
+
console.log("No webhook captures found.");
|
|
590
|
+
console.log(`Run 'better-webhook capture' to start capturing webhooks.`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
console.log(
|
|
594
|
+
`\u{1F4CB} Found ${captures.length} captured webhooks (showing ${Math.min(limit, captures.length)}):
|
|
595
|
+
`
|
|
596
|
+
);
|
|
597
|
+
captures.slice(0, limit).forEach(({ file, capture: capture2 }) => {
|
|
598
|
+
const date = new Date(capture2.timestamp).toLocaleString();
|
|
599
|
+
const bodySize = capture2.rawBody ? capture2.rawBody.length : 0;
|
|
600
|
+
console.log(`\u{1F194} ${capture2.id} | \u{1F4C5} ${date}`);
|
|
601
|
+
console.log(` ${capture2.method} ${capture2.url} | ${bodySize} bytes`);
|
|
602
|
+
console.log(` \u{1F4C4} ${file}
|
|
603
|
+
`);
|
|
604
|
+
});
|
|
605
|
+
if (captures.length > limit) {
|
|
606
|
+
console.log(
|
|
607
|
+
`... and ${captures.length - limit} more. Use --limit to show more.`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
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(
|
|
612
|
+
"-u, --url <url>",
|
|
613
|
+
"Template URL (default: http://localhost:3000/webhook)"
|
|
614
|
+
).option("-o, --output-dir <dir>", "Output directory (default: .webhooks)").action(
|
|
615
|
+
async (captureId, templateName, options) => {
|
|
616
|
+
const cwd = process.cwd();
|
|
617
|
+
const capturesDir = findCapturesDir(cwd);
|
|
618
|
+
const replayer = new WebhookReplayer(capturesDir);
|
|
619
|
+
try {
|
|
620
|
+
const template = replayer.captureToTemplate(captureId, options?.url);
|
|
621
|
+
if (!templateName) {
|
|
622
|
+
const capture2 = replayer.loadCapture(captureId);
|
|
623
|
+
const date = new Date(capture2.timestamp).toISOString().split("T")[0];
|
|
624
|
+
const pathPart = capture2.url.replace(/[^a-zA-Z0-9]/g, "_").substring(1) || "webhook";
|
|
625
|
+
templateName = `captured_${date}_${pathPart}_${capture2.id}`;
|
|
626
|
+
}
|
|
627
|
+
const outputDir = options?.outputDir ? resolve3(cwd, options.outputDir) : findWebhooksDir(cwd);
|
|
628
|
+
mkdirSync3(outputDir, { recursive: true });
|
|
629
|
+
const templatePath = join5(outputDir, `${templateName}.json`);
|
|
630
|
+
writeFileSync3(templatePath, JSON.stringify(template, null, 2));
|
|
631
|
+
console.log(`\u2705 Template created: ${templatePath}`);
|
|
632
|
+
console.log(
|
|
633
|
+
`\u{1F504} Run it with: better-webhook webhooks run ${templateName}`
|
|
634
|
+
);
|
|
635
|
+
console.log("\n\u{1F4CA} Template Summary:");
|
|
636
|
+
console.log(replayer.getCaptureSummary(captureId));
|
|
637
|
+
} catch (error) {
|
|
638
|
+
console.error("\u274C Template generation failed:", error.message);
|
|
639
|
+
process.exitCode = 1;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
);
|
|
643
|
+
var capture = new Command2().name("capture").description(
|
|
644
|
+
"Start a server to capture incoming webhook requests, or list captured webhooks"
|
|
645
|
+
).option("-p, --port <port>", "Port to listen on", "3001").action(async (options) => {
|
|
646
|
+
const cwd = process.cwd();
|
|
647
|
+
const capturesDir = findCapturesDir(cwd);
|
|
648
|
+
const server = new WebhookCaptureServer(capturesDir);
|
|
649
|
+
let actualPort;
|
|
227
650
|
try {
|
|
228
|
-
|
|
229
|
-
} catch (
|
|
230
|
-
console.error(
|
|
651
|
+
actualPort = await server.start(parseInt(options.port));
|
|
652
|
+
} catch (error) {
|
|
653
|
+
console.error("Failed to start capture server:", error.message);
|
|
231
654
|
process.exitCode = 1;
|
|
232
655
|
return;
|
|
233
656
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
657
|
+
const shutdown = async () => {
|
|
658
|
+
console.log("\n\u{1F6D1} Shutting down server...");
|
|
659
|
+
await server.stop();
|
|
660
|
+
process.exit(0);
|
|
661
|
+
};
|
|
662
|
+
process.on("SIGINT", shutdown);
|
|
663
|
+
process.on("SIGTERM", shutdown);
|
|
664
|
+
}).addCommand(listCommand2).addCommand(templateCommand);
|
|
665
|
+
|
|
666
|
+
// src/commands/replay.ts
|
|
667
|
+
import { Command as Command3 } from "commander";
|
|
668
|
+
var replay = new Command3().name("replay").argument("<captureId>", "ID of the captured webhook to replay").argument("<targetUrl>", "Target URL to replay the webhook to").description("Replay a captured webhook to a target URL").option("-m, --method <method>", "Override HTTP method").option(
|
|
669
|
+
"-H, --header <header>",
|
|
670
|
+
"Add custom header (format: key:value)",
|
|
671
|
+
(value, previous) => {
|
|
672
|
+
const [key, ...valueParts] = value.split(":");
|
|
673
|
+
const headerValue = valueParts.join(":");
|
|
674
|
+
if (!key || !headerValue) {
|
|
675
|
+
throw new Error("Header format should be key:value");
|
|
243
676
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
677
|
+
return (previous || []).concat([
|
|
678
|
+
{ key: key.trim(), value: headerValue.trim() }
|
|
679
|
+
]);
|
|
680
|
+
},
|
|
681
|
+
[]
|
|
682
|
+
).action(
|
|
683
|
+
async (captureId, targetUrl, options) => {
|
|
684
|
+
const cwd = process.cwd();
|
|
685
|
+
const capturesDir = findCapturesDir(cwd);
|
|
686
|
+
const replayer = new WebhookReplayer(capturesDir);
|
|
687
|
+
try {
|
|
688
|
+
const result = await replayer.replay(captureId, targetUrl, {
|
|
689
|
+
method: options.method,
|
|
690
|
+
headers: options.header
|
|
691
|
+
});
|
|
692
|
+
console.log("\u2705 Replay completed successfully!");
|
|
693
|
+
console.log("Status:", result.status);
|
|
694
|
+
console.log("Headers:");
|
|
695
|
+
for (const [k, v] of Object.entries(result.headers)) {
|
|
696
|
+
console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
|
|
697
|
+
}
|
|
698
|
+
if (result.json !== void 0) {
|
|
699
|
+
console.log("Response JSON:");
|
|
700
|
+
console.log(JSON.stringify(result.json, null, 2));
|
|
701
|
+
} else {
|
|
702
|
+
console.log("Response Body:");
|
|
703
|
+
console.log(result.bodyText);
|
|
704
|
+
}
|
|
705
|
+
} catch (error) {
|
|
706
|
+
console.error("\u274C Replay failed:", error.message);
|
|
707
|
+
process.exitCode = 1;
|
|
250
708
|
}
|
|
251
|
-
} catch (err) {
|
|
252
|
-
console.error("Request failed:", err.message);
|
|
253
|
-
process.exitCode = 1;
|
|
254
709
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
} catch {
|
|
261
|
-
return false;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
// src/index.ts
|
|
713
|
+
var program = new Command4().name("better-webhook").description("CLI for listing, downloading and executing predefined webhooks").version("0.2.0");
|
|
714
|
+
program.addCommand(webhooks).addCommand(capture).addCommand(replay);
|
|
264
715
|
program.parseAsync(process.argv);
|