@testsmith/api-spector 0.4.6 → 0.4.7

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.
@@ -24,15 +24,17 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
  const promises = require("fs/promises");
26
26
  const path = require("path");
27
- const snapshots = require("./chunks/snapshots-BDe-B5iQ.js");
27
+ const snapshots = require("./chunks/snapshots-CtYxkSLz.js");
28
28
  const crypto = require("crypto");
29
29
  const undici = require("undici");
30
30
  const os = require("os");
31
- const cliCommon = require("./chunks/cli-common-CDQY1erJ.js");
31
+ const cliCommon = require("./chunks/cli-common-BKYgsbGB.js");
32
+ const child_process = require("child_process");
33
+ const jsYaml = require("js-yaml");
32
34
  const environments = require("./chunks/environments-iM3SUM-4.js");
33
- require("./chunks/request-exec-CBi2kL6s.js");
35
+ require("./chunks/request-exec-BH-M3KqZ.js");
34
36
  require("tls");
35
- require("./chunks/handle-BGYDylL2.js");
37
+ require("./chunks/handle-rimXXdJH.js");
36
38
  require("dayjs");
37
39
  require("vm");
38
40
  require("tv4");
@@ -40,7 +42,6 @@ require("jsonpath-plus");
40
42
  require("@xmldom/xmldom");
41
43
  require("http");
42
44
  require("ajv");
43
- require("js-yaml");
44
45
  function escapeXml(s) {
45
46
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
46
47
  }
@@ -72,290 +73,6 @@ function toJUnitXml(report, suiteName = "contract") {
72
73
  ];
73
74
  return lines.join("\n") + "\n";
74
75
  }
75
- function parsePath(path2) {
76
- const tokens = [];
77
- const re = /\.([^.[\]]+)|\['([^']*)'\]|\[(\d+|\*)\]/g;
78
- let m;
79
- while ((m = re.exec(path2)) !== null) {
80
- if (m[1] !== void 0) tokens.push({ key: m[1] });
81
- else if (m[2] !== void 0) tokens.push({ key: m[2] });
82
- else if (m[3] !== void 0) tokens.push({ index: m[3] === "*" ? "*" : Number(m[3]) });
83
- }
84
- return tokens;
85
- }
86
- function ruleToMatcher(value, rule) {
87
- switch (rule.match) {
88
- case "type":
89
- if (Array.isArray(value)) {
90
- return { [snapshots.MATCH_KEY]: "eachLike", value: value[0] ?? {}, min: rule.min ?? 1 };
91
- }
92
- return { [snapshots.MATCH_KEY]: "type", value };
93
- case "regex":
94
- return { [snapshots.MATCH_KEY]: "regex", regex: rule.regex ?? ".*", value };
95
- case "integer":
96
- return { [snapshots.MATCH_KEY]: "integer", value };
97
- case "number":
98
- case "decimal":
99
- return { [snapshots.MATCH_KEY]: "decimal", value };
100
- case "boolean":
101
- return { [snapshots.MATCH_KEY]: "boolean", value };
102
- case "null":
103
- return { [snapshots.MATCH_KEY]: "null", value: null };
104
- case "datetime":
105
- case "timestamp":
106
- return { [snapshots.MATCH_KEY]: "datetime", value, format: rule.format };
107
- case "date":
108
- return { [snapshots.MATCH_KEY]: "date", value };
109
- case "time":
110
- return { [snapshots.MATCH_KEY]: "time", value };
111
- default:
112
- return value;
113
- }
114
- }
115
- function applyAtPath(root, tokens, transform) {
116
- if (tokens.length === 0) return transform(root);
117
- const [head, ...rest] = tokens;
118
- if ("index" in head) {
119
- if (!Array.isArray(root)) return root;
120
- if (head.index === "*") {
121
- return root.map((el) => applyAtPath(el, rest, transform));
122
- }
123
- const arr = [...root];
124
- if (head.index < arr.length) arr[head.index] = applyAtPath(arr[head.index], rest, transform);
125
- return arr;
126
- }
127
- if (typeof root !== "object" || root === null || Array.isArray(root)) return root;
128
- const obj = { ...root };
129
- if (head.key in obj) obj[head.key] = applyAtPath(obj[head.key], rest, transform);
130
- return obj;
131
- }
132
- function bodyWithMatchers(body, matchingRules) {
133
- const bodyRules = matchingRules?.["body"] ?? matchingRules?.["content"];
134
- if (!bodyRules || body === void 0) return body;
135
- const entries = Object.entries(bodyRules).sort((a, b) => parsePath(b[0]).length - parsePath(a[0]).length);
136
- let result = body;
137
- for (const [path2, def] of entries) {
138
- const rule = def?.matchers?.[0];
139
- if (!rule) continue;
140
- const tokens = parsePath(path2);
141
- result = applyAtPath(result, tokens, (v) => ruleToMatcher(v, rule));
142
- }
143
- return result;
144
- }
145
- function providerStatesOf(interaction) {
146
- const v3 = interaction["providerStates"];
147
- if (Array.isArray(v3)) return v3.map((s) => s?.name ?? "").filter(Boolean);
148
- const v2 = interaction["providerState"] ?? interaction["provider_state"];
149
- return typeof v2 === "string" && v2 ? [v2] : [];
150
- }
151
- function queryToParams(query) {
152
- const params = [];
153
- if (!query) return params;
154
- if (typeof query === "string") {
155
- for (const pair of query.split("&")) {
156
- const [k, v = ""] = pair.split("=");
157
- if (k) params.push({ key: decodeURIComponent(k), value: decodeURIComponent(v), enabled: true });
158
- }
159
- } else if (typeof query === "object") {
160
- for (const [k, vals] of Object.entries(query)) {
161
- const list = Array.isArray(vals) ? vals : [vals];
162
- for (const v of list) params.push({ key: k, value: String(v ?? ""), enabled: true });
163
- }
164
- }
165
- return params;
166
- }
167
- function headerEntries(headers) {
168
- if (!headers || typeof headers !== "object") return [];
169
- return Object.entries(headers).map(([key, value]) => ({
170
- key,
171
- value: Array.isArray(value) ? value.join(", ") : String(value ?? "")
172
- }));
173
- }
174
- function headersToKv(headers) {
175
- return headerEntries(headers).map((h) => ({ ...h, enabled: true }));
176
- }
177
- function headersToContract(headers) {
178
- return headerEntries(headers).map((h) => ({ ...h, required: true }));
179
- }
180
- function importInteraction(interaction) {
181
- const request = interaction["request"] ?? {};
182
- const response = interaction["response"] ?? {};
183
- const method = String(request["method"] ?? "GET").toUpperCase();
184
- const path2 = String(request["path"] ?? "/");
185
- const reqBody = request["body"];
186
- const body = reqBody === void 0 ? { mode: "none" } : typeof reqBody === "string" ? { mode: "raw", raw: reqBody } : { mode: "json", json: JSON.stringify(reqBody, null, 2) };
187
- const contract = {};
188
- if (typeof response["status"] === "number") contract.statusCode = response["status"];
189
- const respHeaders = headersToContract(response["headers"]);
190
- if (respHeaders?.length) contract.headers = respHeaders;
191
- if (response["body"] !== void 0) {
192
- const example = bodyWithMatchers(response["body"], response["matchingRules"]);
193
- contract.bodyMatcher = JSON.stringify(example, null, 2);
194
- }
195
- const states = providerStatesOf(interaction);
196
- if (states.length) contract.providerStates = states;
197
- return {
198
- id: crypto.randomUUID(),
199
- name: String(interaction["description"] ?? `${method} ${path2}`),
200
- method,
201
- url: `{{baseUrl}}${path2}`,
202
- headers: headersToKv(request["headers"]),
203
- params: queryToParams(request["query"]),
204
- auth: { type: "none" },
205
- body,
206
- contract,
207
- meta: { tags: ["pact"] }
208
- };
209
- }
210
- function importPact(json) {
211
- const pact = typeof json === "string" ? JSON.parse(json) : json;
212
- const consumer = pact["consumer"]?.name ?? "consumer";
213
- const provider = pact["provider"]?.name ?? "provider";
214
- const metadata = pact["metadata"];
215
- const specVersion = String(
216
- metadata?.["pactSpecification"]?.version ?? metadata?.["pact-specification"]?.version ?? "3.0.0"
217
- );
218
- const interactions = pact["interactions"] ?? [];
219
- const httpInteractions = interactions.filter((i) => {
220
- const type = i["type"];
221
- return type === void 0 || type === "Synchronous/HTTP" || type === "HTTP";
222
- });
223
- return {
224
- consumer,
225
- provider,
226
- specVersion,
227
- requests: httpInteractions.map(importInteraction)
228
- };
229
- }
230
- function pactToCollection(result) {
231
- const requests = {};
232
- for (const r of result.requests) requests[r.id] = r;
233
- return {
234
- version: "1.0",
235
- id: crypto.randomUUID(),
236
- name: `${result.consumer} → ${result.provider}`,
237
- description: `Imported from Pact (spec ${result.specVersion})`,
238
- rootFolder: {
239
- id: crypto.randomUUID(),
240
- name: "root",
241
- folders: [],
242
- requestIds: result.requests.map((r) => r.id)
243
- },
244
- requests,
245
- collectionVariables: { baseUrl: "" }
246
- };
247
- }
248
- function isMatcher(node) {
249
- return typeof node === "object" && node !== null && !Array.isArray(node) && typeof node[snapshots.MATCH_KEY] === "string";
250
- }
251
- function exampleToPactBody(node) {
252
- const rules = {};
253
- function walk(n, path2) {
254
- if (isMatcher(n)) {
255
- const kind = n[snapshots.MATCH_KEY];
256
- switch (kind) {
257
- case "type":
258
- rules[path2] = { matchers: [{ match: "type" }] };
259
- return walk(n.value, path2);
260
- case "eachLike": {
261
- rules[path2] = { matchers: [{ match: "type", min: n.min ?? 1 }] };
262
- return [walk(n.value, `${path2}[*]`)];
263
- }
264
- case "regex":
265
- rules[path2] = { matchers: [{ match: "regex", regex: n.regex ?? ".*" }] };
266
- return n.value ?? "";
267
- case "integer":
268
- rules[path2] = { matchers: [{ match: "integer" }] };
269
- return n.value ?? 0;
270
- case "decimal":
271
- case "number":
272
- rules[path2] = { matchers: [{ match: "decimal" }] };
273
- return n.value ?? 0;
274
- case "boolean":
275
- rules[path2] = { matchers: [{ match: "boolean" }] };
276
- return n.value ?? false;
277
- case "string":
278
- rules[path2] = { matchers: [{ match: "type" }] };
279
- return n.value ?? "";
280
- case "null":
281
- rules[path2] = { matchers: [{ match: "null" }] };
282
- return null;
283
- case "datetime":
284
- case "timestamp":
285
- rules[path2] = { matchers: [{ match: "datetime", format: n.format }] };
286
- return n.value ?? "";
287
- case "date":
288
- rules[path2] = { matchers: [{ match: "date" }] };
289
- return n.value ?? "";
290
- case "time":
291
- rules[path2] = { matchers: [{ match: "time" }] };
292
- return n.value ?? "";
293
- default:
294
- return n.value;
295
- }
296
- }
297
- if (Array.isArray(n)) return n.map((el, i) => walk(el, `${path2}[${i}]`));
298
- if (typeof n === "object" && n !== null) {
299
- const out = {};
300
- for (const [k, v] of Object.entries(n)) out[k] = walk(v, `${path2}.${k}`);
301
- return out;
302
- }
303
- return n;
304
- }
305
- const body = walk(node, "$");
306
- return { body, rules };
307
- }
308
- function kvToHeaders(kv) {
309
- if (!kv?.length) return void 0;
310
- const out = {};
311
- for (const h of kv) if (h.enabled !== false && h.key) out[h.key] = h.value;
312
- return Object.keys(out).length ? out : void 0;
313
- }
314
- function exportPact(consumer, provider, requests) {
315
- const interactions = requests.filter((r) => r.contract).map((r) => {
316
- const c = r.contract;
317
- const path2 = r.url.replace(/^\{\{baseUrl\}\}/, "").replace(/^https?:\/\/[^/]+/, "") || "/";
318
- const query = {};
319
- for (const p of r.params ?? []) {
320
- if (p.enabled === false || !p.key) continue;
321
- (query[p.key] ??= []).push(p.value);
322
- }
323
- const response = {};
324
- if (c.statusCode !== void 0) response["status"] = c.statusCode;
325
- const respHeaders = kvToHeaders(c.headers);
326
- if (respHeaders) response["headers"] = respHeaders;
327
- if (c.bodyMatcher?.trim()) {
328
- try {
329
- const { body, rules } = exampleToPactBody(JSON.parse(c.bodyMatcher));
330
- response["body"] = body;
331
- if (Object.keys(rules).length) response["matchingRules"] = { body: rules };
332
- } catch {
333
- }
334
- }
335
- const request = { method: r.method, path: path2 };
336
- if (Object.keys(query).length) request["query"] = query;
337
- const reqHeaders = kvToHeaders(r.headers);
338
- if (reqHeaders) request["headers"] = reqHeaders;
339
- if (r.body?.mode === "json" && r.body.json) {
340
- try {
341
- request["body"] = JSON.parse(r.body.json);
342
- } catch {
343
- }
344
- } else if (r.body?.mode === "raw" && r.body.raw) request["body"] = r.body.raw;
345
- const interaction = { description: r.name, request, response };
346
- if (c.providerStates?.length) interaction["providerStates"] = c.providerStates.map((name) => ({ name }));
347
- return interaction;
348
- });
349
- return {
350
- consumer: { name: consumer },
351
- provider: { name: provider },
352
- interactions,
353
- metadata: {
354
- pactSpecification: { version: "3.0.0" },
355
- client: { name: "api-spector" }
356
- }
357
- };
358
- }
359
76
  const PENDING_FILE = "contracts/pending.json";
360
77
  function interactionKey(req) {
361
78
  const contractHash = crypto.createHash("sha256").update(JSON.stringify(req.contract ?? {})).digest("hex").slice(0, 16);
@@ -499,6 +216,127 @@ function watchContractEvents(dir, hooks, intervalMs = 1e4, log = console.log) {
499
216
  }, intervalMs);
500
217
  return () => clearInterval(timer);
501
218
  }
219
+ function brokerConfigFromEnv() {
220
+ return {
221
+ endpoint: (process.env["API_SPECTOR_CLOUD_ENDPOINT"] || "https://api-spector.dev").replace(/\/+$/, ""),
222
+ token: process.env["API_SPECTOR_TOKEN"] || ""
223
+ };
224
+ }
225
+ async function brokerFetch(cfg, path2, method, body) {
226
+ if (!cfg.token) throw new Error("No API token. Set API_SPECTOR_TOKEN (create one in the cloud dashboard under Tokens).");
227
+ const url = cfg.endpoint + path2;
228
+ let res;
229
+ try {
230
+ res = await undici.fetch(url, {
231
+ method,
232
+ headers: {
233
+ "Content-Type": "application/json",
234
+ Accept: "application/json",
235
+ Authorization: `Bearer ${cfg.token}`
236
+ },
237
+ body: body === void 0 ? void 0 : JSON.stringify(body)
238
+ });
239
+ } catch (err) {
240
+ throw new Error(`Could not reach ${url}: ${err.message}`);
241
+ }
242
+ const text = await res.text();
243
+ let json;
244
+ try {
245
+ json = text ? JSON.parse(text) : {};
246
+ } catch {
247
+ json = { raw: text };
248
+ }
249
+ return { status: res.status, ok: res.ok, json };
250
+ }
251
+ function fail(r, what) {
252
+ const detail = r.json?.error || r.json?.message || (r.status === 401 ? "Unauthorized (check API_SPECTOR_TOKEN)" : `HTTP ${r.status}`);
253
+ throw new Error(`${what}: ${detail}`);
254
+ }
255
+ async function publishPact(cfg, input) {
256
+ const r = await brokerFetch(cfg, "/api/contracts", "PUT", {
257
+ consumer: input.consumer,
258
+ consumerVersion: input.consumerVersion,
259
+ provider: input.provider,
260
+ content: input.pact
261
+ });
262
+ if (!r.ok) fail(r, "Publish pact failed");
263
+ }
264
+ async function publishSpec(cfg, input) {
265
+ const r = await brokerFetch(cfg, "/api/provider-contracts", "PUT", {
266
+ pacticipant: input.pacticipant,
267
+ version: input.version,
268
+ spec: input.spec,
269
+ // Optional provider self-verification (its own tests run against this spec).
270
+ ...input.results ? { results: input.results } : {}
271
+ });
272
+ if (!r.ok) fail(r, "Publish spec failed");
273
+ }
274
+ async function canIDeploy(cfg, input) {
275
+ const q = new URLSearchParams({ pacticipant: input.pacticipant, version: input.version, environment: input.environment }).toString();
276
+ const r = await brokerFetch(cfg, "/can-i-deploy?" + q, "GET");
277
+ if (r.status !== 200 && r.status !== 409) fail(r, "can-i-deploy failed");
278
+ return { deployable: r.status === 200, reason: r.json?.summary?.reason || "" };
279
+ }
280
+ async function deployPreview(cfg, input) {
281
+ const q = new URLSearchParams({ pacticipant: input.pacticipant, version: input.version, environment: input.environment }).toString();
282
+ const r = await brokerFetch(cfg, "/api/deploy-preview?" + q, "GET");
283
+ if (!r.ok) fail(r, "deploy-preview failed");
284
+ return r.json;
285
+ }
286
+ async function recordDeployment(cfg, input) {
287
+ const path2 = `/pacticipants/${encodeURIComponent(input.pacticipant)}/versions/${encodeURIComponent(input.version)}/deployed-versions/environment/${encodeURIComponent(input.environment)}`;
288
+ const r = await brokerFetch(cfg, path2, "POST", {});
289
+ if (!r.ok) fail(r, "record-deployment failed");
290
+ }
291
+ function resolveVersion(override) {
292
+ if (override) return override;
293
+ const env = process.env["GITHUB_SHA"] || process.env["CI_COMMIT_SHA"] || process.env["GIT_COMMIT"] || process.env["CIRCLE_SHA1"] || process.env["BUILD_SOURCEVERSION"] || process.env["BITBUCKET_COMMIT"];
294
+ if (env) return env.trim();
295
+ try {
296
+ return child_process.execSync("git rev-parse HEAD", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
297
+ } catch {
298
+ throw new Error("Could not resolve a version. Pass --version <sha>, or run inside a git repo / CI.");
299
+ }
300
+ }
301
+ function parseSelfVerification(text, path2) {
302
+ const trimmed = text.trimStart();
303
+ const isXml = path2.toLowerCase().endsWith(".xml") || trimmed.startsWith("<");
304
+ if (isXml) {
305
+ const num = (tag, name) => {
306
+ const m = tag.match(new RegExp(`\\b${name}="(\\d+(?:\\.\\d+)?)"`));
307
+ return m ? Math.round(Number(m[1])) : 0;
308
+ };
309
+ const agg = text.match(/<testsuites\b[^>]*>/);
310
+ let tests = 0, failures = 0, errors = 0;
311
+ if (agg) {
312
+ tests = num(agg[0], "tests");
313
+ failures = num(agg[0], "failures");
314
+ errors = num(agg[0], "errors");
315
+ }
316
+ if (!tests) {
317
+ for (const t of text.match(/<testsuite\b[^>]*>/g) ?? []) {
318
+ tests += num(t, "tests");
319
+ failures += num(t, "failures");
320
+ errors += num(t, "errors");
321
+ }
322
+ }
323
+ const failed2 = failures + errors;
324
+ return { success: failed2 === 0, total: tests, passed: tests - failed2, failed: failed2, source: "junit" };
325
+ }
326
+ let o;
327
+ try {
328
+ o = JSON.parse(text);
329
+ } catch {
330
+ throw new Error("Could not parse the results file (expected JUnit XML or JSON).");
331
+ }
332
+ if (typeof o.success === "boolean") return o;
333
+ const total = o.total ?? o.tests;
334
+ const failed = o.failed ?? o.failures ?? 0;
335
+ if (typeof total === "number") {
336
+ return { success: failed === 0, total, passed: total - failed, failed, source: o.source ?? "json" };
337
+ }
338
+ throw new Error("Could not read the results file: JSON needs a boolean `success` or a numeric `total`/`tests`.");
339
+ }
502
340
  async function cmdList(args) {
503
341
  const wsArg = args["workspace"];
504
342
  if (typeof wsArg !== "string") {
@@ -587,7 +425,17 @@ async function cmdRun(args) {
587
425
  const collectionVars = {};
588
426
  for (const c of collections) Object.assign(collectionVars, c.collectionVariables ?? {});
589
427
  const allRequests = collections.flatMap((c) => Object.values(c.requests));
590
- const contractRequests = allRequests.filter((r) => snapshots.hasContract(r.contract));
428
+ const collectionContracts = allRequests.filter((r) => snapshots.hasContract(r.contract));
429
+ const keyOf = (r) => `${r.method} ${r.url} ${r.name}`;
430
+ const collectionKeys = new Set(collectionContracts.map(keyOf));
431
+ const designContracts = (await snapshots.loadDesignContractRequests(workspace, dir)).filter((r) => !collectionKeys.has(keyOf(r)));
432
+ const contractRequests = [...collectionContracts, ...designContracts];
433
+ if (designContracts.length) {
434
+ console.log(` + ${designContracts.length} design-first interaction(s) from the Contract Designer / pacts/`);
435
+ if (envVars["baseUrl"] === void 0 && collectionVars["baseUrl"] === void 0) {
436
+ collectionVars["baseUrl"] = "";
437
+ }
438
+ }
591
439
  let specUrl = typeof args["spec-url"] === "string" ? args["spec-url"] : void 0;
592
440
  let specPath = typeof args["spec-path"] === "string" ? args["spec-path"] : void 0;
593
441
  let snapshotLabel;
@@ -987,10 +835,10 @@ async function cmdPactImport(args) {
987
835
  console.error(" [error] --file <pact.json> is required");
988
836
  process.exit(2);
989
837
  }
990
- const result = importPact(await promises.readFile(file, "utf8"));
838
+ const result = snapshots.importPact(await promises.readFile(file, "utf8"));
991
839
  console.log(` Imported ${result.requests.length} interaction(s): ${result.consumer} → ${result.provider} (spec ${result.specVersion})`);
992
840
  if (typeof args["out"] === "string") {
993
- const collection = pactToCollection(result);
841
+ const collection = snapshots.pactToCollection(result);
994
842
  await promises.writeFile(args["out"], JSON.stringify(collection, null, 2), "utf8");
995
843
  console.log(` Collection written to ${args["out"]}`);
996
844
  console.log(` Set the "baseUrl" collection variable, then verify with:`);
@@ -1018,18 +866,132 @@ async function cmdPactExport(args) {
1018
866
  }
1019
867
  const consumer = typeof args["consumer"] === "string" ? args["consumer"] : collections[0]?.name ?? "consumer";
1020
868
  const provider = typeof args["provider"] === "string" ? args["provider"] : "provider";
1021
- const pact = exportPact(consumer, provider, requests);
869
+ const pact = snapshots.exportPact(consumer, provider, requests);
1022
870
  await promises.writeFile(out, JSON.stringify(pact, null, 2), "utf8");
1023
871
  console.log(` Exported ${requests.length} interaction(s) to ${out} (${consumer} → ${provider})`);
1024
872
  }
873
+ const str = (v) => typeof v === "string" ? v : void 0;
874
+ const die = (msg) => {
875
+ console.error(` [error] ${msg}`);
876
+ process.exit(2);
877
+ };
878
+ function wantsCloud(args) {
879
+ return !!args["broker"] || !!process.env["API_SPECTOR_TOKEN"] && !args["workspace"];
880
+ }
881
+ function deriveContract(req) {
882
+ const ex = (req.examples ?? []).find((e) => e.response && typeof e.response.status === "number");
883
+ if (!ex?.response) return void 0;
884
+ const c = { statusCode: ex.response.status };
885
+ try {
886
+ const body = JSON.parse(ex.response.body || "null");
887
+ if (body && typeof body === "object" && !Array.isArray(body)) {
888
+ const jsType = (v) => Array.isArray(v) ? "array" : v === null ? "null" : typeof v === "number" ? Number.isInteger(v) ? "integer" : "number" : typeof v === "boolean" ? "boolean" : typeof v === "object" ? "object" : "string";
889
+ c.bodySchema = JSON.stringify({
890
+ type: "object",
891
+ required: Object.keys(body),
892
+ properties: Object.fromEntries(Object.entries(body).map(([k, v]) => [k, { type: jsType(v) }]))
893
+ });
894
+ }
895
+ } catch {
896
+ }
897
+ return c;
898
+ }
899
+ async function cmdPublish(args) {
900
+ const wsArg = str(args["workspace"]) ?? die("--workspace <path> is required");
901
+ const consumer = str(args["consumer"]) ?? die("--consumer <name> is required");
902
+ const provider = str(args["provider"]) ?? die("--provider <name> is required");
903
+ const version = resolveVersion(str(args["version"]));
904
+ const { workspace, dir } = await cliCommon.loadWorkspace(wsArg);
905
+ const collections = await cliCommon.loadCollections(workspace, dir, { filterName: str(args["collection"]) });
906
+ let all = collections.flatMap((c) => Object.values(c.requests));
907
+ const tag = str(args["tag"]);
908
+ if (tag) all = all.filter((r) => (r.meta?.tags ?? []).includes(tag));
909
+ let requests = all.filter((r) => snapshots.hasContract(r.contract));
910
+ if (args["derive"]) {
911
+ const derived = all.filter((r) => !snapshots.hasContract(r.contract)).map((r) => ({ ...r, contract: deriveContract(r) })).filter((r) => snapshots.hasContract(r.contract));
912
+ requests = [...requests, ...derived];
913
+ }
914
+ if (requests.length === 0) die("No requests with contract expectations found to publish (set them on the Contract tab, or pass --derive to use saved example responses).");
915
+ const pact = snapshots.exportPact(consumer, provider, requests);
916
+ await publishPact(brokerConfigFromEnv(), { consumer, provider, consumerVersion: version, pact });
917
+ console.log(` Published ${requests.length} interaction(s): ${consumer}@${version.slice(0, 7)} → ${provider}`);
918
+ }
919
+ async function cmdPublishSpecLocal(args) {
920
+ const norm = { ...args };
921
+ if (typeof norm["spec"] === "string" && typeof norm["spec-path"] !== "string") norm["spec-path"] = norm["spec"];
922
+ if (typeof norm["provider"] === "string" && typeof norm["name"] !== "string") norm["name"] = norm["provider"];
923
+ return cmdPin(norm);
924
+ }
925
+ async function cmdPublishSpec(args) {
926
+ const pacticipant = str(args["provider"]) ?? str(args["pacticipant"]) ?? die("--provider <name> is required");
927
+ const version = resolveVersion(str(args["version"]));
928
+ let specText;
929
+ const specUrl = str(args["spec-url"]);
930
+ const specPath = str(args["spec"]) ?? str(args["spec-path"]);
931
+ if (specUrl) {
932
+ const res = await undici.fetch(specUrl);
933
+ if (!res.ok) die(`Could not fetch spec from ${specUrl} (HTTP ${res.status})`);
934
+ specText = await res.text();
935
+ } else if (specPath) {
936
+ specText = await promises.readFile(specPath, "utf8");
937
+ } else {
938
+ return die("--spec <path> or --spec-url <url> is required");
939
+ }
940
+ let spec;
941
+ try {
942
+ spec = JSON.parse(specText);
943
+ } catch {
944
+ spec = jsYaml.load(specText);
945
+ }
946
+ if (!spec || typeof spec !== "object") die("Could not parse the OpenAPI spec (expected JSON or YAML).");
947
+ let results;
948
+ const resultsPath = str(args["results"]);
949
+ if (resultsPath) results = parseSelfVerification(await promises.readFile(resultsPath, "utf8"), resultsPath);
950
+ await publishSpec(brokerConfigFromEnv(), { pacticipant, version, spec, results });
951
+ const selfNote = results ? ` (self-verification: ${results.success ? "passed" : "FAILED"})` : "";
952
+ console.log(` Published spec: ${pacticipant}@${version.slice(0, 7)}${selfNote}`);
953
+ }
954
+ async function cmdCloudCanIDeploy(args) {
955
+ const pacticipant = str(args["pacticipant"]) ?? die("--pacticipant <name> is required");
956
+ const version = resolveVersion(str(args["version"]));
957
+ const environment = str(args["environment"]) ?? str(args["to"]) ?? die("--environment <name> is required");
958
+ const { deployable, reason } = await canIDeploy(brokerConfigFromEnv(), { pacticipant, version, environment });
959
+ if (deployable) {
960
+ console.log(` ✓ ${pacticipant}@${version.slice(0, 7)} can deploy to ${environment}`);
961
+ } else {
962
+ console.error(` ✗ ${pacticipant}@${version.slice(0, 7)} cannot deploy to ${environment}`);
963
+ if (reason) console.error(` ${reason}`);
964
+ process.exit(1);
965
+ }
966
+ }
967
+ async function cmdPreview(args) {
968
+ const pacticipant = str(args["pacticipant"]) ?? die("--pacticipant <name> is required");
969
+ const version = resolveVersion(str(args["version"]));
970
+ const environment = str(args["environment"]) ?? str(args["to"]) ?? die("--environment <name> is required");
971
+ const preview = await deployPreview(brokerConfigFromEnv(), { pacticipant, version, environment });
972
+ console.log(`### ${preview.check.title}
973
+
974
+ ${preview.check.summary}`);
975
+ if (!preview.deployable && args["fail-on-break"]) process.exit(1);
976
+ }
977
+ async function cmdCloudRecordDeployment(args) {
978
+ const pacticipant = str(args["pacticipant"]) ?? die("--pacticipant <name> is required");
979
+ const version = resolveVersion(str(args["version"]));
980
+ const environment = str(args["environment"]) ?? str(args["env"]) ?? die("--environment <name> is required");
981
+ await recordDeployment(brokerConfigFromEnv(), { pacticipant, version, environment });
982
+ console.log(` Recorded ${pacticipant}@${version.slice(0, 7)} deployed to ${environment}`);
983
+ }
1025
984
  async function main() {
1026
985
  const [, , sub, ...rest] = process.argv;
1027
986
  const args = cliCommon.parseArgs(rest);
1028
987
  if (sub === "list") return cmdList(args);
1029
988
  if (sub === "pin") return cmdPin(args);
1030
989
  if (sub === "run") return cmdRun(args);
1031
- if (sub === "can-i-deploy") return cmdCanIDeploy(args);
1032
- if (sub === "record-deployment") return cmdRecordDeployment(args);
990
+ if (sub === "publish") return cmdPublish(args);
991
+ if (sub === "publish-spec") return wantsCloud(args) ? cmdPublishSpec(args) : cmdPublishSpecLocal(args);
992
+ if (sub === "preview") return cmdPreview(args);
993
+ if (sub === "deploy-check" || sub === "can-i-deploy") return wantsCloud(args) ? cmdCloudCanIDeploy(args) : cmdCanIDeploy(args);
994
+ if (sub === "record-deployment") return wantsCloud(args) ? cmdCloudRecordDeployment(args) : cmdRecordDeployment(args);
1033
995
  if (sub === "environments") return cmdEnvironments(args);
1034
996
  if (sub === "webhooks") return cmdWebhooks(args);
1035
997
  if (sub === "fuzz") return cmdFuzz(args);
@@ -1042,7 +1004,7 @@ async function main() {
1042
1004
  api-spector contract pin --workspace <path> --spec-url <url> | --spec-path <file> [--name <label>]
1043
1005
  api-spector contract run --workspace <path> --mode <consumer|provider|provider-live|bidirectional> [options]
1044
1006
  api-spector contract report --workspace <path> [--html <path>] [--serve [--port <n>]]
1045
- api-spector contract can-i-deploy --workspace <path> --pacticipant <name> --app-version <ver> [--to <env>]
1007
+ api-spector contract deploy-check --workspace <path> --pacticipant <name> --app-version <ver> [--to <env>]
1046
1008
  api-spector contract record-deployment --workspace <path> --pacticipant <name> --app-version <ver> --env <name>
1047
1009
  api-spector contract environments --workspace <path>
1048
1010
  api-spector contract webhooks --workspace <path> [--test]
@@ -1050,6 +1012,13 @@ async function main() {
1050
1012
  api-spector contract pact-import --file <pact.json> [--out <collection.json>]
1051
1013
  api-spector contract pact-export --workspace <path> --out <pact.json> [--consumer <name> --provider <name> --collection <name>]
1052
1014
 
1015
+ Cloud broker (git-native; auth via API_SPECTOR_TOKEN, version = git SHA):
1016
+ api-spector contract publish --workspace <path> --consumer <name> --provider <name> [--tag <folder>] [--version <sha>]
1017
+ api-spector contract publish-spec --provider <name> --spec <file> | --spec-url <url> [--broker] [--version <sha>]
1018
+ # --broker → cloud; otherwise pins into --workspace (local / git)
1019
+ api-spector contract deploy-check --broker --pacticipant <name> --environment <env> [--version <sha>] # exit 1 = blocked (alias: can-i-deploy)
1020
+ api-spector contract record-deployment --broker --pacticipant <name> --environment <env> [--version <sha>]
1021
+
1053
1022
  Modes:
1054
1023
  consumer Send requests to the real provider, assert each response (live).
1055
1024
  provider Static check that requests conform to an OpenAPI spec (no HTTP).