stubsmith 0.0.0-stage → 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 +21 -0
- package/README.md +619 -2
- package/dist/cli.js +575 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +5254 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +806 -0
- package/dist/index.d.ts +806 -0
- package/dist/index.js +5199 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { realpathSync } from "fs";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
6
|
+
|
|
7
|
+
// src/_version.ts
|
|
8
|
+
var VERSION = "0.2.0";
|
|
9
|
+
|
|
10
|
+
// src/pull.ts
|
|
11
|
+
import { randomBytes } from "crypto";
|
|
12
|
+
import { promises as fsp } from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
|
|
15
|
+
// src/env.ts
|
|
16
|
+
var DEFAULT_BACKEND_URL = "https://app.stubsmith.dev/api";
|
|
17
|
+
function env(name) {
|
|
18
|
+
return process.env[name];
|
|
19
|
+
}
|
|
20
|
+
function resolveApiUrl() {
|
|
21
|
+
return env("STUBSMITH_API_URL") || env("STUBSMITH_BACKEND_URL") || DEFAULT_BACKEND_URL;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/internal/codepoint.ts
|
|
25
|
+
function compareCodePoints(a, b) {
|
|
26
|
+
const la = a.length;
|
|
27
|
+
const lb = b.length;
|
|
28
|
+
let i = 0;
|
|
29
|
+
let j = 0;
|
|
30
|
+
while (i < la && j < lb) {
|
|
31
|
+
const pa = a.codePointAt(i);
|
|
32
|
+
const pb = b.codePointAt(j);
|
|
33
|
+
if (pa !== pb) return pa < pb ? -1 : 1;
|
|
34
|
+
i += pa > 65535 ? 2 : 1;
|
|
35
|
+
j += pb > 65535 ? 2 : 1;
|
|
36
|
+
}
|
|
37
|
+
if (i < la) return 1;
|
|
38
|
+
if (j < lb) return -1;
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/replayState.ts
|
|
43
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
44
|
+
import http from "http";
|
|
45
|
+
import https from "https";
|
|
46
|
+
var DEPTH_KEY = /* @__PURE__ */ Symbol.for("stubsmith.replayDepth");
|
|
47
|
+
var GUARD_KEY = /* @__PURE__ */ Symbol.for("stubsmith.captureGuard");
|
|
48
|
+
var REQUEST_KEY = /* @__PURE__ */ Symbol.for("stubsmith.originalRequest");
|
|
49
|
+
var g = globalThis;
|
|
50
|
+
function sharedSlot(key, init) {
|
|
51
|
+
if (g[key] === void 0) g[key] = init();
|
|
52
|
+
return g[key];
|
|
53
|
+
}
|
|
54
|
+
var depth = sharedSlot(DEPTH_KEY, () => ({ n: 0 }));
|
|
55
|
+
var captureGuard = sharedSlot(GUARD_KEY, () => new AsyncLocalStorage());
|
|
56
|
+
var ORIGINAL_REQUEST = sharedSlot(REQUEST_KEY, () => ({
|
|
57
|
+
http: http.request,
|
|
58
|
+
https: https.request
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
// src/transport.ts
|
|
62
|
+
var HOLD_TOTAL_MS = 12e4;
|
|
63
|
+
function sendRequest(url, opts) {
|
|
64
|
+
let req = null;
|
|
65
|
+
let aborted = false;
|
|
66
|
+
const abort = () => {
|
|
67
|
+
aborted = true;
|
|
68
|
+
req?.destroy(new Error("aborted"));
|
|
69
|
+
};
|
|
70
|
+
const promise = new Promise((resolve, reject) => {
|
|
71
|
+
let settled = false;
|
|
72
|
+
let total = null;
|
|
73
|
+
const fail = (err2) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
settled = true;
|
|
76
|
+
if (total) clearTimeout(total);
|
|
77
|
+
reject(err2 instanceof Error ? err2 : new Error(String(err2)));
|
|
78
|
+
};
|
|
79
|
+
const succeed = (r2) => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
settled = true;
|
|
82
|
+
if (total) clearTimeout(total);
|
|
83
|
+
resolve(r2);
|
|
84
|
+
};
|
|
85
|
+
let u;
|
|
86
|
+
try {
|
|
87
|
+
u = new URL(url);
|
|
88
|
+
} catch (err2) {
|
|
89
|
+
fail(err2);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const request = u.protocol === "https:" ? ORIGINAL_REQUEST.https : u.protocol === "http:" ? ORIGINAL_REQUEST.http : null;
|
|
93
|
+
if (request === null) {
|
|
94
|
+
fail(new Error(`unsupported protocol ${u.protocol}`));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const headers = { ...opts.headers };
|
|
98
|
+
if (opts.body !== void 0) headers["content-length"] = String(opts.body.length);
|
|
99
|
+
try {
|
|
100
|
+
captureGuard.run(true, () => {
|
|
101
|
+
req = request(u, { method: opts.method, headers }, (res) => {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let got = 0;
|
|
104
|
+
let truncated = false;
|
|
105
|
+
const finish = () => {
|
|
106
|
+
if (aborted) fail(new Error("aborted"));
|
|
107
|
+
else {
|
|
108
|
+
succeed({
|
|
109
|
+
status: res.statusCode ?? 0,
|
|
110
|
+
headers: res.headers,
|
|
111
|
+
body: Buffer.concat(chunks).subarray(0, opts.maxBytes),
|
|
112
|
+
truncated,
|
|
113
|
+
complete: !truncated && res.complete
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
res.on("data", (c) => {
|
|
118
|
+
if (got < opts.maxBytes) chunks.push(c);
|
|
119
|
+
got += c.length;
|
|
120
|
+
if (got > opts.maxBytes) {
|
|
121
|
+
truncated = true;
|
|
122
|
+
finish();
|
|
123
|
+
res.destroy();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
res.on("end", finish);
|
|
127
|
+
res.on("error", finish);
|
|
128
|
+
res.on("close", finish);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
} catch (err2) {
|
|
132
|
+
fail(err2);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const r = req;
|
|
136
|
+
const hold = opts.holdProcess === true;
|
|
137
|
+
const totalMs = opts.totalTimeoutMs ?? (hold ? HOLD_TOTAL_MS : opts.timeoutMs);
|
|
138
|
+
total = setTimeout(() => r.destroy(new Error("timeout")), totalMs);
|
|
139
|
+
if (!hold) {
|
|
140
|
+
total.unref();
|
|
141
|
+
r.on("socket", (s) => s.unref());
|
|
142
|
+
}
|
|
143
|
+
r.setTimeout(opts.timeoutMs, () => r.destroy(new Error("timeout")));
|
|
144
|
+
r.on("error", fail);
|
|
145
|
+
r.end(opts.body);
|
|
146
|
+
if (aborted) r.destroy(new Error("aborted"));
|
|
147
|
+
});
|
|
148
|
+
return { promise, abort };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/pull.ts
|
|
152
|
+
var TIMEOUT_MS = 3e4;
|
|
153
|
+
var MAX_BYTES = 128 * 1024 * 1024;
|
|
154
|
+
var MAX_MIB = MAX_BYTES / (1024 * 1024);
|
|
155
|
+
function isObj(v) {
|
|
156
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
157
|
+
}
|
|
158
|
+
function arr(v) {
|
|
159
|
+
return Array.isArray(v) ? v.filter(isObj) : [];
|
|
160
|
+
}
|
|
161
|
+
function str(v) {
|
|
162
|
+
return typeof v === "string" ? v : "";
|
|
163
|
+
}
|
|
164
|
+
function parseEndpoint(raw) {
|
|
165
|
+
const i = raw.indexOf(" ");
|
|
166
|
+
if (i < 0) return null;
|
|
167
|
+
const method = raw.slice(0, i).trim();
|
|
168
|
+
const p = raw.slice(i + 1).trim();
|
|
169
|
+
if (!method || !p) return null;
|
|
170
|
+
return { method: method.toUpperCase(), path: p };
|
|
171
|
+
}
|
|
172
|
+
function normaliseSamples(samples, endpointGiven) {
|
|
173
|
+
const raw = String(samples).trim().toLowerCase();
|
|
174
|
+
if (raw !== "all" && !/^\d+$/.test(raw)) {
|
|
175
|
+
throw new Error(`samples must be a positive integer or 'all', got: ${JSON.stringify(samples)}`);
|
|
176
|
+
}
|
|
177
|
+
if (raw !== "all" && Number(raw) < 1) throw new Error(`samples must be at least 1, got: ${JSON.stringify(samples)}`);
|
|
178
|
+
if ((raw === "all" || Number(raw) > 1) && !endpointGiven) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'samples greater than 1 requires an endpoint. Fetching every recording for a whole project is not supported; scope it, e.g. endpoints: ["GET /api/orders"].'
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return raw;
|
|
184
|
+
}
|
|
185
|
+
async function fetchOne(apiUrl, apiKey, method, p, samples) {
|
|
186
|
+
const params = new URLSearchParams();
|
|
187
|
+
if (method) params.set("method", method);
|
|
188
|
+
if (p) params.set("path", p);
|
|
189
|
+
if (samples !== null) params.set("samples", samples);
|
|
190
|
+
const qs = [...params].length > 0 ? `?${params.toString()}` : "";
|
|
191
|
+
const url = `${apiUrl.replace(/\/+$/, "")}/v1/replay/bundle${qs}`;
|
|
192
|
+
let res;
|
|
193
|
+
try {
|
|
194
|
+
res = await sendRequest(url, {
|
|
195
|
+
method: "GET",
|
|
196
|
+
headers: { authorization: `Bearer ${apiKey}`, "user-agent": `stubsmith-cli/${VERSION}` },
|
|
197
|
+
timeoutMs: TIMEOUT_MS,
|
|
198
|
+
holdProcess: true,
|
|
199
|
+
maxBytes: MAX_BYTES
|
|
200
|
+
}).promise;
|
|
201
|
+
} catch (err2) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Network error reaching ${url}: ${err2 instanceof Error ? err2.message : String(err2)}
|
|
204
|
+
Check that the backend is reachable, or set STUBSMITH_API_URL to override (default: ${DEFAULT_BACKEND_URL}).`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (res.truncated) throw new Error(`Bundle response exceeded ${MAX_MIB} MiB; narrow with --endpoint.`);
|
|
208
|
+
const text = res.body.toString("utf8");
|
|
209
|
+
if (res.status === 404) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`HTTP 404 from ${url}
|
|
212
|
+
The StubSmith backend was not found at that URL.
|
|
213
|
+
For the hosted service the URL is ${DEFAULT_BACKEND_URL}.
|
|
214
|
+
Override with STUBSMITH_API_URL, or unset it to use the default.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}: ${text}`);
|
|
218
|
+
if (!res.complete) throw new Error(`Connection closed before the response completed (${url}). Retry the pull.`);
|
|
219
|
+
let data;
|
|
220
|
+
try {
|
|
221
|
+
data = JSON.parse(text);
|
|
222
|
+
} catch (err2) {
|
|
223
|
+
throw new Error(`Server returned unparseable JSON: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
224
|
+
}
|
|
225
|
+
if (!isObj(data)) throw new Error("Server returned an unexpected response (not a JSON object)");
|
|
226
|
+
if (!Array.isArray(data.endpoints)) throw new Error("Server returned an unexpected response (no endpoints array)");
|
|
227
|
+
return data;
|
|
228
|
+
}
|
|
229
|
+
function cursorValue(v) {
|
|
230
|
+
if (v === void 0 || v === null || v === "" || v === 0 || v === false) return 0n;
|
|
231
|
+
if (typeof v === "number") return Number.isInteger(v) ? BigInt(v) : BigInt(Math.trunc(v));
|
|
232
|
+
if (typeof v === "string" && /^\s*[+-]?\d+(?:_\d+)*\s*$/.test(v)) return BigInt(v.trim().replace(/_/g, ""));
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
function mergeBundles(typed) {
|
|
236
|
+
const parts = typed;
|
|
237
|
+
if (parts.length === 0) return { ok: true, version: 1, cursor: "0", endpoints: [] };
|
|
238
|
+
if (parts.length === 1) return typed[0];
|
|
239
|
+
const merged = /* @__PURE__ */ new Map();
|
|
240
|
+
const seen = /* @__PURE__ */ new Map();
|
|
241
|
+
const truncated = /* @__PURE__ */ new Map();
|
|
242
|
+
let cursor = 0n;
|
|
243
|
+
for (const part of parts) {
|
|
244
|
+
const c = cursorValue(part.cursor);
|
|
245
|
+
if (c !== null && c > cursor) cursor = c;
|
|
246
|
+
if (isObj(part.truncated)) {
|
|
247
|
+
for (const [k, v] of Object.entries(part.truncated)) if (!truncated.has(k)) truncated.set(k, v);
|
|
248
|
+
}
|
|
249
|
+
for (const ep of arr(part.endpoints)) {
|
|
250
|
+
const key = JSON.stringify([str(ep.domain), str(ep.method).toUpperCase(), str(ep.path_template)]);
|
|
251
|
+
let m = merged.get(key);
|
|
252
|
+
if (!m) {
|
|
253
|
+
m = { ...ep, stubs: [] };
|
|
254
|
+
merged.set(key, m);
|
|
255
|
+
seen.set(key, /* @__PURE__ */ new Set());
|
|
256
|
+
}
|
|
257
|
+
const fps = seen.get(key);
|
|
258
|
+
for (const stub of arr(ep.stubs)) {
|
|
259
|
+
const fp = str(stub.fingerprint);
|
|
260
|
+
if (fps.has(fp)) continue;
|
|
261
|
+
fps.add(fp);
|
|
262
|
+
m.stubs.push(stub);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const out2 = {
|
|
267
|
+
ok: true,
|
|
268
|
+
version: parts[0].version ?? 1,
|
|
269
|
+
cursor: cursor.toString(),
|
|
270
|
+
generated_at: parts[parts.length - 1].generated_at ?? null,
|
|
271
|
+
endpoints: [...merged.values()]
|
|
272
|
+
};
|
|
273
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
274
|
+
const ps = parts[i].project_settings;
|
|
275
|
+
if (isObj(ps) && typeof ps.group_ids_in_paths === "boolean") {
|
|
276
|
+
out2.project_settings = ps;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (truncated.size > 0) out2.truncated = Object.fromEntries(truncated);
|
|
281
|
+
return out2;
|
|
282
|
+
}
|
|
283
|
+
function statusKey(v) {
|
|
284
|
+
return typeof v.status === "number" ? v.status : 0;
|
|
285
|
+
}
|
|
286
|
+
function sortBundle(typed) {
|
|
287
|
+
const data = typed;
|
|
288
|
+
const endpoints = [...arr(data.endpoints)].sort(
|
|
289
|
+
(a, b) => compareCodePoints(str(a.domain), str(b.domain)) || compareCodePoints(str(a.method), str(b.method)) || compareCodePoints(str(a.path_template), str(b.path_template))
|
|
290
|
+
);
|
|
291
|
+
return {
|
|
292
|
+
...data,
|
|
293
|
+
endpoints: endpoints.map((ep) => ({
|
|
294
|
+
...ep,
|
|
295
|
+
stubs: [...arr(ep.stubs)].sort((a, b) => compareCodePoints(str(a.fingerprint), str(b.fingerprint))).map((stub) => ({ ...stub, variants: [...arr(stub.variants)].sort((a, b) => statusKey(a) - statusKey(b)) }))
|
|
296
|
+
}))
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function summarizeBundle(typed) {
|
|
300
|
+
const data = typed;
|
|
301
|
+
const eps = arr(data.endpoints);
|
|
302
|
+
const summary = { endpoints: eps.length, stubs: 0, variants: 0, degraded: [], bodyCapped: 0 };
|
|
303
|
+
for (const ep of eps) {
|
|
304
|
+
for (const stub of arr(ep.stubs)) {
|
|
305
|
+
summary.stubs++;
|
|
306
|
+
if (stub.degraded) {
|
|
307
|
+
summary.degraded.push({
|
|
308
|
+
domain: str(ep.domain),
|
|
309
|
+
method: str(ep.method),
|
|
310
|
+
pathTemplate: str(ep.path_template),
|
|
311
|
+
fingerprint: str(stub.fingerprint)
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
for (const v of arr(stub.variants)) {
|
|
315
|
+
summary.variants++;
|
|
316
|
+
if (v.body_capped) summary.bodyCapped++;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return summary;
|
|
321
|
+
}
|
|
322
|
+
function resolveAuth(opts) {
|
|
323
|
+
const apiKey = opts.apiKey || process.env.STUBSMITH_API_KEY || "";
|
|
324
|
+
if (!apiKey) throw new Error("No API key: pass apiKey or set $STUBSMITH_API_KEY.");
|
|
325
|
+
return { apiKey, apiUrl: opts.apiUrl || resolveApiUrl() };
|
|
326
|
+
}
|
|
327
|
+
async function pullBundle(opts = {}) {
|
|
328
|
+
const { apiKey, apiUrl } = resolveAuth(opts);
|
|
329
|
+
const targets = [];
|
|
330
|
+
for (const raw of opts.endpoints ?? []) {
|
|
331
|
+
const ep = parseEndpoint(raw);
|
|
332
|
+
if (!ep) throw new Error(`endpoint must be "METHOD /path" (e.g. "GET /api/orders"), got: ${JSON.stringify(raw)}`);
|
|
333
|
+
targets.push({ method: ep.method, path: ep.path });
|
|
334
|
+
}
|
|
335
|
+
const samples = opts.samples === void 0 ? null : normaliseSamples(opts.samples, targets.length > 0);
|
|
336
|
+
if (targets.length === 0) targets.push({ method: null, path: null });
|
|
337
|
+
const fetched = [];
|
|
338
|
+
for (const t of targets) {
|
|
339
|
+
if (t.method && t.path) opts.onFetch?.(t.method, t.path);
|
|
340
|
+
fetched.push(await fetchOne(apiUrl, apiKey, t.method, t.path, samples));
|
|
341
|
+
}
|
|
342
|
+
const bundle = sortBundle(mergeBundles(fetched));
|
|
343
|
+
return { bundle, summary: summarizeBundle(bundle) };
|
|
344
|
+
}
|
|
345
|
+
function compareKeys(a, b) {
|
|
346
|
+
return compareCodePoints(a, b);
|
|
347
|
+
}
|
|
348
|
+
function stringifyBundle(value, indent = "") {
|
|
349
|
+
if (Array.isArray(value)) {
|
|
350
|
+
if (value.length === 0) return "[]";
|
|
351
|
+
const inner = `${indent} `;
|
|
352
|
+
return `[
|
|
353
|
+
${value.map((v) => inner + stringifyBundle(v === void 0 ? null : v, inner)).join(",\n")}
|
|
354
|
+
${indent}]`;
|
|
355
|
+
}
|
|
356
|
+
if (isObj(value)) {
|
|
357
|
+
const keys = Object.keys(value).filter((k) => value[k] !== void 0).sort(compareKeys);
|
|
358
|
+
if (keys.length === 0) return "{}";
|
|
359
|
+
const inner = `${indent} `;
|
|
360
|
+
return `{
|
|
361
|
+
${keys.map((k) => `${inner}${JSON.stringify(k)}: ${stringifyBundle(value[k], inner)}`).join(",\n")}
|
|
362
|
+
${indent}}`;
|
|
363
|
+
}
|
|
364
|
+
return JSON.stringify(value) ?? "null";
|
|
365
|
+
}
|
|
366
|
+
async function writeBundle(outPath, data) {
|
|
367
|
+
const dest = path.resolve(outPath);
|
|
368
|
+
const dir = path.dirname(dest);
|
|
369
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
370
|
+
const serialised = `${stringifyBundle(data)}
|
|
371
|
+
`;
|
|
372
|
+
const tmp = path.join(dir, `.stubsmith-bundle-${randomBytes(6).toString("hex")}`);
|
|
373
|
+
try {
|
|
374
|
+
await fsp.writeFile(tmp, serialised, { encoding: "utf8", flag: "wx", mode: 384 });
|
|
375
|
+
await fsp.chmod(tmp, 420);
|
|
376
|
+
await fsp.rename(tmp, dest);
|
|
377
|
+
} catch (err2) {
|
|
378
|
+
await fsp.unlink(tmp).catch(() => void 0);
|
|
379
|
+
throw err2;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// src/cli.ts
|
|
384
|
+
var DEFAULT_OUT = ".stubsmith/bundle.json";
|
|
385
|
+
var USAGE = `usage: stubsmith [-h] [--version] <command> ...
|
|
386
|
+
|
|
387
|
+
StubSmith command-line tools.
|
|
388
|
+
|
|
389
|
+
commands:
|
|
390
|
+
pull Fetch the replay bundle and write it to disk.
|
|
391
|
+
|
|
392
|
+
usage: stubsmith pull [-h] [--out PATH] [--endpoint "METHOD /path/template"]... [--samples N|all]
|
|
393
|
+
|
|
394
|
+
--out PATH Destination file (default: ${DEFAULT_OUT})
|
|
395
|
+
--endpoint "METHOD /path" Filter to an endpoint, e.g. "GET /api/users/{id}".
|
|
396
|
+
Repeat to pull several into one bundle. Each endpoint
|
|
397
|
+
is one request; the responses are merged.
|
|
398
|
+
--samples N|all Recordings to keep per response (default: 1, the newest).
|
|
399
|
+
Requires --endpoint.
|
|
400
|
+
|
|
401
|
+
Environment: STUBSMITH_API_KEY (required), STUBSMITH_API_URL.
|
|
402
|
+
`;
|
|
403
|
+
function out(s) {
|
|
404
|
+
process.stdout.write(`${s}
|
|
405
|
+
`);
|
|
406
|
+
}
|
|
407
|
+
function err(s) {
|
|
408
|
+
process.stderr.write(`${s}
|
|
409
|
+
`);
|
|
410
|
+
}
|
|
411
|
+
function parsePull(argv) {
|
|
412
|
+
const args = { out: DEFAULT_OUT, endpoints: [], samples: null };
|
|
413
|
+
for (let i = 0; i < argv.length; i++) {
|
|
414
|
+
const a = argv[i];
|
|
415
|
+
if (a === "-h" || a === "--help") return "help";
|
|
416
|
+
const m = /^(--(?:out|endpoint|samples))(?:=([\s\S]*))?$/.exec(a);
|
|
417
|
+
if (!m) return `unrecognized arguments: ${a}`;
|
|
418
|
+
const flag = m[1];
|
|
419
|
+
let value = m[2];
|
|
420
|
+
if (value === void 0) {
|
|
421
|
+
if (i + 1 >= argv.length) return `argument ${flag}: expected one argument`;
|
|
422
|
+
value = argv[++i];
|
|
423
|
+
}
|
|
424
|
+
if (flag === "--out") args.out = value;
|
|
425
|
+
else if (flag === "--endpoint") args.endpoints.push(value);
|
|
426
|
+
else args.samples = value;
|
|
427
|
+
}
|
|
428
|
+
return args;
|
|
429
|
+
}
|
|
430
|
+
function printSummary(bundle, outPath, s) {
|
|
431
|
+
out(`Wrote ${outPath}`);
|
|
432
|
+
out(` endpoints : ${s.endpoints}`);
|
|
433
|
+
out(` stubs : ${s.stubs}`);
|
|
434
|
+
out(` variants : ${s.variants}`);
|
|
435
|
+
out(` cursor : ${bundle.cursor ?? ""}`);
|
|
436
|
+
out(` generated : ${bundle.generated_at ?? ""}`);
|
|
437
|
+
const t = bundle.truncated;
|
|
438
|
+
if (t && typeof t === "object" && Object.keys(t).length > 0) {
|
|
439
|
+
out("");
|
|
440
|
+
out("WARNING: bundle is truncated - not all data was included.");
|
|
441
|
+
const stubs = t.stubs;
|
|
442
|
+
if (stubs && stubs.dropped) out(` stubs: ${stubs.dropped} stubs dropped (server limit: ${stubs.limit})`);
|
|
443
|
+
const variants = t.variants;
|
|
444
|
+
if (Array.isArray(variants) && variants.length > 0) {
|
|
445
|
+
const dropped = variants.reduce((n, v) => n + (typeof v.dropped === "number" ? v.dropped : 0), 0);
|
|
446
|
+
out(
|
|
447
|
+
` variants: ${dropped} variant(s) dropped across ${variants.length} fingerprint(s) (server limit per fingerprint: ${variants[0].limit})`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
const samples = t.samples;
|
|
451
|
+
if (samples && samples.limit) out(` samples: clamped to the server limit of ${samples.limit} recordings per response`);
|
|
452
|
+
const bb = t.body_bytes;
|
|
453
|
+
if (bb) {
|
|
454
|
+
const limitKb = Math.floor((typeof bb.limit === "number" ? bb.limit : 0) / 1024);
|
|
455
|
+
out(
|
|
456
|
+
` body_bytes: ${bb.capped} variant body/bodies omitted (exceeded ${limitKb} KB cap) - these stubs will replay with an empty body`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (s.degraded.length > 0) {
|
|
461
|
+
out(
|
|
462
|
+
`WARNING: ${s.degraded.length} stub(s) marked degraded - no recorded captures are available; replay will fail for those stubs.`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (s.bodyCapped > 0) {
|
|
466
|
+
out(
|
|
467
|
+
`WARNING: ${s.bodyCapped} variant(s) have body_capped=true - body exceeded the server size cap and was omitted; replay will return an empty body for those variants.`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async function cmdPull(args) {
|
|
472
|
+
const apiKey = process.env.STUBSMITH_API_KEY ?? "";
|
|
473
|
+
if (!apiKey) {
|
|
474
|
+
err("Error: STUBSMITH_API_KEY is not set. Export your project API key before running stubsmith pull.");
|
|
475
|
+
return 1;
|
|
476
|
+
}
|
|
477
|
+
for (const raw of args.endpoints) {
|
|
478
|
+
if (!parseEndpoint(raw)) {
|
|
479
|
+
err(`Error: --endpoint must be "METHOD /path" (e.g. "GET /api/users"), got: ${JSON.stringify(raw)}`);
|
|
480
|
+
return 1;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (args.samples !== null) {
|
|
484
|
+
const raw = args.samples.trim().toLowerCase();
|
|
485
|
+
if (raw !== "all" && !/^\d+$/.test(raw)) {
|
|
486
|
+
err(`Error: --samples must be a positive integer or 'all', got: ${JSON.stringify(args.samples)}`);
|
|
487
|
+
return 1;
|
|
488
|
+
}
|
|
489
|
+
if (raw !== "all" && Number(raw) < 1) {
|
|
490
|
+
err("Error: --samples must be at least 1.");
|
|
491
|
+
return 1;
|
|
492
|
+
}
|
|
493
|
+
if ((raw === "all" || Number(raw) > 1) && args.endpoints.length === 0) {
|
|
494
|
+
err(
|
|
495
|
+
'Error: --samples greater than 1 requires --endpoint.\nFetching every recording for a whole project is not supported; scope it, e.g. --endpoint "GET /api/orders". Repeat --endpoint to cover several in one bundle.'
|
|
496
|
+
);
|
|
497
|
+
return 1;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
let result;
|
|
501
|
+
try {
|
|
502
|
+
result = await pullBundle({
|
|
503
|
+
apiKey,
|
|
504
|
+
endpoints: args.endpoints,
|
|
505
|
+
...args.samples !== null ? { samples: args.samples } : {},
|
|
506
|
+
onFetch: (method, path2) => err(`Fetching ${method} ${path2} ...`)
|
|
507
|
+
});
|
|
508
|
+
} catch (e) {
|
|
509
|
+
err(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
510
|
+
return 1;
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
await writeBundle(args.out, result.bundle);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
err(`Error writing bundle: ${e instanceof Error ? e.message : String(e)}`);
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
printSummary(result.bundle, args.out, result.summary);
|
|
519
|
+
return 0;
|
|
520
|
+
}
|
|
521
|
+
async function main(argv = process.argv.slice(2)) {
|
|
522
|
+
const first = argv[0];
|
|
523
|
+
if (first === "--version" || first === "-V") {
|
|
524
|
+
out(`stubsmith ${VERSION}`);
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
if (first === "-h" || first === "--help") {
|
|
528
|
+
out(USAGE);
|
|
529
|
+
return 0;
|
|
530
|
+
}
|
|
531
|
+
if (first === void 0) {
|
|
532
|
+
err(USAGE);
|
|
533
|
+
err("stubsmith: error: the following arguments are required: <command>");
|
|
534
|
+
return 1;
|
|
535
|
+
}
|
|
536
|
+
if (first !== "pull") {
|
|
537
|
+
err(USAGE);
|
|
538
|
+
err(`stubsmith: error: unrecognized command or argument: ${first}`);
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
541
|
+
const parsed = parsePull(argv.slice(1));
|
|
542
|
+
if (parsed === "help") {
|
|
543
|
+
out(USAGE);
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
if (typeof parsed === "string") {
|
|
547
|
+
err(`stubsmith pull: error: ${parsed}`);
|
|
548
|
+
return 1;
|
|
549
|
+
}
|
|
550
|
+
return cmdPull(parsed);
|
|
551
|
+
}
|
|
552
|
+
function isEntryPoint() {
|
|
553
|
+
const argv1 = process.argv[1];
|
|
554
|
+
if (!argv1) return false;
|
|
555
|
+
try {
|
|
556
|
+
return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
|
|
557
|
+
} catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (isEntryPoint()) {
|
|
562
|
+
main().then(
|
|
563
|
+
(code) => {
|
|
564
|
+
process.exitCode = code;
|
|
565
|
+
},
|
|
566
|
+
(e) => {
|
|
567
|
+
err(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
568
|
+
process.exitCode = 1;
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
export {
|
|
573
|
+
main
|
|
574
|
+
};
|
|
575
|
+
//# sourceMappingURL=cli.js.map
|