@testsmith/api-spector 0.3.4 → 0.3.6

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.
@@ -1,19 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
3
25
  const promises = require("fs/promises");
4
26
  const path = require("path");
5
- const snapshots = require("./chunks/snapshots-UFd3XgSS.js");
27
+ const snapshots = require("./chunks/snapshots-rV5tPwAd.js");
6
28
  const crypto = require("crypto");
29
+ const undici = require("undici");
7
30
  const os = require("os");
8
31
  const cliCommon = require("./chunks/cli-common-CDQY1erJ.js");
9
- require("undici");
10
- require("./chunks/auth-builder-CUs9yzOF.js");
11
- require("http");
12
- require("./chunks/handle-C0IQL-Vl.js");
32
+ const environments = require("./chunks/environments-iM3SUM-4.js");
33
+ require("./chunks/request-exec-DbNHbA8x.js");
34
+ require("./chunks/handle-BtRMtQJg.js");
13
35
  require("dayjs");
14
36
  require("vm");
15
- require("js-yaml");
37
+ require("tv4");
38
+ require("jsonpath-plus");
39
+ require("@xmldom/xmldom");
40
+ require("http");
16
41
  require("ajv");
42
+ require("js-yaml");
17
43
  function escapeXml(s) {
18
44
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
19
45
  }
@@ -45,76 +71,6 @@ function toJUnitXml(report, suiteName = "contract") {
45
71
  ];
46
72
  return lines.join("\n") + "\n";
47
73
  }
48
- const RESULTS_DIR = "contracts/results";
49
- function safe(part) {
50
- return part.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
51
- }
52
- function resultPath(dir, pacticipant, version) {
53
- return path.join(dir, RESULTS_DIR, safe(pacticipant), `${safe(version)}.json`);
54
- }
55
- async function recordResult(dir, pacticipant, version, report, now) {
56
- const file = resultPath(dir, pacticipant, version);
57
- await promises.mkdir(path.join(dir, RESULTS_DIR, safe(pacticipant)), { recursive: true });
58
- const record = {
59
- pacticipant,
60
- version,
61
- recordedAt: now,
62
- passed: report.failed === 0,
63
- report
64
- };
65
- await promises.writeFile(file, JSON.stringify(record, null, 2), "utf8");
66
- return file;
67
- }
68
- async function listResults(dir) {
69
- const root = path.join(dir, RESULTS_DIR);
70
- const out = [];
71
- let pacticipants;
72
- try {
73
- pacticipants = await promises.readdir(root);
74
- } catch {
75
- return out;
76
- }
77
- for (const p of pacticipants) {
78
- let files;
79
- try {
80
- files = await promises.readdir(path.join(root, p));
81
- } catch {
82
- continue;
83
- }
84
- for (const f of files) {
85
- if (!f.endsWith(".json")) continue;
86
- try {
87
- out.push(JSON.parse(await promises.readFile(path.join(root, p, f), "utf8")));
88
- } catch {
89
- }
90
- }
91
- }
92
- return out;
93
- }
94
- async function canIDeploy(dir, pacticipant, version) {
95
- const file = resultPath(dir, pacticipant, version);
96
- let record;
97
- try {
98
- record = JSON.parse(await promises.readFile(file, "utf8"));
99
- } catch {
100
- return {
101
- deployable: false,
102
- reason: `No verification result recorded for ${pacticipant}@${version}. Run \`contract run … --record --pacticipant ${pacticipant} --app-version ${version}\` first.`
103
- };
104
- }
105
- if (record.passed) {
106
- return {
107
- deployable: true,
108
- reason: `${pacticipant}@${version} passed all ${record.report.total} contract checks (verified ${record.recordedAt}).`,
109
- record
110
- };
111
- }
112
- return {
113
- deployable: false,
114
- reason: `${pacticipant}@${version} has ${record.report.failed}/${record.report.total} failing contract checks (verified ${record.recordedAt}).`,
115
- record
116
- };
117
- }
118
74
  function parsePath(path2) {
119
75
  const tokens = [];
120
76
  const re = /\.([^.[\]]+)|\['([^']*)'\]|\[(\d+|\*)\]/g;
@@ -142,6 +98,8 @@ function ruleToMatcher(value, rule) {
142
98
  return { [snapshots.MATCH_KEY]: "decimal", value };
143
99
  case "boolean":
144
100
  return { [snapshots.MATCH_KEY]: "boolean", value };
101
+ case "null":
102
+ return { [snapshots.MATCH_KEY]: "null", value: null };
145
103
  case "datetime":
146
104
  case "timestamp":
147
105
  return { [snapshots.MATCH_KEY]: "datetime", value, format: rule.format };
@@ -313,8 +271,14 @@ function exampleToPactBody(node) {
313
271
  rules[path2] = { matchers: [{ match: "decimal" }] };
314
272
  return n.value ?? 0;
315
273
  case "boolean":
316
- rules[path2] = { matchers: [{ match: "type" }] };
274
+ rules[path2] = { matchers: [{ match: "boolean" }] };
317
275
  return n.value ?? false;
276
+ case "string":
277
+ rules[path2] = { matchers: [{ match: "type" }] };
278
+ return n.value ?? "";
279
+ case "null":
280
+ rules[path2] = { matchers: [{ match: "null" }] };
281
+ return null;
318
282
  case "datetime":
319
283
  case "timestamp":
320
284
  rules[path2] = { matchers: [{ match: "datetime", format: n.format }] };
@@ -391,6 +355,149 @@ function exportPact(consumer, provider, requests) {
391
355
  }
392
356
  };
393
357
  }
358
+ const PENDING_FILE = "contracts/pending.json";
359
+ function interactionKey(req) {
360
+ const contractHash = crypto.createHash("sha256").update(JSON.stringify(req.contract ?? {})).digest("hex").slice(0, 16);
361
+ return `${req.id}:${contractHash}`;
362
+ }
363
+ async function loadPendingStore(dir) {
364
+ try {
365
+ return JSON.parse(await promises.readFile(path.join(dir, PENDING_FILE), "utf8"));
366
+ } catch {
367
+ return { firstPassed: {} };
368
+ }
369
+ }
370
+ async function savePendingStore(dir, store) {
371
+ const file = path.join(dir, PENDING_FILE);
372
+ await promises.mkdir(path.dirname(file), { recursive: true });
373
+ await promises.writeFile(file, JSON.stringify(store, null, 2), "utf8");
374
+ }
375
+ function applyPendingSemantics(report, requests, store, now) {
376
+ const byId = new Map(requests.map((r) => [r.id, r]));
377
+ const newlyPassed = [];
378
+ let pendingCount = 0;
379
+ for (const result of report.results) {
380
+ const req = byId.get(result.requestId);
381
+ if (!req) continue;
382
+ const key = interactionKey(req);
383
+ if (result.passed) {
384
+ if (!store.firstPassed[key]) {
385
+ store.firstPassed[key] = now;
386
+ newlyPassed.push(key);
387
+ }
388
+ continue;
389
+ }
390
+ if (!store.firstPassed[key]) {
391
+ result.pending = true;
392
+ pendingCount++;
393
+ }
394
+ }
395
+ if (pendingCount > 0) {
396
+ report.failed -= pendingCount;
397
+ report.pending = pendingCount;
398
+ }
399
+ return { newlyPassed };
400
+ }
401
+ const WEBHOOKS_FILE = "contracts/webhooks.json";
402
+ function substituteEnv(value, env) {
403
+ return value.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, name) => env[name] ?? "");
404
+ }
405
+ async function loadWebhookConfig(dir) {
406
+ let raw;
407
+ try {
408
+ raw = await promises.readFile(path.join(dir, WEBHOOKS_FILE), "utf8");
409
+ } catch {
410
+ return [];
411
+ }
412
+ const parsed = JSON.parse(raw);
413
+ const hooks = parsed.webhooks ?? [];
414
+ return hooks.filter((h) => typeof h.url === "string" && h.url.trim().length > 0);
415
+ }
416
+ async function fireWebhooks(hooks, payload, env = process.env, log = console.log) {
417
+ const matching = hooks.filter((h) => !h.events?.length || h.events.includes(payload.event));
418
+ await Promise.all(matching.map(async (hook) => {
419
+ const url = substituteEnv(hook.url, env);
420
+ const headers = { "Content-Type": "application/json" };
421
+ for (const [k, v] of Object.entries(hook.headers ?? {})) headers[k] = substituteEnv(v, env);
422
+ const label = hook.name ?? url;
423
+ try {
424
+ const res = await undici.fetch(url, {
425
+ method: "POST",
426
+ headers,
427
+ body: JSON.stringify(payload),
428
+ signal: AbortSignal.timeout(1e4)
429
+ });
430
+ log(` [webhook] ${payload.event} → ${label}: ${res.status}`);
431
+ } catch (e) {
432
+ log(` [webhook] ${payload.event} → ${label}: failed (${e instanceof Error ? e.message : String(e)})`);
433
+ }
434
+ }));
435
+ }
436
+ function snapshotState(results, envs) {
437
+ const state = { results: {}, deployments: {} };
438
+ for (const r of results) state.results[`${r.pacticipant}@@${r.version}`] = r.recordedAt;
439
+ for (const e of envs) {
440
+ for (const [p, d] of Object.entries(e.deployed)) {
441
+ state.deployments[`${e.name}@@${p}`] = `${d.version}@@${d.recordedAt}`;
442
+ }
443
+ }
444
+ return state;
445
+ }
446
+ function diffState(prev, next, results) {
447
+ const events = [];
448
+ for (const [key, recordedAt] of Object.entries(next.results)) {
449
+ if (prev.results[key] === recordedAt) continue;
450
+ const [pacticipant, version] = key.split("@@");
451
+ const rec = results.find((r) => r.pacticipant === pacticipant && r.version === version);
452
+ events.push({
453
+ event: "result-recorded",
454
+ pacticipant,
455
+ version,
456
+ passed: rec?.passed,
457
+ recordedAt
458
+ });
459
+ }
460
+ for (const [key, value] of Object.entries(next.deployments)) {
461
+ if (prev.deployments[key] === value) continue;
462
+ const [environment, pacticipant] = key.split("@@");
463
+ const [version, recordedAt] = value.split("@@");
464
+ events.push({
465
+ event: "deployment-recorded",
466
+ pacticipant,
467
+ version,
468
+ environment,
469
+ recordedAt
470
+ });
471
+ }
472
+ return events;
473
+ }
474
+ function watchContractEvents(dir, hooks, intervalMs = 1e4, log = console.log) {
475
+ let prev = null;
476
+ let running = false;
477
+ const tick = async () => {
478
+ if (running) return;
479
+ running = true;
480
+ try {
481
+ const [results, envs] = await Promise.all([snapshots.listResults(dir), snapshots.listEnvironments(dir)]);
482
+ const next = snapshotState(results, envs);
483
+ if (prev !== null) {
484
+ for (const payload of diffState(prev, next, results)) {
485
+ await fireWebhooks(hooks, payload, process.env, log);
486
+ }
487
+ }
488
+ prev = next;
489
+ } catch (e) {
490
+ log(` [webhook] scan failed: ${e instanceof Error ? e.message : String(e)}`);
491
+ } finally {
492
+ running = false;
493
+ }
494
+ };
495
+ void tick();
496
+ const timer = setInterval(() => {
497
+ void tick();
498
+ }, intervalMs);
499
+ return () => clearInterval(timer);
500
+ }
394
501
  async function cmdList(args) {
395
502
  const wsArg = args["workspace"];
396
503
  if (typeof wsArg !== "string") {
@@ -401,7 +508,7 @@ async function cmdList(args) {
401
508
  const snapshots$1 = await snapshots.listSnapshots(dir, workspace.contracts ?? []);
402
509
  if (snapshots$1.length === 0) {
403
510
  console.log(" No contract snapshots. Capture one from the app or via:");
404
- console.log(" api-spector contract run --workspace <path> --spec-url <url> --pin");
511
+ console.log(" api-spector contract pin --workspace <path> --spec-url <url>");
405
512
  return;
406
513
  }
407
514
  console.log("");
@@ -410,7 +517,7 @@ async function cmdList(args) {
410
517
  for (const { snapshot } of snapshots$1) {
411
518
  const id = snapshot.id.slice(0, 8);
412
519
  const name = snapshot.name.slice(0, 35).padEnd(35);
413
- const version = (snapshot.specVersion ?? "").slice(0, 11).padEnd(11);
520
+ const version = (snapshot.specVersion ?? "-").slice(0, 11).padEnd(11);
414
521
  const when = snapshot.capturedAt.slice(0, 19).replace("T", " ");
415
522
  console.log(` ${id} ${name} ${version} ${when}`);
416
523
  }
@@ -418,6 +525,33 @@ async function cmdList(args) {
418
525
  console.log(" Run against a snapshot:");
419
526
  console.log(" api-spector contract run --workspace <path> --mode provider --snapshot <id>");
420
527
  }
528
+ async function cmdPin(args) {
529
+ const wsArg = args["workspace"];
530
+ if (typeof wsArg !== "string") {
531
+ console.error(" [error] --workspace <path> is required");
532
+ process.exit(2);
533
+ }
534
+ const specUrl = typeof args["spec-url"] === "string" ? args["spec-url"] : void 0;
535
+ const specPath = typeof args["spec-path"] === "string" ? path.resolve(args["spec-path"]) : void 0;
536
+ if (!specUrl && !specPath) {
537
+ console.error(" [error] --spec-url <url> or --spec-path <file> is required");
538
+ process.exit(2);
539
+ }
540
+ const name = typeof args["name"] === "string" ? args["name"] : void 0;
541
+ const { workspace, dir, file } = await cliCommon.loadWorkspace(wsArg);
542
+ const snapshot = await snapshots.captureSnapshot(dir, { specUrl, specPath, name });
543
+ const relPath = snapshots.relPathOf(snapshot);
544
+ if (relPath && !(workspace.contracts ?? []).includes(relPath)) {
545
+ workspace.contracts = [...workspace.contracts ?? [], relPath];
546
+ await promises.writeFile(file, JSON.stringify(workspace, null, 2), "utf8");
547
+ }
548
+ console.log(` Pinned "${snapshot.name}" (spec version ${snapshot.specVersion ?? "unknown"}, sha256 ${snapshot.sha256.slice(0, 12)}...)`);
549
+ console.log(` ID: ${snapshot.id.slice(0, 8)}`);
550
+ if (relPath) console.log(` File: ${relPath}`);
551
+ console.log("");
552
+ console.log(" Run against it:");
553
+ console.log(` api-spector contract run --workspace ${wsArg} --mode provider --snapshot ${snapshot.id.slice(0, 8)}`);
554
+ }
421
555
  async function resolveSnapshot(ws, dir, needle) {
422
556
  const all = await snapshots.listSnapshots(dir, ws.contracts ?? []);
423
557
  const matches = all.filter(
@@ -446,7 +580,7 @@ async function cmdRun(args) {
446
580
  const collections = await cliCommon.loadCollections(workspace, dir, { filterName: collectionName });
447
581
  const envs = await cliCommon.loadEnvironments(workspace, dir);
448
582
  const envName = typeof args["environment"] === "string" ? args["environment"] : void 0;
449
- const activeEnv = envName ? envs.find((e) => e.name === envName) : envs[0];
583
+ const activeEnv = environments.selectEnvironment(workspace, envs, envName) ?? (envName ? void 0 : envs[0] ? environments.resolveEnvironmentChain(envs[0], envs) : void 0);
450
584
  const envVars = {};
451
585
  for (const v of activeEnv?.variables ?? []) if (v.enabled) envVars[v.key] = v.value;
452
586
  const collectionVars = {};
@@ -487,7 +621,7 @@ async function cmdRun(args) {
487
621
  report = await snapshots.runConsumerContracts(contractRequests, envVars, collectionVars);
488
622
  break;
489
623
  case "provider":
490
- report = await snapshots.runProviderVerification(allRequests, envVars, specUrl, specPath, requestBaseUrl);
624
+ report = await snapshots.runProviderVerification(allRequests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
491
625
  break;
492
626
  case "provider-live":
493
627
  report = await snapshots.runLiveProviderVerification(contractRequests, envVars, collectionVars, providerBaseUrl, stateHandlerUrl);
@@ -496,13 +630,29 @@ async function cmdRun(args) {
496
630
  report = await snapshots.runBidirectional(contractRequests, envVars, collectionVars, specUrl, specPath, requestBaseUrl);
497
631
  break;
498
632
  }
633
+ if (args["allow-pending"]) {
634
+ const store = await loadPendingStore(dir);
635
+ const verified = mode === "provider" ? allRequests : contractRequests;
636
+ applyPendingSemantics(report, verified, store, (/* @__PURE__ */ new Date()).toISOString());
637
+ await savePendingStore(dir, store);
638
+ }
499
639
  console.log("");
500
640
  if (report.failed === 0) {
501
- console.log(` ✓ All ${report.passed}/${report.total} passed in ${report.durationMs}ms`);
641
+ console.log(` ✓ All ${report.passed}/${report.total - (report.pending ?? 0)} required passed in ${report.durationMs}ms`);
502
642
  } else {
503
643
  console.log(` ✗ ${report.failed}/${report.total} failed (${report.passed} passed) in ${report.durationMs}ms`);
504
644
  console.log("");
505
- for (const r of report.results.filter((r2) => !r2.passed)) {
645
+ for (const r of report.results.filter((r2) => !r2.passed && !r2.pending)) {
646
+ console.log(` ${r.method} ${r.requestName}`);
647
+ for (const v of r.violations) {
648
+ console.log(` · ${v.type}: ${v.message}`);
649
+ }
650
+ }
651
+ }
652
+ if (report.pending) {
653
+ console.log("");
654
+ console.log(` ⚠ ${report.pending} pending contract${report.pending === 1 ? "" : "s"} failed (never verified before; not blocking):`);
655
+ for (const r of report.results.filter((r2) => r2.pending)) {
506
656
  console.log(` ${r.method} ${r.requestName}`);
507
657
  for (const v of r.violations) {
508
658
  console.log(` · ${v.type}: ${v.message}`);
@@ -535,7 +685,7 @@ async function cmdRun(args) {
535
685
  process.exit(2);
536
686
  }
537
687
  const pacticipant = typeof args["pacticipant"] === "string" ? args["pacticipant"] : collections[0]?.name ?? "app";
538
- const file = await recordResult(dir, pacticipant, appVersion, report, (/* @__PURE__ */ new Date()).toISOString());
688
+ const file = await snapshots.recordResult(dir, pacticipant, appVersion, report, (/* @__PURE__ */ new Date()).toISOString());
539
689
  console.log(` Recorded result for ${pacticipant}@${appVersion} → ${file}`);
540
690
  }
541
691
  process.exit(report.failed === 0 ? 0 : 1);
@@ -556,23 +706,278 @@ async function cmdCanIDeploy(args) {
556
706
  console.error(" [error] --app-version <version> is required");
557
707
  process.exit(2);
558
708
  }
709
+ const toEnv = typeof args["to"] === "string" ? args["to"] : void 0;
559
710
  const { dir } = await cliCommon.loadWorkspace(wsArg);
560
- const verdict = await canIDeploy(dir, pacticipant, appVersion);
711
+ const verdict = await snapshots.canIDeploy(dir, pacticipant, appVersion, toEnv);
561
712
  console.log("");
562
- console.log(verdict.deployable ? ` ✓ Computer says yes safe to deploy.` : ` ✗ Computer says no.`);
713
+ console.log(verdict.deployable ? ` ✓ Computer says yes - safe to deploy.` : ` ✗ Computer says no.`);
563
714
  console.log(` ${verdict.reason}`);
715
+ if (toEnv) {
716
+ if (verdict.currentlyDeployed) {
717
+ console.log(` ${toEnv} currently runs ${pacticipant}@${verdict.currentlyDeployed.version} (since ${verdict.currentlyDeployed.recordedAt}).`);
718
+ } else {
719
+ console.log(` ${toEnv} has no recorded deployment of ${pacticipant} yet.`);
720
+ }
721
+ if (verdict.deployable) {
722
+ console.log(` After deploying, record it:`);
723
+ console.log(` api-spector contract record-deployment --workspace ${wsArg} --pacticipant ${pacticipant} --app-version ${appVersion} --env ${toEnv}`);
724
+ }
725
+ }
564
726
  process.exit(verdict.deployable ? 0 : 1);
565
727
  }
728
+ async function cmdRecordDeployment(args) {
729
+ const wsArg = args["workspace"];
730
+ const pacticipant = args["pacticipant"];
731
+ const appVersion = args["app-version"];
732
+ const env = args["env"];
733
+ if (typeof wsArg !== "string") {
734
+ console.error(" [error] --workspace <path> is required");
735
+ process.exit(2);
736
+ }
737
+ if (typeof pacticipant !== "string") {
738
+ console.error(" [error] --pacticipant <name> is required");
739
+ process.exit(2);
740
+ }
741
+ if (typeof appVersion !== "string") {
742
+ console.error(" [error] --app-version <version> is required");
743
+ process.exit(2);
744
+ }
745
+ if (typeof env !== "string") {
746
+ console.error(" [error] --env <name> is required (e.g. staging, prod)");
747
+ process.exit(2);
748
+ }
749
+ const { dir } = await cliCommon.loadWorkspace(wsArg);
750
+ const verdict = await snapshots.canIDeploy(dir, pacticipant, appVersion);
751
+ const { file, previous } = await snapshots.recordDeployment(dir, env, pacticipant, appVersion, (/* @__PURE__ */ new Date()).toISOString());
752
+ console.log(` Recorded: ${pacticipant}@${appVersion} deployed to ${env}`);
753
+ if (previous) console.log(` Replaces: ${pacticipant}@${previous.version} (deployed ${previous.recordedAt})`);
754
+ console.log(` File: ${file}`);
755
+ if (!verdict.deployable) {
756
+ console.log("");
757
+ console.log(` [warn] ${verdict.reason}`);
758
+ console.log(` [warn] This version was deployed without a passing contract verification.`);
759
+ }
760
+ }
761
+ async function cmdEnvironments(args) {
762
+ const wsArg = args["workspace"];
763
+ if (typeof wsArg !== "string") {
764
+ console.error(" [error] --workspace <path> is required");
765
+ process.exit(2);
766
+ }
767
+ const { dir } = await cliCommon.loadWorkspace(wsArg);
768
+ const envs = await snapshots.listEnvironments(dir);
769
+ if (envs.length === 0) {
770
+ console.log(" No deployments recorded. After a deploy, run:");
771
+ console.log(" api-spector contract record-deployment --workspace <path> --pacticipant <name> --app-version <ver> --env <name>");
772
+ return;
773
+ }
774
+ for (const e of envs) {
775
+ console.log("");
776
+ console.log(` ${e.name}`);
777
+ for (const [p, d] of Object.entries(e.deployed).sort((a, b) => a[0].localeCompare(b[0]))) {
778
+ console.log(` ${p}@${d.version} since ${d.recordedAt.slice(0, 19).replace("T", " ")}`);
779
+ }
780
+ }
781
+ console.log("");
782
+ }
783
+ async function cmdFuzz(args) {
784
+ const wsArg = args["workspace"];
785
+ if (typeof wsArg !== "string") {
786
+ console.error(" [error] --workspace <path> is required");
787
+ process.exit(2);
788
+ }
789
+ const { workspace, dir } = await cliCommon.loadWorkspace(wsArg);
790
+ const collectionName = typeof args["collection"] === "string" ? args["collection"] : void 0;
791
+ const collections = await cliCommon.loadCollections(workspace, dir, { filterName: collectionName });
792
+ const envs = await cliCommon.loadEnvironments(workspace, dir);
793
+ const envName = typeof args["environment"] === "string" ? args["environment"] : void 0;
794
+ const activeEnv = environments.selectEnvironment(workspace, envs, envName) ?? (envName ? void 0 : envs[0] ? environments.resolveEnvironmentChain(envs[0], envs) : void 0);
795
+ const envVars = {};
796
+ for (const v of activeEnv?.variables ?? []) if (v.enabled) envVars[v.key] = v.value;
797
+ const collectionVars = {};
798
+ for (const c of collections) Object.assign(collectionVars, c.collectionVariables ?? {});
799
+ const allRequests = collections.flatMap((c) => Object.values(c.requests));
800
+ let specUrl = typeof args["spec-url"] === "string" ? args["spec-url"] : void 0;
801
+ let specPath = typeof args["spec-path"] === "string" ? args["spec-path"] : void 0;
802
+ if (typeof args["snapshot"] === "string") {
803
+ const { snapshot } = await resolveSnapshot(workspace, dir, args["snapshot"]);
804
+ const tmp = path.join(os.tmpdir(), `api-spector-${crypto.randomUUID()}.${snapshot.format === "yaml" ? "yaml" : "json"}`);
805
+ await promises.writeFile(tmp, snapshot.spec, "utf8");
806
+ specPath = tmp;
807
+ specUrl = void 0;
808
+ }
809
+ const providerBaseUrl = typeof args["provider-base-url"] === "string" ? args["provider-base-url"] : void 0;
810
+ if (!providerBaseUrl && !specUrl && !specPath) {
811
+ console.error(" [error] fuzz needs a target: --provider-base-url <url> (and optionally a spec via --snapshot / --spec-url / --spec-path)");
812
+ process.exit(2);
813
+ }
814
+ const includeWrites = Boolean(args["include-writes"]);
815
+ console.log(` Fuzzing ${allRequests.length} request(s)${specUrl || specPath ? " against the spec" : " from request bodies"}...`);
816
+ if (!includeWrites) console.log(" Write methods (POST/PUT/PATCH/DELETE) are skipped. Add --include-writes to fuzz them (sends malformed writes; target staging or a mock).");
817
+ const report = await snapshots.runFuzz({
818
+ requests: allRequests,
819
+ envVars,
820
+ collectionVars,
821
+ specUrl,
822
+ specPath,
823
+ providerBaseUrl,
824
+ requestBaseUrl: typeof args["request-base-url"] === "string" ? args["request-base-url"] : void 0,
825
+ casesPerOperation: typeof args["cases"] === "string" ? Math.max(1, Number(args["cases"])) : void 0,
826
+ seed: typeof args["seed"] === "string" ? Number(args["seed"]) : void 0,
827
+ includeWrites,
828
+ strictStatus: Boolean(args["strict-status"]),
829
+ checkResponses: Boolean(args["check-responses"]),
830
+ trace: Boolean(args["trace"])
831
+ });
832
+ console.log("");
833
+ if (report.totalFindings === 0) {
834
+ console.log(` ✓ No findings across ${report.totalCases} malformed cases (seed ${report.seed}, ${report.durationMs}ms)`);
835
+ } else {
836
+ console.log(` ✗ ${report.totalFindings} finding(s) across ${report.totalCases} cases (seed ${report.seed}, ${report.durationMs}ms)`);
837
+ for (const op of report.results.filter((r) => r.findings.length > 0)) {
838
+ console.log("");
839
+ console.log(` ${op.method} ${op.requestName} (${op.findings.length}/${op.cases})`);
840
+ for (const f of op.findings) {
841
+ console.log(` · [${f.oracle}] HTTP ${f.status} on ${f.mutation.target} (${f.mutation.kind}): ${f.message}`);
842
+ }
843
+ }
844
+ }
845
+ if (report.skippedWrites) console.log(`
846
+ ${report.skippedWrites} write-method request(s) skipped (use --include-writes).`);
847
+ if (report.skippedNoBody) console.log(` ${report.skippedNoBody} request(s) had no body to fuzz.`);
848
+ if (args["trace"]) {
849
+ for (const op of report.results) {
850
+ if (!op.trace?.length) continue;
851
+ console.log("");
852
+ console.log(` ${op.method} ${op.requestName} - ${op.trace.length} cases sent:`);
853
+ for (const t of op.trace) {
854
+ const mark = t.finding ? "✗" : "·";
855
+ console.log(` ${mark} HTTP ${t.status} ${t.mutation.target} (${t.mutation.kind})`);
856
+ console.log(` req: ${(t.request.body ?? "").slice(0, 200)}`);
857
+ console.log(` resp: ${(t.responseSample ?? "").replace(/\s+/g, " ").slice(0, 200)}`);
858
+ }
859
+ }
860
+ }
861
+ if (typeof args["output"] === "string") {
862
+ await promises.writeFile(args["output"], JSON.stringify(report, null, 2), "utf8");
863
+ console.log(`
864
+ Report written to ${args["output"]}`);
865
+ }
866
+ if (typeof args["html"] === "string") {
867
+ await promises.writeFile(args["html"], snapshots.fuzzReportToHtml(report, (/* @__PURE__ */ new Date()).toISOString()), "utf8");
868
+ console.log(` HTML report written to ${args["html"]}`);
869
+ }
870
+ process.exit(report.totalFindings === 0 ? 0 : 1);
871
+ }
872
+ async function cmdWebhooks(args) {
873
+ const wsArg = args["workspace"];
874
+ if (typeof wsArg !== "string") {
875
+ console.error(" [error] --workspace <path> is required");
876
+ process.exit(2);
877
+ }
878
+ const { dir } = await cliCommon.loadWorkspace(wsArg);
879
+ const hooks = await loadWebhookConfig(dir);
880
+ if (hooks.length === 0) {
881
+ console.log(" No webhooks configured. Create contracts/webhooks.json in the workspace:");
882
+ console.log("");
883
+ console.log(" {");
884
+ console.log(' "webhooks": [');
885
+ console.log(" {");
886
+ console.log(' "name": "trigger provider CI",');
887
+ console.log(' "url": "https://ci.example.com/api/trigger",');
888
+ console.log(' "events": ["result-recorded", "deployment-recorded"],');
889
+ console.log(' "headers": { "Authorization": "Bearer $CI_TOKEN" }');
890
+ console.log(" }");
891
+ console.log(" ]");
892
+ console.log(" }");
893
+ console.log("");
894
+ console.log(" $NAME tokens are replaced from the serving process environment.");
895
+ console.log(" The dashboard (`contract report --serve`) fires them when new results appear.");
896
+ return;
897
+ }
898
+ console.log("");
899
+ for (const h of hooks) {
900
+ console.log(` ${h.name ?? "(unnamed)"}`);
901
+ console.log(` url: ${h.url}`);
902
+ console.log(` events: ${h.events?.length ? h.events.join(", ") : "all"}`);
903
+ }
904
+ console.log("");
905
+ if (args["test"]) {
906
+ console.log(" Sending test event...");
907
+ await fireWebhooks(hooks, {
908
+ event: "result-recorded",
909
+ pacticipant: "webhook-test",
910
+ version: "0.0.0",
911
+ passed: true,
912
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
913
+ });
914
+ }
915
+ }
566
916
  async function cmdReport(args) {
567
917
  const wsArg = args["workspace"];
568
918
  if (typeof wsArg !== "string") {
569
919
  console.error(" [error] --workspace <path> is required");
570
920
  process.exit(2);
571
921
  }
572
- const out = typeof args["html"] === "string" ? args["html"] : "contract-dashboard.html";
573
922
  const { dir } = await cliCommon.loadWorkspace(wsArg);
574
- const records = await listResults(dir);
575
- await promises.writeFile(out, snapshots.dashboardToHtml(records, (/* @__PURE__ */ new Date()).toISOString()), "utf8");
923
+ if (args["serve"]) {
924
+ const port = typeof args["port"] === "string" ? Number(args["port"]) : 8080;
925
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
926
+ console.error(" [error] --port must be a number between 1 and 65535");
927
+ process.exit(2);
928
+ }
929
+ const { createServer } = await import("http");
930
+ const server = createServer(async (req, res) => {
931
+ try {
932
+ const url = req.url ?? "/";
933
+ if (url === "/healthz") {
934
+ res.writeHead(200, { "Content-Type": "text/plain" });
935
+ res.end("ok");
936
+ return;
937
+ }
938
+ const records2 = await snapshots.listResults(dir);
939
+ const runMatch = /^\/run\/([^/]+)\/([^/]+)$/.exec(url);
940
+ if (runMatch) {
941
+ const pacticipant = decodeURIComponent(runMatch[1]);
942
+ const version = decodeURIComponent(runMatch[2]);
943
+ const rec = records2.find((r) => r.pacticipant === pacticipant && r.version === version);
944
+ if (!rec) {
945
+ res.writeHead(404, { "Content-Type": "text/plain" });
946
+ res.end("No recorded result for that pacticipant/version");
947
+ return;
948
+ }
949
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
950
+ res.end(snapshots.reportToHtml(rec.report, {
951
+ title: `${pacticipant} @ ${version}`,
952
+ generatedAt: rec.recordedAt
953
+ }));
954
+ return;
955
+ }
956
+ const environments22 = await snapshots.listEnvironments(dir);
957
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
958
+ res.end(snapshots.dashboardToHtml(records2, (/* @__PURE__ */ new Date()).toISOString(), { runLinkBase: "/run", environments: environments22 }));
959
+ } catch (e) {
960
+ res.writeHead(500, { "Content-Type": "text/plain" });
961
+ res.end(e instanceof Error ? e.message : String(e));
962
+ }
963
+ });
964
+ server.listen(port, () => {
965
+ console.log(` Contract dashboard serving at http://localhost:${port}`);
966
+ console.log(` Workspace: ${wsArg} (results re-read on every request)`);
967
+ console.log(" Read-only: record new results via `contract run --record`, then refresh.");
968
+ });
969
+ const hooks = await loadWebhookConfig(dir);
970
+ if (hooks.length > 0) {
971
+ const intervalMs = typeof args["webhook-interval"] === "string" ? Math.max(2, Number(args["webhook-interval"])) * 1e3 : 1e4;
972
+ watchContractEvents(dir, hooks, intervalMs);
973
+ console.log(` Webhooks: ${hooks.length} configured (polling every ${intervalMs / 1e3}s)`);
974
+ }
975
+ return;
976
+ }
977
+ const out = typeof args["html"] === "string" ? args["html"] : "contract-dashboard.html";
978
+ const records = await snapshots.listResults(dir);
979
+ const environments2 = await snapshots.listEnvironments(dir);
980
+ await promises.writeFile(out, snapshots.dashboardToHtml(records, (/* @__PURE__ */ new Date()).toISOString(), { environments: environments2 }), "utf8");
576
981
  console.log(` Dashboard with ${records.length} recorded result(s) written to ${out}`);
577
982
  }
578
983
  async function cmdPactImport(args) {
@@ -620,17 +1025,27 @@ async function main() {
620
1025
  const [, , sub, ...rest] = process.argv;
621
1026
  const args = cliCommon.parseArgs(rest);
622
1027
  if (sub === "list") return cmdList(args);
1028
+ if (sub === "pin") return cmdPin(args);
623
1029
  if (sub === "run") return cmdRun(args);
624
1030
  if (sub === "can-i-deploy") return cmdCanIDeploy(args);
1031
+ if (sub === "record-deployment") return cmdRecordDeployment(args);
1032
+ if (sub === "environments") return cmdEnvironments(args);
1033
+ if (sub === "webhooks") return cmdWebhooks(args);
1034
+ if (sub === "fuzz") return cmdFuzz(args);
625
1035
  if (sub === "report") return cmdReport(args);
626
1036
  if (sub === "pact-import") return cmdPactImport(args);
627
1037
  if (sub === "pact-export") return cmdPactExport(args);
628
1038
  if (args["help"] || !sub) {
629
1039
  console.log(`
630
1040
  api-spector contract list --workspace <path>
1041
+ api-spector contract pin --workspace <path> --spec-url <url> | --spec-path <file> [--name <label>]
631
1042
  api-spector contract run --workspace <path> --mode <consumer|provider|provider-live|bidirectional> [options]
632
- api-spector contract report --workspace <path> [--html <path>]
633
- api-spector contract can-i-deploy --workspace <path> --pacticipant <name> --app-version <ver>
1043
+ api-spector contract report --workspace <path> [--html <path>] [--serve [--port <n>]]
1044
+ api-spector contract can-i-deploy --workspace <path> --pacticipant <name> --app-version <ver> [--to <env>]
1045
+ api-spector contract record-deployment --workspace <path> --pacticipant <name> --app-version <ver> --env <name>
1046
+ api-spector contract environments --workspace <path>
1047
+ api-spector contract webhooks --workspace <path> [--test]
1048
+ api-spector contract fuzz --workspace <path> --provider-base-url <url> [--snapshot <id> | --spec-url <url>] [--cases <n>] [--seed <n>] [--include-writes] [--trace] [--html <path>]
634
1049
  api-spector contract pact-import --file <pact.json> [--out <collection.json>]
635
1050
  api-spector contract pact-export --workspace <path> --out <pact.json> [--consumer <name> --provider <name> --collection <name>]
636
1051
 
@@ -656,6 +1071,8 @@ async function main() {
656
1071
  --record Record the result for can-i-deploy gating
657
1072
  --pacticipant <name> Name to record under (default: collection name)
658
1073
  --app-version <ver> Version to record under (required with --record)
1074
+ --allow-pending Failures of never-verified interactions report as
1075
+ pending instead of blocking (exit 0)
659
1076
  `);
660
1077
  return;
661
1078
  }