@better-webhook/cli 0.3.0 → 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/README.md +489 -180
- package/dist/index.cjs +1889 -519
- package/dist/index.d.cts +1 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1889 -519
- package/package.json +23 -6
package/dist/index.js
CHANGED
|
@@ -1,42 +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
|
|
10
|
+
import { Command as Command6 } from "commander";
|
|
5
11
|
|
|
6
|
-
// src/commands/
|
|
12
|
+
// src/commands/templates.ts
|
|
7
13
|
import { Command } from "commander";
|
|
8
|
-
import
|
|
9
|
-
import
|
|
14
|
+
import ora from "ora";
|
|
15
|
+
import prompts from "prompts";
|
|
16
|
+
import chalk from "chalk";
|
|
10
17
|
|
|
11
|
-
// src/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
try {
|
|
25
|
-
const entries = readdirSync(dir);
|
|
26
|
-
return entries.filter(
|
|
27
|
-
(e) => statSync(join(dir, e)).isFile() && extname(e) === ".json"
|
|
28
|
-
);
|
|
29
|
-
} catch {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// src/loader.ts
|
|
35
|
-
import { readFileSync } from "fs";
|
|
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";
|
|
36
31
|
|
|
37
|
-
// src/
|
|
32
|
+
// src/types/index.ts
|
|
38
33
|
import { z } from "zod";
|
|
39
|
-
var
|
|
34
|
+
var HttpMethodSchema = z.enum([
|
|
40
35
|
"GET",
|
|
41
36
|
"POST",
|
|
42
37
|
"PUT",
|
|
@@ -45,329 +40,1246 @@ var httpMethodSchema = z.enum([
|
|
|
45
40
|
"HEAD",
|
|
46
41
|
"OPTIONS"
|
|
47
42
|
]);
|
|
48
|
-
var
|
|
49
|
-
key: z.string().min(1,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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()
|
|
53
110
|
});
|
|
54
|
-
var webhookSchema = z.object({
|
|
55
|
-
url: z.string().url("Invalid URL"),
|
|
56
|
-
method: httpMethodSchema.default("POST"),
|
|
57
|
-
headers: z.array(headerEntrySchema).default([]),
|
|
58
|
-
body: z.any().optional()
|
|
59
|
-
// could be anything JSON-serializable
|
|
60
|
-
}).strict();
|
|
61
|
-
function validateWebhookJSON(raw, source) {
|
|
62
|
-
const parsed = webhookSchema.safeParse(raw);
|
|
63
|
-
if (!parsed.success) {
|
|
64
|
-
const issues = parsed.error.issues.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
65
|
-
throw new Error(`Invalid webhook definition in ${source}:
|
|
66
|
-
${issues}`);
|
|
67
|
-
}
|
|
68
|
-
return parsed.data;
|
|
69
|
-
}
|
|
70
111
|
|
|
71
|
-
// src/
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
|
78
130
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Get the templates directory path
|
|
133
|
+
*/
|
|
134
|
+
getTemplatesDir() {
|
|
135
|
+
return this.templatesDir;
|
|
84
136
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
});
|
|
104
|
-
const text = await body.text();
|
|
105
|
-
let parsed;
|
|
106
|
-
if (text) {
|
|
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
|
+
}
|
|
107
155
|
try {
|
|
108
|
-
|
|
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
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
unlinkSync(template.filePath);
|
|
308
|
+
return true;
|
|
109
309
|
} catch {
|
|
310
|
+
return false;
|
|
110
311
|
}
|
|
111
312
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Clear the templates cache
|
|
330
|
+
*/
|
|
331
|
+
clearCache() {
|
|
332
|
+
this.indexCache = null;
|
|
333
|
+
if (existsSync(this.cacheFile)) {
|
|
334
|
+
unlinkSync(this.cacheFile);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
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;
|
|
115
367
|
}
|
|
116
|
-
return {
|
|
117
|
-
status: statusCode,
|
|
118
|
-
headers: resultHeaders,
|
|
119
|
-
bodyText: text,
|
|
120
|
-
json: parsed
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// src/commands/webhooks.ts
|
|
125
|
-
import { mkdirSync, writeFileSync, existsSync } from "fs";
|
|
126
|
-
import { request as request2 } from "undici";
|
|
127
|
-
var TEMPLATE_REPO_BASE = "https://raw.githubusercontent.com/endalk200/better-webhook/main";
|
|
128
|
-
var TEMPLATES = {
|
|
129
|
-
"stripe-invoice.payment_succeeded": "templates/stripe-invoice.payment_succeeded.json"
|
|
130
368
|
};
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
} catch {
|
|
136
|
-
return false;
|
|
369
|
+
var instance = null;
|
|
370
|
+
function getTemplateManager(baseDir) {
|
|
371
|
+
if (!instance) {
|
|
372
|
+
instance = new TemplateManager(baseDir);
|
|
137
373
|
}
|
|
374
|
+
return instance;
|
|
138
375
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
376
|
+
|
|
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();
|
|
383
|
+
try {
|
|
384
|
+
const manager = getTemplateManager();
|
|
385
|
+
const templates2 = await manager.listRemoteTemplates();
|
|
386
|
+
spinner.stop();
|
|
387
|
+
if (templates2.length === 0) {
|
|
388
|
+
console.log(chalk.yellow("\u{1F4ED} No remote templates found."));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
let filtered = templates2;
|
|
392
|
+
if (options.provider) {
|
|
393
|
+
filtered = templates2.filter(
|
|
394
|
+
(t) => t.metadata.provider.toLowerCase() === options.provider?.toLowerCase()
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (filtered.length === 0) {
|
|
398
|
+
console.log(
|
|
399
|
+
chalk.yellow(
|
|
400
|
+
`\u{1F4ED} No templates found for provider: ${options.provider}`
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
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}`));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
console.log();
|
|
424
|
+
}
|
|
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));
|
|
433
|
+
process.exitCode = 1;
|
|
146
434
|
}
|
|
147
|
-
files.forEach((f) => console.log(basename(f, ".json")));
|
|
148
435
|
});
|
|
149
|
-
var
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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;
|
|
165
469
|
}
|
|
166
|
-
}
|
|
167
|
-
if (!statExists(filePath)) {
|
|
168
|
-
console.error(`Webhook file not found: ${filePath}`);
|
|
169
|
-
process.exitCode = 1;
|
|
170
470
|
return;
|
|
171
471
|
}
|
|
172
|
-
|
|
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();
|
|
173
506
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
console.
|
|
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));
|
|
177
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
|
+
);
|
|
178
533
|
return;
|
|
179
534
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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();
|
|
183
563
|
try {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
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();
|
|
196
595
|
}
|
|
197
|
-
}
|
|
198
|
-
|
|
596
|
+
console.log(chalk.gray(` Found: ${totalCount} templates
|
|
597
|
+
`));
|
|
598
|
+
} catch (error) {
|
|
599
|
+
spinner.fail("Search failed");
|
|
600
|
+
console.error(chalk.red(error.message));
|
|
199
601
|
process.exitCode = 1;
|
|
200
602
|
}
|
|
201
603
|
});
|
|
202
|
-
var
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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"));
|
|
209
638
|
return;
|
|
210
639
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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)];
|
|
220
847
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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;
|
|
228
896
|
}
|
|
229
|
-
|
|
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();
|
|
230
1015
|
try {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
)
|
|
235
1025
|
);
|
|
236
|
-
|
|
1026
|
+
return;
|
|
237
1027
|
}
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|
|
247
1037
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
});
|
|
253
1045
|
}
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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;
|
|
261
1055
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.error(
|
|
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;
|
|
266
1062
|
}
|
|
267
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}`));
|
|
1131
|
+
}
|
|
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;
|
|
1160
|
+
}
|
|
268
1161
|
}
|
|
269
1162
|
);
|
|
270
|
-
var webhooks = new Command().name("webhooks").description("Manage and execute webhook definitions").addCommand(listCommand).addCommand(runCommand).addCommand(downloadCommand);
|
|
271
1163
|
|
|
272
1164
|
// src/commands/capture.ts
|
|
273
|
-
import { Command as
|
|
274
|
-
import
|
|
275
|
-
import { join as join5, resolve as resolve3 } from "path";
|
|
1165
|
+
import { Command as Command3 } from "commander";
|
|
1166
|
+
import chalk3 from "chalk";
|
|
276
1167
|
|
|
277
|
-
// src/capture.ts
|
|
278
|
-
import {
|
|
279
|
-
|
|
280
|
-
|
|
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";
|
|
281
1181
|
import { randomUUID } from "crypto";
|
|
282
|
-
|
|
1182
|
+
import { homedir as homedir2 } from "os";
|
|
1183
|
+
var CaptureServer = class {
|
|
283
1184
|
server = null;
|
|
1185
|
+
wss = null;
|
|
284
1186
|
capturesDir;
|
|
1187
|
+
clients = /* @__PURE__ */ new Set();
|
|
1188
|
+
captureCount = 0;
|
|
285
1189
|
constructor(capturesDir) {
|
|
286
|
-
this.capturesDir = capturesDir;
|
|
287
|
-
if (!existsSync2(capturesDir)) {
|
|
288
|
-
mkdirSync2(capturesDir, { recursive: true });
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
307
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}`
|
|
308
1241
|
);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
if (attempt >= maxAttempts) {
|
|
319
|
-
reject(
|
|
320
|
-
new Error(
|
|
321
|
-
`All ${maxAttempts} port attempts starting at ${startPort} are in use.`
|
|
322
|
-
)
|
|
323
|
-
);
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
const nextPort = startPort + attempt;
|
|
327
|
-
tryListen(nextPort);
|
|
328
|
-
} else {
|
|
329
|
-
reject(err);
|
|
330
|
-
}
|
|
331
|
-
};
|
|
332
|
-
const onListening = () => {
|
|
333
|
-
this.server?.off("error", onError);
|
|
334
|
-
this.server?.off("listening", onListening);
|
|
335
|
-
const address = this.server?.address();
|
|
336
|
-
const actualPort = address?.port ?? portToTry;
|
|
337
|
-
console.log(
|
|
338
|
-
`\u{1F3A3} Webhook capture server running on http://localhost:${actualPort}`
|
|
339
|
-
);
|
|
340
|
-
console.log(
|
|
341
|
-
`\u{1F4C1} Captured webhooks will be saved to: ${this.capturesDir}`
|
|
342
|
-
);
|
|
343
|
-
console.log(
|
|
344
|
-
"\u{1F4A1} Send webhooks to any path on this server to capture them"
|
|
345
|
-
);
|
|
346
|
-
console.log("\u23F9\uFE0F Press Ctrl+C to stop the server");
|
|
347
|
-
resolve4(actualPort);
|
|
348
|
-
};
|
|
349
|
-
this.server.on("error", onError);
|
|
350
|
-
this.server.on("listening", onListening);
|
|
351
|
-
this.server.listen(portToTry);
|
|
352
|
-
};
|
|
353
|
-
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
|
+
});
|
|
354
1249
|
});
|
|
355
1250
|
}
|
|
356
|
-
|
|
357
|
-
|
|
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
|
+
}
|
|
358
1264
|
if (this.server) {
|
|
359
1265
|
this.server.close(() => {
|
|
360
|
-
console.log("\u{
|
|
361
|
-
|
|
1266
|
+
console.log("\n\u{1F6D1} Capture server stopped");
|
|
1267
|
+
resolve();
|
|
362
1268
|
});
|
|
363
1269
|
} else {
|
|
364
|
-
|
|
1270
|
+
resolve();
|
|
365
1271
|
}
|
|
366
1272
|
});
|
|
367
1273
|
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Handle incoming HTTP requests
|
|
1276
|
+
*/
|
|
368
1277
|
async handleRequest(req, res) {
|
|
1278
|
+
if (req.headers.upgrade?.toLowerCase() === "websocket") {
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
369
1281
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
370
|
-
const id =
|
|
1282
|
+
const id = randomUUID();
|
|
371
1283
|
const url = req.url || "/";
|
|
372
1284
|
const urlParts = new URL(url, `http://${req.headers.host || "localhost"}`);
|
|
373
1285
|
const query = {};
|
|
@@ -383,291 +1295,654 @@ var WebhookCaptureServer = class {
|
|
|
383
1295
|
}
|
|
384
1296
|
}
|
|
385
1297
|
const chunks = [];
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
});
|
|
1298
|
+
for await (const chunk of req) {
|
|
1299
|
+
chunks.push(chunk);
|
|
1300
|
+
}
|
|
390
1301
|
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
391
|
-
let
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
+
}
|
|
396
1316
|
}
|
|
1317
|
+
const provider = this.detectProvider(req.headers);
|
|
397
1318
|
const captured = {
|
|
398
1319
|
id,
|
|
399
1320
|
timestamp,
|
|
400
1321
|
method: req.method || "GET",
|
|
401
|
-
url
|
|
1322
|
+
url,
|
|
1323
|
+
path: urlParts.pathname,
|
|
402
1324
|
headers: req.headers,
|
|
403
|
-
body
|
|
1325
|
+
body,
|
|
404
1326
|
rawBody,
|
|
405
|
-
query
|
|
1327
|
+
query,
|
|
1328
|
+
provider,
|
|
1329
|
+
contentType: contentType || void 0,
|
|
1330
|
+
contentLength: rawBody.length
|
|
406
1331
|
};
|
|
407
|
-
const
|
|
408
|
-
const
|
|
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);
|
|
409
1337
|
try {
|
|
410
1338
|
writeFileSync2(filepath, JSON.stringify(captured, null, 2));
|
|
1339
|
+
this.captureCount++;
|
|
1340
|
+
const providerStr = provider ? ` [${provider}]` : "";
|
|
411
1341
|
console.log(
|
|
412
|
-
`\u{1F4E6}
|
|
1342
|
+
`\u{1F4E6} ${req.method} ${urlParts.pathname}${providerStr} -> ${filename}`
|
|
413
1343
|
);
|
|
1344
|
+
this.broadcast({
|
|
1345
|
+
type: "capture",
|
|
1346
|
+
payload: {
|
|
1347
|
+
file: filename,
|
|
1348
|
+
capture: captured
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
414
1351
|
} catch (error) {
|
|
415
|
-
console.error(`\u274C Failed to save capture
|
|
1352
|
+
console.error(`\u274C Failed to save capture:`, error);
|
|
416
1353
|
}
|
|
417
1354
|
res.statusCode = 200;
|
|
418
1355
|
res.setHeader("Content-Type", "application/json");
|
|
1356
|
+
res.setHeader("X-Capture-Id", id);
|
|
419
1357
|
res.end(
|
|
420
|
-
JSON.stringify(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
null,
|
|
428
|
-
2
|
|
429
|
-
)
|
|
1358
|
+
JSON.stringify({
|
|
1359
|
+
success: true,
|
|
1360
|
+
message: "Webhook captured successfully",
|
|
1361
|
+
id,
|
|
1362
|
+
timestamp,
|
|
1363
|
+
file: filename
|
|
1364
|
+
})
|
|
430
1365
|
);
|
|
431
1366
|
}
|
|
432
|
-
|
|
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
|
+
}
|
|
433
1455
|
try {
|
|
434
|
-
|
|
1456
|
+
const fs = __require("fs");
|
|
1457
|
+
fs.unlinkSync(join2(this.capturesDir, capture2.file));
|
|
1458
|
+
return true;
|
|
435
1459
|
} catch {
|
|
436
|
-
return
|
|
1460
|
+
return false;
|
|
437
1461
|
}
|
|
438
1462
|
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Get connected client count
|
|
1465
|
+
*/
|
|
1466
|
+
getClientCount() {
|
|
1467
|
+
return this.clients.size;
|
|
1468
|
+
}
|
|
439
1469
|
};
|
|
440
1470
|
|
|
441
|
-
// src/
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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;
|
|
445
1505
|
constructor(capturesDir) {
|
|
446
|
-
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;
|
|
447
1513
|
}
|
|
448
1514
|
/**
|
|
449
1515
|
* List all captured webhooks
|
|
450
1516
|
*/
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const files = listJsonFiles(this.capturesDir).sort().reverse();
|
|
454
|
-
return files.map((file) => ({
|
|
455
|
-
file,
|
|
456
|
-
capture: this.loadCapture(join4(this.capturesDir, file))
|
|
457
|
-
}));
|
|
458
|
-
} catch {
|
|
1517
|
+
listCaptures(limit = 100) {
|
|
1518
|
+
if (!existsSync3(this.capturesDir)) {
|
|
459
1519
|
return [];
|
|
460
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;
|
|
461
1532
|
}
|
|
462
1533
|
/**
|
|
463
|
-
*
|
|
1534
|
+
* Get a specific capture by ID or partial filename
|
|
464
1535
|
*/
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
}
|
|
475
1567
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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()
|
|
479
1573
|
);
|
|
1574
|
+
if (existingIdx >= 0) {
|
|
1575
|
+
headers[existingIdx] = h;
|
|
1576
|
+
} else {
|
|
1577
|
+
headers.push(h);
|
|
1578
|
+
}
|
|
480
1579
|
}
|
|
481
|
-
filepath = join4(this.capturesDir, files[0] ?? "");
|
|
482
|
-
}
|
|
483
|
-
try {
|
|
484
|
-
const content = readFileSync2(filepath, "utf8");
|
|
485
|
-
return JSON.parse(content);
|
|
486
|
-
} catch (error) {
|
|
487
|
-
throw new Error(
|
|
488
|
-
`Failed to load capture from ${filepath}: ${error.message}`
|
|
489
|
-
);
|
|
490
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
|
+
});
|
|
491
1588
|
}
|
|
492
1589
|
/**
|
|
493
|
-
*
|
|
1590
|
+
* Convert a capture to a template
|
|
494
1591
|
*/
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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}`
|
|
502
1638
|
};
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
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";
|
|
512
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");
|
|
513
1662
|
}
|
|
514
1663
|
/**
|
|
515
|
-
*
|
|
1664
|
+
* Search captures
|
|
516
1665
|
*/
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
};
|
|
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
|
+
});
|
|
525
1673
|
}
|
|
526
1674
|
/**
|
|
527
|
-
*
|
|
1675
|
+
* Get captures by provider
|
|
528
1676
|
*/
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
} else {
|
|
548
|
-
result.push({ key, value: value.join(", ") });
|
|
549
|
-
}
|
|
550
|
-
} else {
|
|
551
|
-
result.push({ key, value });
|
|
552
|
-
}
|
|
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;
|
|
553
1695
|
}
|
|
554
|
-
return result;
|
|
555
1696
|
}
|
|
556
1697
|
/**
|
|
557
|
-
*
|
|
1698
|
+
* Delete all captures
|
|
1699
|
+
* @returns Number of captures deleted
|
|
558
1700
|
*/
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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;
|
|
573
1717
|
}
|
|
574
1718
|
};
|
|
1719
|
+
var instance2 = null;
|
|
1720
|
+
function getReplayEngine(capturesDir) {
|
|
1721
|
+
if (!instance2) {
|
|
1722
|
+
instance2 = new ReplayEngine(capturesDir);
|
|
1723
|
+
}
|
|
1724
|
+
return instance2;
|
|
1725
|
+
}
|
|
575
1726
|
|
|
576
|
-
// src/commands/
|
|
577
|
-
var listCommand2 = new
|
|
578
|
-
const cwd = process.cwd();
|
|
579
|
-
const capturesDir = findCapturesDir(cwd);
|
|
580
|
-
const replayer = new WebhookReplayer(capturesDir);
|
|
581
|
-
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) => {
|
|
582
1729
|
const limit = parseInt(options.limit, 10);
|
|
583
|
-
if (
|
|
584
|
-
console.error("Invalid
|
|
1730
|
+
if (isNaN(limit) || limit <= 0) {
|
|
1731
|
+
console.error(chalk4.red("Invalid limit value"));
|
|
585
1732
|
process.exitCode = 1;
|
|
586
1733
|
return;
|
|
587
1734
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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;
|
|
591
1772
|
return;
|
|
592
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}`);
|
|
593
1778
|
console.log(
|
|
594
|
-
|
|
595
|
-
`
|
|
1779
|
+
` ${chalk4.gray("Timestamp:")} ${new Date(capture2.timestamp).toLocaleString()}`
|
|
596
1780
|
);
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
console.log(` ${capture2.method} ${capture2.url} | ${bodySize} bytes`);
|
|
602
|
-
console.log(` \u{1F4C4} ${file}
|
|
603
|
-
`);
|
|
604
|
-
});
|
|
605
|
-
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) {
|
|
606
1785
|
console.log(
|
|
607
|
-
|
|
1786
|
+
` ${chalk4.gray("Provider:")} ${chalk4.cyan(capture2.provider)}`
|
|
608
1787
|
);
|
|
609
1788
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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") {
|
|
632
1812
|
console.log(
|
|
633
|
-
|
|
1813
|
+
chalk4.gray(
|
|
1814
|
+
JSON.stringify(capture2.body, null, 2).split("\n").map((l) => ` ${l}`).join("\n")
|
|
1815
|
+
)
|
|
634
1816
|
);
|
|
635
|
-
|
|
636
|
-
console.log(
|
|
637
|
-
} catch (error) {
|
|
638
|
-
console.error("\u274C Template generation failed:", error.message);
|
|
639
|
-
process.exitCode = 1;
|
|
1817
|
+
} else {
|
|
1818
|
+
console.log(chalk4.gray(` ${capture2.body}`));
|
|
640
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"));
|
|
641
1827
|
}
|
|
642
|
-
);
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
|
|
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
|
+
`));
|
|
654
1860
|
process.exitCode = 1;
|
|
655
1861
|
return;
|
|
656
1862
|
}
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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);
|
|
665
1937
|
|
|
666
1938
|
// src/commands/replay.ts
|
|
667
|
-
import { Command as
|
|
668
|
-
|
|
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(
|
|
669
1944
|
"-H, --header <header>",
|
|
670
|
-
"Add
|
|
1945
|
+
"Add or override header (format: key:value)",
|
|
671
1946
|
(value, previous) => {
|
|
672
1947
|
const [key, ...valueParts] = value.split(":");
|
|
673
1948
|
const headerValue = valueParts.join(":");
|
|
@@ -679,37 +1954,132 @@ var replay = new Command3().name("replay").argument("<captureId>", "ID of the ca
|
|
|
679
1954
|
]);
|
|
680
1955
|
},
|
|
681
1956
|
[]
|
|
682
|
-
).action(
|
|
1957
|
+
).option("-v, --verbose", "Show detailed request/response information").action(
|
|
683
1958
|
async (captureId, targetUrl, options) => {
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
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) => {
|
|
1970
|
+
const date = new Date(c.capture.timestamp).toLocaleString();
|
|
1971
|
+
const provider = c.capture.provider ? `[${c.capture.provider}]` : "";
|
|
1972
|
+
return {
|
|
1973
|
+
title: `${c.capture.id.slice(0, 8)} ${provider} ${c.capture.method} ${c.capture.path}`,
|
|
1974
|
+
description: date,
|
|
1975
|
+
value: c.capture.id
|
|
1976
|
+
};
|
|
1977
|
+
});
|
|
1978
|
+
const response = await prompts4({
|
|
1979
|
+
type: "select",
|
|
1980
|
+
name: "captureId",
|
|
1981
|
+
message: "Select a capture to replay:",
|
|
1982
|
+
choices
|
|
1983
|
+
});
|
|
1984
|
+
if (!response.captureId) {
|
|
1985
|
+
console.log(chalk5.yellow("Cancelled"));
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
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;
|
|
1997
|
+
}
|
|
1998
|
+
if (!targetUrl) {
|
|
1999
|
+
const response = await prompts4({
|
|
2000
|
+
type: "text",
|
|
2001
|
+
name: "url",
|
|
2002
|
+
message: "Enter target URL:",
|
|
2003
|
+
initial: `http://localhost:3000${captureFile.capture.path}`,
|
|
2004
|
+
validate: (value) => {
|
|
2005
|
+
try {
|
|
2006
|
+
new URL(value);
|
|
2007
|
+
return true;
|
|
2008
|
+
} catch {
|
|
2009
|
+
return "Please enter a valid URL";
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
if (!response.url) {
|
|
2014
|
+
console.log(chalk5.yellow("Cancelled"));
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
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}`));
|
|
2025
|
+
}
|
|
2026
|
+
console.log(chalk5.gray(` Target: ${targetUrl}`));
|
|
2027
|
+
console.log();
|
|
2028
|
+
const spinner = ora3("Replaying webhook...").start();
|
|
687
2029
|
try {
|
|
688
|
-
const result = await
|
|
689
|
-
|
|
690
|
-
|
|
2030
|
+
const result = await engine.replay(captureId, {
|
|
2031
|
+
targetUrl,
|
|
2032
|
+
method: options?.method,
|
|
2033
|
+
headers: options?.header
|
|
691
2034
|
});
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
console.log("
|
|
695
|
-
|
|
696
|
-
|
|
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}`)}`
|
|
2040
|
+
);
|
|
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
|
+
}
|
|
697
2048
|
}
|
|
698
2049
|
if (result.json !== void 0) {
|
|
699
|
-
console.log("
|
|
700
|
-
console.log(
|
|
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"));
|
|
701
2064
|
} else {
|
|
702
|
-
console.log(
|
|
703
|
-
|
|
2065
|
+
console.log(
|
|
2066
|
+
chalk5.yellow(`\u26A0 Replay completed with status ${result.status}
|
|
2067
|
+
`)
|
|
2068
|
+
);
|
|
704
2069
|
}
|
|
705
2070
|
} catch (error) {
|
|
706
|
-
|
|
2071
|
+
spinner.fail("Replay failed");
|
|
2072
|
+
console.error(chalk5.red(`
|
|
2073
|
+
\u274C ${error.message}
|
|
2074
|
+
`));
|
|
707
2075
|
process.exitCode = 1;
|
|
708
2076
|
}
|
|
709
2077
|
}
|
|
710
2078
|
);
|
|
711
2079
|
|
|
712
2080
|
// src/index.ts
|
|
713
|
-
var program = new
|
|
714
|
-
|
|
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);
|
|
715
2085
|
program.parseAsync(process.argv);
|