@testsmith/api-spector 0.2.2 → 0.2.4

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.
@@ -0,0 +1,326 @@
1
+ "use strict";
2
+ const https = require("https");
3
+ const http = require("http");
4
+ const xmldom = require("@xmldom/xmldom");
5
+ function fetchUrl(url, headers = {}) {
6
+ return new Promise((resolveP, rejectP) => {
7
+ const lib = url.startsWith("https") ? https : http;
8
+ const req = lib.get(url, { headers }, (res) => {
9
+ const chunks = [];
10
+ res.on("data", (chunk) => chunks.push(chunk));
11
+ res.on("end", () => resolveP(Buffer.concat(chunks).toString("utf8")));
12
+ res.on("error", rejectP);
13
+ });
14
+ req.on("error", rejectP);
15
+ req.setTimeout(15e3, () => {
16
+ req.destroy();
17
+ rejectP(new Error("WSDL fetch timed out"));
18
+ });
19
+ });
20
+ }
21
+ const NS_SOAP = "http://schemas.xmlsoap.org/wsdl/soap/";
22
+ const NS_SOAP12 = "http://schemas.xmlsoap.org/wsdl/soap12/";
23
+ function isElement(node) {
24
+ return node.nodeType === 1;
25
+ }
26
+ function nodeListToArray(list) {
27
+ if (!list || typeof list.length !== "number") return [];
28
+ const out = [];
29
+ for (let i = 0; i < list.length; i++) out.push(list[i]);
30
+ return out;
31
+ }
32
+ function children(node) {
33
+ return nodeListToArray(node.childNodes).filter(isElement);
34
+ }
35
+ function childrenByLocalName(node, localName) {
36
+ return children(node).filter((c) => (c.localName ?? c.tagName) === localName);
37
+ }
38
+ function descendantsByLocalName(node, localName) {
39
+ const out = [];
40
+ const stack = [node];
41
+ while (stack.length) {
42
+ const cur = stack.pop();
43
+ for (const c of nodeListToArray(cur.childNodes)) {
44
+ if (isElement(c)) {
45
+ if ((c.localName ?? c.tagName) === localName) out.push(c);
46
+ stack.push(c);
47
+ }
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ function attr(node, name) {
53
+ const v = node.getAttribute?.(name);
54
+ return v == null || v === "" ? void 0 : v;
55
+ }
56
+ function localOfQName(q) {
57
+ const i = q.indexOf(":");
58
+ return i === -1 ? q : q.slice(i + 1);
59
+ }
60
+ function indexSchema(definitions) {
61
+ const idx = /* @__PURE__ */ new Map();
62
+ for (const types of childrenByLocalName(definitions, "types")) {
63
+ for (const schema of childrenByLocalName(types, "schema")) {
64
+ for (const el of childrenByLocalName(schema, "element")) {
65
+ const n = attr(el, "name");
66
+ if (n) idx.set(n, el);
67
+ }
68
+ for (const ct of childrenByLocalName(schema, "complexType")) {
69
+ const n = attr(ct, "name");
70
+ if (n) idx.set("__type__:" + n, ct);
71
+ }
72
+ }
73
+ }
74
+ return idx;
75
+ }
76
+ function resolveComplexType(el, schemaIdx) {
77
+ const inlineCt = childrenByLocalName(el, "complexType")[0];
78
+ if (inlineCt) return inlineCt;
79
+ const typeAttr = attr(el, "type");
80
+ if (typeAttr) {
81
+ const local = localOfQName(typeAttr);
82
+ const named = schemaIdx.get("__type__:" + local);
83
+ if (named) return named;
84
+ return null;
85
+ }
86
+ return null;
87
+ }
88
+ function buildParams(complexType, schemaIdx, depth = 0) {
89
+ if (depth > 5) return [];
90
+ const params = [];
91
+ const containers = ["sequence", "all", "choice"].flatMap((n) => childrenByLocalName(complexType, n));
92
+ for (const container of containers) {
93
+ for (const childEl of childrenByLocalName(container, "element")) {
94
+ const name = attr(childEl, "name") ?? attr(childEl, "ref");
95
+ if (!name) continue;
96
+ const local = localOfQName(name);
97
+ const typeAttr = attr(childEl, "type");
98
+ let typeHint = "string";
99
+ if (typeAttr) typeHint = localOfQName(typeAttr);
100
+ const nested = resolveComplexType(childEl, schemaIdx);
101
+ if (nested) {
102
+ params.push({ name: local, typeHint: "complex", children: buildParams(nested, schemaIdx, depth + 1) });
103
+ } else {
104
+ params.push({ name: local, typeHint });
105
+ }
106
+ }
107
+ }
108
+ return params;
109
+ }
110
+ function indent(level) {
111
+ return " ".repeat(level);
112
+ }
113
+ function renderParams(params, level) {
114
+ return params.map((p) => {
115
+ if (p.children && p.children.length) {
116
+ return `${indent(level)}<tns:${p.name}>
117
+ ${renderParams(p.children, level + 1)}
118
+ ${indent(level)}</tns:${p.name}>`;
119
+ }
120
+ return `${indent(level)}<tns:${p.name}><!-- ${p.typeHint} --></tns:${p.name}>`;
121
+ }).join("\n");
122
+ }
123
+ function buildEnvelopeTemplate(operationName, namespace, params = [], soapVersion = "1.1") {
124
+ const envNs = soapVersion === "1.2" ? "http://www.w3.org/2003/05/soap-envelope" : "http://schemas.xmlsoap.org/soap/envelope/";
125
+ const body = params.length ? `
126
+ ${renderParams(params, 3)}
127
+ ` : `
128
+ <!-- Add parameters here -->
129
+ `;
130
+ return `<?xml version="1.0" encoding="utf-8"?>
131
+ <soap:Envelope
132
+ xmlns:soap="${envNs}"
133
+ xmlns:tns="${namespace}">
134
+ <soap:Header/>
135
+ <soap:Body>
136
+ <tns:${operationName}>${body}</tns:${operationName}>
137
+ </soap:Body>
138
+ </soap:Envelope>`;
139
+ }
140
+ function parseWsdl(wsdlText) {
141
+ let doc;
142
+ try {
143
+ doc = new xmldom.DOMParser().parseFromString(wsdlText, "text/xml");
144
+ } catch (err) {
145
+ throw new Error(`WSDL parse failed: ${err instanceof Error ? err.message : String(err)}`);
146
+ }
147
+ const definitions = childrenByLocalName(doc, "definitions")[0] ?? descendantsByLocalName(doc, "definitions")[0];
148
+ if (!definitions) {
149
+ return { targetNamespace: "", endpoints: [], operations: [] };
150
+ }
151
+ const targetNamespace = attr(definitions, "targetNamespace") ?? "";
152
+ const schemaIdx = indexSchema(definitions);
153
+ const portTypeInputs = {};
154
+ for (const pt of childrenByLocalName(definitions, "portType")) {
155
+ for (const op of childrenByLocalName(pt, "operation")) {
156
+ const name = attr(op, "name");
157
+ if (!name) continue;
158
+ const inputEl = childrenByLocalName(op, "input")[0];
159
+ portTypeInputs[name] = inputEl ? attr(inputEl, "message") : void 0;
160
+ }
161
+ }
162
+ const messageElements = {};
163
+ for (const msg of childrenByLocalName(definitions, "message")) {
164
+ const name = attr(msg, "name");
165
+ if (!name) continue;
166
+ const part = childrenByLocalName(msg, "part")[0];
167
+ if (!part) continue;
168
+ messageElements[name] = attr(part, "element");
169
+ }
170
+ const bindings = [];
171
+ for (const binding of childrenByLocalName(definitions, "binding")) {
172
+ const bindingName = attr(binding, "name") ?? "";
173
+ let soapVersion = "1.1";
174
+ for (const c of children(binding)) {
175
+ if ((c.localName ?? c.tagName) === "binding") {
176
+ if (c.namespaceURI === NS_SOAP12) soapVersion = "1.2";
177
+ else if (c.namespaceURI === NS_SOAP) soapVersion = "1.1";
178
+ }
179
+ }
180
+ const ops = [];
181
+ for (const op of childrenByLocalName(binding, "operation")) {
182
+ const opName = attr(op, "name");
183
+ if (!opName) continue;
184
+ let soapAction;
185
+ for (const c of children(op)) {
186
+ if ((c.localName ?? c.tagName) === "operation" && (c.namespaceURI === NS_SOAP || c.namespaceURI === NS_SOAP12)) {
187
+ soapAction = attr(c, "soapAction");
188
+ }
189
+ }
190
+ ops.push({ name: opName, soapAction });
191
+ }
192
+ bindings.push({ name: bindingName, soapVersion, operations: ops });
193
+ }
194
+ const endpoints = [];
195
+ for (const svc of childrenByLocalName(definitions, "service")) {
196
+ for (const port of childrenByLocalName(svc, "port")) {
197
+ const bindingRef = attr(port, "binding");
198
+ if (!bindingRef) continue;
199
+ let address;
200
+ let addrVersion = "1.1";
201
+ for (const c of children(port)) {
202
+ if ((c.localName ?? c.tagName) === "address") {
203
+ address = attr(c, "location");
204
+ if (c.namespaceURI === NS_SOAP12) addrVersion = "1.2";
205
+ }
206
+ }
207
+ if (address) {
208
+ endpoints.push({ binding: localOfQName(bindingRef), address, soapVersion: addrVersion });
209
+ }
210
+ }
211
+ }
212
+ const operations = [];
213
+ const seenOpNames = /* @__PURE__ */ new Set();
214
+ for (const binding of bindings) {
215
+ const endpoint = endpoints.find((e) => e.binding === binding.name)?.address;
216
+ for (const op of binding.operations) {
217
+ if (seenOpNames.has(op.name + "@" + binding.name)) continue;
218
+ seenOpNames.add(op.name + "@" + binding.name);
219
+ const messageQName = portTypeInputs[op.name];
220
+ const messageLocal = messageQName ? localOfQName(messageQName) : void 0;
221
+ const elementQName = messageLocal ? messageElements[messageLocal] : void 0;
222
+ const elementLocal = elementQName ? localOfQName(elementQName) : op.name;
223
+ const schemaEl = schemaIdx.get(elementLocal);
224
+ let params = [];
225
+ if (schemaEl) {
226
+ const ct = resolveComplexType(schemaEl, schemaIdx);
227
+ if (ct) params = buildParams(ct, schemaIdx);
228
+ }
229
+ operations.push({
230
+ name: op.name,
231
+ binding: binding.name,
232
+ soapAction: op.soapAction,
233
+ soapVersion: binding.soapVersion,
234
+ endpoint,
235
+ inputTemplate: buildEnvelopeTemplate(elementLocal, targetNamespace, params, binding.soapVersion),
236
+ params
237
+ });
238
+ }
239
+ }
240
+ if (operations.length === 0) {
241
+ const regex = /<(?:wsdl:)?operation\s+name\s*=\s*["']([^"']+)["']/g;
242
+ const seen = /* @__PURE__ */ new Set();
243
+ let m;
244
+ while ((m = regex.exec(wsdlText)) !== null) seen.add(m[1]);
245
+ const soapActionByName = {};
246
+ const blockRx = /<(?:wsdl:)?operation\s+name\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/(?:wsdl:)?operation>/g;
247
+ while ((m = blockRx.exec(wsdlText)) !== null) {
248
+ const sa = m[2].match(/soapAction\s*=\s*["']([^"']*)["']/);
249
+ if (sa) soapActionByName[m[1]] = sa[1];
250
+ }
251
+ for (const name of seen) {
252
+ operations.push({
253
+ name,
254
+ soapAction: soapActionByName[name],
255
+ soapVersion: "1.1",
256
+ inputTemplate: buildEnvelopeTemplate(name, targetNamespace, [], "1.1"),
257
+ params: []
258
+ });
259
+ }
260
+ }
261
+ return { targetNamespace, endpoints, operations };
262
+ }
263
+ function buildResponseEnvelope(operationName, namespace, soapVersion = "1.1") {
264
+ const envNs = soapVersion === "1.2" ? "http://www.w3.org/2003/05/soap-envelope" : "http://schemas.xmlsoap.org/soap/envelope/";
265
+ return `<?xml version="1.0" encoding="utf-8"?>
266
+ <soap:Envelope
267
+ xmlns:soap="${envNs}"
268
+ xmlns:tns="${namespace}">
269
+ <soap:Body>
270
+ <tns:${operationName}Response>
271
+ <tns:${operationName}Result><!-- replace with mock value --></tns:${operationName}Result>
272
+ </tns:${operationName}Response>
273
+ </soap:Body>
274
+ </soap:Envelope>`;
275
+ }
276
+ function buildMockDispatchScript(_opMap) {
277
+ return `// Auto-generated by Import WSDL — dispatches per SOAP operation.
278
+ // Envelopes come from route metadata.soapEnvelopes (set by the WSDL importer).
279
+ const envelopes = (metadata && metadata.soapEnvelopes) || {};
280
+ const ct = (metadata && metadata.soapVersion === '1.2')
281
+ ? 'application/soap+xml; charset=utf-8'
282
+ : 'text/xml; charset=utf-8';
283
+
284
+ const headers = request.headers || {};
285
+ const sa = String(headers['soapaction'] || headers['SOAPAction'] || '').replace(/"/g, '');
286
+ const body = request.body || '';
287
+
288
+ let opName = null;
289
+ for (const k of Object.keys(envelopes)) {
290
+ if (sa && (sa === k || sa.endsWith('/' + k) || sa.endsWith(':' + k))) { opName = k; break; }
291
+ if (body.indexOf('<' + k + ' ') !== -1 || body.indexOf('<' + k + '>') !== -1
292
+ || body.indexOf(':' + k + ' ') !== -1 || body.indexOf(':' + k + '>') !== -1) {
293
+ opName = k; break;
294
+ }
295
+ }
296
+
297
+ response.headers['Content-Type'] = ct;
298
+ if (opName) {
299
+ response.statusCode = 200;
300
+ response.body = envelopes[opName];
301
+ } else {
302
+ response.statusCode = 500;
303
+ response.body = '<?xml version="1.0"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><soap:Fault><faultstring>Unknown SOAP operation</faultstring></soap:Fault></soap:Body></soap:Envelope>';
304
+ }
305
+ `;
306
+ }
307
+ function registerSoapHandlers(ipc) {
308
+ ipc.handle("wsdl:fetch", async (_event, url, extraHeaders = {}) => {
309
+ const { validateWsdlFetchUrl } = await Promise.resolve().then(() => require("./ipc-validate-CscN4HfG.js"));
310
+ validateWsdlFetchUrl(url);
311
+ const wsdlText = await fetchUrl(url, extraHeaders);
312
+ return parseWsdl(wsdlText);
313
+ });
314
+ ipc.handle("wsdl:import", async (_event, opts) => {
315
+ const { validateWsdlImport } = await Promise.resolve().then(() => require("./ipc-validate-CscN4HfG.js"));
316
+ validateWsdlImport(opts);
317
+ const { importWsdl } = await Promise.resolve().then(() => require("./import-C9qdkBCH.js"));
318
+ const wsdlText = opts.xml ?? (opts.url ? await fetchUrl(opts.url) : "");
319
+ if (!wsdlText) throw new Error("wsdl:import requires either `url` or `xml`");
320
+ return importWsdl(wsdlText, { name: opts.name, existingMockPorts: opts.existingMockPorts });
321
+ });
322
+ }
323
+ exports.buildMockDispatchScript = buildMockDispatchScript;
324
+ exports.buildResponseEnvelope = buildResponseEnvelope;
325
+ exports.parseWsdl = parseWsdl;
326
+ exports.registerSoapHandlers = registerSoapHandlers;
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const promises = require("fs/promises");
4
+ const path = require("path");
5
+ const snapshots = require("./chunks/snapshots-C7YbGHM7.js");
6
+ const os = require("os");
7
+ const crypto = require("crypto");
8
+ require("undici");
9
+ require("js-yaml");
10
+ require("ajv");
11
+ require("./chunks/auth-builder-B7-LgcGr.js");
12
+ require("dayjs");
13
+ require("vm");
14
+ function parseArgs(argv) {
15
+ const args = {};
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const arg = argv[i];
18
+ if (arg.startsWith("--")) {
19
+ const key = arg.slice(2);
20
+ const next = argv[i + 1];
21
+ if (!next || next.startsWith("--")) {
22
+ args[key] = true;
23
+ } else {
24
+ args[key] = next;
25
+ i++;
26
+ }
27
+ }
28
+ }
29
+ return args;
30
+ }
31
+ async function resolveWorkspacePath(wsPath) {
32
+ const s = await promises.stat(wsPath);
33
+ if (!s.isDirectory()) return wsPath;
34
+ const entries = await promises.readdir(wsPath);
35
+ const spector = entries.find((e) => e.endsWith(".spector"));
36
+ if (!spector) throw new Error(`No .spector workspace file found in directory: ${wsPath}`);
37
+ return path.join(wsPath, spector);
38
+ }
39
+ async function loadWorkspace(wsPath) {
40
+ const resolved = await resolveWorkspacePath(wsPath);
41
+ const raw = await promises.readFile(resolved, "utf8");
42
+ return { workspace: JSON.parse(raw), dir: path.dirname(path.resolve(resolved)) };
43
+ }
44
+ async function loadCollections(ws, dir, filterName) {
45
+ const cols = [];
46
+ for (const relPath of ws.collections) {
47
+ try {
48
+ const raw = await promises.readFile(path.join(dir, relPath), "utf8");
49
+ const col = JSON.parse(raw);
50
+ if (!filterName || col.name === filterName) cols.push(col);
51
+ } catch {
52
+ }
53
+ }
54
+ return cols;
55
+ }
56
+ async function loadEnvironments(ws, dir) {
57
+ const envs = [];
58
+ for (const relPath of ws.environments) {
59
+ try {
60
+ envs.push(JSON.parse(await promises.readFile(path.join(dir, relPath), "utf8")));
61
+ } catch {
62
+ }
63
+ }
64
+ return envs;
65
+ }
66
+ async function cmdList(args) {
67
+ const wsArg = args["workspace"];
68
+ if (typeof wsArg !== "string") {
69
+ console.error(" [error] --workspace <path> is required");
70
+ process.exit(2);
71
+ }
72
+ const { workspace, dir } = await loadWorkspace(wsArg);
73
+ const snapshots$1 = await snapshots.listSnapshots(dir, workspace.contracts ?? []);
74
+ if (snapshots$1.length === 0) {
75
+ console.log(" No contract snapshots. Capture one from the app or via:");
76
+ console.log(" api-spector contract run --workspace <path> --spec-url <url> --pin");
77
+ return;
78
+ }
79
+ console.log("");
80
+ console.log(" ID Name Version Captured");
81
+ console.log(" ──────── ─────────────────────────────────── ─────────── ──────────────────");
82
+ for (const { snapshot } of snapshots$1) {
83
+ const id = snapshot.id.slice(0, 8);
84
+ const name = snapshot.name.slice(0, 35).padEnd(35);
85
+ const version = (snapshot.specVersion ?? "—").slice(0, 11).padEnd(11);
86
+ const when = snapshot.capturedAt.slice(0, 19).replace("T", " ");
87
+ console.log(` ${id} ${name} ${version} ${when}`);
88
+ }
89
+ console.log("");
90
+ console.log(" Run against a snapshot:");
91
+ console.log(" api-spector contract run --workspace <path> --mode provider --snapshot <id>");
92
+ }
93
+ async function resolveSnapshot(ws, dir, needle) {
94
+ const all = await snapshots.listSnapshots(dir, ws.contracts ?? []);
95
+ const matches = all.filter(
96
+ ({ snapshot }) => snapshot.id === needle || snapshot.id.startsWith(needle) || snapshot.name === needle
97
+ );
98
+ if (matches.length === 0) throw new Error(`No snapshot matches "${needle}". Run \`api-spector contract list --workspace <path>\` to see available snapshots.`);
99
+ if (matches.length > 1) {
100
+ const ids = matches.map((m) => m.snapshot.id.slice(0, 8)).join(", ");
101
+ throw new Error(`Ambiguous snapshot "${needle}" matches multiple (${ids}). Use a longer id prefix.`);
102
+ }
103
+ return matches[0];
104
+ }
105
+ async function cmdRun(args) {
106
+ const wsArg = args["workspace"];
107
+ const mode = args["mode"];
108
+ if (typeof wsArg !== "string") {
109
+ console.error(" [error] --workspace <path> is required");
110
+ process.exit(2);
111
+ }
112
+ if (mode !== "consumer" && mode !== "provider" && mode !== "bidirectional") {
113
+ console.error(" [error] --mode must be one of: consumer, provider, bidirectional");
114
+ process.exit(2);
115
+ }
116
+ const { workspace, dir } = await loadWorkspace(wsArg);
117
+ const collectionName = typeof args["collection"] === "string" ? args["collection"] : void 0;
118
+ const collections = await loadCollections(workspace, dir, collectionName);
119
+ const envs = await loadEnvironments(workspace, dir);
120
+ const envName = typeof args["environment"] === "string" ? args["environment"] : void 0;
121
+ const activeEnv = envName ? envs.find((e) => e.name === envName) : envs[0];
122
+ const envVars = {};
123
+ for (const v of activeEnv?.variables ?? []) if (v.enabled) envVars[v.key] = v.value;
124
+ const collectionVars = {};
125
+ for (const c of collections) Object.assign(collectionVars, c.collectionVariables ?? {});
126
+ const allRequests = collections.flatMap((c) => Object.values(c.requests));
127
+ const contractRequests = allRequests.filter(
128
+ (r) => r.contract && (r.contract.statusCode !== void 0 || r.contract.bodySchema || r.contract.headers?.length)
129
+ );
130
+ let specUrl = typeof args["spec-url"] === "string" ? args["spec-url"] : void 0;
131
+ let specPath = typeof args["spec-path"] === "string" ? args["spec-path"] : void 0;
132
+ let snapshotLabel;
133
+ if (typeof args["snapshot"] === "string") {
134
+ const { snapshot } = await resolveSnapshot(workspace, dir, args["snapshot"]);
135
+ const tmp = path.join(os.tmpdir(), `api-spector-${crypto.randomUUID()}.${snapshot.format === "yaml" ? "yaml" : "json"}`);
136
+ await promises.writeFile(tmp, snapshot.spec, "utf8");
137
+ specPath = tmp;
138
+ specUrl = void 0;
139
+ snapshotLabel = `${snapshot.name}${snapshot.specVersion ? ` (${snapshot.specVersion})` : ""}`;
140
+ }
141
+ const requestBaseUrl = typeof args["request-base-url"] === "string" ? args["request-base-url"] : void 0;
142
+ if (mode !== "consumer" && !specUrl && !specPath) {
143
+ console.error(" [error] Provider / bidirectional mode requires --snapshot, --spec-url, or --spec-path");
144
+ process.exit(2);
145
+ }
146
+ console.log(` Running ${mode} contracts…`);
147
+ if (snapshotLabel) console.log(` Spec: snapshot "${snapshotLabel}"`);
148
+ else if (specUrl) console.log(` Spec: ${specUrl} (live)`);
149
+ else if (specPath) console.log(` Spec: ${specPath}`);
150
+ let report;
151
+ const modeValue = mode;
152
+ switch (modeValue) {
153
+ case "consumer":
154
+ report = await snapshots.runConsumerContracts(contractRequests, envVars, collectionVars);
155
+ break;
156
+ case "provider":
157
+ report = await snapshots.runProviderVerification(allRequests, envVars, specUrl, specPath, requestBaseUrl);
158
+ break;
159
+ case "bidirectional":
160
+ report = await snapshots.runBidirectional(contractRequests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
161
+ break;
162
+ }
163
+ console.log("");
164
+ if (report.failed === 0) {
165
+ console.log(` ✓ All ${report.passed}/${report.total} passed in ${report.durationMs}ms`);
166
+ } else {
167
+ console.log(` ✗ ${report.failed}/${report.total} failed (${report.passed} passed) in ${report.durationMs}ms`);
168
+ console.log("");
169
+ for (const r of report.results.filter((r2) => !r2.passed)) {
170
+ console.log(` ${r.method} ${r.requestName}`);
171
+ for (const v of r.violations) {
172
+ console.log(` · ${v.type}: ${v.message}`);
173
+ }
174
+ }
175
+ }
176
+ if (typeof args["output"] === "string") {
177
+ await promises.writeFile(args["output"], JSON.stringify(report, null, 2), "utf8");
178
+ console.log(`
179
+ Report written to ${args["output"]}`);
180
+ }
181
+ process.exit(report.failed === 0 ? 0 : 1);
182
+ }
183
+ async function main() {
184
+ const [, , sub, ...rest] = process.argv;
185
+ const args = parseArgs(rest);
186
+ if (sub === "list") return cmdList(args);
187
+ if (sub === "run") return cmdRun(args);
188
+ if (args["help"] || !sub) {
189
+ console.log(`
190
+ api-spector contract list --workspace <path>
191
+ api-spector contract run --workspace <path> --mode <consumer|provider|bidirectional> [options]
192
+
193
+ Run options:
194
+ --snapshot <id|name> Pinned snapshot (run list to see IDs)
195
+ --spec-url <url> Live URL (fetched once for this run)
196
+ --spec-path <path> Local spec file
197
+ --collection <name> Filter to one collection
198
+ --environment <name> Environment for {{var}} resolution
199
+ --request-base-url <url> Strip this host before matching spec paths
200
+ --output <path> Write ContractReport JSON here
201
+ `);
202
+ return;
203
+ }
204
+ console.error(` [error] Unknown subcommand "${sub}"`);
205
+ process.exit(2);
206
+ }
207
+ main().catch((e) => {
208
+ console.error(` [error] ${e instanceof Error ? e.message : String(e)}`);
209
+ process.exit(1);
210
+ });