@better-webhook/cli 0.3.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +519 -180
- package/dist/index.cjs +2331 -659
- package/dist/index.d.cts +1 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +2351 -663
- package/package.json +19 -6
package/dist/index.js
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command7 } from "commander";
|
|
5
5
|
|
|
6
|
-
// src/commands/
|
|
6
|
+
// src/commands/templates.ts
|
|
7
7
|
import { Command } from "commander";
|
|
8
|
-
import
|
|
9
|
-
import
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import prompts from "prompts";
|
|
10
|
+
import chalk from "chalk";
|
|
10
11
|
|
|
11
|
-
// src/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
// src/core/template-manager.ts
|
|
13
|
+
import { request } from "undici";
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
rmdirSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
writeFileSync
|
|
22
|
+
} from "fs";
|
|
23
|
+
import { join, basename } from "path";
|
|
24
|
+
import { homedir } from "os";
|
|
14
25
|
|
|
15
|
-
// src/
|
|
26
|
+
// src/types/index.ts
|
|
16
27
|
import { z } from "zod";
|
|
17
|
-
var
|
|
28
|
+
var HttpMethodSchema = z.enum([
|
|
18
29
|
"GET",
|
|
19
30
|
"POST",
|
|
20
31
|
"PUT",
|
|
@@ -23,459 +34,1270 @@ var httpMethodSchema = z.enum([
|
|
|
23
34
|
"HEAD",
|
|
24
35
|
"OPTIONS"
|
|
25
36
|
]);
|
|
26
|
-
var
|
|
27
|
-
key: z.string().min(1,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
var HeaderEntrySchema = z.object({
|
|
38
|
+
key: z.string().min(1),
|
|
39
|
+
value: z.string()
|
|
40
|
+
});
|
|
41
|
+
var WebhookProviderSchema = z.enum([
|
|
42
|
+
"stripe",
|
|
43
|
+
"github",
|
|
44
|
+
"shopify",
|
|
45
|
+
"twilio",
|
|
46
|
+
"ragie",
|
|
47
|
+
"sendgrid",
|
|
48
|
+
"slack",
|
|
49
|
+
"discord",
|
|
50
|
+
"linear",
|
|
51
|
+
"clerk",
|
|
52
|
+
"custom"
|
|
53
|
+
]);
|
|
54
|
+
var TemplateMetadataSchema = z.object({
|
|
55
|
+
id: z.string(),
|
|
56
|
+
name: z.string(),
|
|
57
|
+
description: z.string().optional(),
|
|
58
|
+
provider: WebhookProviderSchema,
|
|
59
|
+
event: z.string(),
|
|
60
|
+
file: z.string(),
|
|
61
|
+
version: z.string().optional(),
|
|
62
|
+
docsUrl: z.string().url().optional()
|
|
63
|
+
});
|
|
64
|
+
var TemplatesIndexSchema = z.object({
|
|
65
|
+
version: z.string(),
|
|
66
|
+
templates: z.array(TemplateMetadataSchema)
|
|
67
|
+
});
|
|
68
|
+
var WebhookTemplateSchema = z.object({
|
|
69
|
+
url: z.string().url().optional(),
|
|
70
|
+
method: HttpMethodSchema.default("POST"),
|
|
71
|
+
headers: z.array(HeaderEntrySchema).default([]),
|
|
72
|
+
body: z.any().optional(),
|
|
73
|
+
provider: WebhookProviderSchema.optional(),
|
|
74
|
+
event: z.string().optional(),
|
|
75
|
+
description: z.string().optional()
|
|
76
|
+
});
|
|
77
|
+
var CapturedWebhookSchema = z.object({
|
|
78
|
+
id: z.string(),
|
|
79
|
+
timestamp: z.string(),
|
|
80
|
+
method: HttpMethodSchema,
|
|
81
|
+
url: z.string(),
|
|
82
|
+
path: z.string(),
|
|
83
|
+
headers: z.record(z.union([z.string(), z.array(z.string())])),
|
|
84
|
+
body: z.any().optional(),
|
|
85
|
+
rawBody: z.string(),
|
|
86
|
+
query: z.record(z.union([z.string(), z.array(z.string())])),
|
|
87
|
+
provider: WebhookProviderSchema.optional(),
|
|
88
|
+
contentType: z.string().optional(),
|
|
89
|
+
contentLength: z.number().optional()
|
|
90
|
+
});
|
|
91
|
+
var ConfigSchema = z.object({
|
|
92
|
+
version: z.string().default("2.0.0"),
|
|
93
|
+
templatesDir: z.string().optional(),
|
|
94
|
+
capturesDir: z.string().optional(),
|
|
95
|
+
defaultTargetUrl: z.string().url().optional(),
|
|
96
|
+
secrets: z.record(z.string()).optional().describe("Provider secrets for signature generation"),
|
|
97
|
+
dashboard: z.object({
|
|
98
|
+
port: z.number().default(4e3),
|
|
99
|
+
host: z.string().default("localhost")
|
|
100
|
+
}).optional(),
|
|
101
|
+
capture: z.object({
|
|
102
|
+
port: z.number().default(3001),
|
|
103
|
+
host: z.string().default("0.0.0.0")
|
|
104
|
+
}).optional()
|
|
31
105
|
});
|
|
32
|
-
var webhookSchema = z.object({
|
|
33
|
-
url: z.string().url("Invalid URL"),
|
|
34
|
-
method: httpMethodSchema.default("POST"),
|
|
35
|
-
headers: z.array(headerEntrySchema).default([]),
|
|
36
|
-
body: z.any().optional()
|
|
37
|
-
// could be anything JSON-serializable
|
|
38
|
-
}).strict();
|
|
39
|
-
function validateWebhookJSON(raw, source) {
|
|
40
|
-
const parsed = webhookSchema.safeParse(raw);
|
|
41
|
-
if (!parsed.success) {
|
|
42
|
-
const issues = parsed.error.issues.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
43
|
-
throw new Error(`Invalid webhook definition in ${source}:
|
|
44
|
-
${issues}`);
|
|
45
|
-
}
|
|
46
|
-
return parsed.data;
|
|
47
|
-
}
|
|
48
106
|
|
|
49
|
-
// src/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
// src/core/template-manager.ts
|
|
108
|
+
var GITHUB_RAW_BASE = "https://raw.githubusercontent.com/endalk200/better-webhook/main";
|
|
109
|
+
var TEMPLATES_INDEX_URL = `${GITHUB_RAW_BASE}/templates/templates.json`;
|
|
110
|
+
var TemplateManager = class {
|
|
111
|
+
baseDir;
|
|
112
|
+
templatesDir;
|
|
113
|
+
cacheFile;
|
|
114
|
+
indexCache = null;
|
|
115
|
+
constructor(baseDir) {
|
|
116
|
+
this.baseDir = baseDir || join(homedir(), ".better-webhook");
|
|
117
|
+
this.templatesDir = join(this.baseDir, "templates");
|
|
118
|
+
this.cacheFile = join(this.baseDir, "templates-cache.json");
|
|
119
|
+
if (!existsSync(this.baseDir)) {
|
|
120
|
+
mkdirSync(this.baseDir, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
if (!existsSync(this.templatesDir)) {
|
|
123
|
+
mkdirSync(this.templatesDir, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Get the templates directory path
|
|
128
|
+
*/
|
|
129
|
+
getTemplatesDir() {
|
|
130
|
+
return this.templatesDir;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Fetch templates index from GitHub
|
|
134
|
+
*/
|
|
135
|
+
async fetchRemoteIndex(forceRefresh = false) {
|
|
136
|
+
if (!forceRefresh && this.indexCache) {
|
|
137
|
+
return this.indexCache;
|
|
138
|
+
}
|
|
139
|
+
if (!forceRefresh && existsSync(this.cacheFile)) {
|
|
140
|
+
try {
|
|
141
|
+
const cached = JSON.parse(readFileSync(this.cacheFile, "utf-8"));
|
|
142
|
+
const cacheAge = Date.now() - (cached.cachedAt || 0);
|
|
143
|
+
if (cacheAge < 36e5) {
|
|
144
|
+
this.indexCache = cached.index;
|
|
145
|
+
return cached.index;
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const { statusCode, body } = await request(TEMPLATES_INDEX_URL);
|
|
152
|
+
if (statusCode !== 200) {
|
|
153
|
+
throw new Error(`HTTP ${statusCode}`);
|
|
154
|
+
}
|
|
155
|
+
const text = await body.text();
|
|
156
|
+
const json = JSON.parse(text);
|
|
157
|
+
const index = TemplatesIndexSchema.parse(json);
|
|
158
|
+
this.indexCache = index;
|
|
159
|
+
writeFileSync(
|
|
160
|
+
this.cacheFile,
|
|
161
|
+
JSON.stringify({ index, cachedAt: Date.now() }, null, 2)
|
|
162
|
+
);
|
|
163
|
+
return index;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (existsSync(this.cacheFile)) {
|
|
166
|
+
try {
|
|
167
|
+
const cached = JSON.parse(readFileSync(this.cacheFile, "utf-8"));
|
|
168
|
+
if (cached.index) {
|
|
169
|
+
this.indexCache = cached.index;
|
|
170
|
+
return cached.index;
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`Failed to fetch templates index: ${error.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* List all remote templates
|
|
180
|
+
*/
|
|
181
|
+
async listRemoteTemplates(options) {
|
|
182
|
+
const index = await this.fetchRemoteIndex(!!options?.forceRefresh);
|
|
183
|
+
const localIds = new Set(this.listLocalTemplates().map((t) => t.id));
|
|
184
|
+
return index.templates.map((metadata) => ({
|
|
185
|
+
metadata,
|
|
186
|
+
isDownloaded: localIds.has(metadata.id)
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Download a template by ID
|
|
191
|
+
*/
|
|
192
|
+
async downloadTemplate(templateId) {
|
|
193
|
+
const index = await this.fetchRemoteIndex();
|
|
194
|
+
const templateMeta = index.templates.find((t) => t.id === templateId);
|
|
195
|
+
if (!templateMeta) {
|
|
196
|
+
throw new Error(`Template not found: ${templateId}`);
|
|
197
|
+
}
|
|
198
|
+
const templateUrl = `${GITHUB_RAW_BASE}/templates/${templateMeta.file}`;
|
|
199
|
+
try {
|
|
200
|
+
const { statusCode, body } = await request(templateUrl);
|
|
201
|
+
if (statusCode !== 200) {
|
|
202
|
+
throw new Error(`HTTP ${statusCode}`);
|
|
203
|
+
}
|
|
204
|
+
const text = await body.text();
|
|
205
|
+
const json = JSON.parse(text);
|
|
206
|
+
const template = WebhookTemplateSchema.parse(json);
|
|
207
|
+
const providerDir = join(this.templatesDir, templateMeta.provider);
|
|
208
|
+
if (!existsSync(providerDir)) {
|
|
209
|
+
mkdirSync(providerDir, { recursive: true });
|
|
210
|
+
}
|
|
211
|
+
const fileName = `${templateId}.json`;
|
|
212
|
+
const filePath = join(providerDir, fileName);
|
|
213
|
+
const localTemplate = {
|
|
214
|
+
id: templateId,
|
|
215
|
+
metadata: templateMeta,
|
|
216
|
+
template,
|
|
217
|
+
downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
218
|
+
filePath
|
|
219
|
+
};
|
|
220
|
+
const saveData = {
|
|
221
|
+
...template,
|
|
222
|
+
_metadata: {
|
|
223
|
+
...templateMeta,
|
|
224
|
+
downloadedAt: localTemplate.downloadedAt
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
writeFileSync(filePath, JSON.stringify(saveData, null, 2));
|
|
228
|
+
return localTemplate;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Failed to download template ${templateId}: ${error.message}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* List all downloaded local templates
|
|
237
|
+
*/
|
|
238
|
+
listLocalTemplates() {
|
|
239
|
+
const templates2 = [];
|
|
240
|
+
if (!existsSync(this.templatesDir)) {
|
|
241
|
+
return templates2;
|
|
242
|
+
}
|
|
243
|
+
const scanDir = (dir) => {
|
|
244
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
const fullPath = join(dir, entry.name);
|
|
247
|
+
if (entry.isDirectory()) {
|
|
248
|
+
scanDir(fullPath);
|
|
249
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
250
|
+
try {
|
|
251
|
+
const content = JSON.parse(readFileSync(fullPath, "utf-8"));
|
|
252
|
+
const metadata = content._metadata;
|
|
253
|
+
if (metadata) {
|
|
254
|
+
const { _metadata, ...templateData } = content;
|
|
255
|
+
templates2.push({
|
|
256
|
+
id: metadata.id,
|
|
257
|
+
metadata,
|
|
258
|
+
template: templateData,
|
|
259
|
+
downloadedAt: metadata.downloadedAt || (/* @__PURE__ */ new Date()).toISOString(),
|
|
260
|
+
filePath: fullPath
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
const id = basename(entry.name, ".json");
|
|
264
|
+
templates2.push({
|
|
265
|
+
id,
|
|
266
|
+
metadata: {
|
|
267
|
+
id,
|
|
268
|
+
name: id,
|
|
269
|
+
provider: "custom",
|
|
270
|
+
event: "unknown",
|
|
271
|
+
file: entry.name
|
|
272
|
+
},
|
|
273
|
+
template: content,
|
|
274
|
+
downloadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
275
|
+
filePath: fullPath
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
scanDir(this.templatesDir);
|
|
284
|
+
return templates2;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Get a specific local template by ID
|
|
288
|
+
*/
|
|
289
|
+
getLocalTemplate(templateId) {
|
|
290
|
+
const templates2 = this.listLocalTemplates();
|
|
291
|
+
return templates2.find((t) => t.id === templateId) || null;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Delete a local template
|
|
295
|
+
*/
|
|
296
|
+
deleteLocalTemplate(templateId) {
|
|
297
|
+
const template = this.getLocalTemplate(templateId);
|
|
298
|
+
if (!template) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
68
301
|
try {
|
|
69
|
-
|
|
302
|
+
unlinkSync(template.filePath);
|
|
303
|
+
return true;
|
|
70
304
|
} catch {
|
|
305
|
+
return false;
|
|
71
306
|
}
|
|
72
307
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Search templates by name, provider, or event
|
|
310
|
+
*/
|
|
311
|
+
async searchTemplates(query) {
|
|
312
|
+
const queryLower = query.toLowerCase();
|
|
313
|
+
const remote = await this.listRemoteTemplates();
|
|
314
|
+
const local = this.listLocalTemplates();
|
|
315
|
+
const matchesMeta = (meta) => {
|
|
316
|
+
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);
|
|
317
|
+
};
|
|
318
|
+
return {
|
|
319
|
+
remote: remote.filter((t) => matchesMeta(t.metadata)),
|
|
320
|
+
local: local.filter((t) => matchesMeta(t.metadata))
|
|
321
|
+
};
|
|
76
322
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// src/utils/index.ts
|
|
86
|
-
function findWebhooksDir(cwd) {
|
|
87
|
-
return resolve(cwd, ".webhooks");
|
|
88
|
-
}
|
|
89
|
-
function listWebhookFiles(dir) {
|
|
90
|
-
return listJsonFiles(dir);
|
|
91
|
-
}
|
|
92
|
-
function findCapturesDir(cwd) {
|
|
93
|
-
return resolve(cwd, ".webhook-captures");
|
|
94
|
-
}
|
|
95
|
-
function listJsonFiles(dir) {
|
|
96
|
-
try {
|
|
97
|
-
const entries = readdirSync(dir);
|
|
98
|
-
return entries.filter(
|
|
99
|
-
(e) => statSync(join(dir, e)).isFile() && extname(e) === ".json"
|
|
100
|
-
);
|
|
101
|
-
} catch {
|
|
102
|
-
return [];
|
|
323
|
+
/**
|
|
324
|
+
* Clear the templates cache
|
|
325
|
+
*/
|
|
326
|
+
clearCache() {
|
|
327
|
+
this.indexCache = null;
|
|
328
|
+
if (existsSync(this.cacheFile)) {
|
|
329
|
+
unlinkSync(this.cacheFile);
|
|
330
|
+
}
|
|
103
331
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Delete all local templates
|
|
334
|
+
* @returns Number of templates deleted
|
|
335
|
+
*/
|
|
336
|
+
deleteAllLocalTemplates() {
|
|
337
|
+
const templates2 = this.listLocalTemplates();
|
|
338
|
+
let deleted = 0;
|
|
339
|
+
for (const template of templates2) {
|
|
340
|
+
try {
|
|
341
|
+
unlinkSync(template.filePath);
|
|
342
|
+
deleted++;
|
|
343
|
+
} catch {
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (existsSync(this.templatesDir)) {
|
|
347
|
+
const entries = readdirSync(this.templatesDir, { withFileTypes: true });
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
if (entry.isDirectory()) {
|
|
350
|
+
const dirPath = join(this.templatesDir, entry.name);
|
|
351
|
+
try {
|
|
352
|
+
const contents = readdirSync(dirPath);
|
|
353
|
+
if (contents.length === 0) {
|
|
354
|
+
rmdirSync(dirPath);
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return deleted;
|
|
111
362
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
363
|
+
};
|
|
364
|
+
var instance = null;
|
|
365
|
+
function getTemplateManager(baseDir) {
|
|
366
|
+
if (!instance) {
|
|
367
|
+
instance = new TemplateManager(baseDir);
|
|
117
368
|
}
|
|
118
|
-
return
|
|
369
|
+
return instance;
|
|
119
370
|
}
|
|
120
371
|
|
|
121
|
-
// src/commands/
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
var TEMPLATES = {
|
|
128
|
-
"stripe-invoice.payment_succeeded": "templates/stripe-invoice.payment_succeeded.json"
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
// src/commands/webhooks.ts
|
|
132
|
-
import prompts from "prompts";
|
|
133
|
-
import ora from "ora";
|
|
134
|
-
function statExists(p) {
|
|
372
|
+
// src/commands/templates.ts
|
|
373
|
+
var listCommand = new Command().name("list").alias("ls").description("List available remote templates from the repository").option(
|
|
374
|
+
"-p, --provider <provider>",
|
|
375
|
+
"Filter by provider (stripe, github, etc.)"
|
|
376
|
+
).option("-r, --refresh", "Force refresh the template index cache").action(async (options) => {
|
|
377
|
+
const spinner = ora("Fetching remote templates...").start();
|
|
135
378
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
var listCommand = new Command().name("list").description("List available webhook JSON definitions in .webhooks directory").action(() => {
|
|
143
|
-
const cwd = process.cwd();
|
|
144
|
-
const dir = findWebhooksDir(cwd);
|
|
145
|
-
const files = listWebhookFiles(dir);
|
|
146
|
-
if (!files.length) {
|
|
147
|
-
console.log("\u{1F4ED} No webhook definitions found in .webhooks directory.");
|
|
148
|
-
console.log("\u{1F4A1} Create webhook files or download templates with:");
|
|
149
|
-
console.log(" better-webhook webhooks download --all");
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
console.log("Available webhook definitions:");
|
|
153
|
-
files.forEach((f) => console.log(` \u2022 ${basename(f, ".json")}`));
|
|
154
|
-
});
|
|
155
|
-
var runCommand = new Command().name("run").argument(
|
|
156
|
-
"[nameOrPath]",
|
|
157
|
-
"Webhook name (in .webhooks) or path to JSON file (optional - will prompt if not provided)"
|
|
158
|
-
).description(
|
|
159
|
-
"Run a webhook by name (in .webhooks) or by providing a path to a JSON file"
|
|
160
|
-
).option("-u, --url <url>", "Override destination URL").option("-m, --method <method>", "Override HTTP method").action(async (nameOrPath, options = {}) => {
|
|
161
|
-
const cwd = process.cwd();
|
|
162
|
-
const webhooksDir = findWebhooksDir(cwd);
|
|
163
|
-
let selectedNameOrPath = nameOrPath;
|
|
164
|
-
if (!selectedNameOrPath) {
|
|
165
|
-
const spinner = ora("Loading available webhooks...").start();
|
|
166
|
-
const localFiles = listWebhookFiles(webhooksDir);
|
|
167
|
-
const templates = Object.keys(TEMPLATES);
|
|
379
|
+
const manager = getTemplateManager();
|
|
380
|
+
const templates2 = await manager.listRemoteTemplates({
|
|
381
|
+
forceRefresh: !!options.refresh
|
|
382
|
+
});
|
|
168
383
|
spinner.stop();
|
|
169
|
-
if (
|
|
170
|
-
console.log("\u{1F4ED} No
|
|
171
|
-
console.log(
|
|
172
|
-
"\u{1F4A1} Create webhook files in .webhooks directory or download templates with:"
|
|
173
|
-
);
|
|
174
|
-
console.log(" better-webhook webhooks download --all");
|
|
384
|
+
if (templates2.length === 0) {
|
|
385
|
+
console.log(chalk.yellow("\u{1F4ED} No remote templates found."));
|
|
175
386
|
return;
|
|
176
387
|
}
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
title: "--- Local Webhooks (.webhooks) ---",
|
|
182
|
-
description: "",
|
|
183
|
-
value: "",
|
|
184
|
-
type: "separator"
|
|
185
|
-
},
|
|
186
|
-
...localFiles.map((file) => ({
|
|
187
|
-
title: basename(file, ".json"),
|
|
188
|
-
description: `Local file: ${file}`,
|
|
189
|
-
value: basename(file, ".json"),
|
|
190
|
-
type: "local"
|
|
191
|
-
}))
|
|
388
|
+
let filtered = templates2;
|
|
389
|
+
if (options.provider) {
|
|
390
|
+
filtered = templates2.filter(
|
|
391
|
+
(t) => t.metadata.provider.toLowerCase() === options.provider?.toLowerCase()
|
|
192
392
|
);
|
|
193
393
|
}
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
value: "",
|
|
200
|
-
type: "separator"
|
|
201
|
-
},
|
|
202
|
-
...templates.map((template) => ({
|
|
203
|
-
title: template,
|
|
204
|
-
description: `Template: ${TEMPLATES[template]}`,
|
|
205
|
-
value: template,
|
|
206
|
-
type: "template"
|
|
207
|
-
}))
|
|
394
|
+
if (filtered.length === 0) {
|
|
395
|
+
console.log(
|
|
396
|
+
chalk.yellow(
|
|
397
|
+
`\u{1F4ED} No templates found for provider: ${options.provider}`
|
|
398
|
+
)
|
|
208
399
|
);
|
|
400
|
+
return;
|
|
209
401
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
402
|
+
console.log(chalk.bold("\n\u{1F4E6} Available Templates\n"));
|
|
403
|
+
const byProvider = /* @__PURE__ */ new Map();
|
|
404
|
+
for (const t of filtered) {
|
|
405
|
+
const provider = t.metadata.provider;
|
|
406
|
+
if (!byProvider.has(provider)) {
|
|
407
|
+
byProvider.set(provider, []);
|
|
408
|
+
}
|
|
409
|
+
byProvider.get(provider).push(t);
|
|
410
|
+
}
|
|
411
|
+
for (const [provider, providerTemplates] of byProvider) {
|
|
412
|
+
console.log(chalk.cyan.bold(` ${provider.toUpperCase()}`));
|
|
413
|
+
for (const t of providerTemplates) {
|
|
414
|
+
const status = t.isDownloaded ? chalk.green("\u2713 downloaded") : chalk.gray("\u25CB remote");
|
|
415
|
+
console.log(` ${chalk.white(t.metadata.id)} ${status}`);
|
|
416
|
+
if (t.metadata.description) {
|
|
417
|
+
console.log(chalk.gray(` ${t.metadata.description}`));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
console.log();
|
|
421
|
+
}
|
|
422
|
+
console.log(chalk.gray(` Total: ${filtered.length} templates`));
|
|
423
|
+
console.log(
|
|
424
|
+
chalk.gray(` Download: better-webhook templates download <id>
|
|
425
|
+
`)
|
|
426
|
+
);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
spinner.fail("Failed to fetch templates");
|
|
429
|
+
console.error(chalk.red(error.message));
|
|
430
|
+
process.exitCode = 1;
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
var downloadCommand = new Command().name("download").alias("get").argument("[templateId]", "Template ID to download").description("Download a template to local storage").option("-a, --all", "Download all available templates").option("-r, --refresh", "Force refresh the template index cache").action(
|
|
434
|
+
async (templateId, options) => {
|
|
435
|
+
const manager = getTemplateManager();
|
|
436
|
+
if (options?.all) {
|
|
437
|
+
const spinner2 = ora("Fetching template list...").start();
|
|
438
|
+
try {
|
|
439
|
+
const templates2 = await manager.listRemoteTemplates({
|
|
440
|
+
forceRefresh: true
|
|
441
|
+
});
|
|
442
|
+
const toDownload = templates2.filter((t) => !t.isDownloaded);
|
|
443
|
+
spinner2.stop();
|
|
444
|
+
if (toDownload.length === 0) {
|
|
445
|
+
console.log(chalk.green("\u2713 All templates already downloaded"));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
console.log(
|
|
449
|
+
chalk.bold(`
|
|
450
|
+
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(
|
|
462
|
+
`Failed: ${t.metadata.id} - ${error.message}`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
console.log(chalk.green("\n\u2713 Download complete\n"));
|
|
467
|
+
} catch (error) {
|
|
468
|
+
spinner2.fail("Failed to fetch templates");
|
|
469
|
+
console.error(chalk.red(error.message));
|
|
470
|
+
process.exitCode = 1;
|
|
471
|
+
}
|
|
221
472
|
return;
|
|
222
473
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const downloadSpinner = ora(
|
|
226
|
-
`Downloading template: ${selectedNameOrPath}...`
|
|
227
|
-
).start();
|
|
474
|
+
if (!templateId) {
|
|
475
|
+
const spinner2 = ora("Fetching templates...").start();
|
|
228
476
|
try {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
477
|
+
const templates2 = await manager.listRemoteTemplates({
|
|
478
|
+
forceRefresh: !!options?.refresh
|
|
479
|
+
});
|
|
480
|
+
spinner2.stop();
|
|
481
|
+
const notDownloaded = templates2.filter((t) => !t.isDownloaded);
|
|
482
|
+
if (notDownloaded.length === 0) {
|
|
483
|
+
console.log(chalk.green("\u2713 All templates already downloaded"));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const choices = notDownloaded.map((t) => ({
|
|
487
|
+
title: t.metadata.id,
|
|
488
|
+
description: `${t.metadata.provider} - ${t.metadata.event}`,
|
|
489
|
+
value: t.metadata.id
|
|
490
|
+
}));
|
|
491
|
+
const response = await prompts({
|
|
492
|
+
type: "select",
|
|
493
|
+
name: "templateId",
|
|
494
|
+
message: "Select a template to download:",
|
|
495
|
+
choices
|
|
496
|
+
});
|
|
497
|
+
if (!response.templateId) {
|
|
498
|
+
console.log(chalk.yellow("Cancelled"));
|
|
499
|
+
return;
|
|
234
500
|
}
|
|
235
|
-
|
|
236
|
-
const json = JSON.parse(text);
|
|
237
|
-
validateWebhookJSON(json, rawUrl);
|
|
238
|
-
mkdirSync(webhooksDir, { recursive: true });
|
|
239
|
-
const fileName = basename(rel);
|
|
240
|
-
const destPath = join2(webhooksDir, fileName);
|
|
241
|
-
writeFileSync(destPath, JSON.stringify(json, null, 2));
|
|
242
|
-
selectedNameOrPath = basename(fileName, ".json");
|
|
243
|
-
downloadSpinner.succeed(`Downloaded template: ${selectedNameOrPath}`);
|
|
501
|
+
templateId = response.templateId;
|
|
244
502
|
} catch (error) {
|
|
245
|
-
|
|
503
|
+
spinner2.fail("Failed to fetch templates");
|
|
504
|
+
console.error(chalk.red(error.message));
|
|
246
505
|
process.exitCode = 1;
|
|
247
506
|
return;
|
|
248
507
|
}
|
|
249
508
|
}
|
|
509
|
+
const spinner = ora(`Downloading ${templateId}...`).start();
|
|
510
|
+
try {
|
|
511
|
+
const template = await manager.downloadTemplate(templateId);
|
|
512
|
+
spinner.succeed(`Downloaded ${templateId}`);
|
|
513
|
+
console.log(chalk.gray(` Saved to: ${template.filePath}`));
|
|
514
|
+
console.log(
|
|
515
|
+
chalk.gray(` Run with: better-webhook run ${templateId}
|
|
516
|
+
`)
|
|
517
|
+
);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
spinner.fail(`Failed to download ${templateId}`);
|
|
520
|
+
console.error(chalk.red(error.message));
|
|
521
|
+
process.exitCode = 1;
|
|
522
|
+
}
|
|
250
523
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
524
|
+
);
|
|
525
|
+
var localCommand = new Command().name("local").description("List downloaded local templates").option("-p, --provider <provider>", "Filter by provider").action((options) => {
|
|
526
|
+
const manager = getTemplateManager();
|
|
527
|
+
let templates2 = manager.listLocalTemplates();
|
|
528
|
+
if (options.provider) {
|
|
529
|
+
templates2 = templates2.filter(
|
|
530
|
+
(t) => t.metadata.provider.toLowerCase() === options.provider?.toLowerCase()
|
|
531
|
+
);
|
|
255
532
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
selectedNameOrPath + (selectedNameOrPath.endsWith(".json") ? "" : ".json")
|
|
533
|
+
if (templates2.length === 0) {
|
|
534
|
+
console.log(chalk.yellow("\n\u{1F4ED} No local templates found."));
|
|
535
|
+
console.log(
|
|
536
|
+
chalk.gray(
|
|
537
|
+
" Download templates with: better-webhook templates download\n"
|
|
538
|
+
)
|
|
263
539
|
);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
console.log(chalk.bold("\n\u{1F4C1} Local Templates\n"));
|
|
543
|
+
const byProvider = /* @__PURE__ */ new Map();
|
|
544
|
+
for (const t of templates2) {
|
|
545
|
+
const provider = t.metadata.provider;
|
|
546
|
+
if (!byProvider.has(provider)) {
|
|
547
|
+
byProvider.set(provider, []);
|
|
268
548
|
}
|
|
549
|
+
byProvider.get(provider).push(t);
|
|
269
550
|
}
|
|
270
|
-
|
|
271
|
-
console.
|
|
272
|
-
|
|
273
|
-
|
|
551
|
+
for (const [provider, providerTemplates] of byProvider) {
|
|
552
|
+
console.log(chalk.cyan.bold(` ${provider.toUpperCase()}`));
|
|
553
|
+
for (const t of providerTemplates) {
|
|
554
|
+
console.log(` ${chalk.white(t.id)}`);
|
|
555
|
+
console.log(chalk.gray(` Event: ${t.metadata.event}`));
|
|
556
|
+
console.log(
|
|
557
|
+
chalk.gray(
|
|
558
|
+
` Downloaded: ${new Date(t.downloadedAt).toLocaleDateString()}`
|
|
559
|
+
)
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
console.log();
|
|
274
563
|
}
|
|
275
|
-
|
|
564
|
+
console.log(chalk.gray(` Total: ${templates2.length} templates`));
|
|
565
|
+
console.log(chalk.gray(` Storage: ${manager.getTemplatesDir()}
|
|
566
|
+
`));
|
|
567
|
+
});
|
|
568
|
+
var searchCommand = new Command().name("search").argument("<query>", "Search query").description("Search templates by name, provider, or event").action(async (query) => {
|
|
569
|
+
const spinner = ora("Searching...").start();
|
|
276
570
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
571
|
+
const manager = getTemplateManager();
|
|
572
|
+
const results = await manager.searchTemplates(query);
|
|
573
|
+
spinner.stop();
|
|
574
|
+
const totalCount = results.remote.length + results.local.length;
|
|
575
|
+
if (totalCount === 0) {
|
|
576
|
+
console.log(chalk.yellow(`
|
|
577
|
+
\u{1F4ED} No templates found for: "${query}"
|
|
578
|
+
`));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
console.log(chalk.bold(`
|
|
582
|
+
\u{1F50D} Search Results for "${query}"
|
|
583
|
+
`));
|
|
584
|
+
if (results.local.length > 0) {
|
|
585
|
+
console.log(chalk.cyan.bold(" LOCAL TEMPLATES"));
|
|
586
|
+
for (const t of results.local) {
|
|
587
|
+
console.log(
|
|
588
|
+
` ${chalk.green("\u2713")} ${t.id} (${t.metadata.provider})`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
console.log();
|
|
592
|
+
}
|
|
593
|
+
if (results.remote.length > 0) {
|
|
594
|
+
console.log(chalk.cyan.bold(" REMOTE TEMPLATES"));
|
|
595
|
+
for (const t of results.remote) {
|
|
596
|
+
const status = t.isDownloaded ? chalk.green("\u2713") : chalk.gray("\u25CB");
|
|
597
|
+
console.log(
|
|
598
|
+
` ${status} ${t.metadata.id} (${t.metadata.provider})`
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
console.log();
|
|
602
|
+
}
|
|
603
|
+
console.log(chalk.gray(` Found: ${totalCount} templates
|
|
604
|
+
`));
|
|
605
|
+
} catch (error) {
|
|
606
|
+
spinner.fail("Search failed");
|
|
607
|
+
console.error(chalk.red(error.message));
|
|
280
608
|
process.exitCode = 1;
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
var cacheCommand = new Command().name("cache").description("Manage template cache").option("-c, --clear", "Clear the template cache").action((options) => {
|
|
612
|
+
if (options.clear) {
|
|
613
|
+
const manager = getTemplateManager();
|
|
614
|
+
manager.clearCache();
|
|
615
|
+
console.log(chalk.green("\u2713 Template cache cleared"));
|
|
616
|
+
} else {
|
|
617
|
+
console.log("Use --clear to clear the template cache");
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
var cleanCommand = new Command().name("clean").alias("remove-all").description("Remove all downloaded templates").option("-f, --force", "Skip confirmation prompt").action(async (options) => {
|
|
621
|
+
const manager = getTemplateManager();
|
|
622
|
+
const templates2 = manager.listLocalTemplates();
|
|
623
|
+
if (templates2.length === 0) {
|
|
624
|
+
console.log(chalk.yellow("\n\u{1F4ED} No local templates to remove.\n"));
|
|
281
625
|
return;
|
|
282
626
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
console.log(result.bodyText);
|
|
627
|
+
console.log(
|
|
628
|
+
chalk.bold(`
|
|
629
|
+
\u{1F5D1}\uFE0F Found ${templates2.length} downloaded template(s)
|
|
630
|
+
`)
|
|
631
|
+
);
|
|
632
|
+
for (const t of templates2) {
|
|
633
|
+
console.log(chalk.gray(` ${t.id} (${t.metadata.provider})`));
|
|
634
|
+
}
|
|
635
|
+
console.log();
|
|
636
|
+
if (!options.force) {
|
|
637
|
+
const response = await prompts({
|
|
638
|
+
type: "confirm",
|
|
639
|
+
name: "confirm",
|
|
640
|
+
message: `Delete all ${templates2.length} template(s)?`,
|
|
641
|
+
initial: false
|
|
642
|
+
});
|
|
643
|
+
if (!response.confirm) {
|
|
644
|
+
console.log(chalk.yellow("Cancelled"));
|
|
645
|
+
return;
|
|
303
646
|
}
|
|
304
|
-
} catch (err) {
|
|
305
|
-
executeSpinner.fail("Request failed");
|
|
306
|
-
console.error("Error:", err.message);
|
|
307
|
-
process.exitCode = 1;
|
|
308
647
|
}
|
|
648
|
+
const deleted = manager.deleteAllLocalTemplates();
|
|
649
|
+
console.log(chalk.green(`
|
|
650
|
+
\u2713 Removed ${deleted} template(s)`));
|
|
651
|
+
console.log(chalk.gray(` Storage: ${manager.getTemplatesDir()}
|
|
652
|
+
`));
|
|
309
653
|
});
|
|
310
|
-
var
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
654
|
+
var templates = new Command().name("templates").alias("t").description("Manage webhook templates").addCommand(listCommand).addCommand(downloadCommand).addCommand(localCommand).addCommand(searchCommand).addCommand(cacheCommand).addCommand(cleanCommand);
|
|
655
|
+
|
|
656
|
+
// src/commands/run.ts
|
|
657
|
+
import { Command as Command2 } from "commander";
|
|
658
|
+
import ora2 from "ora";
|
|
659
|
+
import prompts2 from "prompts";
|
|
660
|
+
import chalk2 from "chalk";
|
|
661
|
+
|
|
662
|
+
// src/core/executor.ts
|
|
663
|
+
import { request as request2 } from "undici";
|
|
664
|
+
|
|
665
|
+
// src/core/signature.ts
|
|
666
|
+
import { createHmac } from "crypto";
|
|
667
|
+
function generateStripeSignature(payload, secret, timestamp) {
|
|
668
|
+
const ts = timestamp || Math.floor(Date.now() / 1e3);
|
|
669
|
+
const signedPayload = `${ts}.${payload}`;
|
|
670
|
+
const signature = createHmac("sha256", secret).update(signedPayload).digest("hex");
|
|
671
|
+
return {
|
|
672
|
+
header: "Stripe-Signature",
|
|
673
|
+
value: `t=${ts},v1=${signature}`
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
function generateGitHubSignature(payload, secret) {
|
|
677
|
+
const signature = createHmac("sha256", secret).update(payload).digest("hex");
|
|
678
|
+
return {
|
|
679
|
+
header: "X-Hub-Signature-256",
|
|
680
|
+
value: `sha256=${signature}`
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function generateShopifySignature(payload, secret) {
|
|
684
|
+
const signature = createHmac("sha256", secret).update(payload).digest("base64");
|
|
685
|
+
return {
|
|
686
|
+
header: "X-Shopify-Hmac-SHA256",
|
|
687
|
+
value: signature
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function generateTwilioSignature(payload, secret, url) {
|
|
691
|
+
const signatureInput = url + payload;
|
|
692
|
+
const signature = createHmac("sha1", secret).update(signatureInput).digest("base64");
|
|
693
|
+
return {
|
|
694
|
+
header: "X-Twilio-Signature",
|
|
695
|
+
value: signature
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
function generateSlackSignature(payload, secret, timestamp) {
|
|
699
|
+
const ts = timestamp || Math.floor(Date.now() / 1e3);
|
|
700
|
+
const signatureBaseString = `v0:${ts}:${payload}`;
|
|
701
|
+
const signature = createHmac("sha256", secret).update(signatureBaseString).digest("hex");
|
|
702
|
+
return {
|
|
703
|
+
header: "X-Slack-Signature",
|
|
704
|
+
value: `v0=${signature}`
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function generateLinearSignature(payload, secret) {
|
|
708
|
+
const signature = createHmac("sha256", secret).update(payload).digest("hex");
|
|
709
|
+
return {
|
|
710
|
+
header: "Linear-Signature",
|
|
711
|
+
value: signature
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function generateClerkSignature(payload, secret, timestamp, webhookId) {
|
|
715
|
+
const ts = timestamp || Math.floor(Date.now() / 1e3);
|
|
716
|
+
const msgId = webhookId || `msg_${Date.now()}`;
|
|
717
|
+
const signedPayload = `${msgId}.${ts}.${payload}`;
|
|
718
|
+
const signature = createHmac("sha256", secret).update(signedPayload).digest("base64");
|
|
719
|
+
return {
|
|
720
|
+
header: "Svix-Signature",
|
|
721
|
+
value: `v1,${signature}`
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function generateSendGridSignature(payload, secret, timestamp) {
|
|
725
|
+
const ts = timestamp || Math.floor(Date.now() / 1e3);
|
|
726
|
+
const signedPayload = `${ts}${payload}`;
|
|
727
|
+
const signature = createHmac("sha256", secret).update(signedPayload).digest("base64");
|
|
728
|
+
return {
|
|
729
|
+
header: "X-Twilio-Email-Event-Webhook-Signature",
|
|
730
|
+
value: signature
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
function generateSignature(provider, payload, secret, options) {
|
|
734
|
+
const timestamp = options?.timestamp;
|
|
735
|
+
switch (provider) {
|
|
736
|
+
case "stripe":
|
|
737
|
+
return generateStripeSignature(payload, secret, timestamp);
|
|
738
|
+
case "github":
|
|
739
|
+
return generateGitHubSignature(payload, secret);
|
|
740
|
+
case "shopify":
|
|
741
|
+
return generateShopifySignature(payload, secret);
|
|
742
|
+
case "twilio":
|
|
743
|
+
if (!options?.url) {
|
|
744
|
+
throw new Error("Twilio signature requires URL");
|
|
745
|
+
}
|
|
746
|
+
return generateTwilioSignature(payload, secret, options.url);
|
|
747
|
+
case "slack":
|
|
748
|
+
return generateSlackSignature(payload, secret, timestamp);
|
|
749
|
+
case "linear":
|
|
750
|
+
return generateLinearSignature(payload, secret);
|
|
751
|
+
case "clerk":
|
|
752
|
+
return generateClerkSignature(
|
|
753
|
+
payload,
|
|
754
|
+
secret,
|
|
755
|
+
timestamp,
|
|
756
|
+
options?.webhookId
|
|
757
|
+
);
|
|
758
|
+
case "sendgrid":
|
|
759
|
+
return generateSendGridSignature(payload, secret, timestamp);
|
|
760
|
+
case "discord":
|
|
761
|
+
case "custom":
|
|
762
|
+
default:
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function getProviderHeaders(provider, options) {
|
|
767
|
+
const headers = [];
|
|
768
|
+
const timestamp = options?.timestamp || Math.floor(Date.now() / 1e3);
|
|
769
|
+
switch (provider) {
|
|
770
|
+
case "stripe":
|
|
771
|
+
headers.push(
|
|
772
|
+
{ key: "Content-Type", value: "application/json" },
|
|
773
|
+
{
|
|
774
|
+
key: "User-Agent",
|
|
775
|
+
value: "Stripe/1.0 (+https://stripe.com/docs/webhooks)"
|
|
776
|
+
}
|
|
777
|
+
);
|
|
778
|
+
break;
|
|
779
|
+
case "github":
|
|
780
|
+
headers.push(
|
|
781
|
+
{ key: "Content-Type", value: "application/json" },
|
|
782
|
+
{ key: "User-Agent", value: "GitHub-Hookshot/better-webhook" },
|
|
783
|
+
{ key: "X-GitHub-Event", value: options?.event || "push" },
|
|
784
|
+
{
|
|
785
|
+
key: "X-GitHub-Delivery",
|
|
786
|
+
value: options?.webhookId || generateDeliveryId()
|
|
787
|
+
}
|
|
788
|
+
);
|
|
789
|
+
break;
|
|
790
|
+
case "shopify":
|
|
791
|
+
headers.push(
|
|
792
|
+
{ key: "Content-Type", value: "application/json" },
|
|
793
|
+
{ key: "X-Shopify-Topic", value: options?.event || "orders/create" },
|
|
794
|
+
{ key: "X-Shopify-Shop-Domain", value: "example.myshopify.com" },
|
|
795
|
+
{ key: "X-Shopify-API-Version", value: "2024-01" }
|
|
796
|
+
);
|
|
797
|
+
break;
|
|
798
|
+
case "slack":
|
|
799
|
+
headers.push(
|
|
800
|
+
{ key: "Content-Type", value: "application/json" },
|
|
801
|
+
{ key: "X-Slack-Request-Timestamp", value: String(timestamp) }
|
|
802
|
+
);
|
|
803
|
+
break;
|
|
804
|
+
case "clerk":
|
|
805
|
+
headers.push(
|
|
806
|
+
{ key: "Content-Type", value: "application/json" },
|
|
807
|
+
{ key: "Svix-Id", value: options?.webhookId || `msg_${Date.now()}` },
|
|
808
|
+
{ key: "Svix-Timestamp", value: String(timestamp) }
|
|
809
|
+
);
|
|
810
|
+
break;
|
|
811
|
+
case "sendgrid":
|
|
812
|
+
headers.push(
|
|
813
|
+
{ key: "Content-Type", value: "application/json" },
|
|
814
|
+
{
|
|
815
|
+
key: "X-Twilio-Email-Event-Webhook-Timestamp",
|
|
816
|
+
value: String(timestamp)
|
|
817
|
+
}
|
|
818
|
+
);
|
|
819
|
+
break;
|
|
820
|
+
case "twilio":
|
|
821
|
+
headers.push({
|
|
822
|
+
key: "Content-Type",
|
|
823
|
+
value: "application/x-www-form-urlencoded"
|
|
824
|
+
});
|
|
825
|
+
break;
|
|
826
|
+
case "linear":
|
|
827
|
+
headers.push(
|
|
828
|
+
{ key: "Content-Type", value: "application/json" },
|
|
829
|
+
{
|
|
830
|
+
key: "Linear-Delivery",
|
|
831
|
+
value: options?.webhookId || generateDeliveryId()
|
|
832
|
+
}
|
|
833
|
+
);
|
|
834
|
+
break;
|
|
835
|
+
case "discord":
|
|
836
|
+
headers.push(
|
|
837
|
+
{ key: "Content-Type", value: "application/json" },
|
|
838
|
+
{ key: "User-Agent", value: "Discord-Webhook/1.0" }
|
|
839
|
+
);
|
|
840
|
+
break;
|
|
841
|
+
default:
|
|
842
|
+
headers.push({ key: "Content-Type", value: "application/json" });
|
|
843
|
+
}
|
|
844
|
+
return headers;
|
|
845
|
+
}
|
|
846
|
+
function generateDeliveryId() {
|
|
847
|
+
const chars = "0123456789abcdef";
|
|
848
|
+
let id = "";
|
|
849
|
+
for (let i = 0; i < 36; i++) {
|
|
850
|
+
if (i === 8 || i === 13 || i === 18 || i === 23) {
|
|
851
|
+
id += "-";
|
|
852
|
+
} else {
|
|
853
|
+
id += chars[Math.floor(Math.random() * chars.length)];
|
|
318
854
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
855
|
+
}
|
|
856
|
+
return id;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/core/executor.ts
|
|
860
|
+
async function executeWebhook(options) {
|
|
861
|
+
const startTime = Date.now();
|
|
862
|
+
let bodyStr;
|
|
863
|
+
if (options.body !== void 0) {
|
|
864
|
+
bodyStr = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
|
|
865
|
+
}
|
|
866
|
+
const headers = {};
|
|
867
|
+
if (options.provider) {
|
|
868
|
+
const providerHeaders = getProviderHeaders(options.provider);
|
|
869
|
+
for (const h of providerHeaders) {
|
|
870
|
+
headers[h.key] = h.value;
|
|
328
871
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
872
|
+
}
|
|
873
|
+
if (options.headers) {
|
|
874
|
+
for (const h of options.headers) {
|
|
875
|
+
headers[h.key] = h.value;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (options.secret && options.provider && bodyStr) {
|
|
879
|
+
const sig = generateSignature(options.provider, bodyStr, options.secret, {
|
|
880
|
+
url: options.url
|
|
881
|
+
});
|
|
882
|
+
if (sig) {
|
|
883
|
+
headers[sig.header] = sig.value;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (!headers["Content-Type"] && !headers["content-type"]) {
|
|
887
|
+
headers["Content-Type"] = "application/json";
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const response = await request2(options.url, {
|
|
891
|
+
method: options.method || "POST",
|
|
892
|
+
headers,
|
|
893
|
+
body: bodyStr,
|
|
894
|
+
headersTimeout: options.timeout || 3e4,
|
|
895
|
+
bodyTimeout: options.timeout || 3e4
|
|
896
|
+
});
|
|
897
|
+
const bodyText = await response.body.text();
|
|
898
|
+
const duration = Date.now() - startTime;
|
|
899
|
+
const responseHeaders = {};
|
|
900
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
901
|
+
if (value !== void 0) {
|
|
902
|
+
responseHeaders[key] = value;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
let json;
|
|
906
|
+
try {
|
|
907
|
+
json = JSON.parse(bodyText);
|
|
908
|
+
} catch {
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
status: response.statusCode,
|
|
912
|
+
statusText: getStatusText(response.statusCode),
|
|
913
|
+
headers: responseHeaders,
|
|
914
|
+
body: json ?? bodyText,
|
|
915
|
+
bodyText,
|
|
916
|
+
json,
|
|
917
|
+
duration
|
|
918
|
+
};
|
|
919
|
+
} catch (error) {
|
|
920
|
+
const duration = Date.now() - startTime;
|
|
921
|
+
throw new ExecutionError(error.message, duration);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
async function executeTemplate(template, options = {}) {
|
|
925
|
+
const targetUrl = options.url || template.url;
|
|
926
|
+
if (!targetUrl) {
|
|
927
|
+
throw new Error(
|
|
928
|
+
"No target URL specified. Use --url or set url in template."
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
const mergedHeaders = [...template.headers || []];
|
|
932
|
+
if (options.headers) {
|
|
933
|
+
for (const h of options.headers) {
|
|
934
|
+
const existingIdx = mergedHeaders.findIndex(
|
|
935
|
+
(mh) => mh.key.toLowerCase() === h.key.toLowerCase()
|
|
936
|
+
);
|
|
937
|
+
if (existingIdx >= 0) {
|
|
938
|
+
mergedHeaders[existingIdx] = h;
|
|
939
|
+
} else {
|
|
940
|
+
mergedHeaders.push(h);
|
|
336
941
|
}
|
|
337
|
-
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return executeWebhook({
|
|
945
|
+
url: targetUrl,
|
|
946
|
+
method: template.method,
|
|
947
|
+
headers: mergedHeaders,
|
|
948
|
+
body: template.body,
|
|
949
|
+
secret: options.secret,
|
|
950
|
+
provider: template.provider
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
var ExecutionError = class extends Error {
|
|
954
|
+
duration;
|
|
955
|
+
constructor(message, duration) {
|
|
956
|
+
super(message);
|
|
957
|
+
this.name = "ExecutionError";
|
|
958
|
+
this.duration = duration;
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
function getStatusText(code) {
|
|
962
|
+
const statusTexts = {
|
|
963
|
+
200: "OK",
|
|
964
|
+
201: "Created",
|
|
965
|
+
202: "Accepted",
|
|
966
|
+
204: "No Content",
|
|
967
|
+
301: "Moved Permanently",
|
|
968
|
+
302: "Found",
|
|
969
|
+
304: "Not Modified",
|
|
970
|
+
400: "Bad Request",
|
|
971
|
+
401: "Unauthorized",
|
|
972
|
+
403: "Forbidden",
|
|
973
|
+
404: "Not Found",
|
|
974
|
+
405: "Method Not Allowed",
|
|
975
|
+
408: "Request Timeout",
|
|
976
|
+
409: "Conflict",
|
|
977
|
+
422: "Unprocessable Entity",
|
|
978
|
+
429: "Too Many Requests",
|
|
979
|
+
500: "Internal Server Error",
|
|
980
|
+
502: "Bad Gateway",
|
|
981
|
+
503: "Service Unavailable",
|
|
982
|
+
504: "Gateway Timeout"
|
|
983
|
+
};
|
|
984
|
+
return statusTexts[code] || "Unknown";
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/commands/run.ts
|
|
988
|
+
function getSecretEnvVarName(provider) {
|
|
989
|
+
const envVarMap = {
|
|
990
|
+
github: "GITHUB_WEBHOOK_SECRET",
|
|
991
|
+
stripe: "STRIPE_WEBHOOK_SECRET",
|
|
992
|
+
shopify: "SHOPIFY_WEBHOOK_SECRET",
|
|
993
|
+
twilio: "TWILIO_WEBHOOK_SECRET",
|
|
994
|
+
ragie: "RAGIE_WEBHOOK_SECRET",
|
|
995
|
+
slack: "SLACK_WEBHOOK_SECRET",
|
|
996
|
+
linear: "LINEAR_WEBHOOK_SECRET",
|
|
997
|
+
clerk: "CLERK_WEBHOOK_SECRET",
|
|
998
|
+
sendgrid: "SENDGRID_WEBHOOK_SECRET",
|
|
999
|
+
discord: "DISCORD_WEBHOOK_SECRET",
|
|
1000
|
+
custom: "WEBHOOK_SECRET"
|
|
1001
|
+
};
|
|
1002
|
+
return envVarMap[provider] || "WEBHOOK_SECRET";
|
|
1003
|
+
}
|
|
1004
|
+
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(
|
|
1005
|
+
"-H, --header <header>",
|
|
1006
|
+
"Add custom header (format: key:value)",
|
|
1007
|
+
(value, previous) => {
|
|
1008
|
+
const [key, ...valueParts] = value.split(":");
|
|
1009
|
+
const headerValue = valueParts.join(":");
|
|
1010
|
+
if (!key || !headerValue) {
|
|
1011
|
+
throw new Error("Header format should be key:value");
|
|
1012
|
+
}
|
|
1013
|
+
return (previous || []).concat([
|
|
1014
|
+
{ key: key.trim(), value: headerValue.trim() }
|
|
1015
|
+
]);
|
|
1016
|
+
},
|
|
1017
|
+
[]
|
|
1018
|
+
).option("-v, --verbose", "Show detailed request/response information").action(
|
|
1019
|
+
async (templateId, options) => {
|
|
1020
|
+
const manager = getTemplateManager();
|
|
1021
|
+
if (!templateId) {
|
|
1022
|
+
const spinner2 = ora2("Loading templates...").start();
|
|
338
1023
|
try {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
1024
|
+
const local = manager.listLocalTemplates();
|
|
1025
|
+
const remote = await manager.listRemoteTemplates();
|
|
1026
|
+
spinner2.stop();
|
|
1027
|
+
if (local.length === 0 && remote.length === 0) {
|
|
1028
|
+
console.log(chalk2.yellow("\n\u{1F4ED} No templates available."));
|
|
1029
|
+
console.log(
|
|
1030
|
+
chalk2.gray(
|
|
1031
|
+
" Download templates with: better-webhook templates download\n"
|
|
1032
|
+
)
|
|
343
1033
|
);
|
|
344
|
-
|
|
1034
|
+
return;
|
|
345
1035
|
}
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
1036
|
+
const choices = [];
|
|
1037
|
+
if (local.length > 0) {
|
|
1038
|
+
for (const t of local) {
|
|
1039
|
+
choices.push({
|
|
1040
|
+
title: `${t.id} ${chalk2.green("(local)")}`,
|
|
1041
|
+
description: `${t.metadata.provider} - ${t.metadata.event}`,
|
|
1042
|
+
value: t.id
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
355
1045
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1046
|
+
const remoteOnly = remote.filter((t) => !t.isDownloaded);
|
|
1047
|
+
for (const t of remoteOnly) {
|
|
1048
|
+
choices.push({
|
|
1049
|
+
title: `${t.metadata.id} ${chalk2.gray("(remote)")}`,
|
|
1050
|
+
description: `${t.metadata.provider} - ${t.metadata.event}`,
|
|
1051
|
+
value: `remote:${t.metadata.id}`
|
|
1052
|
+
});
|
|
361
1053
|
}
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
1054
|
+
const response = await prompts2({
|
|
1055
|
+
type: "select",
|
|
1056
|
+
name: "templateId",
|
|
1057
|
+
message: "Select a template to run:",
|
|
1058
|
+
choices
|
|
1059
|
+
});
|
|
1060
|
+
if (!response.templateId) {
|
|
1061
|
+
console.log(chalk2.yellow("Cancelled"));
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
templateId = response.templateId;
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
spinner2.fail("Failed to load templates");
|
|
1067
|
+
console.error(chalk2.red(error.message));
|
|
1068
|
+
process.exitCode = 1;
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (templateId?.startsWith("remote:")) {
|
|
1073
|
+
const remoteId = templateId.replace("remote:", "");
|
|
1074
|
+
const downloadSpinner = ora2(`Downloading ${remoteId}...`).start();
|
|
1075
|
+
try {
|
|
1076
|
+
await manager.downloadTemplate(remoteId);
|
|
1077
|
+
downloadSpinner.succeed(`Downloaded ${remoteId}`);
|
|
1078
|
+
templateId = remoteId;
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
downloadSpinner.fail(`Failed to download: ${error.message}`);
|
|
1081
|
+
process.exitCode = 1;
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
const localTemplate = manager.getLocalTemplate(templateId);
|
|
1086
|
+
if (!localTemplate) {
|
|
1087
|
+
console.log(chalk2.red(`
|
|
1088
|
+
\u274C Template not found: ${templateId}`));
|
|
1089
|
+
console.log(
|
|
1090
|
+
chalk2.gray(
|
|
1091
|
+
" Download it with: better-webhook templates download " + templateId + "\n"
|
|
1092
|
+
)
|
|
1093
|
+
);
|
|
1094
|
+
process.exitCode = 1;
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const targetUrl = options.url;
|
|
1098
|
+
let secret = options?.secret;
|
|
1099
|
+
if (!secret && localTemplate.metadata.provider) {
|
|
1100
|
+
const envVarName = getSecretEnvVarName(localTemplate.metadata.provider);
|
|
1101
|
+
secret = process.env[envVarName];
|
|
1102
|
+
}
|
|
1103
|
+
console.log(chalk2.bold("\n\u{1F680} Executing Webhook\n"));
|
|
1104
|
+
console.log(chalk2.gray(` Template: ${templateId}`));
|
|
1105
|
+
console.log(
|
|
1106
|
+
chalk2.gray(` Provider: ${localTemplate.metadata.provider}`)
|
|
1107
|
+
);
|
|
1108
|
+
console.log(chalk2.gray(` Event: ${localTemplate.metadata.event}`));
|
|
1109
|
+
console.log(chalk2.gray(` Target: ${targetUrl}`));
|
|
1110
|
+
if (secret) {
|
|
1111
|
+
console.log(chalk2.gray(` Signature: Will be generated`));
|
|
1112
|
+
} else {
|
|
1113
|
+
console.log(
|
|
1114
|
+
chalk2.yellow(
|
|
1115
|
+
` \u26A0\uFE0F No secret provided - signature will not be generated`
|
|
1116
|
+
)
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
console.log();
|
|
1120
|
+
const spinner = ora2("Sending webhook...").start();
|
|
1121
|
+
try {
|
|
1122
|
+
const result = await executeTemplate(localTemplate.template, {
|
|
1123
|
+
url: targetUrl,
|
|
1124
|
+
secret,
|
|
1125
|
+
headers: options?.header
|
|
1126
|
+
});
|
|
1127
|
+
spinner.stop();
|
|
1128
|
+
const statusColor = result.status >= 200 && result.status < 300 ? chalk2.green : result.status >= 400 ? chalk2.red : chalk2.yellow;
|
|
1129
|
+
console.log(chalk2.bold("\u{1F4E5} Response\n"));
|
|
1130
|
+
console.log(
|
|
1131
|
+
` Status: ${statusColor(`${result.status} ${result.statusText}`)}`
|
|
1132
|
+
);
|
|
1133
|
+
console.log(` Duration: ${chalk2.cyan(`${result.duration}ms`)}`);
|
|
1134
|
+
if (options?.verbose) {
|
|
1135
|
+
console.log(chalk2.bold("\n Headers:"));
|
|
1136
|
+
for (const [key, value] of Object.entries(result.headers)) {
|
|
1137
|
+
const headerValue = Array.isArray(value) ? value.join(", ") : value;
|
|
1138
|
+
console.log(chalk2.gray(` ${key}: ${headerValue}`));
|
|
369
1139
|
}
|
|
370
|
-
writeFileSync(destPath, JSON.stringify(json, null, 2));
|
|
371
|
-
console.log(`Downloaded ${templateName} -> .webhooks/${fileName}`);
|
|
372
|
-
} catch (e) {
|
|
373
|
-
console.error(`Error downloading ${templateName}: ${e.message}`);
|
|
374
1140
|
}
|
|
1141
|
+
if (result.json !== void 0) {
|
|
1142
|
+
console.log(chalk2.bold("\n Body:"));
|
|
1143
|
+
console.log(
|
|
1144
|
+
chalk2.gray(
|
|
1145
|
+
JSON.stringify(result.json, null, 2).split("\n").map((l) => ` ${l}`).join("\n")
|
|
1146
|
+
)
|
|
1147
|
+
);
|
|
1148
|
+
} else if (result.bodyText) {
|
|
1149
|
+
console.log(chalk2.bold("\n Body:"));
|
|
1150
|
+
const preview = result.bodyText.length > 500 ? result.bodyText.slice(0, 500) + "..." : result.bodyText;
|
|
1151
|
+
console.log(chalk2.gray(` ${preview}`));
|
|
1152
|
+
}
|
|
1153
|
+
console.log();
|
|
1154
|
+
if (result.status >= 200 && result.status < 300) {
|
|
1155
|
+
console.log(chalk2.green("\u2713 Webhook delivered successfully\n"));
|
|
1156
|
+
} else {
|
|
1157
|
+
console.log(
|
|
1158
|
+
chalk2.yellow(`\u26A0 Webhook delivered with status ${result.status}
|
|
1159
|
+
`)
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
spinner.fail("Request failed");
|
|
1164
|
+
console.error(chalk2.red(`
|
|
1165
|
+
\u274C ${error.message}
|
|
1166
|
+
`));
|
|
1167
|
+
process.exitCode = 1;
|
|
375
1168
|
}
|
|
376
1169
|
}
|
|
377
1170
|
);
|
|
378
|
-
var webhooks = new Command().name("webhooks").description("Manage and execute webhook definitions").addCommand(listCommand).addCommand(runCommand).addCommand(downloadCommand);
|
|
379
1171
|
|
|
380
1172
|
// src/commands/capture.ts
|
|
381
|
-
import { Command as
|
|
382
|
-
import
|
|
383
|
-
import { join as join5, resolve as resolve3 } from "path";
|
|
1173
|
+
import { Command as Command3 } from "commander";
|
|
1174
|
+
import chalk3 from "chalk";
|
|
384
1175
|
|
|
385
|
-
// src/capture.ts
|
|
386
|
-
import {
|
|
387
|
-
|
|
388
|
-
|
|
1176
|
+
// src/core/capture-server.ts
|
|
1177
|
+
import {
|
|
1178
|
+
createServer
|
|
1179
|
+
} from "http";
|
|
1180
|
+
import { WebSocketServer } from "ws";
|
|
1181
|
+
import {
|
|
1182
|
+
writeFileSync as writeFileSync2,
|
|
1183
|
+
mkdirSync as mkdirSync2,
|
|
1184
|
+
existsSync as existsSync2,
|
|
1185
|
+
readdirSync as readdirSync2,
|
|
1186
|
+
readFileSync as readFileSync2,
|
|
1187
|
+
unlinkSync as unlinkSync2
|
|
1188
|
+
} from "fs";
|
|
1189
|
+
import { join as join2 } from "path";
|
|
389
1190
|
import { randomUUID } from "crypto";
|
|
390
|
-
|
|
1191
|
+
import { homedir as homedir2 } from "os";
|
|
1192
|
+
var CaptureServer = class {
|
|
391
1193
|
server = null;
|
|
1194
|
+
wss = null;
|
|
392
1195
|
capturesDir;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1196
|
+
clients = /* @__PURE__ */ new Set();
|
|
1197
|
+
captureCount = 0;
|
|
1198
|
+
enableWebSocket;
|
|
1199
|
+
onCapture;
|
|
1200
|
+
constructor(options) {
|
|
1201
|
+
const capturesDir = typeof options === "string" ? options : options?.capturesDir;
|
|
1202
|
+
this.capturesDir = capturesDir || join2(homedir2(), ".better-webhook", "captures");
|
|
1203
|
+
this.enableWebSocket = typeof options === "object" ? options?.enableWebSocket !== false : true;
|
|
1204
|
+
this.onCapture = typeof options === "object" ? options?.onCapture : void 0;
|
|
1205
|
+
if (!existsSync2(this.capturesDir)) {
|
|
1206
|
+
mkdirSync2(this.capturesDir, { recursive: true });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Get the captures directory path
|
|
1211
|
+
*/
|
|
1212
|
+
getCapturesDir() {
|
|
1213
|
+
return this.capturesDir;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Start the capture server
|
|
1217
|
+
*/
|
|
1218
|
+
async start(port = 3001, host = "0.0.0.0") {
|
|
1219
|
+
return new Promise((resolve, reject) => {
|
|
1220
|
+
this.server = createServer((req, res) => this.handleRequest(req, res));
|
|
1221
|
+
if (this.enableWebSocket) {
|
|
1222
|
+
this.wss = new WebSocketServer({ server: this.server });
|
|
1223
|
+
this.wss.on("connection", (ws) => {
|
|
1224
|
+
this.clients.add(ws);
|
|
1225
|
+
console.log("\u{1F4E1} Dashboard connected via WebSocket");
|
|
1226
|
+
ws.on("close", () => {
|
|
1227
|
+
this.clients.delete(ws);
|
|
1228
|
+
console.log("\u{1F4E1} Dashboard disconnected");
|
|
1229
|
+
});
|
|
1230
|
+
ws.on("error", (error) => {
|
|
1231
|
+
console.error("WebSocket error:", error);
|
|
1232
|
+
this.clients.delete(ws);
|
|
1233
|
+
});
|
|
1234
|
+
this.sendToClient(ws, {
|
|
1235
|
+
type: "captures_updated",
|
|
1236
|
+
payload: {
|
|
1237
|
+
captures: this.listCaptures(),
|
|
1238
|
+
count: this.captureCount
|
|
414
1239
|
}
|
|
415
|
-
}
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
this.server.on("error", (err) => {
|
|
1244
|
+
if (err.code === "EADDRINUSE") {
|
|
1245
|
+
reject(new Error(`Port ${port} is already in use`));
|
|
1246
|
+
} else {
|
|
1247
|
+
reject(err);
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
this.server.listen(port, host, () => {
|
|
1251
|
+
const address = this.server?.address();
|
|
1252
|
+
const actualPort = typeof address === "object" ? address?.port || port : port;
|
|
1253
|
+
console.log(`
|
|
1254
|
+
\u{1F3A3} Webhook Capture Server`);
|
|
1255
|
+
console.log(
|
|
1256
|
+
` Listening on http://${host === "0.0.0.0" ? "localhost" : host}:${actualPort}`
|
|
416
1257
|
);
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (attempt >= maxAttempts) {
|
|
427
|
-
reject(
|
|
428
|
-
new Error(
|
|
429
|
-
`All ${maxAttempts} port attempts starting at ${startPort} are in use.`
|
|
430
|
-
)
|
|
431
|
-
);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
const nextPort = startPort + attempt;
|
|
435
|
-
tryListen(nextPort);
|
|
436
|
-
} else {
|
|
437
|
-
reject(err);
|
|
438
|
-
}
|
|
439
|
-
};
|
|
440
|
-
const onListening = () => {
|
|
441
|
-
this.server?.off("error", onError);
|
|
442
|
-
this.server?.off("listening", onListening);
|
|
443
|
-
const address = this.server?.address();
|
|
444
|
-
const actualPort = address?.port ?? portToTry;
|
|
445
|
-
console.log(
|
|
446
|
-
`\u{1F3A3} Webhook capture server running on http://localhost:${actualPort}`
|
|
447
|
-
);
|
|
448
|
-
console.log(
|
|
449
|
-
`\u{1F4C1} Captured webhooks will be saved to: ${this.capturesDir}`
|
|
450
|
-
);
|
|
451
|
-
console.log(
|
|
452
|
-
"\u{1F4A1} Send webhooks to any path on this server to capture them"
|
|
453
|
-
);
|
|
454
|
-
console.log("\u23F9\uFE0F Press Ctrl+C to stop the server");
|
|
455
|
-
resolve4(actualPort);
|
|
456
|
-
};
|
|
457
|
-
this.server.on("error", onError);
|
|
458
|
-
this.server.on("listening", onListening);
|
|
459
|
-
this.server.listen(portToTry);
|
|
460
|
-
};
|
|
461
|
-
tryListen(startPort === 0 ? 0 : startPort);
|
|
1258
|
+
console.log(` \u{1F4C1} Captures saved to: ${this.capturesDir}`);
|
|
1259
|
+
console.log(` \u{1F4A1} Send webhooks to any path to capture them`);
|
|
1260
|
+
if (this.enableWebSocket) {
|
|
1261
|
+
console.log(` \u{1F310} WebSocket available for real-time updates`);
|
|
1262
|
+
}
|
|
1263
|
+
console.log(` \u23F9\uFE0F Press Ctrl+C to stop
|
|
1264
|
+
`);
|
|
1265
|
+
resolve(actualPort);
|
|
1266
|
+
});
|
|
462
1267
|
});
|
|
463
1268
|
}
|
|
464
|
-
|
|
465
|
-
|
|
1269
|
+
/**
|
|
1270
|
+
* Stop the capture server
|
|
1271
|
+
*/
|
|
1272
|
+
async stop() {
|
|
1273
|
+
return new Promise((resolve) => {
|
|
1274
|
+
for (const client of this.clients) {
|
|
1275
|
+
client.close();
|
|
1276
|
+
}
|
|
1277
|
+
this.clients.clear();
|
|
1278
|
+
if (this.wss) {
|
|
1279
|
+
this.wss.close();
|
|
1280
|
+
this.wss = null;
|
|
1281
|
+
}
|
|
466
1282
|
if (this.server) {
|
|
467
1283
|
this.server.close(() => {
|
|
468
|
-
console.log("\u{
|
|
469
|
-
|
|
1284
|
+
console.log("\n\u{1F6D1} Capture server stopped");
|
|
1285
|
+
resolve();
|
|
470
1286
|
});
|
|
471
1287
|
} else {
|
|
472
|
-
|
|
1288
|
+
resolve();
|
|
473
1289
|
}
|
|
474
1290
|
});
|
|
475
1291
|
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Handle incoming HTTP requests
|
|
1294
|
+
*/
|
|
476
1295
|
async handleRequest(req, res) {
|
|
1296
|
+
if (req.headers.upgrade?.toLowerCase() === "websocket") {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
477
1299
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
478
|
-
const id =
|
|
1300
|
+
const id = randomUUID();
|
|
479
1301
|
const url = req.url || "/";
|
|
480
1302
|
const urlParts = new URL(url, `http://${req.headers.host || "localhost"}`);
|
|
481
1303
|
const query = {};
|
|
@@ -491,299 +1313,659 @@ var WebhookCaptureServer = class {
|
|
|
491
1313
|
}
|
|
492
1314
|
}
|
|
493
1315
|
const chunks = [];
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
});
|
|
1316
|
+
for await (const chunk of req) {
|
|
1317
|
+
chunks.push(chunk);
|
|
1318
|
+
}
|
|
498
1319
|
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
499
|
-
let
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
1320
|
+
let body = null;
|
|
1321
|
+
const contentType = req.headers["content-type"] || "";
|
|
1322
|
+
if (rawBody) {
|
|
1323
|
+
if (contentType.includes("application/json")) {
|
|
1324
|
+
try {
|
|
1325
|
+
body = JSON.parse(rawBody);
|
|
1326
|
+
} catch {
|
|
1327
|
+
body = rawBody;
|
|
1328
|
+
}
|
|
1329
|
+
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
1330
|
+
body = Object.fromEntries(new URLSearchParams(rawBody));
|
|
1331
|
+
} else {
|
|
1332
|
+
body = rawBody;
|
|
1333
|
+
}
|
|
504
1334
|
}
|
|
1335
|
+
const provider = this.detectProvider(req.headers);
|
|
505
1336
|
const captured = {
|
|
506
1337
|
id,
|
|
507
1338
|
timestamp,
|
|
508
1339
|
method: req.method || "GET",
|
|
509
|
-
url
|
|
1340
|
+
url,
|
|
1341
|
+
path: urlParts.pathname,
|
|
510
1342
|
headers: req.headers,
|
|
511
|
-
body
|
|
1343
|
+
body,
|
|
512
1344
|
rawBody,
|
|
513
|
-
query
|
|
1345
|
+
query,
|
|
1346
|
+
provider,
|
|
1347
|
+
contentType: contentType || void 0,
|
|
1348
|
+
contentLength: rawBody.length
|
|
514
1349
|
};
|
|
515
|
-
const
|
|
516
|
-
const
|
|
1350
|
+
const date = new Date(timestamp);
|
|
1351
|
+
const dateStr = date.toISOString().split("T")[0];
|
|
1352
|
+
const timeStr = date.toISOString().split("T")[1]?.replace(/[:.]/g, "-").slice(0, 8);
|
|
1353
|
+
const filename = `${dateStr}_${timeStr}_${id.slice(0, 8)}.json`;
|
|
1354
|
+
const filepath = join2(this.capturesDir, filename);
|
|
517
1355
|
try {
|
|
518
1356
|
writeFileSync2(filepath, JSON.stringify(captured, null, 2));
|
|
1357
|
+
this.captureCount++;
|
|
1358
|
+
const providerStr = provider ? ` [${provider}]` : "";
|
|
519
1359
|
console.log(
|
|
520
|
-
`\u{1F4E6}
|
|
1360
|
+
`\u{1F4E6} ${req.method} ${urlParts.pathname}${providerStr} -> ${filename}`
|
|
521
1361
|
);
|
|
1362
|
+
this.onCapture?.({ file: filename, capture: captured });
|
|
1363
|
+
if (this.enableWebSocket) {
|
|
1364
|
+
this.broadcast({
|
|
1365
|
+
type: "capture",
|
|
1366
|
+
payload: {
|
|
1367
|
+
file: filename,
|
|
1368
|
+
capture: captured
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
522
1372
|
} catch (error) {
|
|
523
|
-
console.error(`\u274C Failed to save capture
|
|
1373
|
+
console.error(`\u274C Failed to save capture:`, error);
|
|
524
1374
|
}
|
|
525
1375
|
res.statusCode = 200;
|
|
526
1376
|
res.setHeader("Content-Type", "application/json");
|
|
1377
|
+
res.setHeader("X-Capture-Id", id);
|
|
527
1378
|
res.end(
|
|
528
|
-
JSON.stringify(
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
null,
|
|
536
|
-
2
|
|
537
|
-
)
|
|
1379
|
+
JSON.stringify({
|
|
1380
|
+
success: true,
|
|
1381
|
+
message: "Webhook captured successfully",
|
|
1382
|
+
id,
|
|
1383
|
+
timestamp,
|
|
1384
|
+
file: filename
|
|
1385
|
+
})
|
|
538
1386
|
);
|
|
539
1387
|
}
|
|
540
|
-
|
|
1388
|
+
/**
|
|
1389
|
+
* Detect webhook provider from headers
|
|
1390
|
+
*/
|
|
1391
|
+
detectProvider(headers) {
|
|
1392
|
+
if (headers["stripe-signature"]) {
|
|
1393
|
+
return "stripe";
|
|
1394
|
+
}
|
|
1395
|
+
if (headers["x-github-event"] || headers["x-hub-signature-256"]) {
|
|
1396
|
+
return "github";
|
|
1397
|
+
}
|
|
1398
|
+
if (headers["x-ragie-delivery"]) {
|
|
1399
|
+
return "ragie";
|
|
1400
|
+
}
|
|
1401
|
+
if (headers["x-shopify-hmac-sha256"] || headers["x-shopify-topic"]) {
|
|
1402
|
+
return "shopify";
|
|
1403
|
+
}
|
|
1404
|
+
if (headers["x-twilio-signature"]) {
|
|
1405
|
+
return "twilio";
|
|
1406
|
+
}
|
|
1407
|
+
if (headers["x-twilio-email-event-webhook-signature"]) {
|
|
1408
|
+
return "sendgrid";
|
|
1409
|
+
}
|
|
1410
|
+
if (headers["x-slack-signature"]) {
|
|
1411
|
+
return "slack";
|
|
1412
|
+
}
|
|
1413
|
+
if (headers["x-signature-ed25519"]) {
|
|
1414
|
+
return "discord";
|
|
1415
|
+
}
|
|
1416
|
+
if (headers["linear-signature"]) {
|
|
1417
|
+
return "linear";
|
|
1418
|
+
}
|
|
1419
|
+
if (headers["svix-signature"]) {
|
|
1420
|
+
return "clerk";
|
|
1421
|
+
}
|
|
1422
|
+
return void 0;
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Broadcast message to all connected WebSocket clients
|
|
1426
|
+
*/
|
|
1427
|
+
broadcast(message) {
|
|
1428
|
+
const data = JSON.stringify(message);
|
|
1429
|
+
for (const client of this.clients) {
|
|
1430
|
+
if (client.readyState === 1) {
|
|
1431
|
+
client.send(data);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Send message to a specific client
|
|
1437
|
+
*/
|
|
1438
|
+
sendToClient(client, message) {
|
|
1439
|
+
if (client.readyState === 1) {
|
|
1440
|
+
client.send(JSON.stringify(message));
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* List all captured webhooks
|
|
1445
|
+
*/
|
|
1446
|
+
listCaptures(limit = 100) {
|
|
1447
|
+
if (!existsSync2(this.capturesDir)) {
|
|
1448
|
+
return [];
|
|
1449
|
+
}
|
|
1450
|
+
const files = readdirSync2(this.capturesDir).filter((f) => f.endsWith(".json")).sort().reverse().slice(0, limit);
|
|
1451
|
+
const captures2 = [];
|
|
1452
|
+
for (const file of files) {
|
|
1453
|
+
try {
|
|
1454
|
+
const content = readFileSync2(join2(this.capturesDir, file), "utf-8");
|
|
1455
|
+
const capture2 = JSON.parse(content);
|
|
1456
|
+
captures2.push({ file, capture: capture2 });
|
|
1457
|
+
} catch {
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return captures2;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Get a specific capture by ID
|
|
1464
|
+
*/
|
|
1465
|
+
getCapture(captureId) {
|
|
1466
|
+
const captures2 = this.listCaptures(1e3);
|
|
1467
|
+
return captures2.find(
|
|
1468
|
+
(c) => c.capture.id === captureId || c.file.includes(captureId)
|
|
1469
|
+
) || null;
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Delete a capture
|
|
1473
|
+
*/
|
|
1474
|
+
deleteCapture(captureId) {
|
|
1475
|
+
const capture2 = this.getCapture(captureId);
|
|
1476
|
+
if (!capture2) {
|
|
1477
|
+
return false;
|
|
1478
|
+
}
|
|
541
1479
|
try {
|
|
542
|
-
|
|
1480
|
+
unlinkSync2(join2(this.capturesDir, capture2.file));
|
|
1481
|
+
return true;
|
|
543
1482
|
} catch {
|
|
544
|
-
return
|
|
1483
|
+
return false;
|
|
545
1484
|
}
|
|
546
1485
|
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Get connected client count
|
|
1488
|
+
*/
|
|
1489
|
+
getClientCount() {
|
|
1490
|
+
return this.clients.size;
|
|
1491
|
+
}
|
|
547
1492
|
};
|
|
548
1493
|
|
|
549
|
-
// src/
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
1494
|
+
// src/commands/capture.ts
|
|
1495
|
+
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) => {
|
|
1496
|
+
const port = parseInt(options.port, 10);
|
|
1497
|
+
if (isNaN(port) || port < 0 || port > 65535) {
|
|
1498
|
+
console.error(chalk3.red("Invalid port number"));
|
|
1499
|
+
process.exitCode = 1;
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const server = new CaptureServer();
|
|
1503
|
+
try {
|
|
1504
|
+
await server.start(port, options.host);
|
|
1505
|
+
const shutdown = async () => {
|
|
1506
|
+
await server.stop();
|
|
1507
|
+
process.exit(0);
|
|
1508
|
+
};
|
|
1509
|
+
process.on("SIGINT", shutdown);
|
|
1510
|
+
process.on("SIGTERM", shutdown);
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
console.error(chalk3.red(`Failed to start server: ${error.message}`));
|
|
1513
|
+
process.exitCode = 1;
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
// src/commands/captures.ts
|
|
1518
|
+
import { Command as Command4 } from "commander";
|
|
1519
|
+
import chalk4 from "chalk";
|
|
1520
|
+
import prompts3 from "prompts";
|
|
1521
|
+
|
|
1522
|
+
// src/core/replay-engine.ts
|
|
1523
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync3, unlinkSync as unlinkSync3 } from "fs";
|
|
1524
|
+
import { join as join3 } from "path";
|
|
1525
|
+
import { homedir as homedir3 } from "os";
|
|
1526
|
+
var ReplayEngine = class {
|
|
1527
|
+
capturesDir;
|
|
553
1528
|
constructor(capturesDir) {
|
|
554
|
-
this.capturesDir = capturesDir;
|
|
1529
|
+
this.capturesDir = capturesDir || join3(homedir3(), ".better-webhook", "captures");
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Get the captures directory
|
|
1533
|
+
*/
|
|
1534
|
+
getCapturesDir() {
|
|
1535
|
+
return this.capturesDir;
|
|
555
1536
|
}
|
|
556
1537
|
/**
|
|
557
1538
|
* List all captured webhooks
|
|
558
1539
|
*/
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const files = listJsonFiles(this.capturesDir).sort().reverse();
|
|
562
|
-
return files.map((file) => ({
|
|
563
|
-
file,
|
|
564
|
-
capture: this.loadCapture(join4(this.capturesDir, file))
|
|
565
|
-
}));
|
|
566
|
-
} catch {
|
|
1540
|
+
listCaptures(limit = 100) {
|
|
1541
|
+
if (!existsSync3(this.capturesDir)) {
|
|
567
1542
|
return [];
|
|
568
1543
|
}
|
|
1544
|
+
const files = readdirSync3(this.capturesDir).filter((f) => f.endsWith(".json")).sort().reverse().slice(0, limit);
|
|
1545
|
+
const captures2 = [];
|
|
1546
|
+
for (const file of files) {
|
|
1547
|
+
try {
|
|
1548
|
+
const content = readFileSync3(join3(this.capturesDir, file), "utf-8");
|
|
1549
|
+
const capture2 = JSON.parse(content);
|
|
1550
|
+
captures2.push({ file, capture: capture2 });
|
|
1551
|
+
} catch {
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
return captures2;
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Get a specific capture by ID or partial filename
|
|
1558
|
+
*/
|
|
1559
|
+
getCapture(captureId) {
|
|
1560
|
+
const captures2 = this.listCaptures(1e3);
|
|
1561
|
+
let found = captures2.find((c) => c.capture.id === captureId);
|
|
1562
|
+
if (found) return found;
|
|
1563
|
+
found = captures2.find((c) => c.file.includes(captureId));
|
|
1564
|
+
if (found) return found;
|
|
1565
|
+
found = captures2.find((c) => c.capture.id.startsWith(captureId));
|
|
1566
|
+
return found || null;
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Replay a captured webhook
|
|
1570
|
+
*/
|
|
1571
|
+
async replay(captureId, options) {
|
|
1572
|
+
const captureFile = this.getCapture(captureId);
|
|
1573
|
+
if (!captureFile) {
|
|
1574
|
+
throw new Error(`Capture not found: ${captureId}`);
|
|
1575
|
+
}
|
|
1576
|
+
const { capture: capture2 } = captureFile;
|
|
1577
|
+
const headers = [];
|
|
1578
|
+
const skipHeaders = [
|
|
1579
|
+
"host",
|
|
1580
|
+
"content-length",
|
|
1581
|
+
"connection",
|
|
1582
|
+
"accept-encoding"
|
|
1583
|
+
];
|
|
1584
|
+
for (const [key, value] of Object.entries(capture2.headers)) {
|
|
1585
|
+
if (!skipHeaders.includes(key.toLowerCase())) {
|
|
1586
|
+
const headerValue = Array.isArray(value) ? value.join(", ") : value;
|
|
1587
|
+
if (headerValue) {
|
|
1588
|
+
headers.push({ key, value: headerValue });
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (options.headers) {
|
|
1593
|
+
for (const h of options.headers) {
|
|
1594
|
+
const existingIdx = headers.findIndex(
|
|
1595
|
+
(eh) => eh.key.toLowerCase() === h.key.toLowerCase()
|
|
1596
|
+
);
|
|
1597
|
+
if (existingIdx >= 0) {
|
|
1598
|
+
headers[existingIdx] = h;
|
|
1599
|
+
} else {
|
|
1600
|
+
headers.push(h);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
const body = capture2.rawBody || capture2.body;
|
|
1605
|
+
return executeWebhook({
|
|
1606
|
+
url: options.targetUrl,
|
|
1607
|
+
method: options.method || capture2.method,
|
|
1608
|
+
headers,
|
|
1609
|
+
body
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Convert a capture to a template
|
|
1614
|
+
*/
|
|
1615
|
+
captureToTemplate(captureId, options) {
|
|
1616
|
+
const captureFile = this.getCapture(captureId);
|
|
1617
|
+
if (!captureFile) {
|
|
1618
|
+
throw new Error(`Capture not found: ${captureId}`);
|
|
1619
|
+
}
|
|
1620
|
+
const { capture: capture2 } = captureFile;
|
|
1621
|
+
const skipHeaders = [
|
|
1622
|
+
"host",
|
|
1623
|
+
"content-length",
|
|
1624
|
+
"connection",
|
|
1625
|
+
"accept-encoding",
|
|
1626
|
+
"stripe-signature",
|
|
1627
|
+
"x-hub-signature-256",
|
|
1628
|
+
"x-hub-signature",
|
|
1629
|
+
"x-shopify-hmac-sha256",
|
|
1630
|
+
"x-twilio-signature",
|
|
1631
|
+
"x-slack-signature",
|
|
1632
|
+
"svix-signature",
|
|
1633
|
+
"linear-signature"
|
|
1634
|
+
];
|
|
1635
|
+
const headers = [];
|
|
1636
|
+
for (const [key, value] of Object.entries(capture2.headers)) {
|
|
1637
|
+
if (!skipHeaders.includes(key.toLowerCase())) {
|
|
1638
|
+
const headerValue = Array.isArray(value) ? value.join(", ") : value;
|
|
1639
|
+
if (headerValue) {
|
|
1640
|
+
headers.push({ key, value: headerValue });
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
let body;
|
|
1645
|
+
if (capture2.body) {
|
|
1646
|
+
body = capture2.body;
|
|
1647
|
+
} else if (capture2.rawBody) {
|
|
1648
|
+
try {
|
|
1649
|
+
body = JSON.parse(capture2.rawBody);
|
|
1650
|
+
} catch {
|
|
1651
|
+
body = capture2.rawBody;
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
return {
|
|
1655
|
+
url: options?.url || `http://localhost:3000${capture2.path}`,
|
|
1656
|
+
method: capture2.method,
|
|
1657
|
+
headers,
|
|
1658
|
+
body,
|
|
1659
|
+
provider: capture2.provider,
|
|
1660
|
+
description: `Captured ${capture2.provider || "webhook"} at ${capture2.timestamp}`
|
|
1661
|
+
};
|
|
569
1662
|
}
|
|
570
1663
|
/**
|
|
571
|
-
*
|
|
1664
|
+
* Get a summary of a capture
|
|
572
1665
|
*/
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
if (
|
|
576
|
-
|
|
577
|
-
} else {
|
|
578
|
-
const files = listJsonFiles(this.capturesDir).filter(
|
|
579
|
-
(f) => f.includes(filePathOrId)
|
|
580
|
-
);
|
|
581
|
-
if (files.length === 0) {
|
|
582
|
-
throw new Error(`No capture found with ID: ${filePathOrId}`);
|
|
583
|
-
}
|
|
584
|
-
if (files.length > 1) {
|
|
585
|
-
throw new Error(
|
|
586
|
-
`Multiple captures found with ID ${filePathOrId}: ${files.join(", ")}`
|
|
587
|
-
);
|
|
588
|
-
}
|
|
589
|
-
filepath = join4(this.capturesDir, files[0] ?? "");
|
|
1666
|
+
getCaptureSummary(captureId) {
|
|
1667
|
+
const captureFile = this.getCapture(captureId);
|
|
1668
|
+
if (!captureFile) {
|
|
1669
|
+
return "Capture not found";
|
|
590
1670
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1671
|
+
const { capture: capture2 } = captureFile;
|
|
1672
|
+
const lines = [];
|
|
1673
|
+
lines.push(`ID: ${capture2.id}`);
|
|
1674
|
+
lines.push(`Timestamp: ${new Date(capture2.timestamp).toLocaleString()}`);
|
|
1675
|
+
lines.push(`Method: ${capture2.method}`);
|
|
1676
|
+
lines.push(`Path: ${capture2.path}`);
|
|
1677
|
+
if (capture2.provider) {
|
|
1678
|
+
lines.push(`Provider: ${capture2.provider}`);
|
|
598
1679
|
}
|
|
1680
|
+
lines.push(`Content-Type: ${capture2.contentType || "unknown"}`);
|
|
1681
|
+
lines.push(`Body Size: ${capture2.contentLength || 0} bytes`);
|
|
1682
|
+
const headerCount = Object.keys(capture2.headers).length;
|
|
1683
|
+
lines.push(`Headers: ${headerCount}`);
|
|
1684
|
+
return lines.join("\n");
|
|
599
1685
|
}
|
|
600
1686
|
/**
|
|
601
|
-
*
|
|
1687
|
+
* Search captures
|
|
602
1688
|
*/
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
};
|
|
611
|
-
console.log(`\u{1F504} Replaying webhook ${capture2.id} (${capture2.timestamp})`);
|
|
612
|
-
console.log(` Method: ${webhookDef.method}`);
|
|
613
|
-
console.log(` URL: ${webhookDef.url}`);
|
|
614
|
-
console.log(` Original: ${capture2.method} ${capture2.url}`);
|
|
615
|
-
try {
|
|
616
|
-
const result = await executeWebhook(webhookDef);
|
|
617
|
-
return result;
|
|
618
|
-
} catch (error) {
|
|
619
|
-
throw new Error(`Replay failed: ${error.message}`);
|
|
620
|
-
}
|
|
1689
|
+
searchCaptures(query) {
|
|
1690
|
+
const queryLower = query.toLowerCase();
|
|
1691
|
+
const captures2 = this.listCaptures(1e3);
|
|
1692
|
+
return captures2.filter((c) => {
|
|
1693
|
+
const { capture: capture2 } = c;
|
|
1694
|
+
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);
|
|
1695
|
+
});
|
|
621
1696
|
}
|
|
622
1697
|
/**
|
|
623
|
-
*
|
|
1698
|
+
* Get captures by provider
|
|
624
1699
|
*/
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
return
|
|
628
|
-
url: templateUrl || "http://localhost:3000/webhook",
|
|
629
|
-
method: capture2.method,
|
|
630
|
-
headers: this.convertHeaders(capture2.headers),
|
|
631
|
-
body: capture2.body
|
|
632
|
-
};
|
|
1700
|
+
getCapturesByProvider(provider) {
|
|
1701
|
+
const captures2 = this.listCaptures(1e3);
|
|
1702
|
+
return captures2.filter((c) => c.capture.provider === provider);
|
|
633
1703
|
}
|
|
634
1704
|
/**
|
|
635
|
-
*
|
|
1705
|
+
* Delete a specific capture by ID
|
|
1706
|
+
* @returns true if deleted, false if not found
|
|
636
1707
|
*/
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
"x-forwarded-proto"
|
|
648
|
-
];
|
|
649
|
-
if (skipHeaders.includes(key.toLowerCase())) {
|
|
650
|
-
continue;
|
|
651
|
-
}
|
|
652
|
-
if (Array.isArray(value)) {
|
|
653
|
-
if (value.length === 1) {
|
|
654
|
-
result.push({ key, value: value[0] ?? "" });
|
|
655
|
-
} else {
|
|
656
|
-
result.push({ key, value: value.join(", ") });
|
|
657
|
-
}
|
|
658
|
-
} else {
|
|
659
|
-
result.push({ key, value });
|
|
660
|
-
}
|
|
1708
|
+
deleteCapture(captureId) {
|
|
1709
|
+
const captureFile = this.getCapture(captureId);
|
|
1710
|
+
if (!captureFile) {
|
|
1711
|
+
return false;
|
|
1712
|
+
}
|
|
1713
|
+
try {
|
|
1714
|
+
unlinkSync3(join3(this.capturesDir, captureFile.file));
|
|
1715
|
+
return true;
|
|
1716
|
+
} catch {
|
|
1717
|
+
return false;
|
|
661
1718
|
}
|
|
662
|
-
return result;
|
|
663
1719
|
}
|
|
664
1720
|
/**
|
|
665
|
-
*
|
|
1721
|
+
* Delete all captures
|
|
1722
|
+
* @returns Number of captures deleted
|
|
666
1723
|
*/
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1724
|
+
deleteAllCaptures() {
|
|
1725
|
+
if (!existsSync3(this.capturesDir)) {
|
|
1726
|
+
return 0;
|
|
1727
|
+
}
|
|
1728
|
+
const files = readdirSync3(this.capturesDir).filter(
|
|
1729
|
+
(f) => f.endsWith(".json")
|
|
1730
|
+
);
|
|
1731
|
+
let deleted = 0;
|
|
1732
|
+
for (const file of files) {
|
|
1733
|
+
try {
|
|
1734
|
+
unlinkSync3(join3(this.capturesDir, file));
|
|
1735
|
+
deleted++;
|
|
1736
|
+
} catch {
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return deleted;
|
|
681
1740
|
}
|
|
682
1741
|
};
|
|
1742
|
+
var instance2 = null;
|
|
1743
|
+
function getReplayEngine(capturesDir) {
|
|
1744
|
+
if (!instance2) {
|
|
1745
|
+
instance2 = new ReplayEngine(capturesDir);
|
|
1746
|
+
}
|
|
1747
|
+
return instance2;
|
|
1748
|
+
}
|
|
683
1749
|
|
|
684
|
-
// src/commands/
|
|
685
|
-
var listCommand2 = new
|
|
686
|
-
const cwd = process.cwd();
|
|
687
|
-
const capturesDir = findCapturesDir(cwd);
|
|
688
|
-
const replayer = new WebhookReplayer(capturesDir);
|
|
689
|
-
const captures = replayer.listCaptured();
|
|
1750
|
+
// src/commands/captures.ts
|
|
1751
|
+
var listCommand2 = new Command4().name("list").alias("ls").description("List captured webhooks").option("-l, --limit <limit>", "Maximum number of captures to show", "20").option("-p, --provider <provider>", "Filter by provider").action((options) => {
|
|
690
1752
|
const limit = parseInt(options.limit, 10);
|
|
691
|
-
if (
|
|
692
|
-
console.error("Invalid
|
|
1753
|
+
if (isNaN(limit) || limit <= 0) {
|
|
1754
|
+
console.error(chalk4.red("Invalid limit value"));
|
|
693
1755
|
process.exitCode = 1;
|
|
694
1756
|
return;
|
|
695
1757
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1758
|
+
const engine = getReplayEngine();
|
|
1759
|
+
let captures2 = engine.listCaptures(limit);
|
|
1760
|
+
if (options.provider) {
|
|
1761
|
+
captures2 = captures2.filter(
|
|
1762
|
+
(c) => c.capture.provider?.toLowerCase() === options.provider?.toLowerCase()
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
if (captures2.length === 0) {
|
|
1766
|
+
console.log(chalk4.yellow("\n\u{1F4ED} No captured webhooks found."));
|
|
1767
|
+
console.log(
|
|
1768
|
+
chalk4.gray(" Start capturing with: better-webhook capture\n")
|
|
1769
|
+
);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
console.log(chalk4.bold("\n\u{1F4E6} Captured Webhooks\n"));
|
|
1773
|
+
for (const { file, capture: capture2 } of captures2) {
|
|
1774
|
+
const date = new Date(capture2.timestamp).toLocaleString();
|
|
1775
|
+
const provider = capture2.provider ? chalk4.cyan(`[${capture2.provider}]`) : chalk4.gray("[unknown]");
|
|
1776
|
+
const size = capture2.contentLength || capture2.rawBody?.length || 0;
|
|
1777
|
+
console.log(` ${chalk4.white(capture2.id.slice(0, 8))} ${provider}`);
|
|
1778
|
+
console.log(chalk4.gray(` ${capture2.method} ${capture2.path}`));
|
|
1779
|
+
console.log(chalk4.gray(` ${date} | ${size} bytes`));
|
|
1780
|
+
console.log(chalk4.gray(` File: ${file}`));
|
|
1781
|
+
console.log();
|
|
1782
|
+
}
|
|
1783
|
+
console.log(chalk4.gray(` Showing ${captures2.length} captures`));
|
|
1784
|
+
console.log(chalk4.gray(` Storage: ${engine.getCapturesDir()}
|
|
1785
|
+
`));
|
|
1786
|
+
});
|
|
1787
|
+
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) => {
|
|
1788
|
+
const engine = getReplayEngine();
|
|
1789
|
+
const captureFile = engine.getCapture(captureId);
|
|
1790
|
+
if (!captureFile) {
|
|
1791
|
+
console.log(chalk4.red(`
|
|
1792
|
+
\u274C Capture not found: ${captureId}
|
|
1793
|
+
`));
|
|
1794
|
+
process.exitCode = 1;
|
|
699
1795
|
return;
|
|
700
1796
|
}
|
|
1797
|
+
const { capture: capture2 } = captureFile;
|
|
1798
|
+
console.log(chalk4.bold("\n\u{1F4CB} Capture Details\n"));
|
|
1799
|
+
console.log(` ${chalk4.gray("ID:")} ${capture2.id}`);
|
|
1800
|
+
console.log(` ${chalk4.gray("File:")} ${captureFile.file}`);
|
|
701
1801
|
console.log(
|
|
702
|
-
|
|
703
|
-
`
|
|
1802
|
+
` ${chalk4.gray("Timestamp:")} ${new Date(capture2.timestamp).toLocaleString()}`
|
|
704
1803
|
);
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
console.log(` ${capture2.method} ${capture2.url} | ${bodySize} bytes`);
|
|
710
|
-
console.log(` \u{1F4C4} ${file}
|
|
711
|
-
`);
|
|
712
|
-
});
|
|
713
|
-
if (captures.length > limit) {
|
|
1804
|
+
console.log(` ${chalk4.gray("Method:")} ${capture2.method}`);
|
|
1805
|
+
console.log(` ${chalk4.gray("Path:")} ${capture2.path}`);
|
|
1806
|
+
console.log(` ${chalk4.gray("URL:")} ${capture2.url}`);
|
|
1807
|
+
if (capture2.provider) {
|
|
714
1808
|
console.log(
|
|
715
|
-
|
|
1809
|
+
` ${chalk4.gray("Provider:")} ${chalk4.cyan(capture2.provider)}`
|
|
716
1810
|
);
|
|
717
1811
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1812
|
+
console.log(
|
|
1813
|
+
` ${chalk4.gray("Content-Type:")} ${capture2.contentType || "unknown"}`
|
|
1814
|
+
);
|
|
1815
|
+
console.log(
|
|
1816
|
+
` ${chalk4.gray("Content-Length:")} ${capture2.contentLength || 0} bytes`
|
|
1817
|
+
);
|
|
1818
|
+
const queryKeys = Object.keys(capture2.query);
|
|
1819
|
+
if (queryKeys.length > 0) {
|
|
1820
|
+
console.log(chalk4.bold("\n Query Parameters:"));
|
|
1821
|
+
for (const [key, value] of Object.entries(capture2.query)) {
|
|
1822
|
+
const queryValue = Array.isArray(value) ? value.join(", ") : value;
|
|
1823
|
+
console.log(chalk4.gray(` ${key}: ${queryValue}`));
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
console.log(chalk4.bold("\n Headers:"));
|
|
1827
|
+
for (const [key, value] of Object.entries(capture2.headers)) {
|
|
1828
|
+
const headerValue = Array.isArray(value) ? value.join(", ") : value;
|
|
1829
|
+
const display = headerValue.length > 80 ? headerValue.slice(0, 80) + "..." : headerValue;
|
|
1830
|
+
console.log(chalk4.gray(` ${key}: ${display}`));
|
|
1831
|
+
}
|
|
1832
|
+
if (options.body && capture2.body) {
|
|
1833
|
+
console.log(chalk4.bold("\n Body:"));
|
|
1834
|
+
if (typeof capture2.body === "object") {
|
|
740
1835
|
console.log(
|
|
741
|
-
|
|
1836
|
+
chalk4.gray(
|
|
1837
|
+
JSON.stringify(capture2.body, null, 2).split("\n").map((l) => ` ${l}`).join("\n")
|
|
1838
|
+
)
|
|
742
1839
|
);
|
|
743
|
-
|
|
744
|
-
console.log(
|
|
745
|
-
} catch (error) {
|
|
746
|
-
console.error("\u274C Template generation failed:", error.message);
|
|
747
|
-
process.exitCode = 1;
|
|
1840
|
+
} else {
|
|
1841
|
+
console.log(chalk4.gray(` ${capture2.body}`));
|
|
748
1842
|
}
|
|
1843
|
+
} else if (capture2.body) {
|
|
1844
|
+
console.log(chalk4.bold("\n Body:"));
|
|
1845
|
+
const preview = JSON.stringify(capture2.body).slice(0, 200);
|
|
1846
|
+
console.log(
|
|
1847
|
+
chalk4.gray(` ${preview}${preview.length >= 200 ? "..." : ""}`)
|
|
1848
|
+
);
|
|
1849
|
+
console.log(chalk4.gray(" Use --body to see full content"));
|
|
749
1850
|
}
|
|
750
|
-
);
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
|
|
1851
|
+
console.log();
|
|
1852
|
+
});
|
|
1853
|
+
var searchCommand2 = new Command4().name("search").argument("<query>", "Search query").description("Search captures by path, method, or provider").action((query) => {
|
|
1854
|
+
const engine = getReplayEngine();
|
|
1855
|
+
const results = engine.searchCaptures(query);
|
|
1856
|
+
if (results.length === 0) {
|
|
1857
|
+
console.log(chalk4.yellow(`
|
|
1858
|
+
\u{1F4ED} No captures found for: "${query}"
|
|
1859
|
+
`));
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
console.log(chalk4.bold(`
|
|
1863
|
+
\u{1F50D} Search Results for "${query}"
|
|
1864
|
+
`));
|
|
1865
|
+
for (const { file, capture: capture2 } of results) {
|
|
1866
|
+
const date = new Date(capture2.timestamp).toLocaleString();
|
|
1867
|
+
const provider = capture2.provider ? chalk4.cyan(`[${capture2.provider}]`) : "";
|
|
1868
|
+
console.log(` ${chalk4.white(capture2.id.slice(0, 8))} ${provider}`);
|
|
1869
|
+
console.log(chalk4.gray(` ${capture2.method} ${capture2.path}`));
|
|
1870
|
+
console.log(chalk4.gray(` ${date}`));
|
|
1871
|
+
console.log();
|
|
1872
|
+
}
|
|
1873
|
+
console.log(chalk4.gray(` Found: ${results.length} captures
|
|
1874
|
+
`));
|
|
1875
|
+
});
|
|
1876
|
+
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) => {
|
|
1877
|
+
const engine = getReplayEngine();
|
|
1878
|
+
const captureFile = engine.getCapture(captureId);
|
|
1879
|
+
if (!captureFile) {
|
|
1880
|
+
console.log(chalk4.red(`
|
|
1881
|
+
\u274C Capture not found: ${captureId}
|
|
1882
|
+
`));
|
|
762
1883
|
process.exitCode = 1;
|
|
763
1884
|
return;
|
|
764
1885
|
}
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1886
|
+
const { capture: capture2 } = captureFile;
|
|
1887
|
+
if (!options.force) {
|
|
1888
|
+
console.log(chalk4.bold("\n\u{1F5D1}\uFE0F Capture to delete:\n"));
|
|
1889
|
+
console.log(` ${chalk4.white(capture2.id.slice(0, 8))}`);
|
|
1890
|
+
console.log(chalk4.gray(` ${capture2.method} ${capture2.path}`));
|
|
1891
|
+
console.log(
|
|
1892
|
+
chalk4.gray(` ${new Date(capture2.timestamp).toLocaleString()}`)
|
|
1893
|
+
);
|
|
1894
|
+
console.log();
|
|
1895
|
+
const response = await prompts3({
|
|
1896
|
+
type: "confirm",
|
|
1897
|
+
name: "confirm",
|
|
1898
|
+
message: "Delete this capture?",
|
|
1899
|
+
initial: false
|
|
1900
|
+
});
|
|
1901
|
+
if (!response.confirm) {
|
|
1902
|
+
console.log(chalk4.yellow("Cancelled"));
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
const deleted = engine.deleteCapture(captureId);
|
|
1907
|
+
if (deleted) {
|
|
1908
|
+
console.log(
|
|
1909
|
+
chalk4.green(`
|
|
1910
|
+
\u2713 Deleted capture: ${capture2.id.slice(0, 8)}
|
|
1911
|
+
`)
|
|
1912
|
+
);
|
|
1913
|
+
} else {
|
|
1914
|
+
console.log(chalk4.red(`
|
|
1915
|
+
\u274C Failed to delete capture
|
|
1916
|
+
`));
|
|
1917
|
+
process.exitCode = 1;
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
var cleanCommand2 = new Command4().name("clean").alias("remove-all").description("Remove all captured webhooks").option("-f, --force", "Skip confirmation prompt").action(async (options) => {
|
|
1921
|
+
const engine = getReplayEngine();
|
|
1922
|
+
const captures2 = engine.listCaptures(1e4);
|
|
1923
|
+
if (captures2.length === 0) {
|
|
1924
|
+
console.log(chalk4.yellow("\n\u{1F4ED} No captures to remove.\n"));
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
console.log(
|
|
1928
|
+
chalk4.bold(`
|
|
1929
|
+
\u{1F5D1}\uFE0F Found ${captures2.length} captured webhook(s)
|
|
1930
|
+
`)
|
|
1931
|
+
);
|
|
1932
|
+
const byProvider = /* @__PURE__ */ new Map();
|
|
1933
|
+
for (const c of captures2) {
|
|
1934
|
+
const provider = c.capture.provider || "unknown";
|
|
1935
|
+
byProvider.set(provider, (byProvider.get(provider) || 0) + 1);
|
|
1936
|
+
}
|
|
1937
|
+
for (const [provider, count] of byProvider) {
|
|
1938
|
+
console.log(chalk4.gray(` ${provider}: ${count}`));
|
|
1939
|
+
}
|
|
1940
|
+
console.log();
|
|
1941
|
+
if (!options.force) {
|
|
1942
|
+
const response = await prompts3({
|
|
1943
|
+
type: "confirm",
|
|
1944
|
+
name: "confirm",
|
|
1945
|
+
message: `Delete all ${captures2.length} capture(s)?`,
|
|
1946
|
+
initial: false
|
|
1947
|
+
});
|
|
1948
|
+
if (!response.confirm) {
|
|
1949
|
+
console.log(chalk4.yellow("Cancelled"));
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
const deleted = engine.deleteAllCaptures();
|
|
1954
|
+
console.log(chalk4.green(`
|
|
1955
|
+
\u2713 Removed ${deleted} capture(s)`));
|
|
1956
|
+
console.log(chalk4.gray(` Storage: ${engine.getCapturesDir()}
|
|
1957
|
+
`));
|
|
1958
|
+
});
|
|
1959
|
+
var captures = new Command4().name("captures").alias("c").description("Manage captured webhooks").addCommand(listCommand2).addCommand(showCommand).addCommand(searchCommand2).addCommand(deleteCommand).addCommand(cleanCommand2);
|
|
773
1960
|
|
|
774
1961
|
// src/commands/replay.ts
|
|
775
|
-
import { Command as
|
|
776
|
-
import
|
|
777
|
-
import
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
"ID of the captured webhook to replay (optional - will prompt if not provided)"
|
|
781
|
-
).argument(
|
|
782
|
-
"[targetUrl]",
|
|
783
|
-
"Target URL to replay the webhook to (optional - will prompt if not provided)"
|
|
784
|
-
).description("Replay a captured webhook to a target URL").option("-m, --method <method>", "Override HTTP method").option(
|
|
1962
|
+
import { Command as Command5 } from "commander";
|
|
1963
|
+
import ora3 from "ora";
|
|
1964
|
+
import prompts4 from "prompts";
|
|
1965
|
+
import chalk5 from "chalk";
|
|
1966
|
+
var replay = new Command5().name("replay").argument("[captureId]", "Capture ID to replay").argument("[targetUrl]", "Target URL to replay to").description("Replay a captured webhook to a target URL").option("-m, --method <method>", "Override HTTP method").option(
|
|
785
1967
|
"-H, --header <header>",
|
|
786
|
-
"Add
|
|
1968
|
+
"Add or override header (format: key:value)",
|
|
787
1969
|
(value, previous) => {
|
|
788
1970
|
const [key, ...valueParts] = value.split(":");
|
|
789
1971
|
const headerValue = valueParts.join(":");
|
|
@@ -795,53 +1977,53 @@ var replay = new Command3().name("replay").argument(
|
|
|
795
1977
|
]);
|
|
796
1978
|
},
|
|
797
1979
|
[]
|
|
798
|
-
).action(
|
|
799
|
-
async (captureId, targetUrl, options
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
)
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
let selectedCaptureId = captureId;
|
|
814
|
-
let selectedTargetUrl = targetUrl;
|
|
815
|
-
if (!selectedCaptureId) {
|
|
816
|
-
const choices = captured.map((c) => {
|
|
1980
|
+
).option("-v, --verbose", "Show detailed request/response information").action(
|
|
1981
|
+
async (captureId, targetUrl, options) => {
|
|
1982
|
+
const engine = getReplayEngine();
|
|
1983
|
+
if (!captureId) {
|
|
1984
|
+
const captures2 = engine.listCaptures(50);
|
|
1985
|
+
if (captures2.length === 0) {
|
|
1986
|
+
console.log(chalk5.yellow("\n\u{1F4ED} No captured webhooks found."));
|
|
1987
|
+
console.log(
|
|
1988
|
+
chalk5.gray(" Start capturing with: better-webhook capture\n")
|
|
1989
|
+
);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
const choices = captures2.map((c) => {
|
|
817
1993
|
const date = new Date(c.capture.timestamp).toLocaleString();
|
|
818
|
-
const
|
|
1994
|
+
const provider = c.capture.provider ? `[${c.capture.provider}]` : "";
|
|
819
1995
|
return {
|
|
820
|
-
title: `${c.capture.id}
|
|
821
|
-
description:
|
|
1996
|
+
title: `${c.capture.id.slice(0, 8)} ${provider} ${c.capture.method} ${c.capture.path}`,
|
|
1997
|
+
description: date,
|
|
822
1998
|
value: c.capture.id
|
|
823
1999
|
};
|
|
824
2000
|
});
|
|
825
|
-
const response = await
|
|
2001
|
+
const response = await prompts4({
|
|
826
2002
|
type: "select",
|
|
827
2003
|
name: "captureId",
|
|
828
|
-
message: "Select a
|
|
829
|
-
choices
|
|
830
|
-
initial: 0
|
|
2004
|
+
message: "Select a capture to replay:",
|
|
2005
|
+
choices
|
|
831
2006
|
});
|
|
832
2007
|
if (!response.captureId) {
|
|
833
|
-
console.log(
|
|
834
|
-
process.exitCode = 1;
|
|
2008
|
+
console.log(chalk5.yellow("Cancelled"));
|
|
835
2009
|
return;
|
|
836
2010
|
}
|
|
837
|
-
|
|
2011
|
+
captureId = response.captureId;
|
|
838
2012
|
}
|
|
839
|
-
|
|
840
|
-
|
|
2013
|
+
const captureFile = engine.getCapture(captureId);
|
|
2014
|
+
if (!captureFile) {
|
|
2015
|
+
console.log(chalk5.red(`
|
|
2016
|
+
\u274C Capture not found: ${captureId}
|
|
2017
|
+
`));
|
|
2018
|
+
process.exitCode = 1;
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if (!targetUrl) {
|
|
2022
|
+
const response = await prompts4({
|
|
841
2023
|
type: "text",
|
|
842
|
-
name: "
|
|
843
|
-
message: "Enter
|
|
844
|
-
initial:
|
|
2024
|
+
name: "url",
|
|
2025
|
+
message: "Enter target URL:",
|
|
2026
|
+
initial: `http://localhost:3000${captureFile.capture.path}`,
|
|
845
2027
|
validate: (value) => {
|
|
846
2028
|
try {
|
|
847
2029
|
new URL(value);
|
|
@@ -851,43 +2033,549 @@ var replay = new Command3().name("replay").argument(
|
|
|
851
2033
|
}
|
|
852
2034
|
}
|
|
853
2035
|
});
|
|
854
|
-
if (!response.
|
|
855
|
-
console.log(
|
|
856
|
-
process.exitCode = 1;
|
|
2036
|
+
if (!response.url) {
|
|
2037
|
+
console.log(chalk5.yellow("Cancelled"));
|
|
857
2038
|
return;
|
|
858
2039
|
}
|
|
859
|
-
|
|
2040
|
+
targetUrl = response.url;
|
|
860
2041
|
}
|
|
2042
|
+
const { capture: capture2 } = captureFile;
|
|
2043
|
+
console.log(chalk5.bold("\n\u{1F504} Replaying Webhook\n"));
|
|
2044
|
+
console.log(chalk5.gray(` Capture ID: ${capture2.id.slice(0, 8)}`));
|
|
2045
|
+
console.log(chalk5.gray(` Original: ${capture2.method} ${capture2.path}`));
|
|
2046
|
+
if (capture2.provider) {
|
|
2047
|
+
console.log(chalk5.gray(` Provider: ${capture2.provider}`));
|
|
2048
|
+
}
|
|
2049
|
+
console.log(chalk5.gray(` Target: ${targetUrl}`));
|
|
2050
|
+
console.log();
|
|
2051
|
+
const spinner = ora3("Replaying webhook...").start();
|
|
861
2052
|
try {
|
|
862
|
-
const result = await
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
2053
|
+
const result = await engine.replay(captureId, {
|
|
2054
|
+
targetUrl,
|
|
2055
|
+
method: options?.method,
|
|
2056
|
+
headers: options?.header
|
|
2057
|
+
});
|
|
2058
|
+
spinner.stop();
|
|
2059
|
+
const statusColor = result.status >= 200 && result.status < 300 ? chalk5.green : result.status >= 400 ? chalk5.red : chalk5.yellow;
|
|
2060
|
+
console.log(chalk5.bold("\u{1F4E5} Response\n"));
|
|
2061
|
+
console.log(
|
|
2062
|
+
` Status: ${statusColor(`${result.status} ${result.statusText}`)}`
|
|
869
2063
|
);
|
|
870
|
-
console.log(
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
2064
|
+
console.log(` Duration: ${chalk5.cyan(`${result.duration}ms`)}`);
|
|
2065
|
+
if (options?.verbose) {
|
|
2066
|
+
console.log(chalk5.bold("\n Headers:"));
|
|
2067
|
+
for (const [key, value] of Object.entries(result.headers)) {
|
|
2068
|
+
const headerValue = Array.isArray(value) ? value.join(", ") : value;
|
|
2069
|
+
console.log(chalk5.gray(` ${key}: ${headerValue}`));
|
|
2070
|
+
}
|
|
875
2071
|
}
|
|
876
2072
|
if (result.json !== void 0) {
|
|
877
|
-
console.log("
|
|
878
|
-
console.log(
|
|
2073
|
+
console.log(chalk5.bold("\n Body:"));
|
|
2074
|
+
console.log(
|
|
2075
|
+
chalk5.gray(
|
|
2076
|
+
JSON.stringify(result.json, null, 2).split("\n").map((l) => ` ${l}`).join("\n")
|
|
2077
|
+
)
|
|
2078
|
+
);
|
|
2079
|
+
} else if (result.bodyText) {
|
|
2080
|
+
console.log(chalk5.bold("\n Body:"));
|
|
2081
|
+
const preview = result.bodyText.length > 500 ? result.bodyText.slice(0, 500) + "..." : result.bodyText;
|
|
2082
|
+
console.log(chalk5.gray(` ${preview}`));
|
|
2083
|
+
}
|
|
2084
|
+
console.log();
|
|
2085
|
+
if (result.status >= 200 && result.status < 300) {
|
|
2086
|
+
console.log(chalk5.green("\u2713 Replay completed successfully\n"));
|
|
879
2087
|
} else {
|
|
880
|
-
console.log(
|
|
881
|
-
|
|
2088
|
+
console.log(
|
|
2089
|
+
chalk5.yellow(`\u26A0 Replay completed with status ${result.status}
|
|
2090
|
+
`)
|
|
2091
|
+
);
|
|
882
2092
|
}
|
|
883
2093
|
} catch (error) {
|
|
884
|
-
|
|
2094
|
+
spinner.fail("Replay failed");
|
|
2095
|
+
console.error(chalk5.red(`
|
|
2096
|
+
\u274C ${error.message}
|
|
2097
|
+
`));
|
|
885
2098
|
process.exitCode = 1;
|
|
886
2099
|
}
|
|
887
2100
|
}
|
|
888
2101
|
);
|
|
889
2102
|
|
|
2103
|
+
// src/commands/dashboard.ts
|
|
2104
|
+
import { Command as Command6 } from "commander";
|
|
2105
|
+
import chalk6 from "chalk";
|
|
2106
|
+
|
|
2107
|
+
// src/core/dashboard-server.ts
|
|
2108
|
+
import express2 from "express";
|
|
2109
|
+
import { createServer as createServer2 } from "http";
|
|
2110
|
+
import { WebSocketServer as WebSocketServer2 } from "ws";
|
|
2111
|
+
import path from "path";
|
|
2112
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2113
|
+
import { fileURLToPath } from "url";
|
|
2114
|
+
|
|
2115
|
+
// src/core/dashboard-api.ts
|
|
2116
|
+
import express from "express";
|
|
2117
|
+
import { z as z2 } from "zod";
|
|
2118
|
+
function jsonError(res, status, message) {
|
|
2119
|
+
return res.status(status).json({ error: message });
|
|
2120
|
+
}
|
|
2121
|
+
function getSecretEnvVarName2(provider) {
|
|
2122
|
+
const envVarMap = {
|
|
2123
|
+
github: "GITHUB_WEBHOOK_SECRET",
|
|
2124
|
+
stripe: "STRIPE_WEBHOOK_SECRET",
|
|
2125
|
+
shopify: "SHOPIFY_WEBHOOK_SECRET",
|
|
2126
|
+
twilio: "TWILIO_WEBHOOK_SECRET",
|
|
2127
|
+
ragie: "RAGIE_WEBHOOK_SECRET",
|
|
2128
|
+
slack: "SLACK_WEBHOOK_SECRET",
|
|
2129
|
+
linear: "LINEAR_WEBHOOK_SECRET",
|
|
2130
|
+
clerk: "CLERK_WEBHOOK_SECRET",
|
|
2131
|
+
sendgrid: "SENDGRID_WEBHOOK_SECRET",
|
|
2132
|
+
discord: "DISCORD_WEBHOOK_SECRET",
|
|
2133
|
+
custom: "WEBHOOK_SECRET"
|
|
2134
|
+
};
|
|
2135
|
+
return envVarMap[provider] || "WEBHOOK_SECRET";
|
|
2136
|
+
}
|
|
2137
|
+
var ReplayBodySchema = z2.object({
|
|
2138
|
+
captureId: z2.string().min(1),
|
|
2139
|
+
targetUrl: z2.string().min(1),
|
|
2140
|
+
method: HttpMethodSchema.optional(),
|
|
2141
|
+
headers: z2.array(HeaderEntrySchema).optional()
|
|
2142
|
+
});
|
|
2143
|
+
var TemplateDownloadBodySchema = z2.object({
|
|
2144
|
+
id: z2.string().min(1)
|
|
2145
|
+
});
|
|
2146
|
+
var RunTemplateBodySchema = z2.object({
|
|
2147
|
+
templateId: z2.string().min(1),
|
|
2148
|
+
url: z2.string().min(1),
|
|
2149
|
+
secret: z2.string().optional(),
|
|
2150
|
+
headers: z2.array(HeaderEntrySchema).optional()
|
|
2151
|
+
});
|
|
2152
|
+
function createDashboardApiRouter(options = {}) {
|
|
2153
|
+
const router = express.Router();
|
|
2154
|
+
const replayEngine = new ReplayEngine(options.capturesDir);
|
|
2155
|
+
const templateManager = new TemplateManager(options.templatesBaseDir);
|
|
2156
|
+
const broadcast = options.broadcast;
|
|
2157
|
+
const broadcastCaptures = () => {
|
|
2158
|
+
if (!broadcast) return;
|
|
2159
|
+
const captures2 = replayEngine.listCaptures(200);
|
|
2160
|
+
broadcast({
|
|
2161
|
+
type: "captures_updated",
|
|
2162
|
+
payload: { captures: captures2, count: captures2.length }
|
|
2163
|
+
});
|
|
2164
|
+
};
|
|
2165
|
+
const broadcastTemplates = async () => {
|
|
2166
|
+
if (!broadcast) return;
|
|
2167
|
+
const local = templateManager.listLocalTemplates();
|
|
2168
|
+
let remote = [];
|
|
2169
|
+
try {
|
|
2170
|
+
const index = await templateManager.fetchRemoteIndex(false);
|
|
2171
|
+
const localIds = new Set(local.map((t) => t.id));
|
|
2172
|
+
remote = index.templates.map((metadata) => ({
|
|
2173
|
+
metadata,
|
|
2174
|
+
isDownloaded: localIds.has(metadata.id)
|
|
2175
|
+
}));
|
|
2176
|
+
} catch {
|
|
2177
|
+
remote = [];
|
|
2178
|
+
}
|
|
2179
|
+
broadcast({
|
|
2180
|
+
type: "templates_updated",
|
|
2181
|
+
payload: { local, remote }
|
|
2182
|
+
});
|
|
2183
|
+
};
|
|
2184
|
+
router.get("/captures", (req, res) => {
|
|
2185
|
+
const limitRaw = typeof req.query.limit === "string" ? req.query.limit : "";
|
|
2186
|
+
const providerRaw = typeof req.query.provider === "string" ? req.query.provider : "";
|
|
2187
|
+
const qRaw = typeof req.query.q === "string" ? req.query.q : "";
|
|
2188
|
+
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
|
|
2189
|
+
if (!Number.isFinite(limit) || limit <= 0 || limit > 5e3) {
|
|
2190
|
+
return jsonError(res, 400, "Invalid limit");
|
|
2191
|
+
}
|
|
2192
|
+
const q = qRaw.trim();
|
|
2193
|
+
const provider = providerRaw.trim();
|
|
2194
|
+
let captures2 = q ? replayEngine.searchCaptures(q) : replayEngine.listCaptures(Math.max(limit, 1e3));
|
|
2195
|
+
if (provider) {
|
|
2196
|
+
captures2 = captures2.filter(
|
|
2197
|
+
(c) => (c.capture.provider || "").toLowerCase() === provider.toLowerCase()
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
captures2 = captures2.slice(0, limit);
|
|
2201
|
+
return res.json({ captures: captures2, count: captures2.length });
|
|
2202
|
+
});
|
|
2203
|
+
router.get("/captures/:id", (req, res) => {
|
|
2204
|
+
const id = req.params.id;
|
|
2205
|
+
if (!id) {
|
|
2206
|
+
return jsonError(res, 400, "Missing capture id");
|
|
2207
|
+
}
|
|
2208
|
+
const captureFile = replayEngine.getCapture(id);
|
|
2209
|
+
if (!captureFile) {
|
|
2210
|
+
return jsonError(res, 404, "Capture not found");
|
|
2211
|
+
}
|
|
2212
|
+
return res.json(captureFile);
|
|
2213
|
+
});
|
|
2214
|
+
router.delete("/captures/:id", (req, res) => {
|
|
2215
|
+
const id = req.params.id;
|
|
2216
|
+
if (!id) {
|
|
2217
|
+
return jsonError(res, 400, "Missing capture id");
|
|
2218
|
+
}
|
|
2219
|
+
const deleted = replayEngine.deleteCapture(id);
|
|
2220
|
+
if (!deleted) {
|
|
2221
|
+
return jsonError(res, 404, "Capture not found");
|
|
2222
|
+
}
|
|
2223
|
+
broadcastCaptures();
|
|
2224
|
+
return res.json({ success: true });
|
|
2225
|
+
});
|
|
2226
|
+
router.delete("/captures", (_req, res) => {
|
|
2227
|
+
const deleted = replayEngine.deleteAllCaptures();
|
|
2228
|
+
broadcastCaptures();
|
|
2229
|
+
return res.json({ success: true, deleted });
|
|
2230
|
+
});
|
|
2231
|
+
router.post("/replay", express.json({ limit: "5mb" }), async (req, res) => {
|
|
2232
|
+
const parsed = ReplayBodySchema.safeParse(req.body);
|
|
2233
|
+
if (!parsed.success) {
|
|
2234
|
+
return jsonError(
|
|
2235
|
+
res,
|
|
2236
|
+
400,
|
|
2237
|
+
parsed.error.issues[0]?.message || "Invalid body"
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
const { captureId, targetUrl, method, headers } = parsed.data;
|
|
2241
|
+
try {
|
|
2242
|
+
new URL(targetUrl);
|
|
2243
|
+
} catch {
|
|
2244
|
+
return jsonError(res, 400, "Invalid targetUrl");
|
|
2245
|
+
}
|
|
2246
|
+
try {
|
|
2247
|
+
const result = await replayEngine.replay(captureId, {
|
|
2248
|
+
targetUrl,
|
|
2249
|
+
method,
|
|
2250
|
+
headers
|
|
2251
|
+
});
|
|
2252
|
+
broadcast?.({
|
|
2253
|
+
type: "replay_result",
|
|
2254
|
+
payload: { captureId, targetUrl, result }
|
|
2255
|
+
});
|
|
2256
|
+
return res.json(result);
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
return jsonError(res, 400, error?.message || "Replay failed");
|
|
2259
|
+
}
|
|
2260
|
+
});
|
|
2261
|
+
router.get("/templates/local", (_req, res) => {
|
|
2262
|
+
const local = templateManager.listLocalTemplates();
|
|
2263
|
+
return res.json({ templates: local, count: local.length });
|
|
2264
|
+
});
|
|
2265
|
+
router.get("/templates/remote", async (req, res) => {
|
|
2266
|
+
const refresh = typeof req.query.refresh === "string" ? req.query.refresh === "1" || req.query.refresh.toLowerCase() === "true" : false;
|
|
2267
|
+
try {
|
|
2268
|
+
const index = await templateManager.fetchRemoteIndex(refresh);
|
|
2269
|
+
const localIds = new Set(
|
|
2270
|
+
templateManager.listLocalTemplates().map((t) => t.id)
|
|
2271
|
+
);
|
|
2272
|
+
const remote = index.templates.map((metadata) => ({
|
|
2273
|
+
metadata,
|
|
2274
|
+
isDownloaded: localIds.has(metadata.id)
|
|
2275
|
+
}));
|
|
2276
|
+
return res.json({ templates: remote, count: remote.length });
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
return jsonError(
|
|
2279
|
+
res,
|
|
2280
|
+
500,
|
|
2281
|
+
error?.message || "Failed to fetch remote templates"
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
});
|
|
2285
|
+
router.post(
|
|
2286
|
+
"/templates/download",
|
|
2287
|
+
express.json({ limit: "2mb" }),
|
|
2288
|
+
async (req, res) => {
|
|
2289
|
+
const parsed = TemplateDownloadBodySchema.safeParse(req.body);
|
|
2290
|
+
if (!parsed.success) {
|
|
2291
|
+
return jsonError(
|
|
2292
|
+
res,
|
|
2293
|
+
400,
|
|
2294
|
+
parsed.error.issues[0]?.message || "Invalid body"
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
try {
|
|
2298
|
+
const template = await templateManager.downloadTemplate(parsed.data.id);
|
|
2299
|
+
void broadcastTemplates();
|
|
2300
|
+
return res.json({ success: true, template });
|
|
2301
|
+
} catch (error) {
|
|
2302
|
+
return jsonError(res, 400, error?.message || "Download failed");
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
);
|
|
2306
|
+
router.post(
|
|
2307
|
+
"/templates/download-all",
|
|
2308
|
+
express.json({ limit: "1mb" }),
|
|
2309
|
+
async (_req, res) => {
|
|
2310
|
+
try {
|
|
2311
|
+
const index = await templateManager.fetchRemoteIndex(true);
|
|
2312
|
+
const localIds = new Set(
|
|
2313
|
+
templateManager.listLocalTemplates().map((t) => t.id)
|
|
2314
|
+
);
|
|
2315
|
+
const toDownload = index.templates.filter((t) => !localIds.has(t.id));
|
|
2316
|
+
const downloaded = [];
|
|
2317
|
+
const failed = [];
|
|
2318
|
+
for (const t of toDownload) {
|
|
2319
|
+
try {
|
|
2320
|
+
await templateManager.downloadTemplate(t.id);
|
|
2321
|
+
downloaded.push(t.id);
|
|
2322
|
+
} catch (e) {
|
|
2323
|
+
failed.push({ id: t.id, error: e?.message || "Failed" });
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
return res.json({
|
|
2327
|
+
success: true,
|
|
2328
|
+
total: index.templates.length,
|
|
2329
|
+
downloaded,
|
|
2330
|
+
failed
|
|
2331
|
+
});
|
|
2332
|
+
} catch (error) {
|
|
2333
|
+
return jsonError(res, 500, error?.message || "Download-all failed");
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
);
|
|
2337
|
+
router.post("/run", express.json({ limit: "10mb" }), async (req, res) => {
|
|
2338
|
+
const parsed = RunTemplateBodySchema.safeParse(req.body);
|
|
2339
|
+
if (!parsed.success) {
|
|
2340
|
+
return jsonError(
|
|
2341
|
+
res,
|
|
2342
|
+
400,
|
|
2343
|
+
parsed.error.issues[0]?.message || "Invalid body"
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
let { templateId, url, secret, headers } = parsed.data;
|
|
2347
|
+
try {
|
|
2348
|
+
new URL(url);
|
|
2349
|
+
} catch {
|
|
2350
|
+
return jsonError(res, 400, "Invalid url");
|
|
2351
|
+
}
|
|
2352
|
+
if (templateId.startsWith("remote:")) {
|
|
2353
|
+
templateId = templateId.slice("remote:".length);
|
|
2354
|
+
try {
|
|
2355
|
+
await templateManager.downloadTemplate(templateId);
|
|
2356
|
+
} catch (error) {
|
|
2357
|
+
return jsonError(
|
|
2358
|
+
res,
|
|
2359
|
+
400,
|
|
2360
|
+
error?.message || "Failed to download template"
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
let localTemplate = templateManager.getLocalTemplate(templateId);
|
|
2365
|
+
if (!localTemplate) {
|
|
2366
|
+
try {
|
|
2367
|
+
await templateManager.downloadTemplate(templateId);
|
|
2368
|
+
localTemplate = templateManager.getLocalTemplate(templateId);
|
|
2369
|
+
} catch {
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (!localTemplate) {
|
|
2373
|
+
return jsonError(res, 404, "Template not found");
|
|
2374
|
+
}
|
|
2375
|
+
if (!secret && localTemplate.metadata.provider) {
|
|
2376
|
+
const envVarName = getSecretEnvVarName2(localTemplate.metadata.provider);
|
|
2377
|
+
secret = process.env[envVarName];
|
|
2378
|
+
}
|
|
2379
|
+
const safeHeaders = headers?.length ? headers : void 0;
|
|
2380
|
+
try {
|
|
2381
|
+
const result = await executeTemplate(localTemplate.template, {
|
|
2382
|
+
url,
|
|
2383
|
+
secret,
|
|
2384
|
+
headers: safeHeaders
|
|
2385
|
+
});
|
|
2386
|
+
broadcast?.({
|
|
2387
|
+
type: "replay_result",
|
|
2388
|
+
payload: { templateId, url, result }
|
|
2389
|
+
});
|
|
2390
|
+
return res.json(result);
|
|
2391
|
+
} catch (error) {
|
|
2392
|
+
return jsonError(res, 400, error?.message || "Run failed");
|
|
2393
|
+
}
|
|
2394
|
+
});
|
|
2395
|
+
return router;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// src/core/dashboard-server.ts
|
|
2399
|
+
function resolveDashboardDistDir(runtimeDir) {
|
|
2400
|
+
const candidates = [
|
|
2401
|
+
path.resolve(runtimeDir, "..", "dashboard"),
|
|
2402
|
+
path.resolve(runtimeDir, "..", "..", "dist", "dashboard"),
|
|
2403
|
+
path.resolve(runtimeDir, "..", "..", "..", "dashboard", "dist")
|
|
2404
|
+
];
|
|
2405
|
+
for (const distDir of candidates) {
|
|
2406
|
+
const indexHtml = path.join(distDir, "index.html");
|
|
2407
|
+
if (existsSync4(indexHtml)) {
|
|
2408
|
+
return { distDir, indexHtml };
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
const details = candidates.map((p) => `- ${p}`).join("\n");
|
|
2412
|
+
throw new Error(
|
|
2413
|
+
`Dashboard UI build output not found.
|
|
2414
|
+
Looked in:
|
|
2415
|
+
${details}
|
|
2416
|
+
|
|
2417
|
+
Build it with:
|
|
2418
|
+
- pnpm --filter @better-webhook/dashboard build
|
|
2419
|
+
- pnpm --filter @better-webhook/cli build
|
|
2420
|
+
`
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
async function startDashboardServer(options = {}) {
|
|
2424
|
+
const app = express2();
|
|
2425
|
+
app.get("/health", (_req, res) => {
|
|
2426
|
+
res.json({ ok: true });
|
|
2427
|
+
});
|
|
2428
|
+
const clients = /* @__PURE__ */ new Set();
|
|
2429
|
+
const broadcast = (message) => {
|
|
2430
|
+
const data = JSON.stringify(message);
|
|
2431
|
+
for (const client of clients) {
|
|
2432
|
+
if (client.readyState === 1) {
|
|
2433
|
+
client.send(data);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
};
|
|
2437
|
+
app.use(
|
|
2438
|
+
"/api",
|
|
2439
|
+
createDashboardApiRouter({
|
|
2440
|
+
capturesDir: options.capturesDir,
|
|
2441
|
+
templatesBaseDir: options.templatesBaseDir,
|
|
2442
|
+
broadcast
|
|
2443
|
+
})
|
|
2444
|
+
);
|
|
2445
|
+
const host = options.host || "localhost";
|
|
2446
|
+
const port = options.port ?? 4e3;
|
|
2447
|
+
const runtimeDir = (
|
|
2448
|
+
// eslint-disable-next-line no-undef
|
|
2449
|
+
typeof __dirname !== "undefined" ? (
|
|
2450
|
+
// eslint-disable-next-line no-undef
|
|
2451
|
+
__dirname
|
|
2452
|
+
) : path.dirname(fileURLToPath(import.meta.url))
|
|
2453
|
+
);
|
|
2454
|
+
const { distDir: dashboardDistDir, indexHtml: dashboardIndexHtml } = resolveDashboardDistDir(runtimeDir);
|
|
2455
|
+
app.use(express2.static(dashboardDistDir));
|
|
2456
|
+
app.get("*", (req, res, next) => {
|
|
2457
|
+
if (req.path.startsWith("/api") || req.path === "/health") return next();
|
|
2458
|
+
res.sendFile(dashboardIndexHtml, (err) => {
|
|
2459
|
+
if (err) next();
|
|
2460
|
+
});
|
|
2461
|
+
});
|
|
2462
|
+
const server = createServer2(app);
|
|
2463
|
+
const wss = new WebSocketServer2({ server, path: "/ws" });
|
|
2464
|
+
wss.on("connection", async (ws) => {
|
|
2465
|
+
clients.add(ws);
|
|
2466
|
+
ws.on("close", () => clients.delete(ws));
|
|
2467
|
+
ws.on("error", () => clients.delete(ws));
|
|
2468
|
+
const replayEngine = new ReplayEngine(options.capturesDir);
|
|
2469
|
+
const templateManager = new TemplateManager(options.templatesBaseDir);
|
|
2470
|
+
const captures2 = replayEngine.listCaptures(200);
|
|
2471
|
+
ws.send(
|
|
2472
|
+
JSON.stringify({
|
|
2473
|
+
type: "captures_updated",
|
|
2474
|
+
payload: { captures: captures2, count: captures2.length }
|
|
2475
|
+
})
|
|
2476
|
+
);
|
|
2477
|
+
const local = templateManager.listLocalTemplates();
|
|
2478
|
+
let remote = [];
|
|
2479
|
+
try {
|
|
2480
|
+
const index = await templateManager.fetchRemoteIndex(true);
|
|
2481
|
+
const localIds = new Set(local.map((t) => t.id));
|
|
2482
|
+
remote = index.templates.map((metadata) => ({
|
|
2483
|
+
metadata,
|
|
2484
|
+
isDownloaded: localIds.has(metadata.id)
|
|
2485
|
+
}));
|
|
2486
|
+
} catch {
|
|
2487
|
+
remote = [];
|
|
2488
|
+
}
|
|
2489
|
+
ws.send(
|
|
2490
|
+
JSON.stringify({
|
|
2491
|
+
type: "templates_updated",
|
|
2492
|
+
payload: { local, remote }
|
|
2493
|
+
})
|
|
2494
|
+
);
|
|
2495
|
+
});
|
|
2496
|
+
await new Promise((resolve, reject) => {
|
|
2497
|
+
server.listen(port, host, () => resolve());
|
|
2498
|
+
server.on("error", reject);
|
|
2499
|
+
});
|
|
2500
|
+
const url = `http://${host}:${port}`;
|
|
2501
|
+
let capture2;
|
|
2502
|
+
const shouldStartCapture = options.startCapture !== false;
|
|
2503
|
+
if (shouldStartCapture) {
|
|
2504
|
+
const captureHost = options.captureHost || "0.0.0.0";
|
|
2505
|
+
const capturePort = options.capturePort ?? 3001;
|
|
2506
|
+
const captureServer = new CaptureServer({
|
|
2507
|
+
capturesDir: options.capturesDir,
|
|
2508
|
+
enableWebSocket: false,
|
|
2509
|
+
onCapture: ({ file, capture: capture3 }) => {
|
|
2510
|
+
broadcast({
|
|
2511
|
+
type: "capture",
|
|
2512
|
+
payload: { file, capture: capture3 }
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
});
|
|
2516
|
+
const actualPort = await captureServer.start(capturePort, captureHost);
|
|
2517
|
+
capture2 = {
|
|
2518
|
+
server: captureServer,
|
|
2519
|
+
url: `http://${captureHost === "0.0.0.0" ? "localhost" : captureHost}:${actualPort}`
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
return { app, server, url, capture: capture2 };
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/commands/dashboard.ts
|
|
2526
|
+
var dashboard = new Command6().name("dashboard").description("Start the local dashboard (UI + API + WebSocket) server").option("-p, --port <port>", "Port to listen on", "4000").option("-h, --host <host>", "Host to bind to", "localhost").option("--capture-port <port>", "Capture server port", "3001").option("--capture-host <host>", "Capture server host", "0.0.0.0").option("--no-capture", "Do not start the capture server").option("--captures-dir <dir>", "Override captures directory").option("--templates-dir <dir>", "Override templates base directory").action(async (options) => {
|
|
2527
|
+
const port = Number.parseInt(String(options.port), 10);
|
|
2528
|
+
if (!Number.isFinite(port) || port < 0 || port > 65535) {
|
|
2529
|
+
console.error(chalk6.red("Invalid port number"));
|
|
2530
|
+
process.exitCode = 1;
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
try {
|
|
2534
|
+
const capturePort = Number.parseInt(String(options.capturePort), 10);
|
|
2535
|
+
if (!Number.isFinite(capturePort) || capturePort < 0 || capturePort > 65535) {
|
|
2536
|
+
console.error(chalk6.red("Invalid capture port number"));
|
|
2537
|
+
process.exitCode = 1;
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
const { url, server, capture: capture2 } = await startDashboardServer({
|
|
2541
|
+
host: options.host,
|
|
2542
|
+
port,
|
|
2543
|
+
captureHost: options.captureHost,
|
|
2544
|
+
capturePort,
|
|
2545
|
+
startCapture: options.capture !== false,
|
|
2546
|
+
capturesDir: options.capturesDir,
|
|
2547
|
+
templatesBaseDir: options.templatesDir
|
|
2548
|
+
});
|
|
2549
|
+
console.log(chalk6.bold("\n\u{1F9ED} Dashboard Server\n"));
|
|
2550
|
+
console.log(chalk6.gray(` Dashboard: ${url}/`));
|
|
2551
|
+
console.log(chalk6.gray(` Health: ${url}/health`));
|
|
2552
|
+
console.log(chalk6.gray(` API Base: ${url}/api`));
|
|
2553
|
+
console.log(chalk6.gray(` WebSocket: ${url.replace("http://", "ws://")}/ws`));
|
|
2554
|
+
if (capture2) {
|
|
2555
|
+
console.log();
|
|
2556
|
+
console.log(chalk6.bold("\u{1F3A3} Capture Server"));
|
|
2557
|
+
console.log(chalk6.gray(` Capture: ${capture2.url}`));
|
|
2558
|
+
console.log(chalk6.gray(` Tip: Send webhooks to any path, e.g. ${capture2.url}/webhooks/github`));
|
|
2559
|
+
}
|
|
2560
|
+
console.log();
|
|
2561
|
+
const shutdown = async () => {
|
|
2562
|
+
if (capture2) {
|
|
2563
|
+
await capture2.server.stop();
|
|
2564
|
+
}
|
|
2565
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
2566
|
+
process.exit(0);
|
|
2567
|
+
};
|
|
2568
|
+
process.on("SIGINT", shutdown);
|
|
2569
|
+
process.on("SIGTERM", shutdown);
|
|
2570
|
+
} catch (error) {
|
|
2571
|
+
console.error(chalk6.red(`Failed to start dashboard server: ${error?.message || error}`));
|
|
2572
|
+
process.exitCode = 1;
|
|
2573
|
+
}
|
|
2574
|
+
});
|
|
2575
|
+
|
|
890
2576
|
// src/index.ts
|
|
891
|
-
var program = new
|
|
892
|
-
|
|
2577
|
+
var program = new Command7().name("better-webhook").description(
|
|
2578
|
+
"Modern CLI for developing, capturing, and replaying webhooks locally"
|
|
2579
|
+
).version("2.0.0");
|
|
2580
|
+
program.addCommand(templates).addCommand(run).addCommand(capture).addCommand(captures).addCommand(replay).addCommand(dashboard);
|
|
893
2581
|
program.parseAsync(process.argv);
|