@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/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 Command6 } from "commander";
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").action(async (templateId, options) => {
437
- const manager = getTemplateManager();
438
- if (options?.all) {
439
- const spinner2 = ora("Fetching template list...").start();
440
- try {
441
- const templates2 = await manager.listRemoteTemplates();
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(`
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
- 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(`Failed: ${t.metadata.id} - ${error.message}`);
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
- console.log(chalk.green("\n\u2713 Download complete\n"));
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
- return;
471
- }
472
- if (!templateId) {
473
- const spinner2 = ora("Fetching templates...").start();
474
- try {
475
- const templates2 = await manager.listRemoteTemplates();
476
- spinner2.stop();
477
- const notDownloaded = templates2.filter((t) => !t.isDownloaded);
478
- if (notDownloaded.length === 0) {
479
- console.log(chalk.green("\u2713 All templates already downloaded"));
480
- return;
481
- }
482
- const choices = notDownloaded.map((t) => ({
483
- title: t.metadata.id,
484
- description: `${t.metadata.provider} - ${t.metadata.event}`,
485
- value: t.metadata.id
486
- }));
487
- const response = await prompts({
488
- type: "select",
489
- name: "templateId",
490
- message: "Select a template to download:",
491
- choices
492
- });
493
- if (!response.templateId) {
494
- console.log(chalk.yellow("Cancelled"));
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
- templateId = response.templateId;
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
- spinner2.fail("Failed to fetch templates");
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
- const spinner = ora(`Downloading ${templateId}...`).start();
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
- constructor(capturesDir) {
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
- this.wss = new WebSocketServer({ server: this.server });
1208
- this.wss.on("connection", (ws) => {
1209
- this.clients.add(ws);
1210
- console.log("\u{1F4E1} Dashboard connected via WebSocket");
1211
- ws.on("close", () => {
1212
- this.clients.delete(ws);
1213
- console.log("\u{1F4E1} Dashboard disconnected");
1214
- });
1215
- ws.on("error", (error) => {
1216
- console.error("WebSocket error:", error);
1217
- this.clients.delete(ws);
1218
- });
1219
- this.sendToClient(ws, {
1220
- type: "captures_updated",
1221
- payload: {
1222
- captures: this.listCaptures(),
1223
- count: this.captureCount
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
- console.log(` \u{1F310} WebSocket available for real-time updates`);
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.broadcast({
1345
- type: "capture",
1346
- payload: {
1347
- file: filename,
1348
- capture: captured
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
- const fs = __require("fs");
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 unlinkSync2 } from "fs";
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
- unlinkSync2(join3(this.capturesDir, captureFile.file));
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
- unlinkSync2(join3(this.capturesDir, file));
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 Command6().name("better-webhook").description(
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);