@better-webhook/cli 3.3.0 → 3.4.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 +24 -12
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.js +30 -0
- package/dist/commands/captures.d.ts +2 -0
- package/dist/commands/captures.js +217 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +63 -0
- package/dist/commands/index.d.ts +6 -0
- package/dist/commands/index.js +6 -0
- package/dist/commands/replay.d.ts +2 -0
- package/dist/commands/replay.js +140 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +181 -0
- package/dist/commands/templates.d.ts +2 -0
- package/dist/commands/templates.js +285 -0
- package/dist/core/capture-server.d.ts +31 -0
- package/dist/core/capture-server.js +292 -0
- package/dist/core/dashboard-api.d.ts +8 -0
- package/dist/core/dashboard-api.js +271 -0
- package/dist/core/dashboard-server.d.ts +20 -0
- package/dist/core/dashboard-server.js +124 -0
- package/dist/core/executor.d.ts +11 -0
- package/dist/core/executor.js +130 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/replay-engine.d.ts +18 -0
- package/dist/core/replay-engine.js +208 -0
- package/dist/core/signature.d.ts +24 -0
- package/dist/core/signature.js +205 -0
- package/dist/core/template-manager.d.ts +24 -0
- package/dist/core/template-manager.js +246 -0
- package/dist/index.cjs +27 -1
- package/dist/index.js +26 -1
- package/dist/types/index.d.ts +299 -0
- package/dist/types/index.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ A modern CLI tool for webhook development, testing, and debugging. Capture incom
|
|
|
18
18
|
|
|
19
19
|
## Supported Providers
|
|
20
20
|
|
|
21
|
+
### Signature Generation Support
|
|
22
|
+
|
|
23
|
+
The CLI can automatically generate signatures for the following providers when you provide a `--secret`:
|
|
24
|
+
|
|
21
25
|
| Provider | Signature Algorithm | Auto-Detection |
|
|
22
26
|
| ------------ | ------------------------------- | -------------- |
|
|
23
27
|
| Stripe | HMAC-SHA256 (`t={ts},v1={sig}`) | ✅ |
|
|
@@ -28,8 +32,16 @@ A modern CLI tool for webhook development, testing, and debugging. Capture incom
|
|
|
28
32
|
| SendGrid | HMAC-SHA256 (Base64) | ✅ |
|
|
29
33
|
| Linear | HMAC-SHA256 (Hex) | ✅ |
|
|
30
34
|
| Clerk (Svix) | HMAC-SHA256 (`v1,{sig}`) | ✅ |
|
|
31
|
-
|
|
|
32
|
-
|
|
35
|
+
| Ragie | HMAC-SHA256 (Hex) | ✅ |
|
|
36
|
+
|
|
37
|
+
### Detection Only (No Signature Generation)
|
|
38
|
+
|
|
39
|
+
These providers are auto-detected from headers but signature generation is not yet implemented:
|
|
40
|
+
|
|
41
|
+
| Provider | Notes |
|
|
42
|
+
| -------- | ----------------------------------------------------- |
|
|
43
|
+
| Discord | Detected from headers; uses Ed25519 (not implemented) |
|
|
44
|
+
| Custom | Generic detection; no signature generation |
|
|
33
45
|
|
|
34
46
|
## Installation
|
|
35
47
|
|
|
@@ -51,7 +63,6 @@ npx @better-webhook/cli --help
|
|
|
51
63
|
|
|
52
64
|
```bash
|
|
53
65
|
better-webhook --version
|
|
54
|
-
# 2.0.0
|
|
55
66
|
```
|
|
56
67
|
|
|
57
68
|
## Quick Start
|
|
@@ -379,15 +390,15 @@ By default, this command starts:
|
|
|
379
390
|
better-webhook dashboard [options]
|
|
380
391
|
```
|
|
381
392
|
|
|
382
|
-
| Option
|
|
383
|
-
|
|
|
384
|
-
| `-p, --port <port>`
|
|
385
|
-
| `-h, --host <host>`
|
|
386
|
-
| `--capture-port <port>`
|
|
387
|
-
| `--capture-host <host>`
|
|
388
|
-
| `--no-capture`
|
|
389
|
-
| `--captures-dir <dir>`
|
|
390
|
-
| `--templates-dir <dir>`
|
|
393
|
+
| Option | Description | Default |
|
|
394
|
+
| ----------------------- | --------------------------- | ----------- |
|
|
395
|
+
| `-p, --port <port>` | Dashboard server port | `4000` |
|
|
396
|
+
| `-h, --host <host>` | Dashboard server host | `localhost` |
|
|
397
|
+
| `--capture-port <port>` | Capture server port | `3001` |
|
|
398
|
+
| `--capture-host <host>` | Capture server host | `0.0.0.0` |
|
|
399
|
+
| `--no-capture` | Do not start capture server | — |
|
|
400
|
+
| `--captures-dir <dir>` | Override captures directory | — |
|
|
401
|
+
| `--templates-dir <dir>` | Override templates base dir | — |
|
|
391
402
|
|
|
392
403
|
**Security note:**
|
|
393
404
|
Keep the dashboard bound to `localhost` unless you trust your network. The API includes endpoints that can send HTTP requests to arbitrary URLs (run/replay).
|
|
@@ -408,6 +419,7 @@ The CLI automatically reads webhook secrets from environment variables based on
|
|
|
408
419
|
| Linear | `LINEAR_WEBHOOK_SECRET` |
|
|
409
420
|
| Clerk | `CLERK_WEBHOOK_SECRET` |
|
|
410
421
|
| SendGrid | `SENDGRID_WEBHOOK_SECRET` |
|
|
422
|
+
| Ragie | `RAGIE_WEBHOOK_SECRET` |
|
|
411
423
|
| Discord | `DISCORD_WEBHOOK_SECRET` |
|
|
412
424
|
| Custom | `WEBHOOK_SECRET` |
|
|
413
425
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { CaptureServer } from "../core/capture-server.js";
|
|
4
|
+
export const capture = new Command()
|
|
5
|
+
.name("capture")
|
|
6
|
+
.description("Start a server to capture incoming webhooks")
|
|
7
|
+
.option("-p, --port <port>", "Port to listen on", "3001")
|
|
8
|
+
.option("-h, --host <host>", "Host to bind to", "0.0.0.0")
|
|
9
|
+
.action(async (options) => {
|
|
10
|
+
const port = parseInt(options.port, 10);
|
|
11
|
+
if (isNaN(port) || port < 0 || port > 65535) {
|
|
12
|
+
console.error(chalk.red("Invalid port number"));
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const server = new CaptureServer();
|
|
17
|
+
try {
|
|
18
|
+
await server.start(port, options.host);
|
|
19
|
+
const shutdown = async () => {
|
|
20
|
+
await server.stop();
|
|
21
|
+
process.exit(0);
|
|
22
|
+
};
|
|
23
|
+
process.on("SIGINT", shutdown);
|
|
24
|
+
process.on("SIGTERM", shutdown);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error(chalk.red(`Failed to start server: ${error.message}`));
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import { getReplayEngine } from "../core/replay-engine.js";
|
|
5
|
+
const listCommand = new Command()
|
|
6
|
+
.name("list")
|
|
7
|
+
.alias("ls")
|
|
8
|
+
.description("List captured webhooks")
|
|
9
|
+
.option("-l, --limit <limit>", "Maximum number of captures to show", "20")
|
|
10
|
+
.option("-p, --provider <provider>", "Filter by provider")
|
|
11
|
+
.action((options) => {
|
|
12
|
+
const limit = parseInt(options.limit, 10);
|
|
13
|
+
if (isNaN(limit) || limit <= 0) {
|
|
14
|
+
console.error(chalk.red("Invalid limit value"));
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const engine = getReplayEngine();
|
|
19
|
+
let captures = engine.listCaptures(limit);
|
|
20
|
+
if (options.provider) {
|
|
21
|
+
captures = captures.filter((c) => c.capture.provider?.toLowerCase() === options.provider?.toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
if (captures.length === 0) {
|
|
24
|
+
console.log(chalk.yellow("\n📭 No captured webhooks found."));
|
|
25
|
+
console.log(chalk.gray(" Start capturing with: better-webhook capture\n"));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.log(chalk.bold("\n📦 Captured Webhooks\n"));
|
|
29
|
+
for (const { file, capture } of captures) {
|
|
30
|
+
const date = new Date(capture.timestamp).toLocaleString();
|
|
31
|
+
const provider = capture.provider
|
|
32
|
+
? chalk.cyan(`[${capture.provider}]`)
|
|
33
|
+
: chalk.gray("[unknown]");
|
|
34
|
+
const size = capture.contentLength || capture.rawBody?.length || 0;
|
|
35
|
+
console.log(` ${chalk.white(capture.id.slice(0, 8))} ${provider}`);
|
|
36
|
+
console.log(chalk.gray(` ${capture.method} ${capture.path}`));
|
|
37
|
+
console.log(chalk.gray(` ${date} | ${size} bytes`));
|
|
38
|
+
console.log(chalk.gray(` File: ${file}`));
|
|
39
|
+
console.log();
|
|
40
|
+
}
|
|
41
|
+
console.log(chalk.gray(` Showing ${captures.length} captures`));
|
|
42
|
+
console.log(chalk.gray(` Storage: ${engine.getCapturesDir()}\n`));
|
|
43
|
+
});
|
|
44
|
+
const showCommand = new Command()
|
|
45
|
+
.name("show")
|
|
46
|
+
.argument("<captureId>", "Capture ID or partial ID")
|
|
47
|
+
.description("Show detailed information about a capture")
|
|
48
|
+
.option("-b, --body", "Show full body content")
|
|
49
|
+
.action((captureId, options) => {
|
|
50
|
+
const engine = getReplayEngine();
|
|
51
|
+
const captureFile = engine.getCapture(captureId);
|
|
52
|
+
if (!captureFile) {
|
|
53
|
+
console.log(chalk.red(`\n❌ Capture not found: ${captureId}\n`));
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const { capture } = captureFile;
|
|
58
|
+
console.log(chalk.bold("\n📋 Capture Details\n"));
|
|
59
|
+
console.log(` ${chalk.gray("ID:")} ${capture.id}`);
|
|
60
|
+
console.log(` ${chalk.gray("File:")} ${captureFile.file}`);
|
|
61
|
+
console.log(` ${chalk.gray("Timestamp:")} ${new Date(capture.timestamp).toLocaleString()}`);
|
|
62
|
+
console.log(` ${chalk.gray("Method:")} ${capture.method}`);
|
|
63
|
+
console.log(` ${chalk.gray("Path:")} ${capture.path}`);
|
|
64
|
+
console.log(` ${chalk.gray("URL:")} ${capture.url}`);
|
|
65
|
+
if (capture.provider) {
|
|
66
|
+
console.log(` ${chalk.gray("Provider:")} ${chalk.cyan(capture.provider)}`);
|
|
67
|
+
}
|
|
68
|
+
console.log(` ${chalk.gray("Content-Type:")} ${capture.contentType || "unknown"}`);
|
|
69
|
+
console.log(` ${chalk.gray("Content-Length:")} ${capture.contentLength || 0} bytes`);
|
|
70
|
+
const queryKeys = Object.keys(capture.query);
|
|
71
|
+
if (queryKeys.length > 0) {
|
|
72
|
+
console.log(chalk.bold("\n Query Parameters:"));
|
|
73
|
+
for (const [key, value] of Object.entries(capture.query)) {
|
|
74
|
+
const queryValue = Array.isArray(value) ? value.join(", ") : value;
|
|
75
|
+
console.log(chalk.gray(` ${key}: ${queryValue}`));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
console.log(chalk.bold("\n Headers:"));
|
|
79
|
+
for (const [key, value] of Object.entries(capture.headers)) {
|
|
80
|
+
const headerValue = Array.isArray(value) ? value.join(", ") : value;
|
|
81
|
+
const display = headerValue.length > 80
|
|
82
|
+
? headerValue.slice(0, 80) + "..."
|
|
83
|
+
: headerValue;
|
|
84
|
+
console.log(chalk.gray(` ${key}: ${display}`));
|
|
85
|
+
}
|
|
86
|
+
if (options.body && capture.body) {
|
|
87
|
+
console.log(chalk.bold("\n Body:"));
|
|
88
|
+
if (typeof capture.body === "object") {
|
|
89
|
+
console.log(chalk.gray(JSON.stringify(capture.body, null, 2)
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((l) => ` ${l}`)
|
|
92
|
+
.join("\n")));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.log(chalk.gray(` ${capture.body}`));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (capture.body) {
|
|
99
|
+
console.log(chalk.bold("\n Body:"));
|
|
100
|
+
const preview = JSON.stringify(capture.body).slice(0, 200);
|
|
101
|
+
console.log(chalk.gray(` ${preview}${preview.length >= 200 ? "..." : ""}`));
|
|
102
|
+
console.log(chalk.gray(" Use --body to see full content"));
|
|
103
|
+
}
|
|
104
|
+
console.log();
|
|
105
|
+
});
|
|
106
|
+
const searchCommand = new Command()
|
|
107
|
+
.name("search")
|
|
108
|
+
.argument("<query>", "Search query")
|
|
109
|
+
.description("Search captures by path, method, or provider")
|
|
110
|
+
.action((query) => {
|
|
111
|
+
const engine = getReplayEngine();
|
|
112
|
+
const results = engine.searchCaptures(query);
|
|
113
|
+
if (results.length === 0) {
|
|
114
|
+
console.log(chalk.yellow(`\n📭 No captures found for: "${query}"\n`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
console.log(chalk.bold(`\n🔍 Search Results for "${query}"\n`));
|
|
118
|
+
for (const { file, capture } of results) {
|
|
119
|
+
const date = new Date(capture.timestamp).toLocaleString();
|
|
120
|
+
const provider = capture.provider
|
|
121
|
+
? chalk.cyan(`[${capture.provider}]`)
|
|
122
|
+
: "";
|
|
123
|
+
console.log(` ${chalk.white(capture.id.slice(0, 8))} ${provider}`);
|
|
124
|
+
console.log(chalk.gray(` ${capture.method} ${capture.path}`));
|
|
125
|
+
console.log(chalk.gray(` ${date}`));
|
|
126
|
+
console.log();
|
|
127
|
+
}
|
|
128
|
+
console.log(chalk.gray(` Found: ${results.length} captures\n`));
|
|
129
|
+
});
|
|
130
|
+
const deleteCommand = new Command()
|
|
131
|
+
.name("delete")
|
|
132
|
+
.alias("rm")
|
|
133
|
+
.argument("<captureId>", "Capture ID or partial ID to delete")
|
|
134
|
+
.description("Delete a specific captured webhook")
|
|
135
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
136
|
+
.action(async (captureId, options) => {
|
|
137
|
+
const engine = getReplayEngine();
|
|
138
|
+
const captureFile = engine.getCapture(captureId);
|
|
139
|
+
if (!captureFile) {
|
|
140
|
+
console.log(chalk.red(`\n❌ Capture not found: ${captureId}\n`));
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const { capture } = captureFile;
|
|
145
|
+
if (!options.force) {
|
|
146
|
+
console.log(chalk.bold("\n🗑️ Capture to delete:\n"));
|
|
147
|
+
console.log(` ${chalk.white(capture.id.slice(0, 8))}`);
|
|
148
|
+
console.log(chalk.gray(` ${capture.method} ${capture.path}`));
|
|
149
|
+
console.log(chalk.gray(` ${new Date(capture.timestamp).toLocaleString()}`));
|
|
150
|
+
console.log();
|
|
151
|
+
const response = await prompts({
|
|
152
|
+
type: "confirm",
|
|
153
|
+
name: "confirm",
|
|
154
|
+
message: "Delete this capture?",
|
|
155
|
+
initial: false,
|
|
156
|
+
});
|
|
157
|
+
if (!response.confirm) {
|
|
158
|
+
console.log(chalk.yellow("Cancelled"));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const deleted = engine.deleteCapture(captureId);
|
|
163
|
+
if (deleted) {
|
|
164
|
+
console.log(chalk.green(`\n✓ Deleted capture: ${capture.id.slice(0, 8)}\n`));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.log(chalk.red(`\n❌ Failed to delete capture\n`));
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
const cleanCommand = new Command()
|
|
172
|
+
.name("clean")
|
|
173
|
+
.alias("remove-all")
|
|
174
|
+
.description("Remove all captured webhooks")
|
|
175
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
176
|
+
.action(async (options) => {
|
|
177
|
+
const engine = getReplayEngine();
|
|
178
|
+
const captures = engine.listCaptures(10000);
|
|
179
|
+
if (captures.length === 0) {
|
|
180
|
+
console.log(chalk.yellow("\n📭 No captures to remove.\n"));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
console.log(chalk.bold(`\n🗑️ Found ${captures.length} captured webhook(s)\n`));
|
|
184
|
+
const byProvider = new Map();
|
|
185
|
+
for (const c of captures) {
|
|
186
|
+
const provider = c.capture.provider || "unknown";
|
|
187
|
+
byProvider.set(provider, (byProvider.get(provider) || 0) + 1);
|
|
188
|
+
}
|
|
189
|
+
for (const [provider, count] of byProvider) {
|
|
190
|
+
console.log(chalk.gray(` ${provider}: ${count}`));
|
|
191
|
+
}
|
|
192
|
+
console.log();
|
|
193
|
+
if (!options.force) {
|
|
194
|
+
const response = await prompts({
|
|
195
|
+
type: "confirm",
|
|
196
|
+
name: "confirm",
|
|
197
|
+
message: `Delete all ${captures.length} capture(s)?`,
|
|
198
|
+
initial: false,
|
|
199
|
+
});
|
|
200
|
+
if (!response.confirm) {
|
|
201
|
+
console.log(chalk.yellow("Cancelled"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const deleted = engine.deleteAllCaptures();
|
|
206
|
+
console.log(chalk.green(`\n✓ Removed ${deleted} capture(s)`));
|
|
207
|
+
console.log(chalk.gray(` Storage: ${engine.getCapturesDir()}\n`));
|
|
208
|
+
});
|
|
209
|
+
export const captures = new Command()
|
|
210
|
+
.name("captures")
|
|
211
|
+
.alias("c")
|
|
212
|
+
.description("Manage captured webhooks")
|
|
213
|
+
.addCommand(listCommand)
|
|
214
|
+
.addCommand(showCommand)
|
|
215
|
+
.addCommand(searchCommand)
|
|
216
|
+
.addCommand(deleteCommand)
|
|
217
|
+
.addCommand(cleanCommand);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { startDashboardServer } from "../core/dashboard-server.js";
|
|
4
|
+
export const dashboard = new Command()
|
|
5
|
+
.name("dashboard")
|
|
6
|
+
.description("Start the local dashboard (UI + API + WebSocket) server")
|
|
7
|
+
.option("-p, --port <port>", "Port to listen on", "4000")
|
|
8
|
+
.option("-h, --host <host>", "Host to bind to", "localhost")
|
|
9
|
+
.option("--capture-port <port>", "Capture server port", "3001")
|
|
10
|
+
.option("--capture-host <host>", "Capture server host", "0.0.0.0")
|
|
11
|
+
.option("--no-capture", "Do not start the capture server")
|
|
12
|
+
.option("--captures-dir <dir>", "Override captures directory")
|
|
13
|
+
.option("--templates-dir <dir>", "Override templates base directory")
|
|
14
|
+
.action(async (options) => {
|
|
15
|
+
const port = Number.parseInt(String(options.port), 10);
|
|
16
|
+
if (!Number.isFinite(port) || port < 0 || port > 65535) {
|
|
17
|
+
console.error(chalk.red("Invalid port number"));
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const capturePort = Number.parseInt(String(options.capturePort), 10);
|
|
23
|
+
if (!Number.isFinite(capturePort) || capturePort < 0 || capturePort > 65535) {
|
|
24
|
+
console.error(chalk.red("Invalid capture port number"));
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const { url, server, capture } = await startDashboardServer({
|
|
29
|
+
host: options.host,
|
|
30
|
+
port,
|
|
31
|
+
captureHost: options.captureHost,
|
|
32
|
+
capturePort,
|
|
33
|
+
startCapture: options.capture !== false,
|
|
34
|
+
capturesDir: options.capturesDir,
|
|
35
|
+
templatesBaseDir: options.templatesDir,
|
|
36
|
+
});
|
|
37
|
+
console.log(chalk.bold("\n🧭 Dashboard Server\n"));
|
|
38
|
+
console.log(chalk.gray(` Dashboard: ${url}/`));
|
|
39
|
+
console.log(chalk.gray(` Health: ${url}/health`));
|
|
40
|
+
console.log(chalk.gray(` API Base: ${url}/api`));
|
|
41
|
+
console.log(chalk.gray(` WebSocket: ${url.replace("http://", "ws://")}/ws`));
|
|
42
|
+
if (capture) {
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(chalk.bold("🎣 Capture Server"));
|
|
45
|
+
console.log(chalk.gray(` Capture: ${capture.url}`));
|
|
46
|
+
console.log(chalk.gray(` Tip: Send webhooks to any path, e.g. ${capture.url}/webhooks/github`));
|
|
47
|
+
}
|
|
48
|
+
console.log();
|
|
49
|
+
const shutdown = async () => {
|
|
50
|
+
if (capture) {
|
|
51
|
+
await capture.server.stop();
|
|
52
|
+
}
|
|
53
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
54
|
+
process.exit(0);
|
|
55
|
+
};
|
|
56
|
+
process.on("SIGINT", shutdown);
|
|
57
|
+
process.on("SIGTERM", shutdown);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
console.error(chalk.red(`Failed to start dashboard server: ${error?.message || error}`));
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { getReplayEngine } from "../core/replay-engine.js";
|
|
6
|
+
export const replay = new Command()
|
|
7
|
+
.name("replay")
|
|
8
|
+
.argument("[captureId]", "Capture ID to replay")
|
|
9
|
+
.argument("[targetUrl]", "Target URL to replay to")
|
|
10
|
+
.description("Replay a captured webhook to a target URL")
|
|
11
|
+
.option("-m, --method <method>", "Override HTTP method")
|
|
12
|
+
.option("-H, --header <header>", "Add or override header (format: key:value)", (value, previous) => {
|
|
13
|
+
const [key, ...valueParts] = value.split(":");
|
|
14
|
+
const headerValue = valueParts.join(":");
|
|
15
|
+
if (!key || !headerValue) {
|
|
16
|
+
throw new Error("Header format should be key:value");
|
|
17
|
+
}
|
|
18
|
+
return (previous || []).concat([
|
|
19
|
+
{ key: key.trim(), value: headerValue.trim() },
|
|
20
|
+
]);
|
|
21
|
+
}, [])
|
|
22
|
+
.option("-v, --verbose", "Show detailed request/response information")
|
|
23
|
+
.action(async (captureId, targetUrl, options) => {
|
|
24
|
+
const engine = getReplayEngine();
|
|
25
|
+
if (!captureId) {
|
|
26
|
+
const captures = engine.listCaptures(50);
|
|
27
|
+
if (captures.length === 0) {
|
|
28
|
+
console.log(chalk.yellow("\n📭 No captured webhooks found."));
|
|
29
|
+
console.log(chalk.gray(" Start capturing with: better-webhook capture\n"));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const choices = captures.map((c) => {
|
|
33
|
+
const date = new Date(c.capture.timestamp).toLocaleString();
|
|
34
|
+
const provider = c.capture.provider ? `[${c.capture.provider}]` : "";
|
|
35
|
+
return {
|
|
36
|
+
title: `${c.capture.id.slice(0, 8)} ${provider} ${c.capture.method} ${c.capture.path}`,
|
|
37
|
+
description: date,
|
|
38
|
+
value: c.capture.id,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const response = await prompts({
|
|
42
|
+
type: "select",
|
|
43
|
+
name: "captureId",
|
|
44
|
+
message: "Select a capture to replay:",
|
|
45
|
+
choices,
|
|
46
|
+
});
|
|
47
|
+
if (!response.captureId) {
|
|
48
|
+
console.log(chalk.yellow("Cancelled"));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
captureId = response.captureId;
|
|
52
|
+
}
|
|
53
|
+
const captureFile = engine.getCapture(captureId);
|
|
54
|
+
if (!captureFile) {
|
|
55
|
+
console.log(chalk.red(`\n❌ Capture not found: ${captureId}\n`));
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!targetUrl) {
|
|
60
|
+
const response = await prompts({
|
|
61
|
+
type: "text",
|
|
62
|
+
name: "url",
|
|
63
|
+
message: "Enter target URL:",
|
|
64
|
+
initial: `http://localhost:3000${captureFile.capture.path}`,
|
|
65
|
+
validate: (value) => {
|
|
66
|
+
try {
|
|
67
|
+
new URL(value);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return "Please enter a valid URL";
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (!response.url) {
|
|
76
|
+
console.log(chalk.yellow("Cancelled"));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
targetUrl = response.url;
|
|
80
|
+
}
|
|
81
|
+
const { capture } = captureFile;
|
|
82
|
+
console.log(chalk.bold("\n🔄 Replaying Webhook\n"));
|
|
83
|
+
console.log(chalk.gray(` Capture ID: ${capture.id.slice(0, 8)}`));
|
|
84
|
+
console.log(chalk.gray(` Original: ${capture.method} ${capture.path}`));
|
|
85
|
+
if (capture.provider) {
|
|
86
|
+
console.log(chalk.gray(` Provider: ${capture.provider}`));
|
|
87
|
+
}
|
|
88
|
+
console.log(chalk.gray(` Target: ${targetUrl}`));
|
|
89
|
+
console.log();
|
|
90
|
+
const spinner = ora("Replaying webhook...").start();
|
|
91
|
+
try {
|
|
92
|
+
const result = await engine.replay(captureId, {
|
|
93
|
+
targetUrl: targetUrl,
|
|
94
|
+
method: options?.method,
|
|
95
|
+
headers: options?.header,
|
|
96
|
+
});
|
|
97
|
+
spinner.stop();
|
|
98
|
+
const statusColor = result.status >= 200 && result.status < 300
|
|
99
|
+
? chalk.green
|
|
100
|
+
: result.status >= 400
|
|
101
|
+
? chalk.red
|
|
102
|
+
: chalk.yellow;
|
|
103
|
+
console.log(chalk.bold("📥 Response\n"));
|
|
104
|
+
console.log(` Status: ${statusColor(`${result.status} ${result.statusText}`)}`);
|
|
105
|
+
console.log(` Duration: ${chalk.cyan(`${result.duration}ms`)}`);
|
|
106
|
+
if (options?.verbose) {
|
|
107
|
+
console.log(chalk.bold("\n Headers:"));
|
|
108
|
+
for (const [key, value] of Object.entries(result.headers)) {
|
|
109
|
+
const headerValue = Array.isArray(value) ? value.join(", ") : value;
|
|
110
|
+
console.log(chalk.gray(` ${key}: ${headerValue}`));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (result.json !== undefined) {
|
|
114
|
+
console.log(chalk.bold("\n Body:"));
|
|
115
|
+
console.log(chalk.gray(JSON.stringify(result.json, null, 2)
|
|
116
|
+
.split("\n")
|
|
117
|
+
.map((l) => ` ${l}`)
|
|
118
|
+
.join("\n")));
|
|
119
|
+
}
|
|
120
|
+
else if (result.bodyText) {
|
|
121
|
+
console.log(chalk.bold("\n Body:"));
|
|
122
|
+
const preview = result.bodyText.length > 500
|
|
123
|
+
? result.bodyText.slice(0, 500) + "..."
|
|
124
|
+
: result.bodyText;
|
|
125
|
+
console.log(chalk.gray(` ${preview}`));
|
|
126
|
+
}
|
|
127
|
+
console.log();
|
|
128
|
+
if (result.status >= 200 && result.status < 300) {
|
|
129
|
+
console.log(chalk.green("✓ Replay completed successfully\n"));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
console.log(chalk.yellow(`⚠ Replay completed with status ${result.status}\n`));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
spinner.fail("Replay failed");
|
|
137
|
+
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
}
|
|
140
|
+
});
|