@schmock/cli 2.3.0 → 2.3.1

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/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@schmock/cli",
3
3
  "description": "CLI for running Schmock mock servers from OpenAPI specs",
4
- "version": "2.3.0",
4
+ "version": "2.3.1",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/khalic-lab/schmock.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://github.com/khalic-lab/schmock/tree/main/packages/cli#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/khalic-lab/schmock/issues"
14
+ },
6
15
  "main": "./dist/index.js",
7
16
  "types": "./dist/index.d.ts",
8
17
  "bin": {
@@ -20,7 +29,8 @@
20
29
  "./package.json": "./package.json"
21
30
  },
22
31
  "scripts": {
23
- "build": "bun build:lib && bun build:types",
32
+ "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo",
33
+ "build": "bun run clean && bun run build:lib && bun run build:types",
24
34
  "build:lib": "bun build --target node --format esm --outdir=dist src/index.ts src/bin.ts --external @schmock/core --external @schmock/openapi",
25
35
  "build:types": "tsc -p tsconfig.json",
26
36
  "test": "vitest run",
@@ -31,9 +41,12 @@
31
41
  "check:publish": "publint && attw --pack --ignore-rules cjs-resolves-to-esm"
32
42
  },
33
43
  "license": "MIT",
44
+ "engines": {
45
+ "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0"
46
+ },
34
47
  "dependencies": {
35
- "@schmock/core": "^2.3.0",
36
- "@schmock/openapi": "^2.3.0"
48
+ "@schmock/core": "^2.3.1",
49
+ "@schmock/openapi": "^2.3.1"
37
50
  },
38
51
  "devDependencies": {
39
52
  "@amiceli/vitest-cucumber": "^7.0.0",
package/dist/cli.js DELETED
@@ -1,351 +0,0 @@
1
- /// <reference path="../../core/schmock.d.ts" />
2
- import { readFileSync, watch } from "node:fs";
3
- import { createServer } from "node:http";
4
- import { parseArgs } from "node:util";
5
- import { collectBody, HTTP_METHODS, isHttpMethod, parseNodeHeaders, parseNodeQuery, schmock, writeSchmockResponse, } from "@schmock/core";
6
- import { openapi } from "@schmock/openapi";
7
- const MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024;
8
- const ALLOWED_METHODS = HTTP_METHODS.join(", ");
9
- const CORS_HEADERS = {
10
- "access-control-allow-origin": "*",
11
- "access-control-allow-methods": ALLOWED_METHODS,
12
- "access-control-allow-headers": "Content-Type, Authorization",
13
- };
14
- class CliHttpError extends Error {
15
- status;
16
- code;
17
- headers;
18
- constructor(status, code, message, headers = {}) {
19
- super(message);
20
- this.status = status;
21
- this.code = code;
22
- this.headers = headers;
23
- this.name = "CliHttpError";
24
- }
25
- }
26
- function isSeedSource(value) {
27
- return (Array.isArray(value) ||
28
- typeof value === "string" ||
29
- (typeof value === "object" && value !== null && "count" in value));
30
- }
31
- function loadSeedFile(seedPath) {
32
- const raw = readFileSync(seedPath, "utf-8");
33
- const parsed = JSON.parse(raw);
34
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
35
- throw new Error(`Seed file must contain a JSON object, got: ${typeof parsed}`);
36
- }
37
- const result = {};
38
- for (const [key, value] of Object.entries(parsed)) {
39
- if (isSeedSource(value)) {
40
- result[key] = value;
41
- }
42
- }
43
- return result;
44
- }
45
- function handleAdminRequest(method, path, mock, res, headers) {
46
- const route = path.replace("/schmock-admin/", "");
47
- if (method === "GET" && route === "routes") {
48
- res.writeHead(200, headers);
49
- res.end(JSON.stringify(mock.getRoutes()));
50
- return;
51
- }
52
- if (method === "GET" && route === "state") {
53
- res.writeHead(200, headers);
54
- res.end(JSON.stringify(mock.getState()));
55
- return;
56
- }
57
- if (method === "POST" && route === "reset") {
58
- mock.resetHistory();
59
- mock.resetState();
60
- res.writeHead(204, headers);
61
- res.end();
62
- return;
63
- }
64
- if (method === "GET" && route === "history") {
65
- res.writeHead(200, headers);
66
- res.end(JSON.stringify(mock.history()));
67
- return;
68
- }
69
- res.writeHead(404, headers);
70
- res.end(JSON.stringify({ error: "Unknown admin endpoint", code: "NOT_FOUND" }));
71
- }
72
- function parseRequestUrl(req) {
73
- const host = req.headers.host;
74
- if (!host) {
75
- throw new CliHttpError(400, "BAD_REQUEST", "Missing Host header");
76
- }
77
- try {
78
- return new URL(req.url ?? "/", `http://${host}`);
79
- }
80
- catch {
81
- throw new CliHttpError(400, "BAD_REQUEST", "Malformed request target");
82
- }
83
- }
84
- function parseRequestMethod(req) {
85
- const method = (req.method ?? "GET").toUpperCase();
86
- if (!isHttpMethod(method)) {
87
- throw new CliHttpError(405, "METHOD_NOT_ALLOWED", `Unsupported HTTP method: ${method}`, { allow: ALLOWED_METHODS });
88
- }
89
- return method;
90
- }
91
- function assertBodySize(headers) {
92
- const contentLength = headers["content-length"];
93
- if (contentLength === undefined)
94
- return;
95
- const declaredSize = Number(contentLength);
96
- if (Number.isFinite(declaredSize) && declaredSize > MAX_REQUEST_BODY_SIZE) {
97
- throw new CliHttpError(413, "PAYLOAD_TOO_LARGE", "Request body too large");
98
- }
99
- }
100
- function toCliHttpError(error) {
101
- if (error instanceof CliHttpError)
102
- return error;
103
- if (error !== null &&
104
- typeof error === "object" &&
105
- "status" in error &&
106
- error.status === 413) {
107
- return new CliHttpError(413, "PAYLOAD_TOO_LARGE", error instanceof Error ? error.message : "Request body too large");
108
- }
109
- return new CliHttpError(500, "SERVER_ERROR", error instanceof Error ? error.message : "Internal Server Error");
110
- }
111
- async function handleCliRequest(req, res, mock, options) {
112
- try {
113
- const url = parseRequestUrl(req);
114
- const method = parseRequestMethod(req);
115
- const path = url.pathname;
116
- if (options.cors && method === "OPTIONS") {
117
- res.writeHead(204, CORS_HEADERS);
118
- res.end();
119
- return;
120
- }
121
- if (options.admin && path.startsWith("/schmock-admin/")) {
122
- const adminHeaders = {
123
- "content-type": "application/json",
124
- ...(options.cors ? CORS_HEADERS : {}),
125
- };
126
- handleAdminRequest(method, path, mock, res, adminHeaders);
127
- return;
128
- }
129
- const headers = parseNodeHeaders(req);
130
- assertBodySize(headers);
131
- const query = parseNodeQuery(url);
132
- const body = await collectBody(req, headers, MAX_REQUEST_BODY_SIZE);
133
- const schmockResponse = await mock.handle(method, path, {
134
- headers,
135
- body,
136
- query,
137
- });
138
- const extraHeaders = options.cors ? CORS_HEADERS : undefined;
139
- writeSchmockResponse(res, schmockResponse, extraHeaders);
140
- }
141
- catch (error) {
142
- const cliError = toCliHttpError(error);
143
- try {
144
- if (!res.headersSent) {
145
- res.writeHead(cliError.status, {
146
- "content-type": "application/json",
147
- ...(options.cors ? CORS_HEADERS : {}),
148
- ...cliError.headers,
149
- });
150
- }
151
- if (!res.writableEnded) {
152
- res.end(JSON.stringify({ error: cliError.message, code: cliError.code }));
153
- }
154
- }
155
- catch {
156
- res.destroy();
157
- }
158
- }
159
- }
160
- async function createCliMock(options) {
161
- const mock = schmock({ debug: options.debug, state: {} });
162
- const openapiOptions = {
163
- spec: options.spec,
164
- fakerSeed: options.fakerSeed,
165
- validateRequests: options.errors,
166
- };
167
- if (options.seed) {
168
- openapiOptions.seed = loadSeedFile(options.seed);
169
- }
170
- const plugin = await openapi(openapiOptions);
171
- mock.pipe(plugin);
172
- return mock;
173
- }
174
- function listenWithMock(options, mock) {
175
- const hostname = options.hostname ?? "127.0.0.1";
176
- const port = options.port ?? 3000;
177
- const cors = options.cors ?? false;
178
- const admin = options.admin ?? false;
179
- const httpServer = createServer((req, res) => {
180
- void handleCliRequest(req, res, mock, { admin, cors });
181
- });
182
- return new Promise((resolve, reject) => {
183
- httpServer.on("error", reject);
184
- httpServer.listen(port, hostname, () => {
185
- const addr = httpServer.address();
186
- const actualPort = addr !== null && typeof addr === "object" ? addr.port : port;
187
- resolve({
188
- server: httpServer,
189
- port: actualPort,
190
- hostname,
191
- close() {
192
- httpServer.close();
193
- },
194
- });
195
- });
196
- });
197
- }
198
- export async function createCliServer(options) {
199
- const mock = await createCliMock(options);
200
- return listenWithMock(options, mock);
201
- }
202
- function validatePort(value) {
203
- const port = Number(value);
204
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
205
- throw new Error(`Invalid port "${value}". Port must be an integer between 0 and 65535.`);
206
- }
207
- return port;
208
- }
209
- export function parseCliArgs(args) {
210
- const { values, positionals } = parseArgs({
211
- args,
212
- options: {
213
- spec: { type: "string" },
214
- port: { type: "string" },
215
- hostname: { type: "string" },
216
- seed: { type: "string" },
217
- cors: { type: "boolean", default: false },
218
- debug: { type: "boolean", default: false },
219
- errors: { type: "boolean", default: false },
220
- watch: { type: "boolean", default: false },
221
- admin: { type: "boolean", default: false },
222
- "seed-random": { type: "string" },
223
- help: { type: "boolean", short: "h", default: false },
224
- },
225
- strict: true,
226
- allowPositionals: true,
227
- });
228
- const spec = values.spec ?? positionals[0] ?? "";
229
- return {
230
- spec,
231
- port: values.port ? validatePort(values.port) : undefined,
232
- hostname: values.hostname,
233
- seed: values.seed,
234
- cors: values.cors,
235
- debug: values.debug,
236
- errors: values.errors,
237
- watch: values.watch,
238
- admin: values.admin,
239
- fakerSeed: values["seed-random"]
240
- ? Number(values["seed-random"])
241
- : undefined,
242
- help: values.help ?? false,
243
- };
244
- }
245
- const USAGE = `Usage: schmock <spec> [options]
246
- schmock --spec <path> [options]
247
-
248
- Options:
249
- --spec <path> OpenAPI/Swagger spec file (or pass as first argument)
250
- --port <number> Port to listen on (default: 3000)
251
- --hostname <host> Hostname to bind to (default: 127.0.0.1)
252
- --seed <path> JSON file with seed data
253
- --cors Enable CORS for all responses
254
- --debug Enable debug logging
255
- --errors Enable request body validation against spec
256
- --watch Watch spec file and hot-reload on changes
257
- --admin Enable /schmock-admin/* introspection endpoints
258
- --seed-random <n> Seed for deterministic random generation
259
- -h, --help Show this help message
260
- `;
261
- const WATCH_DEBOUNCE_MS = 500;
262
- /**
263
- * Prepare a replacement before closing the live server, then bind it to the
264
- * same port. Invalid spec changes therefore leave the current server online.
265
- */
266
- export async function reloadServer(current, options) {
267
- const port = current.port;
268
- const nextOptions = { ...options, port };
269
- const replacementMock = await createCliMock(nextOptions);
270
- // Close existing connections and wait for the server to fully close
271
- current.server.closeAllConnections();
272
- await new Promise((resolve) => {
273
- current.server.close(() => resolve());
274
- });
275
- return listenWithMock(nextOptions, replacementMock);
276
- }
277
- /**
278
- * Watch a spec file and hot-reload the server on changes.
279
- */
280
- export function startWatch(specPath, options, getCurrentServer, onReload) {
281
- let debounceTimer;
282
- let closed = false;
283
- let reloadQueue = Promise.resolve();
284
- const watcher = watch(specPath, () => {
285
- if (debounceTimer)
286
- clearTimeout(debounceTimer);
287
- debounceTimer = setTimeout(() => {
288
- reloadQueue = reloadQueue.then(async () => {
289
- if (closed)
290
- return;
291
- process.stderr.write("\nSpec changed, reloading...\n");
292
- try {
293
- const newServer = await reloadServer(getCurrentServer(), options);
294
- if (closed) {
295
- newServer.close();
296
- return;
297
- }
298
- onReload(newServer);
299
- process.stderr.write(`Schmock server reloaded on http://${newServer.hostname}:${newServer.port}\n`);
300
- }
301
- catch (err) {
302
- process.stderr.write(`Reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
303
- }
304
- });
305
- }, WATCH_DEBOUNCE_MS);
306
- });
307
- return {
308
- close() {
309
- closed = true;
310
- watcher.close();
311
- if (debounceTimer)
312
- clearTimeout(debounceTimer);
313
- },
314
- };
315
- }
316
- export async function run(args) {
317
- const options = parseCliArgs(args);
318
- if (options.help) {
319
- process.stderr.write(USAGE);
320
- return;
321
- }
322
- if (!options.spec) {
323
- process.stderr.write("Error: --spec is required\n\n");
324
- process.stderr.write(USAGE);
325
- process.exitCode = 1;
326
- return;
327
- }
328
- let cliServer = await createCliServer(options);
329
- process.stderr.write(`Schmock server running on http://${cliServer.hostname}:${cliServer.port}\n`);
330
- process.stderr.write(`Spec: ${options.spec}\n`);
331
- if (options.cors) {
332
- process.stderr.write("CORS: enabled\n");
333
- }
334
- if (options.admin) {
335
- process.stderr.write("Admin: enabled (/schmock-admin/*)\n");
336
- }
337
- let watchHandle;
338
- if (options.watch) {
339
- watchHandle = startWatch(options.spec, options, () => cliServer, (newServer) => {
340
- cliServer = newServer;
341
- });
342
- process.stderr.write("Watch: enabled\n");
343
- }
344
- const shutdown = () => {
345
- process.stderr.write("\nShutting down...\n");
346
- watchHandle?.close();
347
- cliServer.close();
348
- };
349
- process.on("SIGINT", shutdown);
350
- process.on("SIGTERM", shutdown);
351
- }