@better-webhook/cli 3.0.0 → 3.1.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/dist/index.cjs CHANGED
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/index.ts
27
- var import_commander6 = require("commander");
27
+ var import_commander7 = require("commander");
28
28
 
29
29
  // src/commands/templates.ts
30
30
  var import_commander = require("commander");
@@ -58,6 +58,7 @@ var WebhookProviderSchema = import_zod.z.enum([
58
58
  "github",
59
59
  "shopify",
60
60
  "twilio",
61
+ "ragie",
61
62
  "sendgrid",
62
63
  "slack",
63
64
  "discord",
@@ -192,8 +193,8 @@ var TemplateManager = class {
192
193
  /**
193
194
  * List all remote templates
194
195
  */
195
- async listRemoteTemplates() {
196
- const index = await this.fetchRemoteIndex();
196
+ async listRemoteTemplates(options) {
197
+ const index = await this.fetchRemoteIndex(!!options?.forceRefresh);
197
198
  const localIds = new Set(this.listLocalTemplates().map((t) => t.id));
198
199
  return index.templates.map((metadata) => ({
199
200
  metadata,
@@ -391,7 +392,9 @@ var listCommand = new import_commander.Command().name("list").alias("ls").descri
391
392
  const spinner = (0, import_ora.default)("Fetching remote templates...").start();
392
393
  try {
393
394
  const manager = getTemplateManager();
394
- const templates2 = await manager.listRemoteTemplates();
395
+ const templates2 = await manager.listRemoteTemplates({
396
+ forceRefresh: !!options.refresh
397
+ });
395
398
  spinner.stop();
396
399
  if (templates2.length === 0) {
397
400
  console.log(import_chalk.default.yellow("\u{1F4ED} No remote templates found."));
@@ -442,88 +445,98 @@ var listCommand = new import_commander.Command().name("list").alias("ls").descri
442
445
  process.exitCode = 1;
443
446
  }
444
447
  });
445
- var downloadCommand = new import_commander.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").action(async (templateId, options) => {
446
- const manager = getTemplateManager();
447
- if (options?.all) {
448
- const spinner2 = (0, import_ora.default)("Fetching template list...").start();
449
- try {
450
- const templates2 = await manager.listRemoteTemplates();
451
- const toDownload = templates2.filter((t) => !t.isDownloaded);
452
- spinner2.stop();
453
- if (toDownload.length === 0) {
454
- console.log(import_chalk.default.green("\u2713 All templates already downloaded"));
455
- return;
456
- }
457
- console.log(
458
- import_chalk.default.bold(`
448
+ var downloadCommand = new import_commander.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(
449
+ async (templateId, options) => {
450
+ const manager = getTemplateManager();
451
+ if (options?.all) {
452
+ const spinner2 = (0, import_ora.default)("Fetching template list...").start();
453
+ try {
454
+ const templates2 = await manager.listRemoteTemplates({
455
+ forceRefresh: true
456
+ });
457
+ const toDownload = templates2.filter((t) => !t.isDownloaded);
458
+ spinner2.stop();
459
+ if (toDownload.length === 0) {
460
+ console.log(import_chalk.default.green("\u2713 All templates already downloaded"));
461
+ return;
462
+ }
463
+ console.log(
464
+ import_chalk.default.bold(`
459
465
  Downloading ${toDownload.length} templates...
460
466
  `)
461
- );
462
- for (const t of toDownload) {
463
- const downloadSpinner = (0, import_ora.default)(
464
- `Downloading ${t.metadata.id}...`
465
- ).start();
466
- try {
467
- await manager.downloadTemplate(t.metadata.id);
468
- downloadSpinner.succeed(`Downloaded ${t.metadata.id}`);
469
- } catch (error) {
470
- downloadSpinner.fail(`Failed: ${t.metadata.id} - ${error.message}`);
467
+ );
468
+ for (const t of toDownload) {
469
+ const downloadSpinner = (0, import_ora.default)(
470
+ `Downloading ${t.metadata.id}...`
471
+ ).start();
472
+ try {
473
+ await manager.downloadTemplate(t.metadata.id);
474
+ downloadSpinner.succeed(`Downloaded ${t.metadata.id}`);
475
+ } catch (error) {
476
+ downloadSpinner.fail(
477
+ `Failed: ${t.metadata.id} - ${error.message}`
478
+ );
479
+ }
471
480
  }
481
+ console.log(import_chalk.default.green("\n\u2713 Download complete\n"));
482
+ } catch (error) {
483
+ spinner2.fail("Failed to fetch templates");
484
+ console.error(import_chalk.default.red(error.message));
485
+ process.exitCode = 1;
472
486
  }
473
- console.log(import_chalk.default.green("\n\u2713 Download complete\n"));
474
- } catch (error) {
475
- spinner2.fail("Failed to fetch templates");
476
- console.error(import_chalk.default.red(error.message));
477
- process.exitCode = 1;
487
+ return;
478
488
  }
479
- return;
480
- }
481
- if (!templateId) {
482
- const spinner2 = (0, import_ora.default)("Fetching templates...").start();
483
- try {
484
- const templates2 = await manager.listRemoteTemplates();
485
- spinner2.stop();
486
- const notDownloaded = templates2.filter((t) => !t.isDownloaded);
487
- if (notDownloaded.length === 0) {
488
- console.log(import_chalk.default.green("\u2713 All templates already downloaded"));
489
- return;
490
- }
491
- const choices = notDownloaded.map((t) => ({
492
- title: t.metadata.id,
493
- description: `${t.metadata.provider} - ${t.metadata.event}`,
494
- value: t.metadata.id
495
- }));
496
- const response = await (0, import_prompts.default)({
497
- type: "select",
498
- name: "templateId",
499
- message: "Select a template to download:",
500
- choices
501
- });
502
- if (!response.templateId) {
503
- console.log(import_chalk.default.yellow("Cancelled"));
489
+ if (!templateId) {
490
+ const spinner2 = (0, import_ora.default)("Fetching templates...").start();
491
+ try {
492
+ const templates2 = await manager.listRemoteTemplates({
493
+ forceRefresh: !!options?.refresh
494
+ });
495
+ spinner2.stop();
496
+ const notDownloaded = templates2.filter((t) => !t.isDownloaded);
497
+ if (notDownloaded.length === 0) {
498
+ console.log(import_chalk.default.green("\u2713 All templates already downloaded"));
499
+ return;
500
+ }
501
+ const choices = notDownloaded.map((t) => ({
502
+ title: t.metadata.id,
503
+ description: `${t.metadata.provider} - ${t.metadata.event}`,
504
+ value: t.metadata.id
505
+ }));
506
+ const response = await (0, import_prompts.default)({
507
+ type: "select",
508
+ name: "templateId",
509
+ message: "Select a template to download:",
510
+ choices
511
+ });
512
+ if (!response.templateId) {
513
+ console.log(import_chalk.default.yellow("Cancelled"));
514
+ return;
515
+ }
516
+ templateId = response.templateId;
517
+ } catch (error) {
518
+ spinner2.fail("Failed to fetch templates");
519
+ console.error(import_chalk.default.red(error.message));
520
+ process.exitCode = 1;
504
521
  return;
505
522
  }
506
- templateId = response.templateId;
523
+ }
524
+ const spinner = (0, import_ora.default)(`Downloading ${templateId}...`).start();
525
+ try {
526
+ const template = await manager.downloadTemplate(templateId);
527
+ spinner.succeed(`Downloaded ${templateId}`);
528
+ console.log(import_chalk.default.gray(` Saved to: ${template.filePath}`));
529
+ console.log(
530
+ import_chalk.default.gray(` Run with: better-webhook run ${templateId}
531
+ `)
532
+ );
507
533
  } catch (error) {
508
- spinner2.fail("Failed to fetch templates");
534
+ spinner.fail(`Failed to download ${templateId}`);
509
535
  console.error(import_chalk.default.red(error.message));
510
536
  process.exitCode = 1;
511
- return;
512
537
  }
513
538
  }
514
- const spinner = (0, import_ora.default)(`Downloading ${templateId}...`).start();
515
- try {
516
- const template = await manager.downloadTemplate(templateId);
517
- spinner.succeed(`Downloaded ${templateId}`);
518
- console.log(import_chalk.default.gray(` Saved to: ${template.filePath}`));
519
- console.log(import_chalk.default.gray(` Run with: better-webhook run ${templateId}
520
- `));
521
- } catch (error) {
522
- spinner.fail(`Failed to download ${templateId}`);
523
- console.error(import_chalk.default.red(error.message));
524
- process.exitCode = 1;
525
- }
526
- });
539
+ );
527
540
  var localCommand = new import_commander.Command().name("local").description("List downloaded local templates").option("-p, --provider <provider>", "Filter by provider").action((options) => {
528
541
  const manager = getTemplateManager();
529
542
  let templates2 = manager.listLocalTemplates();
@@ -993,6 +1006,7 @@ function getSecretEnvVarName(provider) {
993
1006
  stripe: "STRIPE_WEBHOOK_SECRET",
994
1007
  shopify: "SHOPIFY_WEBHOOK_SECRET",
995
1008
  twilio: "TWILIO_WEBHOOK_SECRET",
1009
+ ragie: "RAGIE_WEBHOOK_SECRET",
996
1010
  slack: "SLACK_WEBHOOK_SECRET",
997
1011
  linear: "LINEAR_WEBHOOK_SECRET",
998
1012
  clerk: "CLERK_WEBHOOK_SECRET",
@@ -1187,8 +1201,13 @@ var CaptureServer = class {
1187
1201
  capturesDir;
1188
1202
  clients = /* @__PURE__ */ new Set();
1189
1203
  captureCount = 0;
1190
- constructor(capturesDir) {
1204
+ enableWebSocket;
1205
+ onCapture;
1206
+ constructor(options) {
1207
+ const capturesDir = typeof options === "string" ? options : options?.capturesDir;
1191
1208
  this.capturesDir = capturesDir || (0, import_path2.join)((0, import_os2.homedir)(), ".better-webhook", "captures");
1209
+ this.enableWebSocket = typeof options === "object" ? options?.enableWebSocket !== false : true;
1210
+ this.onCapture = typeof options === "object" ? options?.onCapture : void 0;
1192
1211
  if (!(0, import_fs2.existsSync)(this.capturesDir)) {
1193
1212
  (0, import_fs2.mkdirSync)(this.capturesDir, { recursive: true });
1194
1213
  }
@@ -1205,26 +1224,28 @@ var CaptureServer = class {
1205
1224
  async start(port = 3001, host = "0.0.0.0") {
1206
1225
  return new Promise((resolve, reject) => {
1207
1226
  this.server = (0, import_http.createServer)((req, res) => this.handleRequest(req, res));
1208
- this.wss = new import_ws.WebSocketServer({ server: this.server });
1209
- this.wss.on("connection", (ws) => {
1210
- this.clients.add(ws);
1211
- console.log("\u{1F4E1} Dashboard connected via WebSocket");
1212
- ws.on("close", () => {
1213
- this.clients.delete(ws);
1214
- console.log("\u{1F4E1} Dashboard disconnected");
1215
- });
1216
- ws.on("error", (error) => {
1217
- console.error("WebSocket error:", error);
1218
- this.clients.delete(ws);
1219
- });
1220
- this.sendToClient(ws, {
1221
- type: "captures_updated",
1222
- payload: {
1223
- captures: this.listCaptures(),
1224
- count: this.captureCount
1225
- }
1227
+ if (this.enableWebSocket) {
1228
+ this.wss = new import_ws.WebSocketServer({ server: this.server });
1229
+ this.wss.on("connection", (ws) => {
1230
+ this.clients.add(ws);
1231
+ console.log("\u{1F4E1} Dashboard connected via WebSocket");
1232
+ ws.on("close", () => {
1233
+ this.clients.delete(ws);
1234
+ console.log("\u{1F4E1} Dashboard disconnected");
1235
+ });
1236
+ ws.on("error", (error) => {
1237
+ console.error("WebSocket error:", error);
1238
+ this.clients.delete(ws);
1239
+ });
1240
+ this.sendToClient(ws, {
1241
+ type: "captures_updated",
1242
+ payload: {
1243
+ captures: this.listCaptures(),
1244
+ count: this.captureCount
1245
+ }
1246
+ });
1226
1247
  });
1227
- });
1248
+ }
1228
1249
  this.server.on("error", (err) => {
1229
1250
  if (err.code === "EADDRINUSE") {
1230
1251
  reject(new Error(`Port ${port} is already in use`));
@@ -1242,7 +1263,9 @@ var CaptureServer = class {
1242
1263
  );
1243
1264
  console.log(` \u{1F4C1} Captures saved to: ${this.capturesDir}`);
1244
1265
  console.log(` \u{1F4A1} Send webhooks to any path to capture them`);
1245
- console.log(` \u{1F310} WebSocket available for real-time updates`);
1266
+ if (this.enableWebSocket) {
1267
+ console.log(` \u{1F310} WebSocket available for real-time updates`);
1268
+ }
1246
1269
  console.log(` \u23F9\uFE0F Press Ctrl+C to stop
1247
1270
  `);
1248
1271
  resolve(actualPort);
@@ -1342,13 +1365,16 @@ var CaptureServer = class {
1342
1365
  console.log(
1343
1366
  `\u{1F4E6} ${req.method} ${urlParts.pathname}${providerStr} -> ${filename}`
1344
1367
  );
1345
- this.broadcast({
1346
- type: "capture",
1347
- payload: {
1348
- file: filename,
1349
- capture: captured
1350
- }
1351
- });
1368
+ this.onCapture?.({ file: filename, capture: captured });
1369
+ if (this.enableWebSocket) {
1370
+ this.broadcast({
1371
+ type: "capture",
1372
+ payload: {
1373
+ file: filename,
1374
+ capture: captured
1375
+ }
1376
+ });
1377
+ }
1352
1378
  } catch (error) {
1353
1379
  console.error(`\u274C Failed to save capture:`, error);
1354
1380
  }
@@ -1375,6 +1401,9 @@ var CaptureServer = class {
1375
1401
  if (headers["x-github-event"] || headers["x-hub-signature-256"]) {
1376
1402
  return "github";
1377
1403
  }
1404
+ if (headers["x-ragie-delivery"]) {
1405
+ return "ragie";
1406
+ }
1378
1407
  if (headers["x-shopify-hmac-sha256"] || headers["x-shopify-topic"]) {
1379
1408
  return "shopify";
1380
1409
  }
@@ -1454,8 +1483,7 @@ var CaptureServer = class {
1454
1483
  return false;
1455
1484
  }
1456
1485
  try {
1457
- const fs = require("fs");
1458
- fs.unlinkSync((0, import_path2.join)(this.capturesDir, capture2.file));
1486
+ (0, import_fs2.unlinkSync)((0, import_path2.join)(this.capturesDir, capture2.file));
1459
1487
  return true;
1460
1488
  } catch {
1461
1489
  return false;
@@ -2078,9 +2106,483 @@ var replay = new import_commander5.Command().name("replay").argument("[captureId
2078
2106
  }
2079
2107
  );
2080
2108
 
2109
+ // src/commands/dashboard.ts
2110
+ var import_commander6 = require("commander");
2111
+ var import_chalk6 = __toESM(require("chalk"), 1);
2112
+
2113
+ // src/core/dashboard-server.ts
2114
+ var import_express2 = __toESM(require("express"), 1);
2115
+ var import_http2 = require("http");
2116
+ var import_ws2 = require("ws");
2117
+ var import_path4 = __toESM(require("path"), 1);
2118
+ var import_fs4 = require("fs");
2119
+ var import_url = require("url");
2120
+
2121
+ // src/core/dashboard-api.ts
2122
+ var import_express = __toESM(require("express"), 1);
2123
+ var import_zod2 = require("zod");
2124
+ function jsonError(res, status, message) {
2125
+ return res.status(status).json({ error: message });
2126
+ }
2127
+ function getSecretEnvVarName2(provider) {
2128
+ const envVarMap = {
2129
+ github: "GITHUB_WEBHOOK_SECRET",
2130
+ stripe: "STRIPE_WEBHOOK_SECRET",
2131
+ shopify: "SHOPIFY_WEBHOOK_SECRET",
2132
+ twilio: "TWILIO_WEBHOOK_SECRET",
2133
+ ragie: "RAGIE_WEBHOOK_SECRET",
2134
+ slack: "SLACK_WEBHOOK_SECRET",
2135
+ linear: "LINEAR_WEBHOOK_SECRET",
2136
+ clerk: "CLERK_WEBHOOK_SECRET",
2137
+ sendgrid: "SENDGRID_WEBHOOK_SECRET",
2138
+ discord: "DISCORD_WEBHOOK_SECRET",
2139
+ custom: "WEBHOOK_SECRET"
2140
+ };
2141
+ return envVarMap[provider] || "WEBHOOK_SECRET";
2142
+ }
2143
+ var ReplayBodySchema = import_zod2.z.object({
2144
+ captureId: import_zod2.z.string().min(1),
2145
+ targetUrl: import_zod2.z.string().min(1),
2146
+ method: HttpMethodSchema.optional(),
2147
+ headers: import_zod2.z.array(HeaderEntrySchema).optional()
2148
+ });
2149
+ var TemplateDownloadBodySchema = import_zod2.z.object({
2150
+ id: import_zod2.z.string().min(1)
2151
+ });
2152
+ var RunTemplateBodySchema = import_zod2.z.object({
2153
+ templateId: import_zod2.z.string().min(1),
2154
+ url: import_zod2.z.string().min(1),
2155
+ secret: import_zod2.z.string().optional(),
2156
+ headers: import_zod2.z.array(HeaderEntrySchema).optional()
2157
+ });
2158
+ function createDashboardApiRouter(options = {}) {
2159
+ const router = import_express.default.Router();
2160
+ const replayEngine = new ReplayEngine(options.capturesDir);
2161
+ const templateManager = new TemplateManager(options.templatesBaseDir);
2162
+ const broadcast = options.broadcast;
2163
+ const broadcastCaptures = () => {
2164
+ if (!broadcast) return;
2165
+ const captures2 = replayEngine.listCaptures(200);
2166
+ broadcast({
2167
+ type: "captures_updated",
2168
+ payload: { captures: captures2, count: captures2.length }
2169
+ });
2170
+ };
2171
+ const broadcastTemplates = async () => {
2172
+ if (!broadcast) return;
2173
+ const local = templateManager.listLocalTemplates();
2174
+ let remote = [];
2175
+ try {
2176
+ const index = await templateManager.fetchRemoteIndex(false);
2177
+ const localIds = new Set(local.map((t) => t.id));
2178
+ remote = index.templates.map((metadata) => ({
2179
+ metadata,
2180
+ isDownloaded: localIds.has(metadata.id)
2181
+ }));
2182
+ } catch {
2183
+ remote = [];
2184
+ }
2185
+ broadcast({
2186
+ type: "templates_updated",
2187
+ payload: { local, remote }
2188
+ });
2189
+ };
2190
+ router.get("/captures", (req, res) => {
2191
+ const limitRaw = typeof req.query.limit === "string" ? req.query.limit : "";
2192
+ const providerRaw = typeof req.query.provider === "string" ? req.query.provider : "";
2193
+ const qRaw = typeof req.query.q === "string" ? req.query.q : "";
2194
+ const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
2195
+ if (!Number.isFinite(limit) || limit <= 0 || limit > 5e3) {
2196
+ return jsonError(res, 400, "Invalid limit");
2197
+ }
2198
+ const q = qRaw.trim();
2199
+ const provider = providerRaw.trim();
2200
+ let captures2 = q ? replayEngine.searchCaptures(q) : replayEngine.listCaptures(Math.max(limit, 1e3));
2201
+ if (provider) {
2202
+ captures2 = captures2.filter(
2203
+ (c) => (c.capture.provider || "").toLowerCase() === provider.toLowerCase()
2204
+ );
2205
+ }
2206
+ captures2 = captures2.slice(0, limit);
2207
+ return res.json({ captures: captures2, count: captures2.length });
2208
+ });
2209
+ router.get("/captures/:id", (req, res) => {
2210
+ const id = req.params.id;
2211
+ if (!id) {
2212
+ return jsonError(res, 400, "Missing capture id");
2213
+ }
2214
+ const captureFile = replayEngine.getCapture(id);
2215
+ if (!captureFile) {
2216
+ return jsonError(res, 404, "Capture not found");
2217
+ }
2218
+ return res.json(captureFile);
2219
+ });
2220
+ router.delete("/captures/:id", (req, res) => {
2221
+ const id = req.params.id;
2222
+ if (!id) {
2223
+ return jsonError(res, 400, "Missing capture id");
2224
+ }
2225
+ const deleted = replayEngine.deleteCapture(id);
2226
+ if (!deleted) {
2227
+ return jsonError(res, 404, "Capture not found");
2228
+ }
2229
+ broadcastCaptures();
2230
+ return res.json({ success: true });
2231
+ });
2232
+ router.delete("/captures", (_req, res) => {
2233
+ const deleted = replayEngine.deleteAllCaptures();
2234
+ broadcastCaptures();
2235
+ return res.json({ success: true, deleted });
2236
+ });
2237
+ router.post("/replay", import_express.default.json({ limit: "5mb" }), async (req, res) => {
2238
+ const parsed = ReplayBodySchema.safeParse(req.body);
2239
+ if (!parsed.success) {
2240
+ return jsonError(
2241
+ res,
2242
+ 400,
2243
+ parsed.error.issues[0]?.message || "Invalid body"
2244
+ );
2245
+ }
2246
+ const { captureId, targetUrl, method, headers } = parsed.data;
2247
+ try {
2248
+ new URL(targetUrl);
2249
+ } catch {
2250
+ return jsonError(res, 400, "Invalid targetUrl");
2251
+ }
2252
+ try {
2253
+ const result = await replayEngine.replay(captureId, {
2254
+ targetUrl,
2255
+ method,
2256
+ headers
2257
+ });
2258
+ broadcast?.({
2259
+ type: "replay_result",
2260
+ payload: { captureId, targetUrl, result }
2261
+ });
2262
+ return res.json(result);
2263
+ } catch (error) {
2264
+ return jsonError(res, 400, error?.message || "Replay failed");
2265
+ }
2266
+ });
2267
+ router.get("/templates/local", (_req, res) => {
2268
+ const local = templateManager.listLocalTemplates();
2269
+ return res.json({ templates: local, count: local.length });
2270
+ });
2271
+ router.get("/templates/remote", async (req, res) => {
2272
+ const refresh = typeof req.query.refresh === "string" ? req.query.refresh === "1" || req.query.refresh.toLowerCase() === "true" : false;
2273
+ try {
2274
+ const index = await templateManager.fetchRemoteIndex(refresh);
2275
+ const localIds = new Set(
2276
+ templateManager.listLocalTemplates().map((t) => t.id)
2277
+ );
2278
+ const remote = index.templates.map((metadata) => ({
2279
+ metadata,
2280
+ isDownloaded: localIds.has(metadata.id)
2281
+ }));
2282
+ return res.json({ templates: remote, count: remote.length });
2283
+ } catch (error) {
2284
+ return jsonError(
2285
+ res,
2286
+ 500,
2287
+ error?.message || "Failed to fetch remote templates"
2288
+ );
2289
+ }
2290
+ });
2291
+ router.post(
2292
+ "/templates/download",
2293
+ import_express.default.json({ limit: "2mb" }),
2294
+ async (req, res) => {
2295
+ const parsed = TemplateDownloadBodySchema.safeParse(req.body);
2296
+ if (!parsed.success) {
2297
+ return jsonError(
2298
+ res,
2299
+ 400,
2300
+ parsed.error.issues[0]?.message || "Invalid body"
2301
+ );
2302
+ }
2303
+ try {
2304
+ const template = await templateManager.downloadTemplate(parsed.data.id);
2305
+ void broadcastTemplates();
2306
+ return res.json({ success: true, template });
2307
+ } catch (error) {
2308
+ return jsonError(res, 400, error?.message || "Download failed");
2309
+ }
2310
+ }
2311
+ );
2312
+ router.post(
2313
+ "/templates/download-all",
2314
+ import_express.default.json({ limit: "1mb" }),
2315
+ async (_req, res) => {
2316
+ try {
2317
+ const index = await templateManager.fetchRemoteIndex(true);
2318
+ const localIds = new Set(
2319
+ templateManager.listLocalTemplates().map((t) => t.id)
2320
+ );
2321
+ const toDownload = index.templates.filter((t) => !localIds.has(t.id));
2322
+ const downloaded = [];
2323
+ const failed = [];
2324
+ for (const t of toDownload) {
2325
+ try {
2326
+ await templateManager.downloadTemplate(t.id);
2327
+ downloaded.push(t.id);
2328
+ } catch (e) {
2329
+ failed.push({ id: t.id, error: e?.message || "Failed" });
2330
+ }
2331
+ }
2332
+ return res.json({
2333
+ success: true,
2334
+ total: index.templates.length,
2335
+ downloaded,
2336
+ failed
2337
+ });
2338
+ } catch (error) {
2339
+ return jsonError(res, 500, error?.message || "Download-all failed");
2340
+ }
2341
+ }
2342
+ );
2343
+ router.post("/run", import_express.default.json({ limit: "10mb" }), async (req, res) => {
2344
+ const parsed = RunTemplateBodySchema.safeParse(req.body);
2345
+ if (!parsed.success) {
2346
+ return jsonError(
2347
+ res,
2348
+ 400,
2349
+ parsed.error.issues[0]?.message || "Invalid body"
2350
+ );
2351
+ }
2352
+ let { templateId, url, secret, headers } = parsed.data;
2353
+ try {
2354
+ new URL(url);
2355
+ } catch {
2356
+ return jsonError(res, 400, "Invalid url");
2357
+ }
2358
+ if (templateId.startsWith("remote:")) {
2359
+ templateId = templateId.slice("remote:".length);
2360
+ try {
2361
+ await templateManager.downloadTemplate(templateId);
2362
+ } catch (error) {
2363
+ return jsonError(
2364
+ res,
2365
+ 400,
2366
+ error?.message || "Failed to download template"
2367
+ );
2368
+ }
2369
+ }
2370
+ let localTemplate = templateManager.getLocalTemplate(templateId);
2371
+ if (!localTemplate) {
2372
+ try {
2373
+ await templateManager.downloadTemplate(templateId);
2374
+ localTemplate = templateManager.getLocalTemplate(templateId);
2375
+ } catch {
2376
+ }
2377
+ }
2378
+ if (!localTemplate) {
2379
+ return jsonError(res, 404, "Template not found");
2380
+ }
2381
+ if (!secret && localTemplate.metadata.provider) {
2382
+ const envVarName = getSecretEnvVarName2(localTemplate.metadata.provider);
2383
+ secret = process.env[envVarName];
2384
+ }
2385
+ const safeHeaders = headers?.length ? headers : void 0;
2386
+ try {
2387
+ const result = await executeTemplate(localTemplate.template, {
2388
+ url,
2389
+ secret,
2390
+ headers: safeHeaders
2391
+ });
2392
+ broadcast?.({
2393
+ type: "replay_result",
2394
+ payload: { templateId, url, result }
2395
+ });
2396
+ return res.json(result);
2397
+ } catch (error) {
2398
+ return jsonError(res, 400, error?.message || "Run failed");
2399
+ }
2400
+ });
2401
+ return router;
2402
+ }
2403
+
2404
+ // src/core/dashboard-server.ts
2405
+ var import_meta = {};
2406
+ function resolveDashboardDistDir(runtimeDir) {
2407
+ const candidates = [
2408
+ import_path4.default.resolve(runtimeDir, "..", "dashboard"),
2409
+ import_path4.default.resolve(runtimeDir, "..", "..", "dist", "dashboard"),
2410
+ import_path4.default.resolve(runtimeDir, "..", "..", "..", "dashboard", "dist")
2411
+ ];
2412
+ for (const distDir of candidates) {
2413
+ const indexHtml = import_path4.default.join(distDir, "index.html");
2414
+ if ((0, import_fs4.existsSync)(indexHtml)) {
2415
+ return { distDir, indexHtml };
2416
+ }
2417
+ }
2418
+ const details = candidates.map((p) => `- ${p}`).join("\n");
2419
+ throw new Error(
2420
+ `Dashboard UI build output not found.
2421
+ Looked in:
2422
+ ${details}
2423
+
2424
+ Build it with:
2425
+ - pnpm --filter @better-webhook/dashboard build
2426
+ - pnpm --filter @better-webhook/cli build
2427
+ `
2428
+ );
2429
+ }
2430
+ async function startDashboardServer(options = {}) {
2431
+ const app = (0, import_express2.default)();
2432
+ app.get("/health", (_req, res) => {
2433
+ res.json({ ok: true });
2434
+ });
2435
+ const clients = /* @__PURE__ */ new Set();
2436
+ const broadcast = (message) => {
2437
+ const data = JSON.stringify(message);
2438
+ for (const client of clients) {
2439
+ if (client.readyState === 1) {
2440
+ client.send(data);
2441
+ }
2442
+ }
2443
+ };
2444
+ app.use(
2445
+ "/api",
2446
+ createDashboardApiRouter({
2447
+ capturesDir: options.capturesDir,
2448
+ templatesBaseDir: options.templatesBaseDir,
2449
+ broadcast
2450
+ })
2451
+ );
2452
+ const host = options.host || "localhost";
2453
+ const port = options.port ?? 4e3;
2454
+ const runtimeDir = (
2455
+ // eslint-disable-next-line no-undef
2456
+ typeof __dirname !== "undefined" ? (
2457
+ // eslint-disable-next-line no-undef
2458
+ __dirname
2459
+ ) : import_path4.default.dirname((0, import_url.fileURLToPath)(import_meta.url))
2460
+ );
2461
+ const { distDir: dashboardDistDir, indexHtml: dashboardIndexHtml } = resolveDashboardDistDir(runtimeDir);
2462
+ app.use(import_express2.default.static(dashboardDistDir));
2463
+ app.get("*", (req, res, next) => {
2464
+ if (req.path.startsWith("/api") || req.path === "/health") return next();
2465
+ res.sendFile(dashboardIndexHtml, (err) => {
2466
+ if (err) next();
2467
+ });
2468
+ });
2469
+ const server = (0, import_http2.createServer)(app);
2470
+ const wss = new import_ws2.WebSocketServer({ server, path: "/ws" });
2471
+ wss.on("connection", async (ws) => {
2472
+ clients.add(ws);
2473
+ ws.on("close", () => clients.delete(ws));
2474
+ ws.on("error", () => clients.delete(ws));
2475
+ const replayEngine = new ReplayEngine(options.capturesDir);
2476
+ const templateManager = new TemplateManager(options.templatesBaseDir);
2477
+ const captures2 = replayEngine.listCaptures(200);
2478
+ ws.send(
2479
+ JSON.stringify({
2480
+ type: "captures_updated",
2481
+ payload: { captures: captures2, count: captures2.length }
2482
+ })
2483
+ );
2484
+ const local = templateManager.listLocalTemplates();
2485
+ let remote = [];
2486
+ try {
2487
+ const index = await templateManager.fetchRemoteIndex(true);
2488
+ const localIds = new Set(local.map((t) => t.id));
2489
+ remote = index.templates.map((metadata) => ({
2490
+ metadata,
2491
+ isDownloaded: localIds.has(metadata.id)
2492
+ }));
2493
+ } catch {
2494
+ remote = [];
2495
+ }
2496
+ ws.send(
2497
+ JSON.stringify({
2498
+ type: "templates_updated",
2499
+ payload: { local, remote }
2500
+ })
2501
+ );
2502
+ });
2503
+ await new Promise((resolve, reject) => {
2504
+ server.listen(port, host, () => resolve());
2505
+ server.on("error", reject);
2506
+ });
2507
+ const url = `http://${host}:${port}`;
2508
+ let capture2;
2509
+ const shouldStartCapture = options.startCapture !== false;
2510
+ if (shouldStartCapture) {
2511
+ const captureHost = options.captureHost || "0.0.0.0";
2512
+ const capturePort = options.capturePort ?? 3001;
2513
+ const captureServer = new CaptureServer({
2514
+ capturesDir: options.capturesDir,
2515
+ enableWebSocket: false,
2516
+ onCapture: ({ file, capture: capture3 }) => {
2517
+ broadcast({
2518
+ type: "capture",
2519
+ payload: { file, capture: capture3 }
2520
+ });
2521
+ }
2522
+ });
2523
+ const actualPort = await captureServer.start(capturePort, captureHost);
2524
+ capture2 = {
2525
+ server: captureServer,
2526
+ url: `http://${captureHost === "0.0.0.0" ? "localhost" : captureHost}:${actualPort}`
2527
+ };
2528
+ }
2529
+ return { app, server, url, capture: capture2 };
2530
+ }
2531
+
2532
+ // src/commands/dashboard.ts
2533
+ var dashboard = new import_commander6.Command().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) => {
2534
+ const port = Number.parseInt(String(options.port), 10);
2535
+ if (!Number.isFinite(port) || port < 0 || port > 65535) {
2536
+ console.error(import_chalk6.default.red("Invalid port number"));
2537
+ process.exitCode = 1;
2538
+ return;
2539
+ }
2540
+ try {
2541
+ const capturePort = Number.parseInt(String(options.capturePort), 10);
2542
+ if (!Number.isFinite(capturePort) || capturePort < 0 || capturePort > 65535) {
2543
+ console.error(import_chalk6.default.red("Invalid capture port number"));
2544
+ process.exitCode = 1;
2545
+ return;
2546
+ }
2547
+ const { url, server, capture: capture2 } = await startDashboardServer({
2548
+ host: options.host,
2549
+ port,
2550
+ captureHost: options.captureHost,
2551
+ capturePort,
2552
+ startCapture: options.capture !== false,
2553
+ capturesDir: options.capturesDir,
2554
+ templatesBaseDir: options.templatesDir
2555
+ });
2556
+ console.log(import_chalk6.default.bold("\n\u{1F9ED} Dashboard Server\n"));
2557
+ console.log(import_chalk6.default.gray(` Dashboard: ${url}/`));
2558
+ console.log(import_chalk6.default.gray(` Health: ${url}/health`));
2559
+ console.log(import_chalk6.default.gray(` API Base: ${url}/api`));
2560
+ console.log(import_chalk6.default.gray(` WebSocket: ${url.replace("http://", "ws://")}/ws`));
2561
+ if (capture2) {
2562
+ console.log();
2563
+ console.log(import_chalk6.default.bold("\u{1F3A3} Capture Server"));
2564
+ console.log(import_chalk6.default.gray(` Capture: ${capture2.url}`));
2565
+ console.log(import_chalk6.default.gray(` Tip: Send webhooks to any path, e.g. ${capture2.url}/webhooks/github`));
2566
+ }
2567
+ console.log();
2568
+ const shutdown = async () => {
2569
+ if (capture2) {
2570
+ await capture2.server.stop();
2571
+ }
2572
+ await new Promise((resolve) => server.close(() => resolve()));
2573
+ process.exit(0);
2574
+ };
2575
+ process.on("SIGINT", shutdown);
2576
+ process.on("SIGTERM", shutdown);
2577
+ } catch (error) {
2578
+ console.error(import_chalk6.default.red(`Failed to start dashboard server: ${error?.message || error}`));
2579
+ process.exitCode = 1;
2580
+ }
2581
+ });
2582
+
2081
2583
  // src/index.ts
2082
- var program = new import_commander6.Command().name("better-webhook").description(
2584
+ var program = new import_commander7.Command().name("better-webhook").description(
2083
2585
  "Modern CLI for developing, capturing, and replaying webhooks locally"
2084
2586
  ).version("2.0.0");
2085
- program.addCommand(templates).addCommand(run).addCommand(capture).addCommand(captures).addCommand(replay);
2587
+ program.addCommand(templates).addCommand(run).addCommand(capture).addCommand(captures).addCommand(replay).addCommand(dashboard);
2086
2588
  program.parseAsync(process.argv);