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