@better-webhook/cli 0.3.1 → 3.0.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,20 +24,23 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/index.ts
27
- var import_commander4 = require("commander");
27
+ var import_commander6 = require("commander");
28
28
 
29
- // src/commands/webhooks.ts
29
+ // src/commands/templates.ts
30
30
  var import_commander = require("commander");
31
- var import_path2 = require("path");
32
- var import_fs2 = require("fs");
31
+ var import_ora = __toESM(require("ora"), 1);
32
+ var import_prompts = __toESM(require("prompts"), 1);
33
+ var import_chalk = __toESM(require("chalk"), 1);
33
34
 
34
- // src/utils/index.ts
35
+ // src/core/template-manager.ts
36
+ var import_undici = require("undici");
35
37
  var import_fs = require("fs");
36
38
  var import_path = require("path");
39
+ var import_os = require("os");
37
40
 
38
- // src/schema.ts
41
+ // src/types/index.ts
39
42
  var import_zod = require("zod");
40
- var httpMethodSchema = import_zod.z.enum([
43
+ var HttpMethodSchema = import_zod.z.enum([
41
44
  "GET",
42
45
  "POST",
43
46
  "PUT",
@@ -46,459 +49,1238 @@ var httpMethodSchema = import_zod.z.enum([
46
49
  "HEAD",
47
50
  "OPTIONS"
48
51
  ]);
49
- var headerEntrySchema = import_zod.z.object({
50
- key: import_zod.z.string().min(1, "Header key cannot be empty").regex(/^[A-Za-z0-9-]+$/, {
51
- message: "Header key must contain only alphanumerics and -"
52
- }),
53
- value: import_zod.z.string().min(1, "Header value cannot be empty")
52
+ var HeaderEntrySchema = import_zod.z.object({
53
+ key: import_zod.z.string().min(1),
54
+ value: import_zod.z.string()
55
+ });
56
+ var WebhookProviderSchema = import_zod.z.enum([
57
+ "stripe",
58
+ "github",
59
+ "shopify",
60
+ "twilio",
61
+ "sendgrid",
62
+ "slack",
63
+ "discord",
64
+ "linear",
65
+ "clerk",
66
+ "custom"
67
+ ]);
68
+ var TemplateMetadataSchema = import_zod.z.object({
69
+ id: import_zod.z.string(),
70
+ name: import_zod.z.string(),
71
+ description: import_zod.z.string().optional(),
72
+ provider: WebhookProviderSchema,
73
+ event: import_zod.z.string(),
74
+ file: import_zod.z.string(),
75
+ version: import_zod.z.string().optional(),
76
+ docsUrl: import_zod.z.string().url().optional()
77
+ });
78
+ var TemplatesIndexSchema = import_zod.z.object({
79
+ version: import_zod.z.string(),
80
+ templates: import_zod.z.array(TemplateMetadataSchema)
81
+ });
82
+ var WebhookTemplateSchema = import_zod.z.object({
83
+ url: import_zod.z.string().url().optional(),
84
+ method: HttpMethodSchema.default("POST"),
85
+ headers: import_zod.z.array(HeaderEntrySchema).default([]),
86
+ body: import_zod.z.any().optional(),
87
+ provider: WebhookProviderSchema.optional(),
88
+ event: import_zod.z.string().optional(),
89
+ description: import_zod.z.string().optional()
90
+ });
91
+ var CapturedWebhookSchema = import_zod.z.object({
92
+ id: import_zod.z.string(),
93
+ timestamp: import_zod.z.string(),
94
+ method: HttpMethodSchema,
95
+ url: import_zod.z.string(),
96
+ path: import_zod.z.string(),
97
+ headers: import_zod.z.record(import_zod.z.union([import_zod.z.string(), import_zod.z.array(import_zod.z.string())])),
98
+ body: import_zod.z.any().optional(),
99
+ rawBody: import_zod.z.string(),
100
+ query: import_zod.z.record(import_zod.z.union([import_zod.z.string(), import_zod.z.array(import_zod.z.string())])),
101
+ provider: WebhookProviderSchema.optional(),
102
+ contentType: import_zod.z.string().optional(),
103
+ contentLength: import_zod.z.number().optional()
104
+ });
105
+ var ConfigSchema = import_zod.z.object({
106
+ version: import_zod.z.string().default("2.0.0"),
107
+ templatesDir: import_zod.z.string().optional(),
108
+ capturesDir: import_zod.z.string().optional(),
109
+ defaultTargetUrl: import_zod.z.string().url().optional(),
110
+ secrets: import_zod.z.record(import_zod.z.string()).optional().describe("Provider secrets for signature generation"),
111
+ dashboard: import_zod.z.object({
112
+ port: import_zod.z.number().default(4e3),
113
+ host: import_zod.z.string().default("localhost")
114
+ }).optional(),
115
+ capture: import_zod.z.object({
116
+ port: import_zod.z.number().default(3001),
117
+ host: import_zod.z.string().default("0.0.0.0")
118
+ }).optional()
54
119
  });
55
- var webhookSchema = import_zod.z.object({
56
- url: import_zod.z.string().url("Invalid URL"),
57
- method: httpMethodSchema.default("POST"),
58
- headers: import_zod.z.array(headerEntrySchema).default([]),
59
- body: import_zod.z.any().optional()
60
- // could be anything JSON-serializable
61
- }).strict();
62
- function validateWebhookJSON(raw, source) {
63
- const parsed = webhookSchema.safeParse(raw);
64
- if (!parsed.success) {
65
- const issues = parsed.error.issues.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
66
- throw new Error(`Invalid webhook definition in ${source}:
67
- ${issues}`);
68
- }
69
- return parsed.data;
70
- }
71
120
 
72
- // src/utils/http.ts
73
- var import_undici = require("undici");
74
- async function executeWebhook(def) {
75
- const headerMap = {};
76
- for (const h of def.headers) {
77
- headerMap[h.key] = h.value;
78
- }
79
- if (!headerMap["content-type"] && def.body !== void 0) {
80
- headerMap["content-type"] = "application/json";
81
- }
82
- const bodyPayload = def.body !== void 0 ? JSON.stringify(def.body) : void 0;
83
- const { statusCode, headers, body } = await (0, import_undici.request)(def.url, {
84
- method: def.method,
85
- headers: headerMap,
86
- body: bodyPayload
87
- });
88
- const text = await body.text();
89
- let parsed;
90
- if (text) {
121
+ // src/core/template-manager.ts
122
+ var GITHUB_RAW_BASE = "https://raw.githubusercontent.com/endalk200/better-webhook/main";
123
+ var TEMPLATES_INDEX_URL = `${GITHUB_RAW_BASE}/templates/templates.json`;
124
+ var TemplateManager = class {
125
+ baseDir;
126
+ templatesDir;
127
+ cacheFile;
128
+ indexCache = null;
129
+ constructor(baseDir) {
130
+ this.baseDir = baseDir || (0, import_path.join)((0, import_os.homedir)(), ".better-webhook");
131
+ this.templatesDir = (0, import_path.join)(this.baseDir, "templates");
132
+ this.cacheFile = (0, import_path.join)(this.baseDir, "templates-cache.json");
133
+ if (!(0, import_fs.existsSync)(this.baseDir)) {
134
+ (0, import_fs.mkdirSync)(this.baseDir, { recursive: true });
135
+ }
136
+ if (!(0, import_fs.existsSync)(this.templatesDir)) {
137
+ (0, import_fs.mkdirSync)(this.templatesDir, { recursive: true });
138
+ }
139
+ }
140
+ /**
141
+ * Get the templates directory path
142
+ */
143
+ getTemplatesDir() {
144
+ return this.templatesDir;
145
+ }
146
+ /**
147
+ * Fetch templates index from GitHub
148
+ */
149
+ async fetchRemoteIndex(forceRefresh = false) {
150
+ if (!forceRefresh && this.indexCache) {
151
+ return this.indexCache;
152
+ }
153
+ if (!forceRefresh && (0, import_fs.existsSync)(this.cacheFile)) {
154
+ try {
155
+ const cached = JSON.parse((0, import_fs.readFileSync)(this.cacheFile, "utf-8"));
156
+ const cacheAge = Date.now() - (cached.cachedAt || 0);
157
+ if (cacheAge < 36e5) {
158
+ this.indexCache = cached.index;
159
+ return cached.index;
160
+ }
161
+ } catch {
162
+ }
163
+ }
164
+ try {
165
+ const { statusCode, body } = await (0, import_undici.request)(TEMPLATES_INDEX_URL);
166
+ if (statusCode !== 200) {
167
+ throw new Error(`HTTP ${statusCode}`);
168
+ }
169
+ const text = await body.text();
170
+ const json = JSON.parse(text);
171
+ const index = TemplatesIndexSchema.parse(json);
172
+ this.indexCache = index;
173
+ (0, import_fs.writeFileSync)(
174
+ this.cacheFile,
175
+ JSON.stringify({ index, cachedAt: Date.now() }, null, 2)
176
+ );
177
+ return index;
178
+ } catch (error) {
179
+ if ((0, import_fs.existsSync)(this.cacheFile)) {
180
+ try {
181
+ const cached = JSON.parse((0, import_fs.readFileSync)(this.cacheFile, "utf-8"));
182
+ if (cached.index) {
183
+ this.indexCache = cached.index;
184
+ return cached.index;
185
+ }
186
+ } catch {
187
+ }
188
+ }
189
+ throw new Error(`Failed to fetch templates index: ${error.message}`);
190
+ }
191
+ }
192
+ /**
193
+ * List all remote templates
194
+ */
195
+ async listRemoteTemplates() {
196
+ const index = await this.fetchRemoteIndex();
197
+ const localIds = new Set(this.listLocalTemplates().map((t) => t.id));
198
+ return index.templates.map((metadata) => ({
199
+ metadata,
200
+ isDownloaded: localIds.has(metadata.id)
201
+ }));
202
+ }
203
+ /**
204
+ * Download a template by ID
205
+ */
206
+ async downloadTemplate(templateId) {
207
+ const index = await this.fetchRemoteIndex();
208
+ const templateMeta = index.templates.find((t) => t.id === templateId);
209
+ if (!templateMeta) {
210
+ throw new Error(`Template not found: ${templateId}`);
211
+ }
212
+ const templateUrl = `${GITHUB_RAW_BASE}/templates/${templateMeta.file}`;
213
+ try {
214
+ const { statusCode, body } = await (0, import_undici.request)(templateUrl);
215
+ if (statusCode !== 200) {
216
+ throw new Error(`HTTP ${statusCode}`);
217
+ }
218
+ const text = await body.text();
219
+ const json = JSON.parse(text);
220
+ const template = WebhookTemplateSchema.parse(json);
221
+ const providerDir = (0, import_path.join)(this.templatesDir, templateMeta.provider);
222
+ if (!(0, import_fs.existsSync)(providerDir)) {
223
+ (0, import_fs.mkdirSync)(providerDir, { recursive: true });
224
+ }
225
+ const fileName = `${templateId}.json`;
226
+ const filePath = (0, import_path.join)(providerDir, fileName);
227
+ const localTemplate = {
228
+ id: templateId,
229
+ metadata: templateMeta,
230
+ template,
231
+ downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
232
+ filePath
233
+ };
234
+ const saveData = {
235
+ ...template,
236
+ _metadata: {
237
+ ...templateMeta,
238
+ downloadedAt: localTemplate.downloadedAt
239
+ }
240
+ };
241
+ (0, import_fs.writeFileSync)(filePath, JSON.stringify(saveData, null, 2));
242
+ return localTemplate;
243
+ } catch (error) {
244
+ throw new Error(
245
+ `Failed to download template ${templateId}: ${error.message}`
246
+ );
247
+ }
248
+ }
249
+ /**
250
+ * List all downloaded local templates
251
+ */
252
+ listLocalTemplates() {
253
+ const templates2 = [];
254
+ if (!(0, import_fs.existsSync)(this.templatesDir)) {
255
+ return templates2;
256
+ }
257
+ const scanDir = (dir) => {
258
+ const entries = (0, import_fs.readdirSync)(dir, { withFileTypes: true });
259
+ for (const entry of entries) {
260
+ const fullPath = (0, import_path.join)(dir, entry.name);
261
+ if (entry.isDirectory()) {
262
+ scanDir(fullPath);
263
+ } else if (entry.isFile() && entry.name.endsWith(".json")) {
264
+ try {
265
+ const content = JSON.parse((0, import_fs.readFileSync)(fullPath, "utf-8"));
266
+ const metadata = content._metadata;
267
+ if (metadata) {
268
+ const { _metadata, ...templateData } = content;
269
+ templates2.push({
270
+ id: metadata.id,
271
+ metadata,
272
+ template: templateData,
273
+ downloadedAt: metadata.downloadedAt || (/* @__PURE__ */ new Date()).toISOString(),
274
+ filePath: fullPath
275
+ });
276
+ } else {
277
+ const id = (0, import_path.basename)(entry.name, ".json");
278
+ templates2.push({
279
+ id,
280
+ metadata: {
281
+ id,
282
+ name: id,
283
+ provider: "custom",
284
+ event: "unknown",
285
+ file: entry.name
286
+ },
287
+ template: content,
288
+ downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
289
+ filePath: fullPath
290
+ });
291
+ }
292
+ } catch {
293
+ }
294
+ }
295
+ }
296
+ };
297
+ scanDir(this.templatesDir);
298
+ return templates2;
299
+ }
300
+ /**
301
+ * Get a specific local template by ID
302
+ */
303
+ getLocalTemplate(templateId) {
304
+ const templates2 = this.listLocalTemplates();
305
+ return templates2.find((t) => t.id === templateId) || null;
306
+ }
307
+ /**
308
+ * Delete a local template
309
+ */
310
+ deleteLocalTemplate(templateId) {
311
+ const template = this.getLocalTemplate(templateId);
312
+ if (!template) {
313
+ return false;
314
+ }
91
315
  try {
92
- parsed = JSON.parse(text);
316
+ (0, import_fs.unlinkSync)(template.filePath);
317
+ return true;
93
318
  } catch {
319
+ return false;
94
320
  }
95
321
  }
96
- const resultHeaders = {};
97
- for (const [k, v] of Object.entries(headers)) {
98
- resultHeaders[k] = v;
322
+ /**
323
+ * Search templates by name, provider, or event
324
+ */
325
+ async searchTemplates(query) {
326
+ const queryLower = query.toLowerCase();
327
+ const remote = await this.listRemoteTemplates();
328
+ const local = this.listLocalTemplates();
329
+ const matchesMeta = (meta) => {
330
+ return meta.id.toLowerCase().includes(queryLower) || meta.name.toLowerCase().includes(queryLower) || meta.provider.toLowerCase().includes(queryLower) || meta.event.toLowerCase().includes(queryLower) || (meta.description?.toLowerCase().includes(queryLower) ?? false);
331
+ };
332
+ return {
333
+ remote: remote.filter((t) => matchesMeta(t.metadata)),
334
+ local: local.filter((t) => matchesMeta(t.metadata))
335
+ };
99
336
  }
100
- return {
101
- status: statusCode,
102
- headers: resultHeaders,
103
- bodyText: text,
104
- json: parsed
105
- };
106
- }
107
-
108
- // src/utils/index.ts
109
- function findWebhooksDir(cwd) {
110
- return (0, import_path.resolve)(cwd, ".webhooks");
111
- }
112
- function listWebhookFiles(dir) {
113
- return listJsonFiles(dir);
114
- }
115
- function findCapturesDir(cwd) {
116
- return (0, import_path.resolve)(cwd, ".webhook-captures");
117
- }
118
- function listJsonFiles(dir) {
119
- try {
120
- const entries = (0, import_fs.readdirSync)(dir);
121
- return entries.filter(
122
- (e) => (0, import_fs.statSync)((0, import_path.join)(dir, e)).isFile() && (0, import_path.extname)(e) === ".json"
123
- );
124
- } catch {
125
- return [];
337
+ /**
338
+ * Clear the templates cache
339
+ */
340
+ clearCache() {
341
+ this.indexCache = null;
342
+ if ((0, import_fs.existsSync)(this.cacheFile)) {
343
+ (0, import_fs.unlinkSync)(this.cacheFile);
344
+ }
126
345
  }
127
- }
128
- function loadWebhookFile(path) {
129
- let rawContent;
130
- try {
131
- rawContent = (0, import_fs.readFileSync)(path, "utf8");
132
- } catch (e) {
133
- throw new Error(`Failed to read file ${path}: ${e.message}`);
346
+ /**
347
+ * Delete all local templates
348
+ * @returns Number of templates deleted
349
+ */
350
+ deleteAllLocalTemplates() {
351
+ const templates2 = this.listLocalTemplates();
352
+ let deleted = 0;
353
+ for (const template of templates2) {
354
+ try {
355
+ (0, import_fs.unlinkSync)(template.filePath);
356
+ deleted++;
357
+ } catch {
358
+ }
359
+ }
360
+ if ((0, import_fs.existsSync)(this.templatesDir)) {
361
+ const entries = (0, import_fs.readdirSync)(this.templatesDir, { withFileTypes: true });
362
+ for (const entry of entries) {
363
+ if (entry.isDirectory()) {
364
+ const dirPath = (0, import_path.join)(this.templatesDir, entry.name);
365
+ try {
366
+ const contents = (0, import_fs.readdirSync)(dirPath);
367
+ if (contents.length === 0) {
368
+ (0, import_fs.rmdirSync)(dirPath);
369
+ }
370
+ } catch {
371
+ }
372
+ }
373
+ }
374
+ }
375
+ return deleted;
134
376
  }
135
- let json;
136
- try {
137
- json = JSON.parse(rawContent);
138
- } catch (e) {
139
- throw new Error(`Invalid JSON in file ${path}: ${e.message}`);
377
+ };
378
+ var instance = null;
379
+ function getTemplateManager(baseDir) {
380
+ if (!instance) {
381
+ instance = new TemplateManager(baseDir);
140
382
  }
141
- return validateWebhookJSON(json, path);
383
+ return instance;
142
384
  }
143
385
 
144
- // src/commands/webhooks.ts
145
- var import_fs3 = require("fs");
146
- var import_undici2 = require("undici");
147
-
148
- // src/config.ts
149
- var TEMPLATE_REPO_BASE = "https://raw.githubusercontent.com/endalk200/better-webhook/main";
150
- var TEMPLATES = {
151
- "stripe-invoice.payment_succeeded": "templates/stripe-invoice.payment_succeeded.json"
152
- };
153
-
154
- // src/commands/webhooks.ts
155
- var import_prompts = __toESM(require("prompts"), 1);
156
- var import_ora = __toESM(require("ora"), 1);
157
- function statExists(p) {
386
+ // src/commands/templates.ts
387
+ var listCommand = new import_commander.Command().name("list").alias("ls").description("List available remote templates from the repository").option(
388
+ "-p, --provider <provider>",
389
+ "Filter by provider (stripe, github, etc.)"
390
+ ).option("-r, --refresh", "Force refresh the template index cache").action(async (options) => {
391
+ const spinner = (0, import_ora.default)("Fetching remote templates...").start();
158
392
  try {
159
- (0, import_fs2.statSync)(p);
160
- return true;
161
- } catch {
162
- return false;
163
- }
164
- }
165
- var listCommand = new import_commander.Command().name("list").description("List available webhook JSON definitions in .webhooks directory").action(() => {
166
- const cwd = process.cwd();
167
- const dir = findWebhooksDir(cwd);
168
- const files = listWebhookFiles(dir);
169
- if (!files.length) {
170
- console.log("\u{1F4ED} No webhook definitions found in .webhooks directory.");
171
- console.log("\u{1F4A1} Create webhook files or download templates with:");
172
- console.log(" better-webhook webhooks download --all");
173
- return;
174
- }
175
- console.log("Available webhook definitions:");
176
- files.forEach((f) => console.log(` \u2022 ${(0, import_path2.basename)(f, ".json")}`));
177
- });
178
- var runCommand = new import_commander.Command().name("run").argument(
179
- "[nameOrPath]",
180
- "Webhook name (in .webhooks) or path to JSON file (optional - will prompt if not provided)"
181
- ).description(
182
- "Run a webhook by name (in .webhooks) or by providing a path to a JSON file"
183
- ).option("-u, --url <url>", "Override destination URL").option("-m, --method <method>", "Override HTTP method").action(async (nameOrPath, options = {}) => {
184
- const cwd = process.cwd();
185
- const webhooksDir = findWebhooksDir(cwd);
186
- let selectedNameOrPath = nameOrPath;
187
- if (!selectedNameOrPath) {
188
- const spinner = (0, import_ora.default)("Loading available webhooks...").start();
189
- const localFiles = listWebhookFiles(webhooksDir);
190
- const templates = Object.keys(TEMPLATES);
393
+ const manager = getTemplateManager();
394
+ const templates2 = await manager.listRemoteTemplates();
191
395
  spinner.stop();
192
- if (localFiles.length === 0 && templates.length === 0) {
193
- console.log("\u{1F4ED} No webhook definitions found.");
194
- console.log(
195
- "\u{1F4A1} Create webhook files in .webhooks directory or download templates with:"
196
- );
197
- console.log(" better-webhook webhooks download --all");
396
+ if (templates2.length === 0) {
397
+ console.log(import_chalk.default.yellow("\u{1F4ED} No remote templates found."));
198
398
  return;
199
399
  }
200
- const choices = [];
201
- if (localFiles.length > 0) {
202
- choices.push(
203
- {
204
- title: "--- Local Webhooks (.webhooks) ---",
205
- description: "",
206
- value: "",
207
- type: "separator"
208
- },
209
- ...localFiles.map((file) => ({
210
- title: (0, import_path2.basename)(file, ".json"),
211
- description: `Local file: ${file}`,
212
- value: (0, import_path2.basename)(file, ".json"),
213
- type: "local"
214
- }))
400
+ let filtered = templates2;
401
+ if (options.provider) {
402
+ filtered = templates2.filter(
403
+ (t) => t.metadata.provider.toLowerCase() === options.provider?.toLowerCase()
215
404
  );
216
405
  }
217
- if (templates.length > 0) {
218
- choices.push(
219
- {
220
- title: "--- Available Templates ---",
221
- description: "",
222
- value: "",
223
- type: "separator"
224
- },
225
- ...templates.map((template) => ({
226
- title: template,
227
- description: `Template: ${TEMPLATES[template]}`,
228
- value: template,
229
- type: "template"
230
- }))
406
+ if (filtered.length === 0) {
407
+ console.log(
408
+ import_chalk.default.yellow(
409
+ `\u{1F4ED} No templates found for provider: ${options.provider}`
410
+ )
231
411
  );
232
- }
233
- const response = await (0, import_prompts.default)({
234
- type: "select",
235
- name: "webhook",
236
- message: "Select a webhook to run:",
237
- choices: choices.filter((choice) => choice.value !== ""),
238
- // Remove separators for selection
239
- initial: 0
240
- });
241
- if (!response.webhook) {
242
- console.log("\u274C No webhook selected. Exiting.");
243
- process.exitCode = 1;
244
412
  return;
245
413
  }
246
- selectedNameOrPath = response.webhook;
247
- if (selectedNameOrPath && templates.includes(selectedNameOrPath)) {
248
- const downloadSpinner = (0, import_ora.default)(
249
- `Downloading template: ${selectedNameOrPath}...`
250
- ).start();
251
- try {
252
- const rel = TEMPLATES[selectedNameOrPath];
253
- const rawUrl = `${TEMPLATE_REPO_BASE}/${rel}`;
254
- const { statusCode, body } = await (0, import_undici2.request)(rawUrl);
255
- if (statusCode !== 200) {
256
- throw new Error(`HTTP ${statusCode}`);
414
+ console.log(import_chalk.default.bold("\n\u{1F4E6} Available Templates\n"));
415
+ const byProvider = /* @__PURE__ */ new Map();
416
+ for (const t of filtered) {
417
+ const provider = t.metadata.provider;
418
+ if (!byProvider.has(provider)) {
419
+ byProvider.set(provider, []);
420
+ }
421
+ byProvider.get(provider).push(t);
422
+ }
423
+ for (const [provider, providerTemplates] of byProvider) {
424
+ console.log(import_chalk.default.cyan.bold(` ${provider.toUpperCase()}`));
425
+ for (const t of providerTemplates) {
426
+ const status = t.isDownloaded ? import_chalk.default.green("\u2713 downloaded") : import_chalk.default.gray("\u25CB remote");
427
+ console.log(` ${import_chalk.default.white(t.metadata.id)} ${status}`);
428
+ if (t.metadata.description) {
429
+ console.log(import_chalk.default.gray(` ${t.metadata.description}`));
257
430
  }
258
- const text = await body.text();
259
- const json = JSON.parse(text);
260
- validateWebhookJSON(json, rawUrl);
261
- (0, import_fs3.mkdirSync)(webhooksDir, { recursive: true });
262
- const fileName = (0, import_path2.basename)(rel);
263
- const destPath = (0, import_path2.join)(webhooksDir, fileName);
264
- (0, import_fs3.writeFileSync)(destPath, JSON.stringify(json, null, 2));
265
- selectedNameOrPath = (0, import_path2.basename)(fileName, ".json");
266
- downloadSpinner.succeed(`Downloaded template: ${selectedNameOrPath}`);
267
- } catch (error) {
268
- downloadSpinner.fail(`Failed to download template: ${error.message}`);
269
- process.exitCode = 1;
270
- return;
271
431
  }
432
+ console.log();
272
433
  }
273
- }
274
- if (!selectedNameOrPath) {
275
- console.error("\u274C No webhook selected.");
434
+ console.log(import_chalk.default.gray(` Total: ${filtered.length} templates`));
435
+ console.log(
436
+ import_chalk.default.gray(` Download: better-webhook templates download <id>
437
+ `)
438
+ );
439
+ } catch (error) {
440
+ spinner.fail("Failed to fetch templates");
441
+ console.error(import_chalk.default.red(error.message));
276
442
  process.exitCode = 1;
277
- return;
278
443
  }
279
- let filePath;
280
- if (selectedNameOrPath.endsWith(".json") && !selectedNameOrPath.includes("/") && !selectedNameOrPath.startsWith(".")) {
281
- filePath = (0, import_path2.join)(webhooksDir, selectedNameOrPath);
282
- } else {
283
- const candidate = (0, import_path2.join)(
284
- webhooksDir,
285
- selectedNameOrPath + (selectedNameOrPath.endsWith(".json") ? "" : ".json")
286
- );
287
- if (statExists(candidate)) {
288
- filePath = candidate;
289
- } else {
290
- filePath = (0, import_path2.resolve)(cwd, selectedNameOrPath);
444
+ });
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(`
459
+ Downloading ${toDownload.length} templates...
460
+ `)
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}`);
471
+ }
472
+ }
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;
291
478
  }
292
- }
293
- if (!statExists(filePath)) {
294
- console.error(`Webhook file not found: ${filePath}`);
295
- process.exitCode = 1;
296
479
  return;
297
480
  }
298
- let def;
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"));
504
+ return;
505
+ }
506
+ templateId = response.templateId;
507
+ } catch (error) {
508
+ spinner2.fail("Failed to fetch templates");
509
+ console.error(import_chalk.default.red(error.message));
510
+ process.exitCode = 1;
511
+ return;
512
+ }
513
+ }
514
+ const spinner = (0, import_ora.default)(`Downloading ${templateId}...`).start();
299
515
  try {
300
- def = loadWebhookFile(filePath);
301
- } catch (err) {
302
- console.error(err.message);
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));
303
524
  process.exitCode = 1;
525
+ }
526
+ });
527
+ var localCommand = new import_commander.Command().name("local").description("List downloaded local templates").option("-p, --provider <provider>", "Filter by provider").action((options) => {
528
+ const manager = getTemplateManager();
529
+ let templates2 = manager.listLocalTemplates();
530
+ if (options.provider) {
531
+ templates2 = templates2.filter(
532
+ (t) => t.metadata.provider.toLowerCase() === options.provider?.toLowerCase()
533
+ );
534
+ }
535
+ if (templates2.length === 0) {
536
+ console.log(import_chalk.default.yellow("\n\u{1F4ED} No local templates found."));
537
+ console.log(
538
+ import_chalk.default.gray(
539
+ " Download templates with: better-webhook templates download\n"
540
+ )
541
+ );
304
542
  return;
305
543
  }
306
- if (options.url) def = { ...def, url: options.url };
307
- if (options.method)
308
- def = { ...def, method: options.method.toUpperCase() };
309
- const executeSpinner = (0, import_ora.default)(
310
- `Executing webhook: ${(0, import_path2.basename)(filePath, ".json")}...`
311
- ).start();
544
+ console.log(import_chalk.default.bold("\n\u{1F4C1} Local Templates\n"));
545
+ const byProvider = /* @__PURE__ */ new Map();
546
+ for (const t of templates2) {
547
+ const provider = t.metadata.provider;
548
+ if (!byProvider.has(provider)) {
549
+ byProvider.set(provider, []);
550
+ }
551
+ byProvider.get(provider).push(t);
552
+ }
553
+ for (const [provider, providerTemplates] of byProvider) {
554
+ console.log(import_chalk.default.cyan.bold(` ${provider.toUpperCase()}`));
555
+ for (const t of providerTemplates) {
556
+ console.log(` ${import_chalk.default.white(t.id)}`);
557
+ console.log(import_chalk.default.gray(` Event: ${t.metadata.event}`));
558
+ console.log(
559
+ import_chalk.default.gray(
560
+ ` Downloaded: ${new Date(t.downloadedAt).toLocaleDateString()}`
561
+ )
562
+ );
563
+ }
564
+ console.log();
565
+ }
566
+ console.log(import_chalk.default.gray(` Total: ${templates2.length} templates`));
567
+ console.log(import_chalk.default.gray(` Storage: ${manager.getTemplatesDir()}
568
+ `));
569
+ });
570
+ var searchCommand = new import_commander.Command().name("search").argument("<query>", "Search query").description("Search templates by name, provider, or event").action(async (query) => {
571
+ const spinner = (0, import_ora.default)("Searching...").start();
312
572
  try {
313
- const result = await executeWebhook(def);
314
- executeSpinner.succeed("Webhook executed successfully!");
315
- console.log("Status:", result.status);
316
- console.log("Headers:");
317
- for (const [k, v] of Object.entries(result.headers)) {
318
- console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
319
- }
320
- if (result.json !== void 0) {
321
- console.log("Response JSON:");
322
- console.log(JSON.stringify(result.json, null, 2));
323
- } else {
324
- console.log("Response Body:");
325
- console.log(result.bodyText);
573
+ const manager = getTemplateManager();
574
+ const results = await manager.searchTemplates(query);
575
+ spinner.stop();
576
+ const totalCount = results.remote.length + results.local.length;
577
+ if (totalCount === 0) {
578
+ console.log(import_chalk.default.yellow(`
579
+ \u{1F4ED} No templates found for: "${query}"
580
+ `));
581
+ return;
582
+ }
583
+ console.log(import_chalk.default.bold(`
584
+ \u{1F50D} Search Results for "${query}"
585
+ `));
586
+ if (results.local.length > 0) {
587
+ console.log(import_chalk.default.cyan.bold(" LOCAL TEMPLATES"));
588
+ for (const t of results.local) {
589
+ console.log(
590
+ ` ${import_chalk.default.green("\u2713")} ${t.id} (${t.metadata.provider})`
591
+ );
592
+ }
593
+ console.log();
594
+ }
595
+ if (results.remote.length > 0) {
596
+ console.log(import_chalk.default.cyan.bold(" REMOTE TEMPLATES"));
597
+ for (const t of results.remote) {
598
+ const status = t.isDownloaded ? import_chalk.default.green("\u2713") : import_chalk.default.gray("\u25CB");
599
+ console.log(
600
+ ` ${status} ${t.metadata.id} (${t.metadata.provider})`
601
+ );
602
+ }
603
+ console.log();
326
604
  }
327
- } catch (err) {
328
- executeSpinner.fail("Request failed");
329
- console.error("Error:", err.message);
605
+ console.log(import_chalk.default.gray(` Found: ${totalCount} templates
606
+ `));
607
+ } catch (error) {
608
+ spinner.fail("Search failed");
609
+ console.error(import_chalk.default.red(error.message));
330
610
  process.exitCode = 1;
331
611
  }
332
612
  });
333
- var downloadCommand = new import_commander.Command().name("download").argument("[name]", "Template name to download").description(
334
- "Download official webhook template(s) into the .webhooks directory. If no name is provided, prints available templates."
335
- ).option("-a, --all", "Download all available templates").option("-f, --force", "Overwrite existing files if they exist").action(
336
- async (name, opts) => {
337
- if (name && opts.all) {
338
- console.error("Specify either a template name or --all, not both.");
339
- process.exitCode = 1;
613
+ var cacheCommand = new import_commander.Command().name("cache").description("Manage template cache").option("-c, --clear", "Clear the template cache").action((options) => {
614
+ if (options.clear) {
615
+ const manager = getTemplateManager();
616
+ manager.clearCache();
617
+ console.log(import_chalk.default.green("\u2713 Template cache cleared"));
618
+ } else {
619
+ console.log("Use --clear to clear the template cache");
620
+ }
621
+ });
622
+ var cleanCommand = new import_commander.Command().name("clean").alias("remove-all").description("Remove all downloaded templates").option("-f, --force", "Skip confirmation prompt").action(async (options) => {
623
+ const manager = getTemplateManager();
624
+ const templates2 = manager.listLocalTemplates();
625
+ if (templates2.length === 0) {
626
+ console.log(import_chalk.default.yellow("\n\u{1F4ED} No local templates to remove.\n"));
627
+ return;
628
+ }
629
+ console.log(
630
+ import_chalk.default.bold(`
631
+ \u{1F5D1}\uFE0F Found ${templates2.length} downloaded template(s)
632
+ `)
633
+ );
634
+ for (const t of templates2) {
635
+ console.log(import_chalk.default.gray(` ${t.id} (${t.metadata.provider})`));
636
+ }
637
+ console.log();
638
+ if (!options.force) {
639
+ const response = await (0, import_prompts.default)({
640
+ type: "confirm",
641
+ name: "confirm",
642
+ message: `Delete all ${templates2.length} template(s)?`,
643
+ initial: false
644
+ });
645
+ if (!response.confirm) {
646
+ console.log(import_chalk.default.yellow("Cancelled"));
340
647
  return;
341
648
  }
342
- const cwd = process.cwd();
343
- const dir = findWebhooksDir(cwd);
344
- (0, import_fs3.mkdirSync)(dir, { recursive: true });
345
- const toDownload = opts.all ? Object.keys(TEMPLATES) : name ? [name] : [];
346
- if (!toDownload.length) {
347
- console.log("Available templates:");
348
- for (const key of Object.keys(TEMPLATES)) console.log(` - ${key}`);
349
- console.log("Use: better-webhook webhooks download <name> OR --all");
350
- return;
649
+ }
650
+ const deleted = manager.deleteAllLocalTemplates();
651
+ console.log(import_chalk.default.green(`
652
+ \u2713 Removed ${deleted} template(s)`));
653
+ console.log(import_chalk.default.gray(` Storage: ${manager.getTemplatesDir()}
654
+ `));
655
+ });
656
+ var templates = new import_commander.Command().name("templates").alias("t").description("Manage webhook templates").addCommand(listCommand).addCommand(downloadCommand).addCommand(localCommand).addCommand(searchCommand).addCommand(cacheCommand).addCommand(cleanCommand);
657
+
658
+ // src/commands/run.ts
659
+ var import_commander2 = require("commander");
660
+ var import_ora2 = __toESM(require("ora"), 1);
661
+ var import_prompts2 = __toESM(require("prompts"), 1);
662
+ var import_chalk2 = __toESM(require("chalk"), 1);
663
+
664
+ // src/core/executor.ts
665
+ var import_undici2 = require("undici");
666
+
667
+ // src/core/signature.ts
668
+ var import_crypto = require("crypto");
669
+ function generateStripeSignature(payload, secret, timestamp) {
670
+ const ts = timestamp || Math.floor(Date.now() / 1e3);
671
+ const signedPayload = `${ts}.${payload}`;
672
+ const signature = (0, import_crypto.createHmac)("sha256", secret).update(signedPayload).digest("hex");
673
+ return {
674
+ header: "Stripe-Signature",
675
+ value: `t=${ts},v1=${signature}`
676
+ };
677
+ }
678
+ function generateGitHubSignature(payload, secret) {
679
+ const signature = (0, import_crypto.createHmac)("sha256", secret).update(payload).digest("hex");
680
+ return {
681
+ header: "X-Hub-Signature-256",
682
+ value: `sha256=${signature}`
683
+ };
684
+ }
685
+ function generateShopifySignature(payload, secret) {
686
+ const signature = (0, import_crypto.createHmac)("sha256", secret).update(payload).digest("base64");
687
+ return {
688
+ header: "X-Shopify-Hmac-SHA256",
689
+ value: signature
690
+ };
691
+ }
692
+ function generateTwilioSignature(payload, secret, url) {
693
+ const signatureInput = url + payload;
694
+ const signature = (0, import_crypto.createHmac)("sha1", secret).update(signatureInput).digest("base64");
695
+ return {
696
+ header: "X-Twilio-Signature",
697
+ value: signature
698
+ };
699
+ }
700
+ function generateSlackSignature(payload, secret, timestamp) {
701
+ const ts = timestamp || Math.floor(Date.now() / 1e3);
702
+ const signatureBaseString = `v0:${ts}:${payload}`;
703
+ const signature = (0, import_crypto.createHmac)("sha256", secret).update(signatureBaseString).digest("hex");
704
+ return {
705
+ header: "X-Slack-Signature",
706
+ value: `v0=${signature}`
707
+ };
708
+ }
709
+ function generateLinearSignature(payload, secret) {
710
+ const signature = (0, import_crypto.createHmac)("sha256", secret).update(payload).digest("hex");
711
+ return {
712
+ header: "Linear-Signature",
713
+ value: signature
714
+ };
715
+ }
716
+ function generateClerkSignature(payload, secret, timestamp, webhookId) {
717
+ const ts = timestamp || Math.floor(Date.now() / 1e3);
718
+ const msgId = webhookId || `msg_${Date.now()}`;
719
+ const signedPayload = `${msgId}.${ts}.${payload}`;
720
+ const signature = (0, import_crypto.createHmac)("sha256", secret).update(signedPayload).digest("base64");
721
+ return {
722
+ header: "Svix-Signature",
723
+ value: `v1,${signature}`
724
+ };
725
+ }
726
+ function generateSendGridSignature(payload, secret, timestamp) {
727
+ const ts = timestamp || Math.floor(Date.now() / 1e3);
728
+ const signedPayload = `${ts}${payload}`;
729
+ const signature = (0, import_crypto.createHmac)("sha256", secret).update(signedPayload).digest("base64");
730
+ return {
731
+ header: "X-Twilio-Email-Event-Webhook-Signature",
732
+ value: signature
733
+ };
734
+ }
735
+ function generateSignature(provider, payload, secret, options) {
736
+ const timestamp = options?.timestamp;
737
+ switch (provider) {
738
+ case "stripe":
739
+ return generateStripeSignature(payload, secret, timestamp);
740
+ case "github":
741
+ return generateGitHubSignature(payload, secret);
742
+ case "shopify":
743
+ return generateShopifySignature(payload, secret);
744
+ case "twilio":
745
+ if (!options?.url) {
746
+ throw new Error("Twilio signature requires URL");
747
+ }
748
+ return generateTwilioSignature(payload, secret, options.url);
749
+ case "slack":
750
+ return generateSlackSignature(payload, secret, timestamp);
751
+ case "linear":
752
+ return generateLinearSignature(payload, secret);
753
+ case "clerk":
754
+ return generateClerkSignature(
755
+ payload,
756
+ secret,
757
+ timestamp,
758
+ options?.webhookId
759
+ );
760
+ case "sendgrid":
761
+ return generateSendGridSignature(payload, secret, timestamp);
762
+ case "discord":
763
+ case "custom":
764
+ default:
765
+ return null;
766
+ }
767
+ }
768
+ function getProviderHeaders(provider, options) {
769
+ const headers = [];
770
+ const timestamp = options?.timestamp || Math.floor(Date.now() / 1e3);
771
+ switch (provider) {
772
+ case "stripe":
773
+ headers.push(
774
+ { key: "Content-Type", value: "application/json" },
775
+ {
776
+ key: "User-Agent",
777
+ value: "Stripe/1.0 (+https://stripe.com/docs/webhooks)"
778
+ }
779
+ );
780
+ break;
781
+ case "github":
782
+ headers.push(
783
+ { key: "Content-Type", value: "application/json" },
784
+ { key: "User-Agent", value: "GitHub-Hookshot/better-webhook" },
785
+ { key: "X-GitHub-Event", value: options?.event || "push" },
786
+ {
787
+ key: "X-GitHub-Delivery",
788
+ value: options?.webhookId || generateDeliveryId()
789
+ }
790
+ );
791
+ break;
792
+ case "shopify":
793
+ headers.push(
794
+ { key: "Content-Type", value: "application/json" },
795
+ { key: "X-Shopify-Topic", value: options?.event || "orders/create" },
796
+ { key: "X-Shopify-Shop-Domain", value: "example.myshopify.com" },
797
+ { key: "X-Shopify-API-Version", value: "2024-01" }
798
+ );
799
+ break;
800
+ case "slack":
801
+ headers.push(
802
+ { key: "Content-Type", value: "application/json" },
803
+ { key: "X-Slack-Request-Timestamp", value: String(timestamp) }
804
+ );
805
+ break;
806
+ case "clerk":
807
+ headers.push(
808
+ { key: "Content-Type", value: "application/json" },
809
+ { key: "Svix-Id", value: options?.webhookId || `msg_${Date.now()}` },
810
+ { key: "Svix-Timestamp", value: String(timestamp) }
811
+ );
812
+ break;
813
+ case "sendgrid":
814
+ headers.push(
815
+ { key: "Content-Type", value: "application/json" },
816
+ {
817
+ key: "X-Twilio-Email-Event-Webhook-Timestamp",
818
+ value: String(timestamp)
819
+ }
820
+ );
821
+ break;
822
+ case "twilio":
823
+ headers.push({
824
+ key: "Content-Type",
825
+ value: "application/x-www-form-urlencoded"
826
+ });
827
+ break;
828
+ case "linear":
829
+ headers.push(
830
+ { key: "Content-Type", value: "application/json" },
831
+ {
832
+ key: "Linear-Delivery",
833
+ value: options?.webhookId || generateDeliveryId()
834
+ }
835
+ );
836
+ break;
837
+ case "discord":
838
+ headers.push(
839
+ { key: "Content-Type", value: "application/json" },
840
+ { key: "User-Agent", value: "Discord-Webhook/1.0" }
841
+ );
842
+ break;
843
+ default:
844
+ headers.push({ key: "Content-Type", value: "application/json" });
845
+ }
846
+ return headers;
847
+ }
848
+ function generateDeliveryId() {
849
+ const chars = "0123456789abcdef";
850
+ let id = "";
851
+ for (let i = 0; i < 36; i++) {
852
+ if (i === 8 || i === 13 || i === 18 || i === 23) {
853
+ id += "-";
854
+ } else {
855
+ id += chars[Math.floor(Math.random() * chars.length)];
351
856
  }
352
- for (const templateName of toDownload) {
353
- const rel = TEMPLATES[templateName];
354
- if (!rel) {
355
- console.error(
356
- `Unknown template '${templateName}'. Run without arguments to list available templates.`
357
- );
358
- continue;
857
+ }
858
+ return id;
859
+ }
860
+
861
+ // src/core/executor.ts
862
+ async function executeWebhook(options) {
863
+ const startTime = Date.now();
864
+ let bodyStr;
865
+ if (options.body !== void 0) {
866
+ bodyStr = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
867
+ }
868
+ const headers = {};
869
+ if (options.provider) {
870
+ const providerHeaders = getProviderHeaders(options.provider);
871
+ for (const h of providerHeaders) {
872
+ headers[h.key] = h.value;
873
+ }
874
+ }
875
+ if (options.headers) {
876
+ for (const h of options.headers) {
877
+ headers[h.key] = h.value;
878
+ }
879
+ }
880
+ if (options.secret && options.provider && bodyStr) {
881
+ const sig = generateSignature(options.provider, bodyStr, options.secret, {
882
+ url: options.url
883
+ });
884
+ if (sig) {
885
+ headers[sig.header] = sig.value;
886
+ }
887
+ }
888
+ if (!headers["Content-Type"] && !headers["content-type"]) {
889
+ headers["Content-Type"] = "application/json";
890
+ }
891
+ try {
892
+ const response = await (0, import_undici2.request)(options.url, {
893
+ method: options.method || "POST",
894
+ headers,
895
+ body: bodyStr,
896
+ headersTimeout: options.timeout || 3e4,
897
+ bodyTimeout: options.timeout || 3e4
898
+ });
899
+ const bodyText = await response.body.text();
900
+ const duration = Date.now() - startTime;
901
+ const responseHeaders = {};
902
+ for (const [key, value] of Object.entries(response.headers)) {
903
+ if (value !== void 0) {
904
+ responseHeaders[key] = value;
359
905
  }
360
- const rawUrl = `${TEMPLATE_REPO_BASE}/${rel}`;
906
+ }
907
+ let json;
908
+ try {
909
+ json = JSON.parse(bodyText);
910
+ } catch {
911
+ }
912
+ return {
913
+ status: response.statusCode,
914
+ statusText: getStatusText(response.statusCode),
915
+ headers: responseHeaders,
916
+ body: json ?? bodyText,
917
+ bodyText,
918
+ json,
919
+ duration
920
+ };
921
+ } catch (error) {
922
+ const duration = Date.now() - startTime;
923
+ throw new ExecutionError(error.message, duration);
924
+ }
925
+ }
926
+ async function executeTemplate(template, options = {}) {
927
+ const targetUrl = options.url || template.url;
928
+ if (!targetUrl) {
929
+ throw new Error(
930
+ "No target URL specified. Use --url or set url in template."
931
+ );
932
+ }
933
+ const mergedHeaders = [...template.headers || []];
934
+ if (options.headers) {
935
+ for (const h of options.headers) {
936
+ const existingIdx = mergedHeaders.findIndex(
937
+ (mh) => mh.key.toLowerCase() === h.key.toLowerCase()
938
+ );
939
+ if (existingIdx >= 0) {
940
+ mergedHeaders[existingIdx] = h;
941
+ } else {
942
+ mergedHeaders.push(h);
943
+ }
944
+ }
945
+ }
946
+ return executeWebhook({
947
+ url: targetUrl,
948
+ method: template.method,
949
+ headers: mergedHeaders,
950
+ body: template.body,
951
+ secret: options.secret,
952
+ provider: template.provider
953
+ });
954
+ }
955
+ var ExecutionError = class extends Error {
956
+ duration;
957
+ constructor(message, duration) {
958
+ super(message);
959
+ this.name = "ExecutionError";
960
+ this.duration = duration;
961
+ }
962
+ };
963
+ function getStatusText(code) {
964
+ const statusTexts = {
965
+ 200: "OK",
966
+ 201: "Created",
967
+ 202: "Accepted",
968
+ 204: "No Content",
969
+ 301: "Moved Permanently",
970
+ 302: "Found",
971
+ 304: "Not Modified",
972
+ 400: "Bad Request",
973
+ 401: "Unauthorized",
974
+ 403: "Forbidden",
975
+ 404: "Not Found",
976
+ 405: "Method Not Allowed",
977
+ 408: "Request Timeout",
978
+ 409: "Conflict",
979
+ 422: "Unprocessable Entity",
980
+ 429: "Too Many Requests",
981
+ 500: "Internal Server Error",
982
+ 502: "Bad Gateway",
983
+ 503: "Service Unavailable",
984
+ 504: "Gateway Timeout"
985
+ };
986
+ return statusTexts[code] || "Unknown";
987
+ }
988
+
989
+ // src/commands/run.ts
990
+ function getSecretEnvVarName(provider) {
991
+ const envVarMap = {
992
+ github: "GITHUB_WEBHOOK_SECRET",
993
+ stripe: "STRIPE_WEBHOOK_SECRET",
994
+ shopify: "SHOPIFY_WEBHOOK_SECRET",
995
+ twilio: "TWILIO_WEBHOOK_SECRET",
996
+ slack: "SLACK_WEBHOOK_SECRET",
997
+ linear: "LINEAR_WEBHOOK_SECRET",
998
+ clerk: "CLERK_WEBHOOK_SECRET",
999
+ sendgrid: "SENDGRID_WEBHOOK_SECRET",
1000
+ discord: "DISCORD_WEBHOOK_SECRET",
1001
+ custom: "WEBHOOK_SECRET"
1002
+ };
1003
+ return envVarMap[provider] || "WEBHOOK_SECRET";
1004
+ }
1005
+ var run = new import_commander2.Command().name("run").argument("[templateId]", "Template ID to run").description("Run a webhook template against a target URL").requiredOption("-u, --url <url>", "Target URL to send the webhook to").option("-s, --secret <secret>", "Secret for signature generation").option(
1006
+ "-H, --header <header>",
1007
+ "Add custom header (format: key:value)",
1008
+ (value, previous) => {
1009
+ const [key, ...valueParts] = value.split(":");
1010
+ const headerValue = valueParts.join(":");
1011
+ if (!key || !headerValue) {
1012
+ throw new Error("Header format should be key:value");
1013
+ }
1014
+ return (previous || []).concat([
1015
+ { key: key.trim(), value: headerValue.trim() }
1016
+ ]);
1017
+ },
1018
+ []
1019
+ ).option("-v, --verbose", "Show detailed request/response information").action(
1020
+ async (templateId, options) => {
1021
+ const manager = getTemplateManager();
1022
+ if (!templateId) {
1023
+ const spinner2 = (0, import_ora2.default)("Loading templates...").start();
361
1024
  try {
362
- const { statusCode, body } = await (0, import_undici2.request)(rawUrl);
363
- if (statusCode !== 200) {
364
- console.error(
365
- `Failed to fetch ${templateName} (HTTP ${statusCode}) from ${rawUrl}`
1025
+ const local = manager.listLocalTemplates();
1026
+ const remote = await manager.listRemoteTemplates();
1027
+ spinner2.stop();
1028
+ if (local.length === 0 && remote.length === 0) {
1029
+ console.log(import_chalk2.default.yellow("\n\u{1F4ED} No templates available."));
1030
+ console.log(
1031
+ import_chalk2.default.gray(
1032
+ " Download templates with: better-webhook templates download\n"
1033
+ )
366
1034
  );
367
- continue;
1035
+ return;
368
1036
  }
369
- const text = await body.text();
370
- let json;
371
- try {
372
- json = JSON.parse(text);
373
- } catch (e) {
374
- console.error(
375
- `Invalid JSON in remote template ${templateName}: ${e.message}`
376
- );
377
- continue;
1037
+ const choices = [];
1038
+ if (local.length > 0) {
1039
+ for (const t of local) {
1040
+ choices.push({
1041
+ title: `${t.id} ${import_chalk2.default.green("(local)")}`,
1042
+ description: `${t.metadata.provider} - ${t.metadata.event}`,
1043
+ value: t.id
1044
+ });
1045
+ }
378
1046
  }
379
- try {
380
- validateWebhookJSON(json, rawUrl);
381
- } catch (e) {
382
- console.error(`Template failed schema validation: ${e.message}`);
383
- continue;
1047
+ const remoteOnly = remote.filter((t) => !t.isDownloaded);
1048
+ for (const t of remoteOnly) {
1049
+ choices.push({
1050
+ title: `${t.metadata.id} ${import_chalk2.default.gray("(remote)")}`,
1051
+ description: `${t.metadata.provider} - ${t.metadata.event}`,
1052
+ value: `remote:${t.metadata.id}`
1053
+ });
384
1054
  }
385
- const fileName = (0, import_path2.basename)(rel);
386
- const destPath = (0, import_path2.join)(dir, fileName);
387
- if ((0, import_fs3.existsSync)(destPath) && !opts.force) {
388
- console.log(
389
- `Skipping existing file ${fileName} (use --force to overwrite)`
390
- );
391
- continue;
1055
+ const response = await (0, import_prompts2.default)({
1056
+ type: "select",
1057
+ name: "templateId",
1058
+ message: "Select a template to run:",
1059
+ choices
1060
+ });
1061
+ if (!response.templateId) {
1062
+ console.log(import_chalk2.default.yellow("Cancelled"));
1063
+ return;
392
1064
  }
393
- (0, import_fs3.writeFileSync)(destPath, JSON.stringify(json, null, 2));
394
- console.log(`Downloaded ${templateName} -> .webhooks/${fileName}`);
395
- } catch (e) {
396
- console.error(`Error downloading ${templateName}: ${e.message}`);
1065
+ templateId = response.templateId;
1066
+ } catch (error) {
1067
+ spinner2.fail("Failed to load templates");
1068
+ console.error(import_chalk2.default.red(error.message));
1069
+ process.exitCode = 1;
1070
+ return;
1071
+ }
1072
+ }
1073
+ if (templateId?.startsWith("remote:")) {
1074
+ const remoteId = templateId.replace("remote:", "");
1075
+ const downloadSpinner = (0, import_ora2.default)(`Downloading ${remoteId}...`).start();
1076
+ try {
1077
+ await manager.downloadTemplate(remoteId);
1078
+ downloadSpinner.succeed(`Downloaded ${remoteId}`);
1079
+ templateId = remoteId;
1080
+ } catch (error) {
1081
+ downloadSpinner.fail(`Failed to download: ${error.message}`);
1082
+ process.exitCode = 1;
1083
+ return;
397
1084
  }
398
1085
  }
1086
+ const localTemplate = manager.getLocalTemplate(templateId);
1087
+ if (!localTemplate) {
1088
+ console.log(import_chalk2.default.red(`
1089
+ \u274C Template not found: ${templateId}`));
1090
+ console.log(
1091
+ import_chalk2.default.gray(
1092
+ " Download it with: better-webhook templates download " + templateId + "\n"
1093
+ )
1094
+ );
1095
+ process.exitCode = 1;
1096
+ return;
1097
+ }
1098
+ const targetUrl = options.url;
1099
+ let secret = options?.secret;
1100
+ if (!secret && localTemplate.metadata.provider) {
1101
+ const envVarName = getSecretEnvVarName(localTemplate.metadata.provider);
1102
+ secret = process.env[envVarName];
1103
+ }
1104
+ console.log(import_chalk2.default.bold("\n\u{1F680} Executing Webhook\n"));
1105
+ console.log(import_chalk2.default.gray(` Template: ${templateId}`));
1106
+ console.log(
1107
+ import_chalk2.default.gray(` Provider: ${localTemplate.metadata.provider}`)
1108
+ );
1109
+ console.log(import_chalk2.default.gray(` Event: ${localTemplate.metadata.event}`));
1110
+ console.log(import_chalk2.default.gray(` Target: ${targetUrl}`));
1111
+ if (secret) {
1112
+ console.log(import_chalk2.default.gray(` Signature: Will be generated`));
1113
+ } else {
1114
+ console.log(
1115
+ import_chalk2.default.yellow(
1116
+ ` \u26A0\uFE0F No secret provided - signature will not be generated`
1117
+ )
1118
+ );
1119
+ }
1120
+ console.log();
1121
+ const spinner = (0, import_ora2.default)("Sending webhook...").start();
1122
+ try {
1123
+ const result = await executeTemplate(localTemplate.template, {
1124
+ url: targetUrl,
1125
+ secret,
1126
+ headers: options?.header
1127
+ });
1128
+ spinner.stop();
1129
+ const statusColor = result.status >= 200 && result.status < 300 ? import_chalk2.default.green : result.status >= 400 ? import_chalk2.default.red : import_chalk2.default.yellow;
1130
+ console.log(import_chalk2.default.bold("\u{1F4E5} Response\n"));
1131
+ console.log(
1132
+ ` Status: ${statusColor(`${result.status} ${result.statusText}`)}`
1133
+ );
1134
+ console.log(` Duration: ${import_chalk2.default.cyan(`${result.duration}ms`)}`);
1135
+ if (options?.verbose) {
1136
+ console.log(import_chalk2.default.bold("\n Headers:"));
1137
+ for (const [key, value] of Object.entries(result.headers)) {
1138
+ const headerValue = Array.isArray(value) ? value.join(", ") : value;
1139
+ console.log(import_chalk2.default.gray(` ${key}: ${headerValue}`));
1140
+ }
1141
+ }
1142
+ if (result.json !== void 0) {
1143
+ console.log(import_chalk2.default.bold("\n Body:"));
1144
+ console.log(
1145
+ import_chalk2.default.gray(
1146
+ JSON.stringify(result.json, null, 2).split("\n").map((l) => ` ${l}`).join("\n")
1147
+ )
1148
+ );
1149
+ } else if (result.bodyText) {
1150
+ console.log(import_chalk2.default.bold("\n Body:"));
1151
+ const preview = result.bodyText.length > 500 ? result.bodyText.slice(0, 500) + "..." : result.bodyText;
1152
+ console.log(import_chalk2.default.gray(` ${preview}`));
1153
+ }
1154
+ console.log();
1155
+ if (result.status >= 200 && result.status < 300) {
1156
+ console.log(import_chalk2.default.green("\u2713 Webhook delivered successfully\n"));
1157
+ } else {
1158
+ console.log(
1159
+ import_chalk2.default.yellow(`\u26A0 Webhook delivered with status ${result.status}
1160
+ `)
1161
+ );
1162
+ }
1163
+ } catch (error) {
1164
+ spinner.fail("Request failed");
1165
+ console.error(import_chalk2.default.red(`
1166
+ \u274C ${error.message}
1167
+ `));
1168
+ process.exitCode = 1;
1169
+ }
399
1170
  }
400
1171
  );
401
- var webhooks = new import_commander.Command().name("webhooks").description("Manage and execute webhook definitions").addCommand(listCommand).addCommand(runCommand).addCommand(downloadCommand);
402
1172
 
403
1173
  // src/commands/capture.ts
404
- var import_commander2 = require("commander");
405
- var import_fs6 = require("fs");
406
- var import_path5 = require("path");
1174
+ var import_commander3 = require("commander");
1175
+ var import_chalk3 = __toESM(require("chalk"), 1);
407
1176
 
408
- // src/capture.ts
409
- var import_http2 = require("http");
410
- var import_fs4 = require("fs");
411
- var import_path3 = require("path");
412
- var import_crypto = require("crypto");
413
- var WebhookCaptureServer = class {
1177
+ // src/core/capture-server.ts
1178
+ var import_http = require("http");
1179
+ var import_ws = require("ws");
1180
+ var import_fs2 = require("fs");
1181
+ var import_path2 = require("path");
1182
+ var import_crypto2 = require("crypto");
1183
+ var import_os2 = require("os");
1184
+ var CaptureServer = class {
414
1185
  server = null;
1186
+ wss = null;
415
1187
  capturesDir;
1188
+ clients = /* @__PURE__ */ new Set();
1189
+ captureCount = 0;
416
1190
  constructor(capturesDir) {
417
- this.capturesDir = capturesDir;
418
- if (!(0, import_fs4.existsSync)(capturesDir)) {
419
- (0, import_fs4.mkdirSync)(capturesDir, { recursive: true });
420
- }
421
- }
422
- start(startPort = 3001, maxAttempts = 20) {
423
- return new Promise((resolve4, reject) => {
424
- if (!Number.isInteger(startPort) || startPort < 0 || startPort > 65535) {
425
- startPort = 3001;
426
- }
427
- let attempt = 0;
428
- const tryListen = (portToTry) => {
429
- this.server = (0, import_http2.createServer)(
430
- async (req, res) => {
431
- try {
432
- await this.handleRequest(req, res);
433
- } catch (error) {
434
- console.error("Error handling request:", error);
435
- res.statusCode = 500;
436
- res.end("Internal Server Error");
437
- }
1191
+ this.capturesDir = capturesDir || (0, import_path2.join)((0, import_os2.homedir)(), ".better-webhook", "captures");
1192
+ if (!(0, import_fs2.existsSync)(this.capturesDir)) {
1193
+ (0, import_fs2.mkdirSync)(this.capturesDir, { recursive: true });
1194
+ }
1195
+ }
1196
+ /**
1197
+ * Get the captures directory path
1198
+ */
1199
+ getCapturesDir() {
1200
+ return this.capturesDir;
1201
+ }
1202
+ /**
1203
+ * Start the capture server
1204
+ */
1205
+ async start(port = 3001, host = "0.0.0.0") {
1206
+ return new Promise((resolve, reject) => {
1207
+ 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
438
1225
  }
1226
+ });
1227
+ });
1228
+ this.server.on("error", (err) => {
1229
+ if (err.code === "EADDRINUSE") {
1230
+ reject(new Error(`Port ${port} is already in use`));
1231
+ } else {
1232
+ reject(err);
1233
+ }
1234
+ });
1235
+ this.server.listen(port, host, () => {
1236
+ const address = this.server?.address();
1237
+ const actualPort = typeof address === "object" ? address?.port || port : port;
1238
+ console.log(`
1239
+ \u{1F3A3} Webhook Capture Server`);
1240
+ console.log(
1241
+ ` Listening on http://${host === "0.0.0.0" ? "localhost" : host}:${actualPort}`
439
1242
  );
440
- const onError = (err) => {
441
- this.server?.off("error", onError);
442
- this.server?.off("listening", onListening);
443
- if (err && err.code === "EADDRINUSE") {
444
- attempt += 1;
445
- if (startPort === 0) {
446
- reject(new Error("Failed to bind to an ephemeral port."));
447
- return;
448
- }
449
- if (attempt >= maxAttempts) {
450
- reject(
451
- new Error(
452
- `All ${maxAttempts} port attempts starting at ${startPort} are in use.`
453
- )
454
- );
455
- return;
456
- }
457
- const nextPort = startPort + attempt;
458
- tryListen(nextPort);
459
- } else {
460
- reject(err);
461
- }
462
- };
463
- const onListening = () => {
464
- this.server?.off("error", onError);
465
- this.server?.off("listening", onListening);
466
- const address = this.server?.address();
467
- const actualPort = address?.port ?? portToTry;
468
- console.log(
469
- `\u{1F3A3} Webhook capture server running on http://localhost:${actualPort}`
470
- );
471
- console.log(
472
- `\u{1F4C1} Captured webhooks will be saved to: ${this.capturesDir}`
473
- );
474
- console.log(
475
- "\u{1F4A1} Send webhooks to any path on this server to capture them"
476
- );
477
- console.log("\u23F9\uFE0F Press Ctrl+C to stop the server");
478
- resolve4(actualPort);
479
- };
480
- this.server.on("error", onError);
481
- this.server.on("listening", onListening);
482
- this.server.listen(portToTry);
483
- };
484
- tryListen(startPort === 0 ? 0 : startPort);
1243
+ console.log(` \u{1F4C1} Captures saved to: ${this.capturesDir}`);
1244
+ console.log(` \u{1F4A1} Send webhooks to any path to capture them`);
1245
+ console.log(` \u{1F310} WebSocket available for real-time updates`);
1246
+ console.log(` \u23F9\uFE0F Press Ctrl+C to stop
1247
+ `);
1248
+ resolve(actualPort);
1249
+ });
485
1250
  });
486
1251
  }
487
- stop() {
488
- return new Promise((resolve4) => {
1252
+ /**
1253
+ * Stop the capture server
1254
+ */
1255
+ async stop() {
1256
+ return new Promise((resolve) => {
1257
+ for (const client of this.clients) {
1258
+ client.close();
1259
+ }
1260
+ this.clients.clear();
1261
+ if (this.wss) {
1262
+ this.wss.close();
1263
+ this.wss = null;
1264
+ }
489
1265
  if (this.server) {
490
1266
  this.server.close(() => {
491
- console.log("\u{1F4F4} Webhook capture server stopped");
492
- resolve4();
1267
+ console.log("\n\u{1F6D1} Capture server stopped");
1268
+ resolve();
493
1269
  });
494
1270
  } else {
495
- resolve4();
1271
+ resolve();
496
1272
  }
497
1273
  });
498
1274
  }
1275
+ /**
1276
+ * Handle incoming HTTP requests
1277
+ */
499
1278
  async handleRequest(req, res) {
1279
+ if (req.headers.upgrade?.toLowerCase() === "websocket") {
1280
+ return;
1281
+ }
500
1282
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
501
- const id = this.generateId();
1283
+ const id = (0, import_crypto2.randomUUID)();
502
1284
  const url = req.url || "/";
503
1285
  const urlParts = new URL(url, `http://${req.headers.host || "localhost"}`);
504
1286
  const query = {};
@@ -514,299 +1296,654 @@ var WebhookCaptureServer = class {
514
1296
  }
515
1297
  }
516
1298
  const chunks = [];
517
- req.on("data", (chunk) => chunks.push(chunk));
518
- await new Promise((resolve4) => {
519
- req.on("end", () => resolve4());
520
- });
1299
+ for await (const chunk of req) {
1300
+ chunks.push(chunk);
1301
+ }
521
1302
  const rawBody = Buffer.concat(chunks).toString("utf8");
522
- let parsedBody;
523
- try {
524
- parsedBody = rawBody ? JSON.parse(rawBody) : null;
525
- } catch {
526
- parsedBody = rawBody || null;
1303
+ let body = null;
1304
+ const contentType = req.headers["content-type"] || "";
1305
+ if (rawBody) {
1306
+ if (contentType.includes("application/json")) {
1307
+ try {
1308
+ body = JSON.parse(rawBody);
1309
+ } catch {
1310
+ body = rawBody;
1311
+ }
1312
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
1313
+ body = Object.fromEntries(new URLSearchParams(rawBody));
1314
+ } else {
1315
+ body = rawBody;
1316
+ }
527
1317
  }
1318
+ const provider = this.detectProvider(req.headers);
528
1319
  const captured = {
529
1320
  id,
530
1321
  timestamp,
531
1322
  method: req.method || "GET",
532
- url: urlParts.pathname,
1323
+ url,
1324
+ path: urlParts.pathname,
533
1325
  headers: req.headers,
534
- body: parsedBody,
1326
+ body,
535
1327
  rawBody,
536
- query
1328
+ query,
1329
+ provider,
1330
+ contentType: contentType || void 0,
1331
+ contentLength: rawBody.length
537
1332
  };
538
- const filename = `${timestamp.replace(/[:.]/g, "-")}_${id}.json`;
539
- const filepath = (0, import_path3.join)(this.capturesDir, filename);
1333
+ const date = new Date(timestamp);
1334
+ const dateStr = date.toISOString().split("T")[0];
1335
+ const timeStr = date.toISOString().split("T")[1]?.replace(/[:.]/g, "-").slice(0, 8);
1336
+ const filename = `${dateStr}_${timeStr}_${id.slice(0, 8)}.json`;
1337
+ const filepath = (0, import_path2.join)(this.capturesDir, filename);
540
1338
  try {
541
- (0, import_fs4.writeFileSync)(filepath, JSON.stringify(captured, null, 2));
1339
+ (0, import_fs2.writeFileSync)(filepath, JSON.stringify(captured, null, 2));
1340
+ this.captureCount++;
1341
+ const providerStr = provider ? ` [${provider}]` : "";
542
1342
  console.log(
543
- `\u{1F4E6} Captured ${req.method} ${urlParts.pathname} -> ${filename}`
1343
+ `\u{1F4E6} ${req.method} ${urlParts.pathname}${providerStr} -> ${filename}`
544
1344
  );
1345
+ this.broadcast({
1346
+ type: "capture",
1347
+ payload: {
1348
+ file: filename,
1349
+ capture: captured
1350
+ }
1351
+ });
545
1352
  } catch (error) {
546
- console.error(`\u274C Failed to save capture: ${error}`);
1353
+ console.error(`\u274C Failed to save capture:`, error);
547
1354
  }
548
1355
  res.statusCode = 200;
549
1356
  res.setHeader("Content-Type", "application/json");
1357
+ res.setHeader("X-Capture-Id", id);
550
1358
  res.end(
551
- JSON.stringify(
552
- {
553
- message: "Webhook captured successfully",
554
- id,
555
- timestamp,
556
- file: filename
557
- },
558
- null,
559
- 2
560
- )
1359
+ JSON.stringify({
1360
+ success: true,
1361
+ message: "Webhook captured successfully",
1362
+ id,
1363
+ timestamp,
1364
+ file: filename
1365
+ })
561
1366
  );
562
1367
  }
563
- generateId() {
1368
+ /**
1369
+ * Detect webhook provider from headers
1370
+ */
1371
+ detectProvider(headers) {
1372
+ if (headers["stripe-signature"]) {
1373
+ return "stripe";
1374
+ }
1375
+ if (headers["x-github-event"] || headers["x-hub-signature-256"]) {
1376
+ return "github";
1377
+ }
1378
+ if (headers["x-shopify-hmac-sha256"] || headers["x-shopify-topic"]) {
1379
+ return "shopify";
1380
+ }
1381
+ if (headers["x-twilio-signature"]) {
1382
+ return "twilio";
1383
+ }
1384
+ if (headers["x-twilio-email-event-webhook-signature"]) {
1385
+ return "sendgrid";
1386
+ }
1387
+ if (headers["x-slack-signature"]) {
1388
+ return "slack";
1389
+ }
1390
+ if (headers["x-signature-ed25519"]) {
1391
+ return "discord";
1392
+ }
1393
+ if (headers["linear-signature"]) {
1394
+ return "linear";
1395
+ }
1396
+ if (headers["svix-signature"]) {
1397
+ return "clerk";
1398
+ }
1399
+ return void 0;
1400
+ }
1401
+ /**
1402
+ * Broadcast message to all connected WebSocket clients
1403
+ */
1404
+ broadcast(message) {
1405
+ const data = JSON.stringify(message);
1406
+ for (const client of this.clients) {
1407
+ if (client.readyState === 1) {
1408
+ client.send(data);
1409
+ }
1410
+ }
1411
+ }
1412
+ /**
1413
+ * Send message to a specific client
1414
+ */
1415
+ sendToClient(client, message) {
1416
+ if (client.readyState === 1) {
1417
+ client.send(JSON.stringify(message));
1418
+ }
1419
+ }
1420
+ /**
1421
+ * List all captured webhooks
1422
+ */
1423
+ listCaptures(limit = 100) {
1424
+ if (!(0, import_fs2.existsSync)(this.capturesDir)) {
1425
+ return [];
1426
+ }
1427
+ const files = (0, import_fs2.readdirSync)(this.capturesDir).filter((f) => f.endsWith(".json")).sort().reverse().slice(0, limit);
1428
+ const captures2 = [];
1429
+ for (const file of files) {
1430
+ try {
1431
+ const content = (0, import_fs2.readFileSync)((0, import_path2.join)(this.capturesDir, file), "utf-8");
1432
+ const capture2 = JSON.parse(content);
1433
+ captures2.push({ file, capture: capture2 });
1434
+ } catch {
1435
+ }
1436
+ }
1437
+ return captures2;
1438
+ }
1439
+ /**
1440
+ * Get a specific capture by ID
1441
+ */
1442
+ getCapture(captureId) {
1443
+ const captures2 = this.listCaptures(1e3);
1444
+ return captures2.find(
1445
+ (c) => c.capture.id === captureId || c.file.includes(captureId)
1446
+ ) || null;
1447
+ }
1448
+ /**
1449
+ * Delete a capture
1450
+ */
1451
+ deleteCapture(captureId) {
1452
+ const capture2 = this.getCapture(captureId);
1453
+ if (!capture2) {
1454
+ return false;
1455
+ }
564
1456
  try {
565
- return (0, import_crypto.randomUUID)();
1457
+ const fs = require("fs");
1458
+ fs.unlinkSync((0, import_path2.join)(this.capturesDir, capture2.file));
1459
+ return true;
566
1460
  } catch {
567
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1461
+ return false;
568
1462
  }
569
1463
  }
1464
+ /**
1465
+ * Get connected client count
1466
+ */
1467
+ getClientCount() {
1468
+ return this.clients.size;
1469
+ }
570
1470
  };
571
1471
 
572
- // src/replay.ts
573
- var import_fs5 = require("fs");
574
- var import_path4 = require("path");
575
- var WebhookReplayer = class {
1472
+ // src/commands/capture.ts
1473
+ var capture = new import_commander3.Command().name("capture").description("Start a server to capture incoming webhooks").option("-p, --port <port>", "Port to listen on", "3001").option("-h, --host <host>", "Host to bind to", "0.0.0.0").action(async (options) => {
1474
+ const port = parseInt(options.port, 10);
1475
+ if (isNaN(port) || port < 0 || port > 65535) {
1476
+ console.error(import_chalk3.default.red("Invalid port number"));
1477
+ process.exitCode = 1;
1478
+ return;
1479
+ }
1480
+ const server = new CaptureServer();
1481
+ try {
1482
+ await server.start(port, options.host);
1483
+ const shutdown = async () => {
1484
+ await server.stop();
1485
+ process.exit(0);
1486
+ };
1487
+ process.on("SIGINT", shutdown);
1488
+ process.on("SIGTERM", shutdown);
1489
+ } catch (error) {
1490
+ console.error(import_chalk3.default.red(`Failed to start server: ${error.message}`));
1491
+ process.exitCode = 1;
1492
+ }
1493
+ });
1494
+
1495
+ // src/commands/captures.ts
1496
+ var import_commander4 = require("commander");
1497
+ var import_chalk4 = __toESM(require("chalk"), 1);
1498
+ var import_prompts3 = __toESM(require("prompts"), 1);
1499
+
1500
+ // src/core/replay-engine.ts
1501
+ var import_fs3 = require("fs");
1502
+ var import_path3 = require("path");
1503
+ var import_os3 = require("os");
1504
+ var ReplayEngine = class {
1505
+ capturesDir;
576
1506
  constructor(capturesDir) {
577
- this.capturesDir = capturesDir;
1507
+ this.capturesDir = capturesDir || (0, import_path3.join)((0, import_os3.homedir)(), ".better-webhook", "captures");
1508
+ }
1509
+ /**
1510
+ * Get the captures directory
1511
+ */
1512
+ getCapturesDir() {
1513
+ return this.capturesDir;
578
1514
  }
579
1515
  /**
580
1516
  * List all captured webhooks
581
1517
  */
582
- listCaptured() {
583
- try {
584
- const files = listJsonFiles(this.capturesDir).sort().reverse();
585
- return files.map((file) => ({
586
- file,
587
- capture: this.loadCapture((0, import_path4.join)(this.capturesDir, file))
588
- }));
589
- } catch {
1518
+ listCaptures(limit = 100) {
1519
+ if (!(0, import_fs3.existsSync)(this.capturesDir)) {
590
1520
  return [];
591
1521
  }
1522
+ const files = (0, import_fs3.readdirSync)(this.capturesDir).filter((f) => f.endsWith(".json")).sort().reverse().slice(0, limit);
1523
+ const captures2 = [];
1524
+ for (const file of files) {
1525
+ try {
1526
+ const content = (0, import_fs3.readFileSync)((0, import_path3.join)(this.capturesDir, file), "utf-8");
1527
+ const capture2 = JSON.parse(content);
1528
+ captures2.push({ file, capture: capture2 });
1529
+ } catch {
1530
+ }
1531
+ }
1532
+ return captures2;
592
1533
  }
593
1534
  /**
594
- * Load a specific captured webhook by filename or ID
1535
+ * Get a specific capture by ID or partial filename
595
1536
  */
596
- loadCapture(filePathOrId) {
597
- let filepath;
598
- if (filePathOrId.includes("/") || filePathOrId.endsWith(".json")) {
599
- filepath = filePathOrId;
600
- } else {
601
- const files = listJsonFiles(this.capturesDir).filter(
602
- (f) => f.includes(filePathOrId)
603
- );
604
- if (files.length === 0) {
605
- throw new Error(`No capture found with ID: ${filePathOrId}`);
1537
+ getCapture(captureId) {
1538
+ const captures2 = this.listCaptures(1e3);
1539
+ let found = captures2.find((c) => c.capture.id === captureId);
1540
+ if (found) return found;
1541
+ found = captures2.find((c) => c.file.includes(captureId));
1542
+ if (found) return found;
1543
+ found = captures2.find((c) => c.capture.id.startsWith(captureId));
1544
+ return found || null;
1545
+ }
1546
+ /**
1547
+ * Replay a captured webhook
1548
+ */
1549
+ async replay(captureId, options) {
1550
+ const captureFile = this.getCapture(captureId);
1551
+ if (!captureFile) {
1552
+ throw new Error(`Capture not found: ${captureId}`);
1553
+ }
1554
+ const { capture: capture2 } = captureFile;
1555
+ const headers = [];
1556
+ const skipHeaders = [
1557
+ "host",
1558
+ "content-length",
1559
+ "connection",
1560
+ "accept-encoding"
1561
+ ];
1562
+ for (const [key, value] of Object.entries(capture2.headers)) {
1563
+ if (!skipHeaders.includes(key.toLowerCase())) {
1564
+ const headerValue = Array.isArray(value) ? value.join(", ") : value;
1565
+ if (headerValue) {
1566
+ headers.push({ key, value: headerValue });
1567
+ }
606
1568
  }
607
- if (files.length > 1) {
608
- throw new Error(
609
- `Multiple captures found with ID ${filePathOrId}: ${files.join(", ")}`
1569
+ }
1570
+ if (options.headers) {
1571
+ for (const h of options.headers) {
1572
+ const existingIdx = headers.findIndex(
1573
+ (eh) => eh.key.toLowerCase() === h.key.toLowerCase()
610
1574
  );
1575
+ if (existingIdx >= 0) {
1576
+ headers[existingIdx] = h;
1577
+ } else {
1578
+ headers.push(h);
1579
+ }
611
1580
  }
612
- filepath = (0, import_path4.join)(this.capturesDir, files[0] ?? "");
613
- }
614
- try {
615
- const content = (0, import_fs5.readFileSync)(filepath, "utf8");
616
- return JSON.parse(content);
617
- } catch (error) {
618
- throw new Error(
619
- `Failed to load capture from ${filepath}: ${error.message}`
620
- );
621
1581
  }
1582
+ const body = capture2.rawBody || capture2.body;
1583
+ return executeWebhook({
1584
+ url: options.targetUrl,
1585
+ method: options.method || capture2.method,
1586
+ headers,
1587
+ body
1588
+ });
622
1589
  }
623
1590
  /**
624
- * Replay a captured webhook to a target URL
1591
+ * Convert a capture to a template
625
1592
  */
626
- async replay(captureId, targetUrl, options = {}) {
627
- const capture2 = this.loadCapture(captureId);
628
- const webhookDef = {
629
- url: options.url || targetUrl,
630
- method: options.method || capture2.method,
631
- headers: options.headers || this.convertHeaders(capture2.headers),
632
- body: capture2.body
1593
+ captureToTemplate(captureId, options) {
1594
+ const captureFile = this.getCapture(captureId);
1595
+ if (!captureFile) {
1596
+ throw new Error(`Capture not found: ${captureId}`);
1597
+ }
1598
+ const { capture: capture2 } = captureFile;
1599
+ const skipHeaders = [
1600
+ "host",
1601
+ "content-length",
1602
+ "connection",
1603
+ "accept-encoding",
1604
+ "stripe-signature",
1605
+ "x-hub-signature-256",
1606
+ "x-hub-signature",
1607
+ "x-shopify-hmac-sha256",
1608
+ "x-twilio-signature",
1609
+ "x-slack-signature",
1610
+ "svix-signature",
1611
+ "linear-signature"
1612
+ ];
1613
+ const headers = [];
1614
+ for (const [key, value] of Object.entries(capture2.headers)) {
1615
+ if (!skipHeaders.includes(key.toLowerCase())) {
1616
+ const headerValue = Array.isArray(value) ? value.join(", ") : value;
1617
+ if (headerValue) {
1618
+ headers.push({ key, value: headerValue });
1619
+ }
1620
+ }
1621
+ }
1622
+ let body;
1623
+ if (capture2.body) {
1624
+ body = capture2.body;
1625
+ } else if (capture2.rawBody) {
1626
+ try {
1627
+ body = JSON.parse(capture2.rawBody);
1628
+ } catch {
1629
+ body = capture2.rawBody;
1630
+ }
1631
+ }
1632
+ return {
1633
+ url: options?.url || `http://localhost:3000${capture2.path}`,
1634
+ method: capture2.method,
1635
+ headers,
1636
+ body,
1637
+ provider: capture2.provider,
1638
+ description: `Captured ${capture2.provider || "webhook"} at ${capture2.timestamp}`
633
1639
  };
634
- console.log(`\u{1F504} Replaying webhook ${capture2.id} (${capture2.timestamp})`);
635
- console.log(` Method: ${webhookDef.method}`);
636
- console.log(` URL: ${webhookDef.url}`);
637
- console.log(` Original: ${capture2.method} ${capture2.url}`);
638
- try {
639
- const result = await executeWebhook(webhookDef);
640
- return result;
641
- } catch (error) {
642
- throw new Error(`Replay failed: ${error.message}`);
1640
+ }
1641
+ /**
1642
+ * Get a summary of a capture
1643
+ */
1644
+ getCaptureSummary(captureId) {
1645
+ const captureFile = this.getCapture(captureId);
1646
+ if (!captureFile) {
1647
+ return "Capture not found";
643
1648
  }
1649
+ const { capture: capture2 } = captureFile;
1650
+ const lines = [];
1651
+ lines.push(`ID: ${capture2.id}`);
1652
+ lines.push(`Timestamp: ${new Date(capture2.timestamp).toLocaleString()}`);
1653
+ lines.push(`Method: ${capture2.method}`);
1654
+ lines.push(`Path: ${capture2.path}`);
1655
+ if (capture2.provider) {
1656
+ lines.push(`Provider: ${capture2.provider}`);
1657
+ }
1658
+ lines.push(`Content-Type: ${capture2.contentType || "unknown"}`);
1659
+ lines.push(`Body Size: ${capture2.contentLength || 0} bytes`);
1660
+ const headerCount = Object.keys(capture2.headers).length;
1661
+ lines.push(`Headers: ${headerCount}`);
1662
+ return lines.join("\n");
644
1663
  }
645
1664
  /**
646
- * Convert captured webhook to template format
1665
+ * Search captures
647
1666
  */
648
- captureToTemplate(captureId, templateUrl) {
649
- const capture2 = this.loadCapture(captureId);
650
- return {
651
- url: templateUrl || "http://localhost:3000/webhook",
652
- method: capture2.method,
653
- headers: this.convertHeaders(capture2.headers),
654
- body: capture2.body
655
- };
1667
+ searchCaptures(query) {
1668
+ const queryLower = query.toLowerCase();
1669
+ const captures2 = this.listCaptures(1e3);
1670
+ return captures2.filter((c) => {
1671
+ const { capture: capture2 } = c;
1672
+ return capture2.id.toLowerCase().includes(queryLower) || capture2.path.toLowerCase().includes(queryLower) || capture2.method.toLowerCase().includes(queryLower) || capture2.provider?.toLowerCase().includes(queryLower) || c.file.toLowerCase().includes(queryLower);
1673
+ });
656
1674
  }
657
1675
  /**
658
- * Convert captured headers to template format
1676
+ * Get captures by provider
659
1677
  */
660
- convertHeaders(headers) {
661
- const result = [];
662
- for (const [key, value] of Object.entries(headers)) {
663
- const skipHeaders = [
664
- "host",
665
- "content-length",
666
- "connection",
667
- "accept-encoding",
668
- "user-agent",
669
- "x-forwarded-for",
670
- "x-forwarded-proto"
671
- ];
672
- if (skipHeaders.includes(key.toLowerCase())) {
673
- continue;
674
- }
675
- if (Array.isArray(value)) {
676
- if (value.length === 1) {
677
- result.push({ key, value: value[0] ?? "" });
678
- } else {
679
- result.push({ key, value: value.join(", ") });
680
- }
681
- } else {
682
- result.push({ key, value });
683
- }
1678
+ getCapturesByProvider(provider) {
1679
+ const captures2 = this.listCaptures(1e3);
1680
+ return captures2.filter((c) => c.capture.provider === provider);
1681
+ }
1682
+ /**
1683
+ * Delete a specific capture by ID
1684
+ * @returns true if deleted, false if not found
1685
+ */
1686
+ deleteCapture(captureId) {
1687
+ const captureFile = this.getCapture(captureId);
1688
+ if (!captureFile) {
1689
+ return false;
1690
+ }
1691
+ try {
1692
+ (0, import_fs3.unlinkSync)((0, import_path3.join)(this.capturesDir, captureFile.file));
1693
+ return true;
1694
+ } catch {
1695
+ return false;
684
1696
  }
685
- return result;
686
1697
  }
687
1698
  /**
688
- * Get summary information about a capture
1699
+ * Delete all captures
1700
+ * @returns Number of captures deleted
689
1701
  */
690
- getCaptureSummary(captureId) {
691
- const capture2 = this.loadCapture(captureId);
692
- const date = new Date(capture2.timestamp);
693
- const bodySize = capture2.rawBody ? capture2.rawBody.length : 0;
694
- const headerCount = Object.keys(capture2.headers).length;
695
- return [
696
- `ID: ${capture2.id}`,
697
- `Date: ${date.toLocaleString()}`,
698
- `Method: ${capture2.method}`,
699
- `Path: ${capture2.url}`,
700
- `Headers: ${headerCount}`,
701
- `Body Size: ${bodySize} bytes`,
702
- `Query Params: ${Object.keys(capture2.query).length}`
703
- ].join("\n");
1702
+ deleteAllCaptures() {
1703
+ if (!(0, import_fs3.existsSync)(this.capturesDir)) {
1704
+ return 0;
1705
+ }
1706
+ const files = (0, import_fs3.readdirSync)(this.capturesDir).filter(
1707
+ (f) => f.endsWith(".json")
1708
+ );
1709
+ let deleted = 0;
1710
+ for (const file of files) {
1711
+ try {
1712
+ (0, import_fs3.unlinkSync)((0, import_path3.join)(this.capturesDir, file));
1713
+ deleted++;
1714
+ } catch {
1715
+ }
1716
+ }
1717
+ return deleted;
704
1718
  }
705
1719
  };
1720
+ var instance2 = null;
1721
+ function getReplayEngine(capturesDir) {
1722
+ if (!instance2) {
1723
+ instance2 = new ReplayEngine(capturesDir);
1724
+ }
1725
+ return instance2;
1726
+ }
706
1727
 
707
- // src/commands/capture.ts
708
- var listCommand2 = new import_commander2.Command().name("list").description("List captured webhook requests").option("-l, --limit <limit>", "Maximum number of captures to show", "10").action((options) => {
709
- const cwd = process.cwd();
710
- const capturesDir = findCapturesDir(cwd);
711
- const replayer = new WebhookReplayer(capturesDir);
712
- const captures = replayer.listCaptured();
1728
+ // src/commands/captures.ts
1729
+ var listCommand2 = new import_commander4.Command().name("list").alias("ls").description("List captured webhooks").option("-l, --limit <limit>", "Maximum number of captures to show", "20").option("-p, --provider <provider>", "Filter by provider").action((options) => {
713
1730
  const limit = parseInt(options.limit, 10);
714
- if (Number.isNaN(limit) || limit <= 0) {
715
- console.error("Invalid --limit: must be a positive integer.");
1731
+ if (isNaN(limit) || limit <= 0) {
1732
+ console.error(import_chalk4.default.red("Invalid limit value"));
716
1733
  process.exitCode = 1;
717
1734
  return;
718
1735
  }
719
- if (captures.length === 0) {
720
- console.log("No webhook captures found.");
721
- console.log(`Run 'better-webhook capture' to start capturing webhooks.`);
1736
+ const engine = getReplayEngine();
1737
+ let captures2 = engine.listCaptures(limit);
1738
+ if (options.provider) {
1739
+ captures2 = captures2.filter(
1740
+ (c) => c.capture.provider?.toLowerCase() === options.provider?.toLowerCase()
1741
+ );
1742
+ }
1743
+ if (captures2.length === 0) {
1744
+ console.log(import_chalk4.default.yellow("\n\u{1F4ED} No captured webhooks found."));
1745
+ console.log(
1746
+ import_chalk4.default.gray(" Start capturing with: better-webhook capture\n")
1747
+ );
1748
+ return;
1749
+ }
1750
+ console.log(import_chalk4.default.bold("\n\u{1F4E6} Captured Webhooks\n"));
1751
+ for (const { file, capture: capture2 } of captures2) {
1752
+ const date = new Date(capture2.timestamp).toLocaleString();
1753
+ const provider = capture2.provider ? import_chalk4.default.cyan(`[${capture2.provider}]`) : import_chalk4.default.gray("[unknown]");
1754
+ const size = capture2.contentLength || capture2.rawBody?.length || 0;
1755
+ console.log(` ${import_chalk4.default.white(capture2.id.slice(0, 8))} ${provider}`);
1756
+ console.log(import_chalk4.default.gray(` ${capture2.method} ${capture2.path}`));
1757
+ console.log(import_chalk4.default.gray(` ${date} | ${size} bytes`));
1758
+ console.log(import_chalk4.default.gray(` File: ${file}`));
1759
+ console.log();
1760
+ }
1761
+ console.log(import_chalk4.default.gray(` Showing ${captures2.length} captures`));
1762
+ console.log(import_chalk4.default.gray(` Storage: ${engine.getCapturesDir()}
1763
+ `));
1764
+ });
1765
+ var showCommand = new import_commander4.Command().name("show").argument("<captureId>", "Capture ID or partial ID").description("Show detailed information about a capture").option("-b, --body", "Show full body content").action((captureId, options) => {
1766
+ const engine = getReplayEngine();
1767
+ const captureFile = engine.getCapture(captureId);
1768
+ if (!captureFile) {
1769
+ console.log(import_chalk4.default.red(`
1770
+ \u274C Capture not found: ${captureId}
1771
+ `));
1772
+ process.exitCode = 1;
722
1773
  return;
723
1774
  }
1775
+ const { capture: capture2 } = captureFile;
1776
+ console.log(import_chalk4.default.bold("\n\u{1F4CB} Capture Details\n"));
1777
+ console.log(` ${import_chalk4.default.gray("ID:")} ${capture2.id}`);
1778
+ console.log(` ${import_chalk4.default.gray("File:")} ${captureFile.file}`);
724
1779
  console.log(
725
- `\u{1F4CB} Found ${captures.length} captured webhooks (showing ${Math.min(limit, captures.length)}):
726
- `
1780
+ ` ${import_chalk4.default.gray("Timestamp:")} ${new Date(capture2.timestamp).toLocaleString()}`
727
1781
  );
728
- captures.slice(0, limit).forEach(({ file, capture: capture2 }) => {
729
- const date = new Date(capture2.timestamp).toLocaleString();
730
- const bodySize = capture2.rawBody ? capture2.rawBody.length : 0;
731
- console.log(`\u{1F194} ${capture2.id} | \u{1F4C5} ${date}`);
732
- console.log(` ${capture2.method} ${capture2.url} | ${bodySize} bytes`);
733
- console.log(` \u{1F4C4} ${file}
734
- `);
735
- });
736
- if (captures.length > limit) {
1782
+ console.log(` ${import_chalk4.default.gray("Method:")} ${capture2.method}`);
1783
+ console.log(` ${import_chalk4.default.gray("Path:")} ${capture2.path}`);
1784
+ console.log(` ${import_chalk4.default.gray("URL:")} ${capture2.url}`);
1785
+ if (capture2.provider) {
737
1786
  console.log(
738
- `... and ${captures.length - limit} more. Use --limit to show more.`
1787
+ ` ${import_chalk4.default.gray("Provider:")} ${import_chalk4.default.cyan(capture2.provider)}`
739
1788
  );
740
1789
  }
741
- });
742
- var templateCommand = new import_commander2.Command().name("template").argument("<captureId>", "ID of the captured webhook to create template from").argument("[templateName]", "Name for the generated template").description("Generate a webhook template from a captured request").option(
743
- "-u, --url <url>",
744
- "Template URL (default: http://localhost:3000/webhook)"
745
- ).option("-o, --output-dir <dir>", "Output directory (default: .webhooks)").action(
746
- async (captureId, templateName, options) => {
747
- const cwd = process.cwd();
748
- const capturesDir = findCapturesDir(cwd);
749
- const replayer = new WebhookReplayer(capturesDir);
750
- try {
751
- const template = replayer.captureToTemplate(captureId, options?.url);
752
- if (!templateName) {
753
- const capture2 = replayer.loadCapture(captureId);
754
- const date = new Date(capture2.timestamp).toISOString().split("T")[0];
755
- const pathPart = capture2.url.replace(/[^a-zA-Z0-9]/g, "_").substring(1) || "webhook";
756
- templateName = `captured_${date}_${pathPart}_${capture2.id}`;
757
- }
758
- const outputDir = options?.outputDir ? (0, import_path5.resolve)(cwd, options.outputDir) : findWebhooksDir(cwd);
759
- (0, import_fs6.mkdirSync)(outputDir, { recursive: true });
760
- const templatePath = (0, import_path5.join)(outputDir, `${templateName}.json`);
761
- (0, import_fs6.writeFileSync)(templatePath, JSON.stringify(template, null, 2));
762
- console.log(`\u2705 Template created: ${templatePath}`);
1790
+ console.log(
1791
+ ` ${import_chalk4.default.gray("Content-Type:")} ${capture2.contentType || "unknown"}`
1792
+ );
1793
+ console.log(
1794
+ ` ${import_chalk4.default.gray("Content-Length:")} ${capture2.contentLength || 0} bytes`
1795
+ );
1796
+ const queryKeys = Object.keys(capture2.query);
1797
+ if (queryKeys.length > 0) {
1798
+ console.log(import_chalk4.default.bold("\n Query Parameters:"));
1799
+ for (const [key, value] of Object.entries(capture2.query)) {
1800
+ const queryValue = Array.isArray(value) ? value.join(", ") : value;
1801
+ console.log(import_chalk4.default.gray(` ${key}: ${queryValue}`));
1802
+ }
1803
+ }
1804
+ console.log(import_chalk4.default.bold("\n Headers:"));
1805
+ for (const [key, value] of Object.entries(capture2.headers)) {
1806
+ const headerValue = Array.isArray(value) ? value.join(", ") : value;
1807
+ const display = headerValue.length > 80 ? headerValue.slice(0, 80) + "..." : headerValue;
1808
+ console.log(import_chalk4.default.gray(` ${key}: ${display}`));
1809
+ }
1810
+ if (options.body && capture2.body) {
1811
+ console.log(import_chalk4.default.bold("\n Body:"));
1812
+ if (typeof capture2.body === "object") {
763
1813
  console.log(
764
- `\u{1F504} Run it with: better-webhook webhooks run ${templateName}`
1814
+ import_chalk4.default.gray(
1815
+ JSON.stringify(capture2.body, null, 2).split("\n").map((l) => ` ${l}`).join("\n")
1816
+ )
765
1817
  );
766
- console.log("\n\u{1F4CA} Template Summary:");
767
- console.log(replayer.getCaptureSummary(captureId));
768
- } catch (error) {
769
- console.error("\u274C Template generation failed:", error.message);
770
- process.exitCode = 1;
1818
+ } else {
1819
+ console.log(import_chalk4.default.gray(` ${capture2.body}`));
771
1820
  }
1821
+ } else if (capture2.body) {
1822
+ console.log(import_chalk4.default.bold("\n Body:"));
1823
+ const preview = JSON.stringify(capture2.body).slice(0, 200);
1824
+ console.log(
1825
+ import_chalk4.default.gray(` ${preview}${preview.length >= 200 ? "..." : ""}`)
1826
+ );
1827
+ console.log(import_chalk4.default.gray(" Use --body to see full content"));
772
1828
  }
773
- );
774
- var capture = new import_commander2.Command().name("capture").description(
775
- "Start a server to capture incoming webhook requests, or list captured webhooks"
776
- ).option("-p, --port <port>", "Port to listen on", "3001").action(async (options) => {
777
- const cwd = process.cwd();
778
- const capturesDir = findCapturesDir(cwd);
779
- const server = new WebhookCaptureServer(capturesDir);
780
- let actualPort;
781
- try {
782
- actualPort = await server.start(parseInt(options.port));
783
- } catch (error) {
784
- console.error("Failed to start capture server:", error.message);
1829
+ console.log();
1830
+ });
1831
+ var searchCommand2 = new import_commander4.Command().name("search").argument("<query>", "Search query").description("Search captures by path, method, or provider").action((query) => {
1832
+ const engine = getReplayEngine();
1833
+ const results = engine.searchCaptures(query);
1834
+ if (results.length === 0) {
1835
+ console.log(import_chalk4.default.yellow(`
1836
+ \u{1F4ED} No captures found for: "${query}"
1837
+ `));
1838
+ return;
1839
+ }
1840
+ console.log(import_chalk4.default.bold(`
1841
+ \u{1F50D} Search Results for "${query}"
1842
+ `));
1843
+ for (const { file, capture: capture2 } of results) {
1844
+ const date = new Date(capture2.timestamp).toLocaleString();
1845
+ const provider = capture2.provider ? import_chalk4.default.cyan(`[${capture2.provider}]`) : "";
1846
+ console.log(` ${import_chalk4.default.white(capture2.id.slice(0, 8))} ${provider}`);
1847
+ console.log(import_chalk4.default.gray(` ${capture2.method} ${capture2.path}`));
1848
+ console.log(import_chalk4.default.gray(` ${date}`));
1849
+ console.log();
1850
+ }
1851
+ console.log(import_chalk4.default.gray(` Found: ${results.length} captures
1852
+ `));
1853
+ });
1854
+ var deleteCommand = new import_commander4.Command().name("delete").alias("rm").argument("<captureId>", "Capture ID or partial ID to delete").description("Delete a specific captured webhook").option("-f, --force", "Skip confirmation prompt").action(async (captureId, options) => {
1855
+ const engine = getReplayEngine();
1856
+ const captureFile = engine.getCapture(captureId);
1857
+ if (!captureFile) {
1858
+ console.log(import_chalk4.default.red(`
1859
+ \u274C Capture not found: ${captureId}
1860
+ `));
785
1861
  process.exitCode = 1;
786
1862
  return;
787
1863
  }
788
- const shutdown = async () => {
789
- console.log("\n\u{1F6D1} Shutting down server...");
790
- await server.stop();
791
- process.exit(0);
792
- };
793
- process.on("SIGINT", shutdown);
794
- process.on("SIGTERM", shutdown);
795
- }).addCommand(listCommand2).addCommand(templateCommand);
1864
+ const { capture: capture2 } = captureFile;
1865
+ if (!options.force) {
1866
+ console.log(import_chalk4.default.bold("\n\u{1F5D1}\uFE0F Capture to delete:\n"));
1867
+ console.log(` ${import_chalk4.default.white(capture2.id.slice(0, 8))}`);
1868
+ console.log(import_chalk4.default.gray(` ${capture2.method} ${capture2.path}`));
1869
+ console.log(
1870
+ import_chalk4.default.gray(` ${new Date(capture2.timestamp).toLocaleString()}`)
1871
+ );
1872
+ console.log();
1873
+ const response = await (0, import_prompts3.default)({
1874
+ type: "confirm",
1875
+ name: "confirm",
1876
+ message: "Delete this capture?",
1877
+ initial: false
1878
+ });
1879
+ if (!response.confirm) {
1880
+ console.log(import_chalk4.default.yellow("Cancelled"));
1881
+ return;
1882
+ }
1883
+ }
1884
+ const deleted = engine.deleteCapture(captureId);
1885
+ if (deleted) {
1886
+ console.log(
1887
+ import_chalk4.default.green(`
1888
+ \u2713 Deleted capture: ${capture2.id.slice(0, 8)}
1889
+ `)
1890
+ );
1891
+ } else {
1892
+ console.log(import_chalk4.default.red(`
1893
+ \u274C Failed to delete capture
1894
+ `));
1895
+ process.exitCode = 1;
1896
+ }
1897
+ });
1898
+ var cleanCommand2 = new import_commander4.Command().name("clean").alias("remove-all").description("Remove all captured webhooks").option("-f, --force", "Skip confirmation prompt").action(async (options) => {
1899
+ const engine = getReplayEngine();
1900
+ const captures2 = engine.listCaptures(1e4);
1901
+ if (captures2.length === 0) {
1902
+ console.log(import_chalk4.default.yellow("\n\u{1F4ED} No captures to remove.\n"));
1903
+ return;
1904
+ }
1905
+ console.log(
1906
+ import_chalk4.default.bold(`
1907
+ \u{1F5D1}\uFE0F Found ${captures2.length} captured webhook(s)
1908
+ `)
1909
+ );
1910
+ const byProvider = /* @__PURE__ */ new Map();
1911
+ for (const c of captures2) {
1912
+ const provider = c.capture.provider || "unknown";
1913
+ byProvider.set(provider, (byProvider.get(provider) || 0) + 1);
1914
+ }
1915
+ for (const [provider, count] of byProvider) {
1916
+ console.log(import_chalk4.default.gray(` ${provider}: ${count}`));
1917
+ }
1918
+ console.log();
1919
+ if (!options.force) {
1920
+ const response = await (0, import_prompts3.default)({
1921
+ type: "confirm",
1922
+ name: "confirm",
1923
+ message: `Delete all ${captures2.length} capture(s)?`,
1924
+ initial: false
1925
+ });
1926
+ if (!response.confirm) {
1927
+ console.log(import_chalk4.default.yellow("Cancelled"));
1928
+ return;
1929
+ }
1930
+ }
1931
+ const deleted = engine.deleteAllCaptures();
1932
+ console.log(import_chalk4.default.green(`
1933
+ \u2713 Removed ${deleted} capture(s)`));
1934
+ console.log(import_chalk4.default.gray(` Storage: ${engine.getCapturesDir()}
1935
+ `));
1936
+ });
1937
+ var captures = new import_commander4.Command().name("captures").alias("c").description("Manage captured webhooks").addCommand(listCommand2).addCommand(showCommand).addCommand(searchCommand2).addCommand(deleteCommand).addCommand(cleanCommand2);
796
1938
 
797
1939
  // src/commands/replay.ts
798
- var import_commander3 = require("commander");
799
- var import_prompts2 = __toESM(require("prompts"), 1);
800
- var import_ora2 = __toESM(require("ora"), 1);
801
- var replay = new import_commander3.Command().name("replay").argument(
802
- "[captureId]",
803
- "ID of the captured webhook to replay (optional - will prompt if not provided)"
804
- ).argument(
805
- "[targetUrl]",
806
- "Target URL to replay the webhook to (optional - will prompt if not provided)"
807
- ).description("Replay a captured webhook to a target URL").option("-m, --method <method>", "Override HTTP method").option(
1940
+ var import_commander5 = require("commander");
1941
+ var import_ora3 = __toESM(require("ora"), 1);
1942
+ var import_prompts4 = __toESM(require("prompts"), 1);
1943
+ var import_chalk5 = __toESM(require("chalk"), 1);
1944
+ var replay = new import_commander5.Command().name("replay").argument("[captureId]", "Capture ID to replay").argument("[targetUrl]", "Target URL to replay to").description("Replay a captured webhook to a target URL").option("-m, --method <method>", "Override HTTP method").option(
808
1945
  "-H, --header <header>",
809
- "Add custom header (format: key:value)",
1946
+ "Add or override header (format: key:value)",
810
1947
  (value, previous) => {
811
1948
  const [key, ...valueParts] = value.split(":");
812
1949
  const headerValue = valueParts.join(":");
@@ -818,53 +1955,53 @@ var replay = new import_commander3.Command().name("replay").argument(
818
1955
  ]);
819
1956
  },
820
1957
  []
821
- ).action(
822
- async (captureId, targetUrl, options = {}) => {
823
- const cwd = process.cwd();
824
- const capturesDir = findCapturesDir(cwd);
825
- const replayer = new WebhookReplayer(capturesDir);
826
- const spinner = (0, import_ora2.default)("Loading captured webhooks...").start();
827
- const captured = replayer.listCaptured();
828
- spinner.stop();
829
- if (captured.length === 0) {
830
- console.log("\u{1F4ED} No captured webhooks found.");
831
- console.log(
832
- "\u{1F4A1} Run 'better-webhook capture' to start capturing webhooks first."
833
- );
834
- return;
835
- }
836
- let selectedCaptureId = captureId;
837
- let selectedTargetUrl = targetUrl;
838
- if (!selectedCaptureId) {
839
- const choices = captured.map((c) => {
1958
+ ).option("-v, --verbose", "Show detailed request/response information").action(
1959
+ async (captureId, targetUrl, options) => {
1960
+ const engine = getReplayEngine();
1961
+ if (!captureId) {
1962
+ const captures2 = engine.listCaptures(50);
1963
+ if (captures2.length === 0) {
1964
+ console.log(import_chalk5.default.yellow("\n\u{1F4ED} No captured webhooks found."));
1965
+ console.log(
1966
+ import_chalk5.default.gray(" Start capturing with: better-webhook capture\n")
1967
+ );
1968
+ return;
1969
+ }
1970
+ const choices = captures2.map((c) => {
840
1971
  const date = new Date(c.capture.timestamp).toLocaleString();
841
- const bodySize = c.capture.rawBody?.length ?? 0;
1972
+ const provider = c.capture.provider ? `[${c.capture.provider}]` : "";
842
1973
  return {
843
- title: `${c.capture.id} - ${c.capture.method} ${c.capture.url}`,
844
- description: `${date} | Body: ${bodySize} bytes`,
1974
+ title: `${c.capture.id.slice(0, 8)} ${provider} ${c.capture.method} ${c.capture.path}`,
1975
+ description: date,
845
1976
  value: c.capture.id
846
1977
  };
847
1978
  });
848
- const response = await (0, import_prompts2.default)({
1979
+ const response = await (0, import_prompts4.default)({
849
1980
  type: "select",
850
1981
  name: "captureId",
851
- message: "Select a captured webhook to replay:",
852
- choices,
853
- initial: 0
1982
+ message: "Select a capture to replay:",
1983
+ choices
854
1984
  });
855
1985
  if (!response.captureId) {
856
- console.log("\u274C No webhook selected. Exiting.");
857
- process.exitCode = 1;
1986
+ console.log(import_chalk5.default.yellow("Cancelled"));
858
1987
  return;
859
1988
  }
860
- selectedCaptureId = response.captureId;
1989
+ captureId = response.captureId;
1990
+ }
1991
+ const captureFile = engine.getCapture(captureId);
1992
+ if (!captureFile) {
1993
+ console.log(import_chalk5.default.red(`
1994
+ \u274C Capture not found: ${captureId}
1995
+ `));
1996
+ process.exitCode = 1;
1997
+ return;
861
1998
  }
862
- if (!selectedTargetUrl) {
863
- const response = await (0, import_prompts2.default)({
1999
+ if (!targetUrl) {
2000
+ const response = await (0, import_prompts4.default)({
864
2001
  type: "text",
865
- name: "targetUrl",
866
- message: "Enter the target URL to replay to:",
867
- initial: "http://localhost:3000/webhook",
2002
+ name: "url",
2003
+ message: "Enter target URL:",
2004
+ initial: `http://localhost:3000${captureFile.capture.path}`,
868
2005
  validate: (value) => {
869
2006
  try {
870
2007
  new URL(value);
@@ -874,43 +2011,76 @@ var replay = new import_commander3.Command().name("replay").argument(
874
2011
  }
875
2012
  }
876
2013
  });
877
- if (!response.targetUrl) {
878
- console.log("\u274C No target URL provided. Exiting.");
879
- process.exitCode = 1;
2014
+ if (!response.url) {
2015
+ console.log(import_chalk5.default.yellow("Cancelled"));
880
2016
  return;
881
2017
  }
882
- selectedTargetUrl = response.targetUrl;
2018
+ targetUrl = response.url;
2019
+ }
2020
+ const { capture: capture2 } = captureFile;
2021
+ console.log(import_chalk5.default.bold("\n\u{1F504} Replaying Webhook\n"));
2022
+ console.log(import_chalk5.default.gray(` Capture ID: ${capture2.id.slice(0, 8)}`));
2023
+ console.log(import_chalk5.default.gray(` Original: ${capture2.method} ${capture2.path}`));
2024
+ if (capture2.provider) {
2025
+ console.log(import_chalk5.default.gray(` Provider: ${capture2.provider}`));
883
2026
  }
2027
+ console.log(import_chalk5.default.gray(` Target: ${targetUrl}`));
2028
+ console.log();
2029
+ const spinner = (0, import_ora3.default)("Replaying webhook...").start();
884
2030
  try {
885
- const result = await replayer.replay(
886
- selectedCaptureId,
887
- selectedTargetUrl,
888
- {
889
- method: options.method,
890
- headers: options.header
891
- }
2031
+ const result = await engine.replay(captureId, {
2032
+ targetUrl,
2033
+ method: options?.method,
2034
+ headers: options?.header
2035
+ });
2036
+ spinner.stop();
2037
+ const statusColor = result.status >= 200 && result.status < 300 ? import_chalk5.default.green : result.status >= 400 ? import_chalk5.default.red : import_chalk5.default.yellow;
2038
+ console.log(import_chalk5.default.bold("\u{1F4E5} Response\n"));
2039
+ console.log(
2040
+ ` Status: ${statusColor(`${result.status} ${result.statusText}`)}`
892
2041
  );
893
- console.log("\u2705 Replay completed successfully!");
894
- console.log("Status:", result.status);
895
- console.log("Headers:");
896
- for (const [k, v] of Object.entries(result.headers)) {
897
- console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
2042
+ console.log(` Duration: ${import_chalk5.default.cyan(`${result.duration}ms`)}`);
2043
+ if (options?.verbose) {
2044
+ console.log(import_chalk5.default.bold("\n Headers:"));
2045
+ for (const [key, value] of Object.entries(result.headers)) {
2046
+ const headerValue = Array.isArray(value) ? value.join(", ") : value;
2047
+ console.log(import_chalk5.default.gray(` ${key}: ${headerValue}`));
2048
+ }
898
2049
  }
899
2050
  if (result.json !== void 0) {
900
- console.log("Response JSON:");
901
- console.log(JSON.stringify(result.json, null, 2));
2051
+ console.log(import_chalk5.default.bold("\n Body:"));
2052
+ console.log(
2053
+ import_chalk5.default.gray(
2054
+ JSON.stringify(result.json, null, 2).split("\n").map((l) => ` ${l}`).join("\n")
2055
+ )
2056
+ );
2057
+ } else if (result.bodyText) {
2058
+ console.log(import_chalk5.default.bold("\n Body:"));
2059
+ const preview = result.bodyText.length > 500 ? result.bodyText.slice(0, 500) + "..." : result.bodyText;
2060
+ console.log(import_chalk5.default.gray(` ${preview}`));
2061
+ }
2062
+ console.log();
2063
+ if (result.status >= 200 && result.status < 300) {
2064
+ console.log(import_chalk5.default.green("\u2713 Replay completed successfully\n"));
902
2065
  } else {
903
- console.log("Response Body:");
904
- console.log(result.bodyText);
2066
+ console.log(
2067
+ import_chalk5.default.yellow(`\u26A0 Replay completed with status ${result.status}
2068
+ `)
2069
+ );
905
2070
  }
906
2071
  } catch (error) {
907
- console.error("\u274C Replay failed:", error.message);
2072
+ spinner.fail("Replay failed");
2073
+ console.error(import_chalk5.default.red(`
2074
+ \u274C ${error.message}
2075
+ `));
908
2076
  process.exitCode = 1;
909
2077
  }
910
2078
  }
911
2079
  );
912
2080
 
913
2081
  // src/index.ts
914
- var program = new import_commander4.Command().name("better-webhook").description("CLI for listing, downloading and executing predefined webhooks").version("0.2.0");
915
- program.addCommand(webhooks).addCommand(capture).addCommand(replay);
2082
+ var program = new import_commander6.Command().name("better-webhook").description(
2083
+ "Modern CLI for developing, capturing, and replaying webhooks locally"
2084
+ ).version("2.0.0");
2085
+ program.addCommand(templates).addCommand(run).addCommand(capture).addCommand(captures).addCommand(replay);
916
2086
  program.parseAsync(process.argv);