@better-webhook/cli 3.0.0 → 3.2.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 +30 -0
- package/dist/dashboard/assets/index--Ns7zZwD.css +1 -0
- package/dist/dashboard/assets/index-JTnjYuBA.js +23 -0
- package/dist/dashboard/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/dashboard/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/dashboard/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/dashboard/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/dashboard/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/dashboard/vite.svg +1 -0
- package/dist/index.cjs +608 -106
- package/dist/index.js +612 -116
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
-
}) : x)(function(x) {
|
|
5
|
-
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
-
});
|
|
8
2
|
|
|
9
3
|
// src/index.ts
|
|
10
|
-
import { Command as
|
|
4
|
+
import { Command as Command7 } from "commander";
|
|
11
5
|
|
|
12
6
|
// src/commands/templates.ts
|
|
13
7
|
import { Command } from "commander";
|
|
@@ -49,6 +43,7 @@ var WebhookProviderSchema = z.enum([
|
|
|
49
43
|
"github",
|
|
50
44
|
"shopify",
|
|
51
45
|
"twilio",
|
|
46
|
+
"ragie",
|
|
52
47
|
"sendgrid",
|
|
53
48
|
"slack",
|
|
54
49
|
"discord",
|
|
@@ -183,8 +178,8 @@ var TemplateManager = class {
|
|
|
183
178
|
/**
|
|
184
179
|
* List all remote templates
|
|
185
180
|
*/
|
|
186
|
-
async listRemoteTemplates() {
|
|
187
|
-
const index = await this.fetchRemoteIndex();
|
|
181
|
+
async listRemoteTemplates(options) {
|
|
182
|
+
const index = await this.fetchRemoteIndex(!!options?.forceRefresh);
|
|
188
183
|
const localIds = new Set(this.listLocalTemplates().map((t) => t.id));
|
|
189
184
|
return index.templates.map((metadata) => ({
|
|
190
185
|
metadata,
|
|
@@ -382,7 +377,9 @@ var listCommand = new Command().name("list").alias("ls").description("List avail
|
|
|
382
377
|
const spinner = ora("Fetching remote templates...").start();
|
|
383
378
|
try {
|
|
384
379
|
const manager = getTemplateManager();
|
|
385
|
-
const templates2 = await manager.listRemoteTemplates(
|
|
380
|
+
const templates2 = await manager.listRemoteTemplates({
|
|
381
|
+
forceRefresh: !!options.refresh
|
|
382
|
+
});
|
|
386
383
|
spinner.stop();
|
|
387
384
|
if (templates2.length === 0) {
|
|
388
385
|
console.log(chalk.yellow("\u{1F4ED} No remote templates found."));
|
|
@@ -433,88 +430,98 @@ var listCommand = new Command().name("list").alias("ls").description("List avail
|
|
|
433
430
|
process.exitCode = 1;
|
|
434
431
|
}
|
|
435
432
|
});
|
|
436
|
-
var downloadCommand = new Command().name("download").alias("get").argument("[templateId]", "Template ID to download").description("Download a template to local storage").option("-a, --all", "Download all available templates").
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
433
|
+
var downloadCommand = new Command().name("download").alias("get").argument("[templateId]", "Template ID to download").description("Download a template to local storage").option("-a, --all", "Download all available templates").option("-r, --refresh", "Force refresh the template index cache").action(
|
|
434
|
+
async (templateId, options) => {
|
|
435
|
+
const manager = getTemplateManager();
|
|
436
|
+
if (options?.all) {
|
|
437
|
+
const spinner2 = ora("Fetching template list...").start();
|
|
438
|
+
try {
|
|
439
|
+
const templates2 = await manager.listRemoteTemplates({
|
|
440
|
+
forceRefresh: true
|
|
441
|
+
});
|
|
442
|
+
const toDownload = templates2.filter((t) => !t.isDownloaded);
|
|
443
|
+
spinner2.stop();
|
|
444
|
+
if (toDownload.length === 0) {
|
|
445
|
+
console.log(chalk.green("\u2713 All templates already downloaded"));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
console.log(
|
|
449
|
+
chalk.bold(`
|
|
450
450
|
Downloading ${toDownload.length} templates...
|
|
451
451
|
`)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
452
|
+
);
|
|
453
|
+
for (const t of toDownload) {
|
|
454
|
+
const downloadSpinner = ora(
|
|
455
|
+
`Downloading ${t.metadata.id}...`
|
|
456
|
+
).start();
|
|
457
|
+
try {
|
|
458
|
+
await manager.downloadTemplate(t.metadata.id);
|
|
459
|
+
downloadSpinner.succeed(`Downloaded ${t.metadata.id}`);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
downloadSpinner.fail(
|
|
462
|
+
`Failed: ${t.metadata.id} - ${error.message}`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
462
465
|
}
|
|
466
|
+
console.log(chalk.green("\n\u2713 Download complete\n"));
|
|
467
|
+
} catch (error) {
|
|
468
|
+
spinner2.fail("Failed to fetch templates");
|
|
469
|
+
console.error(chalk.red(error.message));
|
|
470
|
+
process.exitCode = 1;
|
|
463
471
|
}
|
|
464
|
-
|
|
465
|
-
} catch (error) {
|
|
466
|
-
spinner2.fail("Failed to fetch templates");
|
|
467
|
-
console.error(chalk.red(error.message));
|
|
468
|
-
process.exitCode = 1;
|
|
472
|
+
return;
|
|
469
473
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
474
|
+
if (!templateId) {
|
|
475
|
+
const spinner2 = ora("Fetching templates...").start();
|
|
476
|
+
try {
|
|
477
|
+
const templates2 = await manager.listRemoteTemplates({
|
|
478
|
+
forceRefresh: !!options?.refresh
|
|
479
|
+
});
|
|
480
|
+
spinner2.stop();
|
|
481
|
+
const notDownloaded = templates2.filter((t) => !t.isDownloaded);
|
|
482
|
+
if (notDownloaded.length === 0) {
|
|
483
|
+
console.log(chalk.green("\u2713 All templates already downloaded"));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const choices = notDownloaded.map((t) => ({
|
|
487
|
+
title: t.metadata.id,
|
|
488
|
+
description: `${t.metadata.provider} - ${t.metadata.event}`,
|
|
489
|
+
value: t.metadata.id
|
|
490
|
+
}));
|
|
491
|
+
const response = await prompts({
|
|
492
|
+
type: "select",
|
|
493
|
+
name: "templateId",
|
|
494
|
+
message: "Select a template to download:",
|
|
495
|
+
choices
|
|
496
|
+
});
|
|
497
|
+
if (!response.templateId) {
|
|
498
|
+
console.log(chalk.yellow("Cancelled"));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
templateId = response.templateId;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
spinner2.fail("Failed to fetch templates");
|
|
504
|
+
console.error(chalk.red(error.message));
|
|
505
|
+
process.exitCode = 1;
|
|
495
506
|
return;
|
|
496
507
|
}
|
|
497
|
-
|
|
508
|
+
}
|
|
509
|
+
const spinner = ora(`Downloading ${templateId}...`).start();
|
|
510
|
+
try {
|
|
511
|
+
const template = await manager.downloadTemplate(templateId);
|
|
512
|
+
spinner.succeed(`Downloaded ${templateId}`);
|
|
513
|
+
console.log(chalk.gray(` Saved to: ${template.filePath}`));
|
|
514
|
+
console.log(
|
|
515
|
+
chalk.gray(` Run with: better-webhook run ${templateId}
|
|
516
|
+
`)
|
|
517
|
+
);
|
|
498
518
|
} catch (error) {
|
|
499
|
-
|
|
519
|
+
spinner.fail(`Failed to download ${templateId}`);
|
|
500
520
|
console.error(chalk.red(error.message));
|
|
501
521
|
process.exitCode = 1;
|
|
502
|
-
return;
|
|
503
522
|
}
|
|
504
523
|
}
|
|
505
|
-
|
|
506
|
-
try {
|
|
507
|
-
const template = await manager.downloadTemplate(templateId);
|
|
508
|
-
spinner.succeed(`Downloaded ${templateId}`);
|
|
509
|
-
console.log(chalk.gray(` Saved to: ${template.filePath}`));
|
|
510
|
-
console.log(chalk.gray(` Run with: better-webhook run ${templateId}
|
|
511
|
-
`));
|
|
512
|
-
} catch (error) {
|
|
513
|
-
spinner.fail(`Failed to download ${templateId}`);
|
|
514
|
-
console.error(chalk.red(error.message));
|
|
515
|
-
process.exitCode = 1;
|
|
516
|
-
}
|
|
517
|
-
});
|
|
524
|
+
);
|
|
518
525
|
var localCommand = new Command().name("local").description("List downloaded local templates").option("-p, --provider <provider>", "Filter by provider").action((options) => {
|
|
519
526
|
const manager = getTemplateManager();
|
|
520
527
|
let templates2 = manager.listLocalTemplates();
|
|
@@ -984,6 +991,7 @@ function getSecretEnvVarName(provider) {
|
|
|
984
991
|
stripe: "STRIPE_WEBHOOK_SECRET",
|
|
985
992
|
shopify: "SHOPIFY_WEBHOOK_SECRET",
|
|
986
993
|
twilio: "TWILIO_WEBHOOK_SECRET",
|
|
994
|
+
ragie: "RAGIE_WEBHOOK_SECRET",
|
|
987
995
|
slack: "SLACK_WEBHOOK_SECRET",
|
|
988
996
|
linear: "LINEAR_WEBHOOK_SECRET",
|
|
989
997
|
clerk: "CLERK_WEBHOOK_SECRET",
|
|
@@ -1175,7 +1183,8 @@ import {
|
|
|
1175
1183
|
mkdirSync as mkdirSync2,
|
|
1176
1184
|
existsSync as existsSync2,
|
|
1177
1185
|
readdirSync as readdirSync2,
|
|
1178
|
-
readFileSync as readFileSync2
|
|
1186
|
+
readFileSync as readFileSync2,
|
|
1187
|
+
unlinkSync as unlinkSync2
|
|
1179
1188
|
} from "fs";
|
|
1180
1189
|
import { join as join2 } from "path";
|
|
1181
1190
|
import { randomUUID } from "crypto";
|
|
@@ -1186,8 +1195,13 @@ var CaptureServer = class {
|
|
|
1186
1195
|
capturesDir;
|
|
1187
1196
|
clients = /* @__PURE__ */ new Set();
|
|
1188
1197
|
captureCount = 0;
|
|
1189
|
-
|
|
1198
|
+
enableWebSocket;
|
|
1199
|
+
onCapture;
|
|
1200
|
+
constructor(options) {
|
|
1201
|
+
const capturesDir = typeof options === "string" ? options : options?.capturesDir;
|
|
1190
1202
|
this.capturesDir = capturesDir || join2(homedir2(), ".better-webhook", "captures");
|
|
1203
|
+
this.enableWebSocket = typeof options === "object" ? options?.enableWebSocket !== false : true;
|
|
1204
|
+
this.onCapture = typeof options === "object" ? options?.onCapture : void 0;
|
|
1191
1205
|
if (!existsSync2(this.capturesDir)) {
|
|
1192
1206
|
mkdirSync2(this.capturesDir, { recursive: true });
|
|
1193
1207
|
}
|
|
@@ -1204,26 +1218,28 @@ var CaptureServer = class {
|
|
|
1204
1218
|
async start(port = 3001, host = "0.0.0.0") {
|
|
1205
1219
|
return new Promise((resolve, reject) => {
|
|
1206
1220
|
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
this.
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1221
|
+
if (this.enableWebSocket) {
|
|
1222
|
+
this.wss = new WebSocketServer({ server: this.server });
|
|
1223
|
+
this.wss.on("connection", (ws) => {
|
|
1224
|
+
this.clients.add(ws);
|
|
1225
|
+
console.log("\u{1F4E1} Dashboard connected via WebSocket");
|
|
1226
|
+
ws.on("close", () => {
|
|
1227
|
+
this.clients.delete(ws);
|
|
1228
|
+
console.log("\u{1F4E1} Dashboard disconnected");
|
|
1229
|
+
});
|
|
1230
|
+
ws.on("error", (error) => {
|
|
1231
|
+
console.error("WebSocket error:", error);
|
|
1232
|
+
this.clients.delete(ws);
|
|
1233
|
+
});
|
|
1234
|
+
this.sendToClient(ws, {
|
|
1235
|
+
type: "captures_updated",
|
|
1236
|
+
payload: {
|
|
1237
|
+
captures: this.listCaptures(),
|
|
1238
|
+
count: this.captureCount
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1225
1241
|
});
|
|
1226
|
-
}
|
|
1242
|
+
}
|
|
1227
1243
|
this.server.on("error", (err) => {
|
|
1228
1244
|
if (err.code === "EADDRINUSE") {
|
|
1229
1245
|
reject(new Error(`Port ${port} is already in use`));
|
|
@@ -1241,7 +1257,9 @@ var CaptureServer = class {
|
|
|
1241
1257
|
);
|
|
1242
1258
|
console.log(` \u{1F4C1} Captures saved to: ${this.capturesDir}`);
|
|
1243
1259
|
console.log(` \u{1F4A1} Send webhooks to any path to capture them`);
|
|
1244
|
-
|
|
1260
|
+
if (this.enableWebSocket) {
|
|
1261
|
+
console.log(` \u{1F310} WebSocket available for real-time updates`);
|
|
1262
|
+
}
|
|
1245
1263
|
console.log(` \u23F9\uFE0F Press Ctrl+C to stop
|
|
1246
1264
|
`);
|
|
1247
1265
|
resolve(actualPort);
|
|
@@ -1341,13 +1359,16 @@ var CaptureServer = class {
|
|
|
1341
1359
|
console.log(
|
|
1342
1360
|
`\u{1F4E6} ${req.method} ${urlParts.pathname}${providerStr} -> ${filename}`
|
|
1343
1361
|
);
|
|
1344
|
-
this.
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1362
|
+
this.onCapture?.({ file: filename, capture: captured });
|
|
1363
|
+
if (this.enableWebSocket) {
|
|
1364
|
+
this.broadcast({
|
|
1365
|
+
type: "capture",
|
|
1366
|
+
payload: {
|
|
1367
|
+
file: filename,
|
|
1368
|
+
capture: captured
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1351
1372
|
} catch (error) {
|
|
1352
1373
|
console.error(`\u274C Failed to save capture:`, error);
|
|
1353
1374
|
}
|
|
@@ -1374,6 +1395,9 @@ var CaptureServer = class {
|
|
|
1374
1395
|
if (headers["x-github-event"] || headers["x-hub-signature-256"]) {
|
|
1375
1396
|
return "github";
|
|
1376
1397
|
}
|
|
1398
|
+
if (headers["x-ragie-delivery"]) {
|
|
1399
|
+
return "ragie";
|
|
1400
|
+
}
|
|
1377
1401
|
if (headers["x-shopify-hmac-sha256"] || headers["x-shopify-topic"]) {
|
|
1378
1402
|
return "shopify";
|
|
1379
1403
|
}
|
|
@@ -1453,8 +1477,7 @@ var CaptureServer = class {
|
|
|
1453
1477
|
return false;
|
|
1454
1478
|
}
|
|
1455
1479
|
try {
|
|
1456
|
-
|
|
1457
|
-
fs.unlinkSync(join2(this.capturesDir, capture2.file));
|
|
1480
|
+
unlinkSync2(join2(this.capturesDir, capture2.file));
|
|
1458
1481
|
return true;
|
|
1459
1482
|
} catch {
|
|
1460
1483
|
return false;
|
|
@@ -1497,7 +1520,7 @@ import chalk4 from "chalk";
|
|
|
1497
1520
|
import prompts3 from "prompts";
|
|
1498
1521
|
|
|
1499
1522
|
// src/core/replay-engine.ts
|
|
1500
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync3, unlinkSync as
|
|
1523
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync3, unlinkSync as unlinkSync3 } from "fs";
|
|
1501
1524
|
import { join as join3 } from "path";
|
|
1502
1525
|
import { homedir as homedir3 } from "os";
|
|
1503
1526
|
var ReplayEngine = class {
|
|
@@ -1688,7 +1711,7 @@ var ReplayEngine = class {
|
|
|
1688
1711
|
return false;
|
|
1689
1712
|
}
|
|
1690
1713
|
try {
|
|
1691
|
-
|
|
1714
|
+
unlinkSync3(join3(this.capturesDir, captureFile.file));
|
|
1692
1715
|
return true;
|
|
1693
1716
|
} catch {
|
|
1694
1717
|
return false;
|
|
@@ -1708,7 +1731,7 @@ var ReplayEngine = class {
|
|
|
1708
1731
|
let deleted = 0;
|
|
1709
1732
|
for (const file of files) {
|
|
1710
1733
|
try {
|
|
1711
|
-
|
|
1734
|
+
unlinkSync3(join3(this.capturesDir, file));
|
|
1712
1735
|
deleted++;
|
|
1713
1736
|
} catch {
|
|
1714
1737
|
}
|
|
@@ -2077,9 +2100,482 @@ var replay = new Command5().name("replay").argument("[captureId]", "Capture ID t
|
|
|
2077
2100
|
}
|
|
2078
2101
|
);
|
|
2079
2102
|
|
|
2103
|
+
// src/commands/dashboard.ts
|
|
2104
|
+
import { Command as Command6 } from "commander";
|
|
2105
|
+
import chalk6 from "chalk";
|
|
2106
|
+
|
|
2107
|
+
// src/core/dashboard-server.ts
|
|
2108
|
+
import express2 from "express";
|
|
2109
|
+
import { createServer as createServer2 } from "http";
|
|
2110
|
+
import { WebSocketServer as WebSocketServer2 } from "ws";
|
|
2111
|
+
import path from "path";
|
|
2112
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2113
|
+
import { fileURLToPath } from "url";
|
|
2114
|
+
|
|
2115
|
+
// src/core/dashboard-api.ts
|
|
2116
|
+
import express from "express";
|
|
2117
|
+
import { z as z2 } from "zod";
|
|
2118
|
+
function jsonError(res, status, message) {
|
|
2119
|
+
return res.status(status).json({ error: message });
|
|
2120
|
+
}
|
|
2121
|
+
function getSecretEnvVarName2(provider) {
|
|
2122
|
+
const envVarMap = {
|
|
2123
|
+
github: "GITHUB_WEBHOOK_SECRET",
|
|
2124
|
+
stripe: "STRIPE_WEBHOOK_SECRET",
|
|
2125
|
+
shopify: "SHOPIFY_WEBHOOK_SECRET",
|
|
2126
|
+
twilio: "TWILIO_WEBHOOK_SECRET",
|
|
2127
|
+
ragie: "RAGIE_WEBHOOK_SECRET",
|
|
2128
|
+
slack: "SLACK_WEBHOOK_SECRET",
|
|
2129
|
+
linear: "LINEAR_WEBHOOK_SECRET",
|
|
2130
|
+
clerk: "CLERK_WEBHOOK_SECRET",
|
|
2131
|
+
sendgrid: "SENDGRID_WEBHOOK_SECRET",
|
|
2132
|
+
discord: "DISCORD_WEBHOOK_SECRET",
|
|
2133
|
+
custom: "WEBHOOK_SECRET"
|
|
2134
|
+
};
|
|
2135
|
+
return envVarMap[provider] || "WEBHOOK_SECRET";
|
|
2136
|
+
}
|
|
2137
|
+
var ReplayBodySchema = z2.object({
|
|
2138
|
+
captureId: z2.string().min(1),
|
|
2139
|
+
targetUrl: z2.string().min(1),
|
|
2140
|
+
method: HttpMethodSchema.optional(),
|
|
2141
|
+
headers: z2.array(HeaderEntrySchema).optional()
|
|
2142
|
+
});
|
|
2143
|
+
var TemplateDownloadBodySchema = z2.object({
|
|
2144
|
+
id: z2.string().min(1)
|
|
2145
|
+
});
|
|
2146
|
+
var RunTemplateBodySchema = z2.object({
|
|
2147
|
+
templateId: z2.string().min(1),
|
|
2148
|
+
url: z2.string().min(1),
|
|
2149
|
+
secret: z2.string().optional(),
|
|
2150
|
+
headers: z2.array(HeaderEntrySchema).optional()
|
|
2151
|
+
});
|
|
2152
|
+
function createDashboardApiRouter(options = {}) {
|
|
2153
|
+
const router = express.Router();
|
|
2154
|
+
const replayEngine = new ReplayEngine(options.capturesDir);
|
|
2155
|
+
const templateManager = new TemplateManager(options.templatesBaseDir);
|
|
2156
|
+
const broadcast = options.broadcast;
|
|
2157
|
+
const broadcastCaptures = () => {
|
|
2158
|
+
if (!broadcast) return;
|
|
2159
|
+
const captures2 = replayEngine.listCaptures(200);
|
|
2160
|
+
broadcast({
|
|
2161
|
+
type: "captures_updated",
|
|
2162
|
+
payload: { captures: captures2, count: captures2.length }
|
|
2163
|
+
});
|
|
2164
|
+
};
|
|
2165
|
+
const broadcastTemplates = async () => {
|
|
2166
|
+
if (!broadcast) return;
|
|
2167
|
+
const local = templateManager.listLocalTemplates();
|
|
2168
|
+
let remote = [];
|
|
2169
|
+
try {
|
|
2170
|
+
const index = await templateManager.fetchRemoteIndex(false);
|
|
2171
|
+
const localIds = new Set(local.map((t) => t.id));
|
|
2172
|
+
remote = index.templates.map((metadata) => ({
|
|
2173
|
+
metadata,
|
|
2174
|
+
isDownloaded: localIds.has(metadata.id)
|
|
2175
|
+
}));
|
|
2176
|
+
} catch {
|
|
2177
|
+
remote = [];
|
|
2178
|
+
}
|
|
2179
|
+
broadcast({
|
|
2180
|
+
type: "templates_updated",
|
|
2181
|
+
payload: { local, remote }
|
|
2182
|
+
});
|
|
2183
|
+
};
|
|
2184
|
+
router.get("/captures", (req, res) => {
|
|
2185
|
+
const limitRaw = typeof req.query.limit === "string" ? req.query.limit : "";
|
|
2186
|
+
const providerRaw = typeof req.query.provider === "string" ? req.query.provider : "";
|
|
2187
|
+
const qRaw = typeof req.query.q === "string" ? req.query.q : "";
|
|
2188
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
|
|
2189
|
+
if (!Number.isFinite(limit) || limit <= 0 || limit > 5e3) {
|
|
2190
|
+
return jsonError(res, 400, "Invalid limit");
|
|
2191
|
+
}
|
|
2192
|
+
const q = qRaw.trim();
|
|
2193
|
+
const provider = providerRaw.trim();
|
|
2194
|
+
let captures2 = q ? replayEngine.searchCaptures(q) : replayEngine.listCaptures(Math.max(limit, 1e3));
|
|
2195
|
+
if (provider) {
|
|
2196
|
+
captures2 = captures2.filter(
|
|
2197
|
+
(c) => (c.capture.provider || "").toLowerCase() === provider.toLowerCase()
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
captures2 = captures2.slice(0, limit);
|
|
2201
|
+
return res.json({ captures: captures2, count: captures2.length });
|
|
2202
|
+
});
|
|
2203
|
+
router.get("/captures/:id", (req, res) => {
|
|
2204
|
+
const id = req.params.id;
|
|
2205
|
+
if (!id) {
|
|
2206
|
+
return jsonError(res, 400, "Missing capture id");
|
|
2207
|
+
}
|
|
2208
|
+
const captureFile = replayEngine.getCapture(id);
|
|
2209
|
+
if (!captureFile) {
|
|
2210
|
+
return jsonError(res, 404, "Capture not found");
|
|
2211
|
+
}
|
|
2212
|
+
return res.json(captureFile);
|
|
2213
|
+
});
|
|
2214
|
+
router.delete("/captures/:id", (req, res) => {
|
|
2215
|
+
const id = req.params.id;
|
|
2216
|
+
if (!id) {
|
|
2217
|
+
return jsonError(res, 400, "Missing capture id");
|
|
2218
|
+
}
|
|
2219
|
+
const deleted = replayEngine.deleteCapture(id);
|
|
2220
|
+
if (!deleted) {
|
|
2221
|
+
return jsonError(res, 404, "Capture not found");
|
|
2222
|
+
}
|
|
2223
|
+
broadcastCaptures();
|
|
2224
|
+
return res.json({ success: true });
|
|
2225
|
+
});
|
|
2226
|
+
router.delete("/captures", (_req, res) => {
|
|
2227
|
+
const deleted = replayEngine.deleteAllCaptures();
|
|
2228
|
+
broadcastCaptures();
|
|
2229
|
+
return res.json({ success: true, deleted });
|
|
2230
|
+
});
|
|
2231
|
+
router.post("/replay", express.json({ limit: "5mb" }), async (req, res) => {
|
|
2232
|
+
const parsed = ReplayBodySchema.safeParse(req.body);
|
|
2233
|
+
if (!parsed.success) {
|
|
2234
|
+
return jsonError(
|
|
2235
|
+
res,
|
|
2236
|
+
400,
|
|
2237
|
+
parsed.error.issues[0]?.message || "Invalid body"
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
const { captureId, targetUrl, method, headers } = parsed.data;
|
|
2241
|
+
try {
|
|
2242
|
+
new URL(targetUrl);
|
|
2243
|
+
} catch {
|
|
2244
|
+
return jsonError(res, 400, "Invalid targetUrl");
|
|
2245
|
+
}
|
|
2246
|
+
try {
|
|
2247
|
+
const result = await replayEngine.replay(captureId, {
|
|
2248
|
+
targetUrl,
|
|
2249
|
+
method,
|
|
2250
|
+
headers
|
|
2251
|
+
});
|
|
2252
|
+
broadcast?.({
|
|
2253
|
+
type: "replay_result",
|
|
2254
|
+
payload: { captureId, targetUrl, result }
|
|
2255
|
+
});
|
|
2256
|
+
return res.json(result);
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
return jsonError(res, 400, error?.message || "Replay failed");
|
|
2259
|
+
}
|
|
2260
|
+
});
|
|
2261
|
+
router.get("/templates/local", (_req, res) => {
|
|
2262
|
+
const local = templateManager.listLocalTemplates();
|
|
2263
|
+
return res.json({ templates: local, count: local.length });
|
|
2264
|
+
});
|
|
2265
|
+
router.get("/templates/remote", async (req, res) => {
|
|
2266
|
+
const refresh = typeof req.query.refresh === "string" ? req.query.refresh === "1" || req.query.refresh.toLowerCase() === "true" : false;
|
|
2267
|
+
try {
|
|
2268
|
+
const index = await templateManager.fetchRemoteIndex(refresh);
|
|
2269
|
+
const localIds = new Set(
|
|
2270
|
+
templateManager.listLocalTemplates().map((t) => t.id)
|
|
2271
|
+
);
|
|
2272
|
+
const remote = index.templates.map((metadata) => ({
|
|
2273
|
+
metadata,
|
|
2274
|
+
isDownloaded: localIds.has(metadata.id)
|
|
2275
|
+
}));
|
|
2276
|
+
return res.json({ templates: remote, count: remote.length });
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
return jsonError(
|
|
2279
|
+
res,
|
|
2280
|
+
500,
|
|
2281
|
+
error?.message || "Failed to fetch remote templates"
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
});
|
|
2285
|
+
router.post(
|
|
2286
|
+
"/templates/download",
|
|
2287
|
+
express.json({ limit: "2mb" }),
|
|
2288
|
+
async (req, res) => {
|
|
2289
|
+
const parsed = TemplateDownloadBodySchema.safeParse(req.body);
|
|
2290
|
+
if (!parsed.success) {
|
|
2291
|
+
return jsonError(
|
|
2292
|
+
res,
|
|
2293
|
+
400,
|
|
2294
|
+
parsed.error.issues[0]?.message || "Invalid body"
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
try {
|
|
2298
|
+
const template = await templateManager.downloadTemplate(parsed.data.id);
|
|
2299
|
+
void broadcastTemplates();
|
|
2300
|
+
return res.json({ success: true, template });
|
|
2301
|
+
} catch (error) {
|
|
2302
|
+
return jsonError(res, 400, error?.message || "Download failed");
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
);
|
|
2306
|
+
router.post(
|
|
2307
|
+
"/templates/download-all",
|
|
2308
|
+
express.json({ limit: "1mb" }),
|
|
2309
|
+
async (_req, res) => {
|
|
2310
|
+
try {
|
|
2311
|
+
const index = await templateManager.fetchRemoteIndex(true);
|
|
2312
|
+
const localIds = new Set(
|
|
2313
|
+
templateManager.listLocalTemplates().map((t) => t.id)
|
|
2314
|
+
);
|
|
2315
|
+
const toDownload = index.templates.filter((t) => !localIds.has(t.id));
|
|
2316
|
+
const downloaded = [];
|
|
2317
|
+
const failed = [];
|
|
2318
|
+
for (const t of toDownload) {
|
|
2319
|
+
try {
|
|
2320
|
+
await templateManager.downloadTemplate(t.id);
|
|
2321
|
+
downloaded.push(t.id);
|
|
2322
|
+
} catch (e) {
|
|
2323
|
+
failed.push({ id: t.id, error: e?.message || "Failed" });
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
return res.json({
|
|
2327
|
+
success: true,
|
|
2328
|
+
total: index.templates.length,
|
|
2329
|
+
downloaded,
|
|
2330
|
+
failed
|
|
2331
|
+
});
|
|
2332
|
+
} catch (error) {
|
|
2333
|
+
return jsonError(res, 500, error?.message || "Download-all failed");
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
);
|
|
2337
|
+
router.post("/run", express.json({ limit: "10mb" }), async (req, res) => {
|
|
2338
|
+
const parsed = RunTemplateBodySchema.safeParse(req.body);
|
|
2339
|
+
if (!parsed.success) {
|
|
2340
|
+
return jsonError(
|
|
2341
|
+
res,
|
|
2342
|
+
400,
|
|
2343
|
+
parsed.error.issues[0]?.message || "Invalid body"
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
let { templateId, url, secret, headers } = parsed.data;
|
|
2347
|
+
try {
|
|
2348
|
+
new URL(url);
|
|
2349
|
+
} catch {
|
|
2350
|
+
return jsonError(res, 400, "Invalid url");
|
|
2351
|
+
}
|
|
2352
|
+
if (templateId.startsWith("remote:")) {
|
|
2353
|
+
templateId = templateId.slice("remote:".length);
|
|
2354
|
+
try {
|
|
2355
|
+
await templateManager.downloadTemplate(templateId);
|
|
2356
|
+
} catch (error) {
|
|
2357
|
+
return jsonError(
|
|
2358
|
+
res,
|
|
2359
|
+
400,
|
|
2360
|
+
error?.message || "Failed to download template"
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
let localTemplate = templateManager.getLocalTemplate(templateId);
|
|
2365
|
+
if (!localTemplate) {
|
|
2366
|
+
try {
|
|
2367
|
+
await templateManager.downloadTemplate(templateId);
|
|
2368
|
+
localTemplate = templateManager.getLocalTemplate(templateId);
|
|
2369
|
+
} catch {
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (!localTemplate) {
|
|
2373
|
+
return jsonError(res, 404, "Template not found");
|
|
2374
|
+
}
|
|
2375
|
+
if (!secret && localTemplate.metadata.provider) {
|
|
2376
|
+
const envVarName = getSecretEnvVarName2(localTemplate.metadata.provider);
|
|
2377
|
+
secret = process.env[envVarName];
|
|
2378
|
+
}
|
|
2379
|
+
const safeHeaders = headers?.length ? headers : void 0;
|
|
2380
|
+
try {
|
|
2381
|
+
const result = await executeTemplate(localTemplate.template, {
|
|
2382
|
+
url,
|
|
2383
|
+
secret,
|
|
2384
|
+
headers: safeHeaders
|
|
2385
|
+
});
|
|
2386
|
+
broadcast?.({
|
|
2387
|
+
type: "replay_result",
|
|
2388
|
+
payload: { templateId, url, result }
|
|
2389
|
+
});
|
|
2390
|
+
return res.json(result);
|
|
2391
|
+
} catch (error) {
|
|
2392
|
+
return jsonError(res, 400, error?.message || "Run failed");
|
|
2393
|
+
}
|
|
2394
|
+
});
|
|
2395
|
+
return router;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// src/core/dashboard-server.ts
|
|
2399
|
+
function resolveDashboardDistDir(runtimeDir) {
|
|
2400
|
+
const candidates = [
|
|
2401
|
+
path.resolve(runtimeDir, "..", "dashboard"),
|
|
2402
|
+
path.resolve(runtimeDir, "..", "..", "dist", "dashboard"),
|
|
2403
|
+
path.resolve(runtimeDir, "..", "..", "..", "dashboard", "dist")
|
|
2404
|
+
];
|
|
2405
|
+
for (const distDir of candidates) {
|
|
2406
|
+
const indexHtml = path.join(distDir, "index.html");
|
|
2407
|
+
if (existsSync4(indexHtml)) {
|
|
2408
|
+
return { distDir, indexHtml };
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
const details = candidates.map((p) => `- ${p}`).join("\n");
|
|
2412
|
+
throw new Error(
|
|
2413
|
+
`Dashboard UI build output not found.
|
|
2414
|
+
Looked in:
|
|
2415
|
+
${details}
|
|
2416
|
+
|
|
2417
|
+
Build it with:
|
|
2418
|
+
- pnpm --filter @better-webhook/dashboard build
|
|
2419
|
+
- pnpm --filter @better-webhook/cli build
|
|
2420
|
+
`
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
async function startDashboardServer(options = {}) {
|
|
2424
|
+
const app = express2();
|
|
2425
|
+
app.get("/health", (_req, res) => {
|
|
2426
|
+
res.json({ ok: true });
|
|
2427
|
+
});
|
|
2428
|
+
const clients = /* @__PURE__ */ new Set();
|
|
2429
|
+
const broadcast = (message) => {
|
|
2430
|
+
const data = JSON.stringify(message);
|
|
2431
|
+
for (const client of clients) {
|
|
2432
|
+
if (client.readyState === 1) {
|
|
2433
|
+
client.send(data);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
};
|
|
2437
|
+
app.use(
|
|
2438
|
+
"/api",
|
|
2439
|
+
createDashboardApiRouter({
|
|
2440
|
+
capturesDir: options.capturesDir,
|
|
2441
|
+
templatesBaseDir: options.templatesBaseDir,
|
|
2442
|
+
broadcast
|
|
2443
|
+
})
|
|
2444
|
+
);
|
|
2445
|
+
const host = options.host || "localhost";
|
|
2446
|
+
const port = options.port ?? 4e3;
|
|
2447
|
+
const runtimeDir = (
|
|
2448
|
+
// eslint-disable-next-line no-undef
|
|
2449
|
+
typeof __dirname !== "undefined" ? (
|
|
2450
|
+
// eslint-disable-next-line no-undef
|
|
2451
|
+
__dirname
|
|
2452
|
+
) : path.dirname(fileURLToPath(import.meta.url))
|
|
2453
|
+
);
|
|
2454
|
+
const { distDir: dashboardDistDir, indexHtml: dashboardIndexHtml } = resolveDashboardDistDir(runtimeDir);
|
|
2455
|
+
app.use(express2.static(dashboardDistDir));
|
|
2456
|
+
app.get("*", (req, res, next) => {
|
|
2457
|
+
if (req.path.startsWith("/api") || req.path === "/health") return next();
|
|
2458
|
+
res.sendFile(dashboardIndexHtml, (err) => {
|
|
2459
|
+
if (err) next();
|
|
2460
|
+
});
|
|
2461
|
+
});
|
|
2462
|
+
const server = createServer2(app);
|
|
2463
|
+
const wss = new WebSocketServer2({ server, path: "/ws" });
|
|
2464
|
+
wss.on("connection", async (ws) => {
|
|
2465
|
+
clients.add(ws);
|
|
2466
|
+
ws.on("close", () => clients.delete(ws));
|
|
2467
|
+
ws.on("error", () => clients.delete(ws));
|
|
2468
|
+
const replayEngine = new ReplayEngine(options.capturesDir);
|
|
2469
|
+
const templateManager = new TemplateManager(options.templatesBaseDir);
|
|
2470
|
+
const captures2 = replayEngine.listCaptures(200);
|
|
2471
|
+
ws.send(
|
|
2472
|
+
JSON.stringify({
|
|
2473
|
+
type: "captures_updated",
|
|
2474
|
+
payload: { captures: captures2, count: captures2.length }
|
|
2475
|
+
})
|
|
2476
|
+
);
|
|
2477
|
+
const local = templateManager.listLocalTemplates();
|
|
2478
|
+
let remote = [];
|
|
2479
|
+
try {
|
|
2480
|
+
const index = await templateManager.fetchRemoteIndex(true);
|
|
2481
|
+
const localIds = new Set(local.map((t) => t.id));
|
|
2482
|
+
remote = index.templates.map((metadata) => ({
|
|
2483
|
+
metadata,
|
|
2484
|
+
isDownloaded: localIds.has(metadata.id)
|
|
2485
|
+
}));
|
|
2486
|
+
} catch {
|
|
2487
|
+
remote = [];
|
|
2488
|
+
}
|
|
2489
|
+
ws.send(
|
|
2490
|
+
JSON.stringify({
|
|
2491
|
+
type: "templates_updated",
|
|
2492
|
+
payload: { local, remote }
|
|
2493
|
+
})
|
|
2494
|
+
);
|
|
2495
|
+
});
|
|
2496
|
+
await new Promise((resolve, reject) => {
|
|
2497
|
+
server.listen(port, host, () => resolve());
|
|
2498
|
+
server.on("error", reject);
|
|
2499
|
+
});
|
|
2500
|
+
const url = `http://${host}:${port}`;
|
|
2501
|
+
let capture2;
|
|
2502
|
+
const shouldStartCapture = options.startCapture !== false;
|
|
2503
|
+
if (shouldStartCapture) {
|
|
2504
|
+
const captureHost = options.captureHost || "0.0.0.0";
|
|
2505
|
+
const capturePort = options.capturePort ?? 3001;
|
|
2506
|
+
const captureServer = new CaptureServer({
|
|
2507
|
+
capturesDir: options.capturesDir,
|
|
2508
|
+
enableWebSocket: false,
|
|
2509
|
+
onCapture: ({ file, capture: capture3 }) => {
|
|
2510
|
+
broadcast({
|
|
2511
|
+
type: "capture",
|
|
2512
|
+
payload: { file, capture: capture3 }
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
});
|
|
2516
|
+
const actualPort = await captureServer.start(capturePort, captureHost);
|
|
2517
|
+
capture2 = {
|
|
2518
|
+
server: captureServer,
|
|
2519
|
+
url: `http://${captureHost === "0.0.0.0" ? "localhost" : captureHost}:${actualPort}`
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
return { app, server, url, capture: capture2 };
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/commands/dashboard.ts
|
|
2526
|
+
var dashboard = new Command6().name("dashboard").description("Start the local dashboard (UI + API + WebSocket) server").option("-p, --port <port>", "Port to listen on", "4000").option("-h, --host <host>", "Host to bind to", "localhost").option("--capture-port <port>", "Capture server port", "3001").option("--capture-host <host>", "Capture server host", "0.0.0.0").option("--no-capture", "Do not start the capture server").option("--captures-dir <dir>", "Override captures directory").option("--templates-dir <dir>", "Override templates base directory").action(async (options) => {
|
|
2527
|
+
const port = Number.parseInt(String(options.port), 10);
|
|
2528
|
+
if (!Number.isFinite(port) || port < 0 || port > 65535) {
|
|
2529
|
+
console.error(chalk6.red("Invalid port number"));
|
|
2530
|
+
process.exitCode = 1;
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
try {
|
|
2534
|
+
const capturePort = Number.parseInt(String(options.capturePort), 10);
|
|
2535
|
+
if (!Number.isFinite(capturePort) || capturePort < 0 || capturePort > 65535) {
|
|
2536
|
+
console.error(chalk6.red("Invalid capture port number"));
|
|
2537
|
+
process.exitCode = 1;
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
const { url, server, capture: capture2 } = await startDashboardServer({
|
|
2541
|
+
host: options.host,
|
|
2542
|
+
port,
|
|
2543
|
+
captureHost: options.captureHost,
|
|
2544
|
+
capturePort,
|
|
2545
|
+
startCapture: options.capture !== false,
|
|
2546
|
+
capturesDir: options.capturesDir,
|
|
2547
|
+
templatesBaseDir: options.templatesDir
|
|
2548
|
+
});
|
|
2549
|
+
console.log(chalk6.bold("\n\u{1F9ED} Dashboard Server\n"));
|
|
2550
|
+
console.log(chalk6.gray(` Dashboard: ${url}/`));
|
|
2551
|
+
console.log(chalk6.gray(` Health: ${url}/health`));
|
|
2552
|
+
console.log(chalk6.gray(` API Base: ${url}/api`));
|
|
2553
|
+
console.log(chalk6.gray(` WebSocket: ${url.replace("http://", "ws://")}/ws`));
|
|
2554
|
+
if (capture2) {
|
|
2555
|
+
console.log();
|
|
2556
|
+
console.log(chalk6.bold("\u{1F3A3} Capture Server"));
|
|
2557
|
+
console.log(chalk6.gray(` Capture: ${capture2.url}`));
|
|
2558
|
+
console.log(chalk6.gray(` Tip: Send webhooks to any path, e.g. ${capture2.url}/webhooks/github`));
|
|
2559
|
+
}
|
|
2560
|
+
console.log();
|
|
2561
|
+
const shutdown = async () => {
|
|
2562
|
+
if (capture2) {
|
|
2563
|
+
await capture2.server.stop();
|
|
2564
|
+
}
|
|
2565
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
2566
|
+
process.exit(0);
|
|
2567
|
+
};
|
|
2568
|
+
process.on("SIGINT", shutdown);
|
|
2569
|
+
process.on("SIGTERM", shutdown);
|
|
2570
|
+
} catch (error) {
|
|
2571
|
+
console.error(chalk6.red(`Failed to start dashboard server: ${error?.message || error}`));
|
|
2572
|
+
process.exitCode = 1;
|
|
2573
|
+
}
|
|
2574
|
+
});
|
|
2575
|
+
|
|
2080
2576
|
// src/index.ts
|
|
2081
|
-
var program = new
|
|
2577
|
+
var program = new Command7().name("better-webhook").description(
|
|
2082
2578
|
"Modern CLI for developing, capturing, and replaying webhooks locally"
|
|
2083
2579
|
).version("2.0.0");
|
|
2084
|
-
program.addCommand(templates).addCommand(run).addCommand(capture).addCommand(captures).addCommand(replay);
|
|
2580
|
+
program.addCommand(templates).addCommand(run).addCommand(capture).addCommand(captures).addCommand(replay).addCommand(dashboard);
|
|
2085
2581
|
program.parseAsync(process.argv);
|