@better-webhook/cli 0.2.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/LICENSE +22 -0
- package/README.md +156 -0
- package/dist/index.cjs +259 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +264 -0
- package/package.json +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Endalkachew Biruk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Better Webhook CLI
|
|
2
|
+
|
|
3
|
+
Simple CLI for listing, downloading, and executing predefined webhook JSON payloads stored in a local `.webhooks` directory.
|
|
4
|
+
|
|
5
|
+
## Install (workspace)
|
|
6
|
+
|
|
7
|
+
From repo root after cloning:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm install
|
|
11
|
+
pnpm --filter @better-webhook/cli build
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Run via pnpm:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm --filter @better-webhook/cli exec better-webhook list
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or in dev (watch) mode:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm --filter @better-webhook/cli dev run sample
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install (published)
|
|
27
|
+
|
|
28
|
+
After publishing to npm you can use:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx @better-webhook/cli list
|
|
32
|
+
# or (after global install)
|
|
33
|
+
pnpm add -g @better-webhook/cli
|
|
34
|
+
better-webhook list
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The binary name is `better-webhook`.
|
|
38
|
+
|
|
39
|
+
## Publishing (maintainers)
|
|
40
|
+
|
|
41
|
+
1. Update version (pnpm):
|
|
42
|
+
```bash
|
|
43
|
+
pnpm --filter @better-webhook/cli version patch # or minor / major
|
|
44
|
+
```
|
|
45
|
+
2. Build & publish (ensure you are logged in with `npm whoami`):
|
|
46
|
+
```bash
|
|
47
|
+
pnpm --filter @better-webhook/cli run build
|
|
48
|
+
cd apps/webhook-cli
|
|
49
|
+
npm publish --access public
|
|
50
|
+
```
|
|
51
|
+
(The `prepublishOnly` script also builds/validates automatically.)
|
|
52
|
+
3. Test install:
|
|
53
|
+
```bash
|
|
54
|
+
pnpm dlx @better-webhook/cli@latest --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Webhook File Schema
|
|
58
|
+
|
|
59
|
+
Every webhook JSON file MUST conform to this schema (validated with `zod`):
|
|
60
|
+
|
|
61
|
+
```jsonc
|
|
62
|
+
{
|
|
63
|
+
"url": "https://example.com/endpoint", // required, valid URL
|
|
64
|
+
"method": "POST", // optional, defaults to POST (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
|
|
65
|
+
"headers": [
|
|
66
|
+
// optional, defaults to []
|
|
67
|
+
{ "key": "X-Custom", "value": "abc" },
|
|
68
|
+
],
|
|
69
|
+
"body": {
|
|
70
|
+
// optional (omit for methods w/out body)
|
|
71
|
+
"event": "user.created",
|
|
72
|
+
"data": { "id": "123" },
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Validation errors list all failing fields with context.
|
|
78
|
+
|
|
79
|
+
## Directory Structure
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
./.webhooks/
|
|
83
|
+
user_created.json
|
|
84
|
+
order_paid.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Commands
|
|
88
|
+
|
|
89
|
+
### List
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
better-webhook list
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Lists all JSON filenames (without extension) in `.webhooks`.
|
|
96
|
+
|
|
97
|
+
### Download Templates
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
better-webhook download # lists available templates
|
|
101
|
+
better-webhook download stripe-invoice.payment_succeeded
|
|
102
|
+
better-webhook download --all
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Run
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
better-webhook run user_created
|
|
109
|
+
better-webhook run path/to/file.json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Overrides:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
--url https://override.test/hook
|
|
116
|
+
--method PUT
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Examples
|
|
120
|
+
|
|
121
|
+
Minimal:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{ "url": "https://example.com/hook" }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
With headers + body:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"url": "https://example.com/hook",
|
|
132
|
+
"method": "POST",
|
|
133
|
+
"headers": [
|
|
134
|
+
{ "key": "X-Env", "value": "staging" },
|
|
135
|
+
{ "key": "Authorization", "value": "Bearer TOKEN" }
|
|
136
|
+
],
|
|
137
|
+
"body": { "event": "deploy", "status": "ok" }
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Output
|
|
142
|
+
|
|
143
|
+
- Prints status code
|
|
144
|
+
- Prints response headers
|
|
145
|
+
- Pretty-prints JSON response or raw body
|
|
146
|
+
|
|
147
|
+
## Error Handling
|
|
148
|
+
|
|
149
|
+
- Invalid JSON -> fails with parse message
|
|
150
|
+
- Schema violations -> detailed list of issues
|
|
151
|
+
- Network errors -> reported with non-zero exit code
|
|
152
|
+
|
|
153
|
+
## Notes
|
|
154
|
+
|
|
155
|
+
- Content-Type automatically set to `application/json` if body present and not already specified.
|
|
156
|
+
- Headers defined later override earlier duplicates.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
var import_commander = require("commander");
|
|
6
|
+
var import_fs2 = require("fs");
|
|
7
|
+
var import_path = require("path");
|
|
8
|
+
|
|
9
|
+
// src/loader.ts
|
|
10
|
+
var import_fs = require("fs");
|
|
11
|
+
|
|
12
|
+
// src/schema.ts
|
|
13
|
+
var import_zod = require("zod");
|
|
14
|
+
var httpMethodSchema = import_zod.z.enum([
|
|
15
|
+
"GET",
|
|
16
|
+
"POST",
|
|
17
|
+
"PUT",
|
|
18
|
+
"PATCH",
|
|
19
|
+
"DELETE",
|
|
20
|
+
"HEAD",
|
|
21
|
+
"OPTIONS"
|
|
22
|
+
]);
|
|
23
|
+
var headerEntrySchema = import_zod.z.object({
|
|
24
|
+
key: import_zod.z.string().min(1, "Header key cannot be empty").regex(/^[A-Za-z0-9-]+$/, {
|
|
25
|
+
message: "Header key must contain only alphanumerics and -"
|
|
26
|
+
}),
|
|
27
|
+
value: import_zod.z.string().min(1, "Header value cannot be empty")
|
|
28
|
+
});
|
|
29
|
+
var webhookSchema = import_zod.z.object({
|
|
30
|
+
url: import_zod.z.string().url("Invalid URL"),
|
|
31
|
+
method: httpMethodSchema.default("POST"),
|
|
32
|
+
headers: import_zod.z.array(headerEntrySchema).default([]),
|
|
33
|
+
body: import_zod.z.any().optional()
|
|
34
|
+
// could be anything JSON-serializable
|
|
35
|
+
}).strict();
|
|
36
|
+
function validateWebhookJSON(raw, source) {
|
|
37
|
+
const parsed = webhookSchema.safeParse(raw);
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
const issues = parsed.error.issues.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
40
|
+
throw new Error(`Invalid webhook definition in ${source}:
|
|
41
|
+
${issues}`);
|
|
42
|
+
}
|
|
43
|
+
return parsed.data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/loader.ts
|
|
47
|
+
function loadWebhookFile(path) {
|
|
48
|
+
let rawContent;
|
|
49
|
+
try {
|
|
50
|
+
rawContent = (0, import_fs.readFileSync)(path, "utf8");
|
|
51
|
+
} catch (e) {
|
|
52
|
+
throw new Error(`Failed to read file ${path}: ${e.message}`);
|
|
53
|
+
}
|
|
54
|
+
let json;
|
|
55
|
+
try {
|
|
56
|
+
json = JSON.parse(rawContent);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
throw new Error(`Invalid JSON in file ${path}: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
return validateWebhookJSON(json, path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/http.ts
|
|
64
|
+
var import_undici = require("undici");
|
|
65
|
+
async function executeWebhook(def) {
|
|
66
|
+
const headerMap = {};
|
|
67
|
+
for (const h of def.headers) {
|
|
68
|
+
headerMap[h.key] = h.value;
|
|
69
|
+
}
|
|
70
|
+
if (!headerMap["content-type"] && def.body !== void 0) {
|
|
71
|
+
headerMap["content-type"] = "application/json";
|
|
72
|
+
}
|
|
73
|
+
const bodyPayload = def.body !== void 0 ? JSON.stringify(def.body) : void 0;
|
|
74
|
+
const { statusCode, headers, body } = await (0, import_undici.request)(def.url, {
|
|
75
|
+
method: def.method,
|
|
76
|
+
headers: headerMap,
|
|
77
|
+
body: bodyPayload
|
|
78
|
+
});
|
|
79
|
+
const text = await body.text();
|
|
80
|
+
let parsed;
|
|
81
|
+
if (text) {
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const resultHeaders = {};
|
|
88
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
89
|
+
resultHeaders[k] = v;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
status: statusCode,
|
|
93
|
+
headers: resultHeaders,
|
|
94
|
+
bodyText: text,
|
|
95
|
+
json: parsed
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/index.ts
|
|
100
|
+
var import_undici2 = require("undici");
|
|
101
|
+
var program = new import_commander.Command();
|
|
102
|
+
program.name("better-webhook").description("CLI for listing, downloading and executing predefined webhooks").version("0.2.0");
|
|
103
|
+
function findWebhooksDir(cwd) {
|
|
104
|
+
return (0, import_path.resolve)(cwd, ".webhooks");
|
|
105
|
+
}
|
|
106
|
+
function listWebhookFiles(dir) {
|
|
107
|
+
try {
|
|
108
|
+
const entries = (0, import_fs2.readdirSync)(dir);
|
|
109
|
+
return entries.filter(
|
|
110
|
+
(e) => (0, import_fs2.statSync)((0, import_path.join)(dir, e)).isFile() && (0, import_path.extname)(e) === ".json"
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
var TEMPLATE_REPO_BASE = "https://raw.githubusercontent.com/endalk200/better-webhook/main";
|
|
117
|
+
var TEMPLATES = {
|
|
118
|
+
"stripe-invoice.payment_succeeded": "templates/stripe-invoice.payment_succeeded.json"
|
|
119
|
+
};
|
|
120
|
+
program.command("download [name]").description(
|
|
121
|
+
"Download official webhook template(s) into the .webhooks directory. If no name is provided, prints available templates."
|
|
122
|
+
).option("-a, --all", "Download all available templates").option("-f, --force", "Overwrite existing files if they exist").action(
|
|
123
|
+
async (name, opts) => {
|
|
124
|
+
if (name && opts.all) {
|
|
125
|
+
console.error("Specify either a template name or --all, not both.");
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const cwd = process.cwd();
|
|
130
|
+
const dir = findWebhooksDir(cwd);
|
|
131
|
+
(0, import_fs2.mkdirSync)(dir, { recursive: true });
|
|
132
|
+
const toDownload = opts.all ? Object.keys(TEMPLATES) : name ? [name] : [];
|
|
133
|
+
if (!toDownload.length) {
|
|
134
|
+
console.log("Available templates:");
|
|
135
|
+
for (const key of Object.keys(TEMPLATES)) console.log(` - ${key}`);
|
|
136
|
+
console.log("Use: better-webhook download <name> OR --all");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const templateName of toDownload) {
|
|
140
|
+
const rel = TEMPLATES[templateName];
|
|
141
|
+
if (!rel) {
|
|
142
|
+
console.error(
|
|
143
|
+
`Unknown template '${templateName}'. Run without arguments to list available templates.`
|
|
144
|
+
);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const rawUrl = `${TEMPLATE_REPO_BASE}/${rel}`;
|
|
148
|
+
try {
|
|
149
|
+
const { statusCode, body } = await (0, import_undici2.request)(rawUrl);
|
|
150
|
+
if (statusCode !== 200) {
|
|
151
|
+
console.error(
|
|
152
|
+
`Failed to fetch ${templateName} (HTTP ${statusCode}) from ${rawUrl}`
|
|
153
|
+
);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const text = await body.text();
|
|
157
|
+
let json;
|
|
158
|
+
try {
|
|
159
|
+
json = JSON.parse(text);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.error(
|
|
162
|
+
`Invalid JSON in remote template ${templateName}: ${e.message}`
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
validateWebhookJSON(json, rawUrl);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error(`Template failed schema validation: ${e.message}`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const fileName = (0, import_path.basename)(rel);
|
|
173
|
+
const destPath = (0, import_path.join)(dir, fileName);
|
|
174
|
+
if ((0, import_fs2.existsSync)(destPath) && !opts.force) {
|
|
175
|
+
console.log(
|
|
176
|
+
`Skipping existing file ${fileName} (use --force to overwrite)`
|
|
177
|
+
);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
(0, import_fs2.writeFileSync)(destPath, JSON.stringify(json, null, 2));
|
|
181
|
+
console.log(`Downloaded ${templateName} -> .webhooks/${fileName}`);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.error(`Error downloading ${templateName}: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
program.command("list").description("List available webhook JSON definitions in .webhooks directory").action(() => {
|
|
189
|
+
const cwd = process.cwd();
|
|
190
|
+
const dir = findWebhooksDir(cwd);
|
|
191
|
+
const files = listWebhookFiles(dir);
|
|
192
|
+
if (!files.length) {
|
|
193
|
+
console.log("No webhook definitions found in .webhooks");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
files.forEach((f) => console.log((0, import_path.basename)(f, ".json")));
|
|
197
|
+
});
|
|
198
|
+
program.command("run <nameOrPath>").description(
|
|
199
|
+
"Run a webhook by name (in .webhooks) or by providing a path to a JSON file"
|
|
200
|
+
).option("-u, --url <url>", "Override destination URL").option("-m, --method <method>", "Override HTTP method").action(async (nameOrPath, options) => {
|
|
201
|
+
const cwd = process.cwd();
|
|
202
|
+
let filePath;
|
|
203
|
+
if (nameOrPath.endsWith(".json") && !nameOrPath.includes("/") && !nameOrPath.startsWith(".")) {
|
|
204
|
+
filePath = (0, import_path.join)(findWebhooksDir(cwd), nameOrPath);
|
|
205
|
+
} else {
|
|
206
|
+
const candidate = (0, import_path.join)(
|
|
207
|
+
findWebhooksDir(cwd),
|
|
208
|
+
nameOrPath + (nameOrPath.endsWith(".json") ? "" : ".json")
|
|
209
|
+
);
|
|
210
|
+
if (statExists(candidate)) {
|
|
211
|
+
filePath = candidate;
|
|
212
|
+
} else {
|
|
213
|
+
filePath = (0, import_path.resolve)(cwd, nameOrPath);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!statExists(filePath)) {
|
|
217
|
+
console.error(`Webhook file not found: ${filePath}`);
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
let def;
|
|
222
|
+
try {
|
|
223
|
+
def = loadWebhookFile(filePath);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error(err.message);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (options.url) def = { ...def, url: options.url };
|
|
230
|
+
if (options.method)
|
|
231
|
+
def = { ...def, method: options.method.toUpperCase() };
|
|
232
|
+
try {
|
|
233
|
+
const result = await executeWebhook(def);
|
|
234
|
+
console.log("Status:", result.status);
|
|
235
|
+
console.log("Headers:");
|
|
236
|
+
for (const [k, v] of Object.entries(result.headers)) {
|
|
237
|
+
console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
|
|
238
|
+
}
|
|
239
|
+
if (result.json !== void 0) {
|
|
240
|
+
console.log("Response JSON:");
|
|
241
|
+
console.log(JSON.stringify(result.json, null, 2));
|
|
242
|
+
} else {
|
|
243
|
+
console.log("Response Body:");
|
|
244
|
+
console.log(result.bodyText);
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error("Request failed:", err.message);
|
|
248
|
+
process.exitCode = 1;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
function statExists(p) {
|
|
252
|
+
try {
|
|
253
|
+
(0, import_fs2.statSync)(p);
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
program.parseAsync(process.argv);
|
package/dist/index.d.cts
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import {
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
existsSync
|
|
11
|
+
} from "fs";
|
|
12
|
+
import { join, resolve, basename, extname } from "path";
|
|
13
|
+
|
|
14
|
+
// src/loader.ts
|
|
15
|
+
import { readFileSync } from "fs";
|
|
16
|
+
|
|
17
|
+
// src/schema.ts
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
var httpMethodSchema = z.enum([
|
|
20
|
+
"GET",
|
|
21
|
+
"POST",
|
|
22
|
+
"PUT",
|
|
23
|
+
"PATCH",
|
|
24
|
+
"DELETE",
|
|
25
|
+
"HEAD",
|
|
26
|
+
"OPTIONS"
|
|
27
|
+
]);
|
|
28
|
+
var headerEntrySchema = z.object({
|
|
29
|
+
key: z.string().min(1, "Header key cannot be empty").regex(/^[A-Za-z0-9-]+$/, {
|
|
30
|
+
message: "Header key must contain only alphanumerics and -"
|
|
31
|
+
}),
|
|
32
|
+
value: z.string().min(1, "Header value cannot be empty")
|
|
33
|
+
});
|
|
34
|
+
var webhookSchema = z.object({
|
|
35
|
+
url: z.string().url("Invalid URL"),
|
|
36
|
+
method: httpMethodSchema.default("POST"),
|
|
37
|
+
headers: z.array(headerEntrySchema).default([]),
|
|
38
|
+
body: z.any().optional()
|
|
39
|
+
// could be anything JSON-serializable
|
|
40
|
+
}).strict();
|
|
41
|
+
function validateWebhookJSON(raw, source) {
|
|
42
|
+
const parsed = webhookSchema.safeParse(raw);
|
|
43
|
+
if (!parsed.success) {
|
|
44
|
+
const issues = parsed.error.issues.map((i) => `- ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
45
|
+
throw new Error(`Invalid webhook definition in ${source}:
|
|
46
|
+
${issues}`);
|
|
47
|
+
}
|
|
48
|
+
return parsed.data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/loader.ts
|
|
52
|
+
function loadWebhookFile(path) {
|
|
53
|
+
let rawContent;
|
|
54
|
+
try {
|
|
55
|
+
rawContent = readFileSync(path, "utf8");
|
|
56
|
+
} catch (e) {
|
|
57
|
+
throw new Error(`Failed to read file ${path}: ${e.message}`);
|
|
58
|
+
}
|
|
59
|
+
let json;
|
|
60
|
+
try {
|
|
61
|
+
json = JSON.parse(rawContent);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
throw new Error(`Invalid JSON in file ${path}: ${e.message}`);
|
|
64
|
+
}
|
|
65
|
+
return validateWebhookJSON(json, path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/http.ts
|
|
69
|
+
import { request } from "undici";
|
|
70
|
+
async function executeWebhook(def) {
|
|
71
|
+
const headerMap = {};
|
|
72
|
+
for (const h of def.headers) {
|
|
73
|
+
headerMap[h.key] = h.value;
|
|
74
|
+
}
|
|
75
|
+
if (!headerMap["content-type"] && def.body !== void 0) {
|
|
76
|
+
headerMap["content-type"] = "application/json";
|
|
77
|
+
}
|
|
78
|
+
const bodyPayload = def.body !== void 0 ? JSON.stringify(def.body) : void 0;
|
|
79
|
+
const { statusCode, headers, body } = await request(def.url, {
|
|
80
|
+
method: def.method,
|
|
81
|
+
headers: headerMap,
|
|
82
|
+
body: bodyPayload
|
|
83
|
+
});
|
|
84
|
+
const text = await body.text();
|
|
85
|
+
let parsed;
|
|
86
|
+
if (text) {
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(text);
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const resultHeaders = {};
|
|
93
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
94
|
+
resultHeaders[k] = v;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
status: statusCode,
|
|
98
|
+
headers: resultHeaders,
|
|
99
|
+
bodyText: text,
|
|
100
|
+
json: parsed
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/index.ts
|
|
105
|
+
import { request as request2 } from "undici";
|
|
106
|
+
var program = new Command();
|
|
107
|
+
program.name("better-webhook").description("CLI for listing, downloading and executing predefined webhooks").version("0.2.0");
|
|
108
|
+
function findWebhooksDir(cwd) {
|
|
109
|
+
return resolve(cwd, ".webhooks");
|
|
110
|
+
}
|
|
111
|
+
function listWebhookFiles(dir) {
|
|
112
|
+
try {
|
|
113
|
+
const entries = readdirSync(dir);
|
|
114
|
+
return entries.filter(
|
|
115
|
+
(e) => statSync(join(dir, e)).isFile() && extname(e) === ".json"
|
|
116
|
+
);
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
var TEMPLATE_REPO_BASE = "https://raw.githubusercontent.com/endalk200/better-webhook/main";
|
|
122
|
+
var TEMPLATES = {
|
|
123
|
+
"stripe-invoice.payment_succeeded": "templates/stripe-invoice.payment_succeeded.json"
|
|
124
|
+
};
|
|
125
|
+
program.command("download [name]").description(
|
|
126
|
+
"Download official webhook template(s) into the .webhooks directory. If no name is provided, prints available templates."
|
|
127
|
+
).option("-a, --all", "Download all available templates").option("-f, --force", "Overwrite existing files if they exist").action(
|
|
128
|
+
async (name, opts) => {
|
|
129
|
+
if (name && opts.all) {
|
|
130
|
+
console.error("Specify either a template name or --all, not both.");
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const cwd = process.cwd();
|
|
135
|
+
const dir = findWebhooksDir(cwd);
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
const toDownload = opts.all ? Object.keys(TEMPLATES) : name ? [name] : [];
|
|
138
|
+
if (!toDownload.length) {
|
|
139
|
+
console.log("Available templates:");
|
|
140
|
+
for (const key of Object.keys(TEMPLATES)) console.log(` - ${key}`);
|
|
141
|
+
console.log("Use: better-webhook download <name> OR --all");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (const templateName of toDownload) {
|
|
145
|
+
const rel = TEMPLATES[templateName];
|
|
146
|
+
if (!rel) {
|
|
147
|
+
console.error(
|
|
148
|
+
`Unknown template '${templateName}'. Run without arguments to list available templates.`
|
|
149
|
+
);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const rawUrl = `${TEMPLATE_REPO_BASE}/${rel}`;
|
|
153
|
+
try {
|
|
154
|
+
const { statusCode, body } = await request2(rawUrl);
|
|
155
|
+
if (statusCode !== 200) {
|
|
156
|
+
console.error(
|
|
157
|
+
`Failed to fetch ${templateName} (HTTP ${statusCode}) from ${rawUrl}`
|
|
158
|
+
);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const text = await body.text();
|
|
162
|
+
let json;
|
|
163
|
+
try {
|
|
164
|
+
json = JSON.parse(text);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error(
|
|
167
|
+
`Invalid JSON in remote template ${templateName}: ${e.message}`
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
validateWebhookJSON(json, rawUrl);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
console.error(`Template failed schema validation: ${e.message}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const fileName = basename(rel);
|
|
178
|
+
const destPath = join(dir, fileName);
|
|
179
|
+
if (existsSync(destPath) && !opts.force) {
|
|
180
|
+
console.log(
|
|
181
|
+
`Skipping existing file ${fileName} (use --force to overwrite)`
|
|
182
|
+
);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
writeFileSync(destPath, JSON.stringify(json, null, 2));
|
|
186
|
+
console.log(`Downloaded ${templateName} -> .webhooks/${fileName}`);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.error(`Error downloading ${templateName}: ${e.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
program.command("list").description("List available webhook JSON definitions in .webhooks directory").action(() => {
|
|
194
|
+
const cwd = process.cwd();
|
|
195
|
+
const dir = findWebhooksDir(cwd);
|
|
196
|
+
const files = listWebhookFiles(dir);
|
|
197
|
+
if (!files.length) {
|
|
198
|
+
console.log("No webhook definitions found in .webhooks");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
files.forEach((f) => console.log(basename(f, ".json")));
|
|
202
|
+
});
|
|
203
|
+
program.command("run <nameOrPath>").description(
|
|
204
|
+
"Run a webhook by name (in .webhooks) or by providing a path to a JSON file"
|
|
205
|
+
).option("-u, --url <url>", "Override destination URL").option("-m, --method <method>", "Override HTTP method").action(async (nameOrPath, options) => {
|
|
206
|
+
const cwd = process.cwd();
|
|
207
|
+
let filePath;
|
|
208
|
+
if (nameOrPath.endsWith(".json") && !nameOrPath.includes("/") && !nameOrPath.startsWith(".")) {
|
|
209
|
+
filePath = join(findWebhooksDir(cwd), nameOrPath);
|
|
210
|
+
} else {
|
|
211
|
+
const candidate = join(
|
|
212
|
+
findWebhooksDir(cwd),
|
|
213
|
+
nameOrPath + (nameOrPath.endsWith(".json") ? "" : ".json")
|
|
214
|
+
);
|
|
215
|
+
if (statExists(candidate)) {
|
|
216
|
+
filePath = candidate;
|
|
217
|
+
} else {
|
|
218
|
+
filePath = resolve(cwd, nameOrPath);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!statExists(filePath)) {
|
|
222
|
+
console.error(`Webhook file not found: ${filePath}`);
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let def;
|
|
227
|
+
try {
|
|
228
|
+
def = loadWebhookFile(filePath);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.error(err.message);
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (options.url) def = { ...def, url: options.url };
|
|
235
|
+
if (options.method)
|
|
236
|
+
def = { ...def, method: options.method.toUpperCase() };
|
|
237
|
+
try {
|
|
238
|
+
const result = await executeWebhook(def);
|
|
239
|
+
console.log("Status:", result.status);
|
|
240
|
+
console.log("Headers:");
|
|
241
|
+
for (const [k, v] of Object.entries(result.headers)) {
|
|
242
|
+
console.log(` ${k}: ${Array.isArray(v) ? v.join(", ") : v}`);
|
|
243
|
+
}
|
|
244
|
+
if (result.json !== void 0) {
|
|
245
|
+
console.log("Response JSON:");
|
|
246
|
+
console.log(JSON.stringify(result.json, null, 2));
|
|
247
|
+
} else {
|
|
248
|
+
console.log("Response Body:");
|
|
249
|
+
console.log(result.bodyText);
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error("Request failed:", err.message);
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
function statExists(p) {
|
|
257
|
+
try {
|
|
258
|
+
statSync(p);
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
program.parseAsync(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-webhook/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CLI for developing and replaying webhook payloads locally.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"better-webhook": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.cjs",
|
|
10
|
+
"module": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"require": "./dist/index.cjs"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"webhook",
|
|
25
|
+
"cli",
|
|
26
|
+
"developer-tools",
|
|
27
|
+
"testing",
|
|
28
|
+
"http"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "Endalk <endalk200>",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/endalk200/better-webhook.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/endalk200/better-webhook/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/endalk200/better-webhook#readme",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"commander": "^14.0.1",
|
|
48
|
+
"undici": "^7.16.0",
|
|
49
|
+
"zod": "^4.1.8"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^24.3.1",
|
|
53
|
+
"tsup": "^8.5.0",
|
|
54
|
+
"tsx": "^4.19.2",
|
|
55
|
+
"typescript": "^5.6.3",
|
|
56
|
+
"@better-webhook/typescript-config": "0.0.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsup",
|
|
60
|
+
"dev": "tsup --watch"
|
|
61
|
+
}
|
|
62
|
+
}
|