specshield 1.0.6 → 1.0.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.
package/README.md CHANGED
@@ -17,6 +17,16 @@ Compare OpenAPI and Swagger specs, detect breaking changes, and fail CI before i
17
17
  - [Authentication](#authentication)
18
18
  - [Generate an API Token](#generate-an-api-token)
19
19
  - [Remote Compare](#remote-compare)
20
+ - [Contracts — Consumer-Driven Testing](#contracts--consumer-driven-testing)
21
+ - [Contract File Format](#contract-file-format)
22
+ - [Publish a Contract](#publish-a-contract)
23
+ - [List Contracts](#list-contracts)
24
+ - [Get Latest Contract](#get-latest-contract)
25
+ - [Verify a Contract](#verify-a-contract)
26
+ - [Verification History](#verification-history)
27
+ - [Can I Deploy?](#can-i-deploy)
28
+ - [Full Publish → Verify → Deploy Workflow](#full-publish--verify--deploy-workflow)
29
+ - [Contracts in CI/CD](#contracts-in-cicd)
20
30
  - [Config File](#config-file)
21
31
  - [All Options](#all-options)
22
32
  - [Exit Codes](#exit-codes)
@@ -194,6 +204,479 @@ Error: No API key found. Run: specshield login --api-key <KEY>
194
204
 
195
205
  ---
196
206
 
207
+ ---
208
+
209
+ ## Contracts — Consumer-Driven Testing
210
+
211
+ SpecShield Contracts lets consumer teams publish API expectations (contracts) to a central registry. Provider teams then verify their service satisfies those contracts before deploying.
212
+
213
+ **Why contract testing?**
214
+ - Catch provider-side regressions before they reach consumers
215
+ - Enforce an agreed-upon API shape across services
216
+ - Gate deployments on verified contracts with `can-i-deploy`
217
+
218
+ All contract commands require an API token. See [Authentication](#authentication).
219
+
220
+ ---
221
+
222
+ ### Contract File Format
223
+
224
+ Create a `.json` file describing the expected API interactions:
225
+
226
+ ```json
227
+ {
228
+ "consumer": {
229
+ "name": "checkout-ui",
230
+ "version": "1.2.0"
231
+ },
232
+ "provider": {
233
+ "name": "payment-service"
234
+ },
235
+ "contractName": "create-payment",
236
+ "contractType": "HTTP",
237
+ "interactions": [
238
+ {
239
+ "description": "create payment",
240
+ "request": {
241
+ "method": "POST",
242
+ "path": "/payments",
243
+ "headers": {
244
+ "Content-Type": "application/json"
245
+ },
246
+ "body": {
247
+ "orderId": "ORD-123",
248
+ "amount": 100,
249
+ "currency": "INR"
250
+ }
251
+ },
252
+ "expectedResponse": {
253
+ "status": 201,
254
+ "headers": {
255
+ "Content-Type": "application/json"
256
+ },
257
+ "body": {
258
+ "paymentId": "PAY-123",
259
+ "status": "CREATED"
260
+ }
261
+ }
262
+ }
263
+ ],
264
+ "metadata": {
265
+ "generatedBy": "specshield-cli",
266
+ "contractFormatVersion": "1.0"
267
+ }
268
+ }
269
+ ```
270
+
271
+ The `consumer.name`, `provider.name`, and `contractName` fields are used to identify the contract in the registry. CLI flags (`--consumer`, `--provider`, `--contract-name`) override file values if provided.
272
+
273
+ ---
274
+
275
+ ### Publish a Contract
276
+
277
+ ```bash
278
+ specshield contracts publish \
279
+ --file ./contracts/create-payment.json \
280
+ --org acme
281
+ ```
282
+
283
+ With all flags explicitly set (flags override file values):
284
+
285
+ ```bash
286
+ specshield contracts publish \
287
+ --file ./contracts/create-payment.json \
288
+ --org acme \
289
+ --consumer checkout-ui \
290
+ --provider payment-service \
291
+ --contract-name create-payment \
292
+ --consumer-version 1.2.0 \
293
+ --tag main
294
+ ```
295
+
296
+ **Example output:**
297
+
298
+ ```
299
+ ✔ Contract Published Successfully
300
+ ─────────────────────────────────────────────────────
301
+ Contract ID : 42
302
+ Contract Name : create-payment
303
+ Consumer : checkout-ui
304
+ Provider : payment-service
305
+ Version : 3
306
+ Status : PUBLISHED
307
+ Content Hash : a3f9c1d2e7b8...
308
+ Published At : 06/04/2026 14:30:00
309
+
310
+ ➜ Run: specshield contracts verify --contract-id 42 --base-url http://localhost:8080
311
+ ```
312
+
313
+ **Options:**
314
+
315
+ | Flag | Description |
316
+ |---|---|
317
+ | `--file <path>` | Path to contract JSON file (required) |
318
+ | `--org <key>` | Organization key |
319
+ | `--consumer <key>` | Consumer service key (overrides file) |
320
+ | `--provider <key>` | Provider service key (overrides file) |
321
+ | `--consumer-version <ver>` | Consumer version tag |
322
+ | `--contract-name <name>` | Contract name (overrides file) |
323
+ | `--tag <tag>` | Git branch or release tag |
324
+ | `--server <url>` | SpecShield server URL (default: `https://specshield.io`) |
325
+ | `--api-token <token>` | API token |
326
+
327
+ ---
328
+
329
+ ### List Contracts
330
+
331
+ ```bash
332
+ specshield contracts list
333
+ ```
334
+
335
+ Filter by provider:
336
+
337
+ ```bash
338
+ specshield contracts list --provider payment-service
339
+ ```
340
+
341
+ Filter by consumer, org, status:
342
+
343
+ ```bash
344
+ specshield contracts list \
345
+ --consumer checkout-ui \
346
+ --provider payment-service \
347
+ --status PUBLISHED
348
+ ```
349
+
350
+ Output raw JSON:
351
+
352
+ ```bash
353
+ specshield contracts list --json
354
+ ```
355
+
356
+ **Example output:**
357
+
358
+ ```
359
+ SpecShield Contract Registry
360
+ ─────────────────────────────────────────────────────
361
+ Showing 2 of 2 contracts
362
+
363
+ ID Contract Name Consumer Provider Ver Status Last Verify Published
364
+ ── ─────────────── ──────────── ─────────────── ─── ───────── ─────────── ─────────────────────
365
+ 42 create-payment checkout-ui payment-service 3 PUBLISHED SUCCESS 06/04/2026 14:30:00
366
+ 41 get-order-status order-ui order-service 1 PUBLISHED FAILED 05/04/2026 09:15:00
367
+ ```
368
+
369
+ **Options:**
370
+
371
+ | Flag | Description |
372
+ |---|---|
373
+ | `--consumer <key>` | Filter by consumer |
374
+ | `--provider <key>` | Filter by provider |
375
+ | `--org <key>` | Filter by organization |
376
+ | `--status <status>` | Filter by status (`PUBLISHED` / `DEPRECATED`) |
377
+ | `--contract-name <name>` | Filter by contract name |
378
+ | `--page <n>` | Page number (0-based, default: `0`) |
379
+ | `--size <n>` | Page size (default: `20`) |
380
+ | `--json` | Output raw JSON |
381
+
382
+ ---
383
+
384
+ ### Get Latest Contract
385
+
386
+ ```bash
387
+ specshield contracts latest \
388
+ --consumer checkout-ui \
389
+ --provider payment-service \
390
+ --contract-name create-payment
391
+ ```
392
+
393
+ Print the full contract content (interactions, body, etc.):
394
+
395
+ ```bash
396
+ specshield contracts latest \
397
+ --consumer checkout-ui \
398
+ --provider payment-service \
399
+ --json
400
+ ```
401
+
402
+ **Options:**
403
+
404
+ | Flag | Description |
405
+ |---|---|
406
+ | `--consumer <key>` | Consumer service key |
407
+ | `--provider <key>` | Provider service key |
408
+ | `--org <key>` | Organization key |
409
+ | `--contract-name <name>` | Contract name |
410
+ | `--json` | Print full contract JSON |
411
+
412
+ ---
413
+
414
+ ### Verify a Contract
415
+
416
+ Run the contract against a live provider. SpecShield replays each interaction against the `--base-url` and compares the response to the expected values.
417
+
418
+ ```bash
419
+ specshield contracts verify \
420
+ --contract-id 42 \
421
+ --base-url http://localhost:8080
422
+ ```
423
+
424
+ With version and environment tags:
425
+
426
+ ```bash
427
+ specshield contracts verify \
428
+ --contract-id 42 \
429
+ --base-url https://payment-service.staging.internal \
430
+ --provider-version v2.1.0 \
431
+ --env staging
432
+ ```
433
+
434
+ Output raw JSON (for CI parsing):
435
+
436
+ ```bash
437
+ specshield contracts verify \
438
+ --contract-id 42 \
439
+ --base-url http://localhost:8080 \
440
+ --json
441
+ ```
442
+
443
+ **Example output (pass):**
444
+
445
+ ```
446
+ ✔ Verification PASSED (1/1 interactions)
447
+ ─────────────────────────────────────────────────────
448
+ Verification ID : 101
449
+ Contract ID : 42
450
+ Status : SUCCESS
451
+ Started At : 06/04/2026 14:35:00
452
+ Completed At : 06/04/2026 14:35:01
453
+
454
+ ➜ Run: specshield contracts can-i-deploy --provider payment-service --version v2.1.0
455
+ ```
456
+
457
+ **Example output (fail):**
458
+
459
+ ```
460
+ ✖ Verification FAILED (0/1 interactions passed, 1 failed)
461
+ ─────────────────────────────────────────────────────
462
+ Verification ID : 102
463
+ Contract ID : 42
464
+ Status : FAILED
465
+
466
+ Mismatches
467
+ ─────────────────────────────────────────────────────
468
+ ● [create payment] STATUS_CODE_MISMATCH at $.status
469
+ expected: 201 → actual: 200
470
+ Expected status 201 but got 200
471
+
472
+ ➜ Run: specshield contracts history --contract-id 42 to inspect previous runs
473
+ ```
474
+
475
+ **Exit codes:** `0` = PASSED, `1` = FAILED, `2` = error
476
+
477
+ **Options:**
478
+
479
+ | Flag | Description |
480
+ |---|---|
481
+ | `--contract-id <id>` | Contract ID to verify (required) |
482
+ | `--base-url <url>` | Provider base URL (required, e.g. `http://localhost:8080`) |
483
+ | `--provider-version <ver>` | Provider version tag |
484
+ | `--env <environment>` | Environment label (`staging`, `qa`, `production`) |
485
+ | `--mode <mode>` | `LIVE` or `REPLAY` (default: `LIVE`) |
486
+ | `--json` | Output raw JSON |
487
+
488
+ ---
489
+
490
+ ### Verification History
491
+
492
+ ```bash
493
+ specshield contracts history --contract-id 42
494
+ ```
495
+
496
+ **Example output:**
497
+
498
+ ```
499
+ Verification History — Contract 42
500
+ ─────────────────────────────────────────────────────
501
+
502
+ ID Status Environment Provider Version Mode Completed At
503
+ ─── ─────── ─────────── ──────────────── ──── ─────────────────────
504
+ 102 FAILED staging v2.1.0 LIVE 06/04/2026 14:35:01
505
+ 101 SUCCESS staging v2.0.0 LIVE 05/04/2026 10:00:00
506
+ 98 SUCCESS qa v1.9.0 LIVE 01/04/2026 08:30:00
507
+ ```
508
+
509
+ **Options:**
510
+
511
+ | Flag | Description |
512
+ |---|---|
513
+ | `--contract-id <id>` | Contract ID (required) |
514
+ | `--json` | Output raw JSON |
515
+
516
+ ---
517
+
518
+ ### Can I Deploy?
519
+
520
+ Check whether a provider version has passing verifications for all associated contracts:
521
+
522
+ ```bash
523
+ specshield contracts can-i-deploy \
524
+ --provider payment-service \
525
+ --version v2.1.0
526
+ ```
527
+
528
+ Scoped to a specific environment:
529
+
530
+ ```bash
531
+ specshield contracts can-i-deploy \
532
+ --provider payment-service \
533
+ --version v2.1.0 \
534
+ --env production
535
+ ```
536
+
537
+ **Example output (allowed):**
538
+
539
+ ```
540
+ ✔ PASS: payment-service v2.1.0 is deployable in production
541
+ ─────────────────────────────────────────────────────
542
+
543
+ Contract Decisions
544
+ ✔ Contract ID 42 — SUCCESS
545
+ All contracts verified
546
+ ```
547
+
548
+ **Example output (blocked):**
549
+
550
+ ```
551
+ ✖ FAIL: payment-service v2.1.0 is NOT deployable in production
552
+ ─────────────────────────────────────────────────────
553
+
554
+ Contract Decisions
555
+ ✖ Contract ID 43 — FAILED
556
+ Unverified or failed contracts found
557
+
558
+ ➜ Run: specshield contracts verify --contract-id 43 --base-url <URL>
559
+ ➜ to verify pending contracts before deploying
560
+ ```
561
+
562
+ **Exit codes:** `0` = deployable, `1` = blocked, `2` = error
563
+
564
+ **Options:**
565
+
566
+ | Flag | Description |
567
+ |---|---|
568
+ | `--provider <key>` | Provider service key (required) |
569
+ | `--version <ver>` | Provider version to check (required) |
570
+ | `--env <environment>` | Target environment |
571
+ | `--json` | Output raw JSON |
572
+
573
+ ---
574
+
575
+ ### Full Publish → Verify → Deploy Workflow
576
+
577
+ ```bash
578
+ # 1. Consumer team publishes a contract
579
+ specshield contracts publish \
580
+ --file ./contracts/create-payment.json \
581
+ --org acme
582
+
583
+ # 2. Provider team starts their service locally
584
+ ./gradlew bootRun &
585
+
586
+ # 3. Provider team verifies the contract
587
+ specshield contracts verify \
588
+ --contract-id 42 \
589
+ --base-url http://localhost:8080 \
590
+ --provider-version v2.1.0 \
591
+ --env staging
592
+
593
+ # 4. Gate the deployment
594
+ specshield contracts can-i-deploy \
595
+ --provider payment-service \
596
+ --version v2.1.0 \
597
+ --env staging
598
+ ```
599
+
600
+ ---
601
+
602
+ ### Contracts in CI/CD
603
+
604
+ #### GitHub Actions — Publish on consumer change
605
+
606
+ ```yaml
607
+ name: Publish Contract
608
+
609
+ on:
610
+ push:
611
+ branches: [main]
612
+ paths:
613
+ - 'contracts/**'
614
+
615
+ jobs:
616
+ publish-contract:
617
+ runs-on: ubuntu-latest
618
+ steps:
619
+ - uses: actions/checkout@v4
620
+ - uses: actions/setup-node@v4
621
+ with:
622
+ node-version: '20'
623
+ - run: npm install -g specshield
624
+ - name: Publish contract
625
+ env:
626
+ SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
627
+ run: |
628
+ specshield contracts publish \
629
+ --file ./contracts/create-payment.json \
630
+ --org acme \
631
+ --tag ${{ github.ref_name }}
632
+ ```
633
+
634
+ #### GitHub Actions — Verify + can-i-deploy on provider change
635
+
636
+ ```yaml
637
+ name: Contract Verification
638
+
639
+ on:
640
+ push:
641
+ branches: [main]
642
+
643
+ jobs:
644
+ verify-contracts:
645
+ runs-on: ubuntu-latest
646
+ services:
647
+ payment-service:
648
+ image: myorg/payment-service:${{ github.sha }}
649
+ ports:
650
+ - 8080:8080
651
+ steps:
652
+ - uses: actions/checkout@v4
653
+ - uses: actions/setup-node@v4
654
+ with:
655
+ node-version: '20'
656
+ - run: npm install -g specshield
657
+
658
+ - name: Verify contract
659
+ env:
660
+ SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
661
+ run: |
662
+ specshield contracts verify \
663
+ --contract-id ${{ vars.PAYMENT_CONTRACT_ID }} \
664
+ --base-url http://localhost:8080 \
665
+ --provider-version ${{ github.sha }} \
666
+ --env staging
667
+
668
+ - name: Can I deploy?
669
+ env:
670
+ SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
671
+ run: |
672
+ specshield contracts can-i-deploy \
673
+ --provider payment-service \
674
+ --version ${{ github.sha }} \
675
+ --env staging
676
+ ```
677
+
678
+ ---
679
+
197
680
  ## Config File
198
681
 
199
682
  Create `.specshield.yml` in your project root to set default behavior:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specshield",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "CLI to compare OpenAPI/Swagger specs and detect breaking changes for CI/CD pipelines and local developer workflows.",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const axios = require('axios');
4
+ const { version } = require('../../package.json');
5
+
6
+ const DEFAULT_SERVER = 'https://specshield.io';
7
+ const TIMEOUT = 15000;
8
+
9
+ function buildClient(server, apiToken) {
10
+ const baseURL = (server || DEFAULT_SERVER).replace(/\/$/, '');
11
+ const headers = {
12
+ 'Content-Type': 'application/json',
13
+ 'X-SpecShield-Client': 'cli',
14
+ 'X-SpecShield-Version': version,
15
+ };
16
+ if (apiToken) headers['X-Api-Key'] = apiToken;
17
+ return axios.create({ baseURL, timeout: TIMEOUT, headers });
18
+ }
19
+
20
+ function apiError(err) {
21
+ if (err.response) {
22
+ const data = err.response.data;
23
+ const msg = (data && (data.message || data.error || data.title))
24
+ || `HTTP ${err.response.status}`;
25
+ return new Error(`API error (${err.response.status}): ${msg}`);
26
+ }
27
+ if (err.request) return new Error(`No response from server: ${err.message}`);
28
+ return new Error(`Request failed: ${err.message}`);
29
+ }
30
+
31
+ async function publishContract(server, apiToken, payload) {
32
+ try {
33
+ const res = await buildClient(server, apiToken).post('/api/contracts/publish', payload);
34
+ return res.data;
35
+ } catch (err) { throw apiError(err); }
36
+ }
37
+
38
+ async function listContracts(server, apiToken, { org, consumer, provider, contractName, status, page = 0, size = 20 } = {}) {
39
+ try {
40
+ const params = { page, size };
41
+ if (org) params.orgKey = org;
42
+ if (consumer) params.consumerServiceKey = consumer;
43
+ if (provider) params.providerServiceKey = provider;
44
+ if (contractName) params.contractName = contractName;
45
+ if (status) params.status = status;
46
+ const res = await buildClient(server, apiToken).get('/api/contracts', { params });
47
+ return res.data;
48
+ } catch (err) { throw apiError(err); }
49
+ }
50
+
51
+ async function getLatestContract(server, apiToken, { org, consumer, provider, contractName } = {}) {
52
+ try {
53
+ const params = {};
54
+ if (org) params.orgKey = org;
55
+ if (consumer) params.consumerServiceKey = consumer;
56
+ if (provider) params.providerServiceKey = provider;
57
+ if (contractName) params.contractName = contractName;
58
+ const res = await buildClient(server, apiToken).get('/api/contracts/latest', { params });
59
+ return res.data;
60
+ } catch (err) { throw apiError(err); }
61
+ }
62
+
63
+ async function verifyContract(server, apiToken, contractId, payload) {
64
+ try {
65
+ const res = await buildClient(server, apiToken).post(`/api/contracts/${contractId}/verify`, payload);
66
+ return res.data;
67
+ } catch (err) { throw apiError(err); }
68
+ }
69
+
70
+ async function getVerificationHistory(server, apiToken, contractId) {
71
+ try {
72
+ const res = await buildClient(server, apiToken).get(`/api/contracts/${contractId}/verifications`);
73
+ return res.data;
74
+ } catch (err) { throw apiError(err); }
75
+ }
76
+
77
+ async function canIDeploy(server, apiToken, { provider, version: ver, environment } = {}) {
78
+ try {
79
+ const params = { version: ver };
80
+ if (environment) params.environment = environment;
81
+ const res = await buildClient(server, apiToken).get(
82
+ `/api/providers/${encodeURIComponent(provider)}/can-i-deploy`, { params }
83
+ );
84
+ return res.data;
85
+ } catch (err) { throw apiError(err); }
86
+ }
87
+
88
+ module.exports = { publishContract, listContracts, getLatestContract, verifyContract, getVerificationHistory, canIDeploy };
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ const { version } = require('../package.json');
5
5
  const compareCommand = require('./commands/compare');
6
6
  const loginCommand = require('./commands/login');
7
7
  const logoutCommand = require('./commands/logout');
8
+ const contractsCommand = require('./commands/contracts');
8
9
 
9
10
  const program = new Command();
10
11
 
@@ -16,6 +17,7 @@ program
16
17
  program.addCommand(compareCommand);
17
18
  program.addCommand(loginCommand);
18
19
  program.addCommand(logoutCommand);
20
+ program.addCommand(contractsCommand);
19
21
 
20
22
  program.parseAsync(process.argv).catch((err) => {
21
23
  const logger = require('./utils/logger');
@@ -0,0 +1,561 @@
1
+ 'use strict';
2
+
3
+ const { Command } = require('commander');
4
+ const chalk = require('chalk');
5
+ const ora = require('ora');
6
+ const path = require('path');
7
+ const fsExtra = require('fs-extra');
8
+ const logger = require('../utils/logger');
9
+ const { getStoredApiKey } = require('../config/localConfig');
10
+ const {
11
+ publishContract,
12
+ listContracts,
13
+ getLatestContract,
14
+ verifyContract,
15
+ getVerificationHistory,
16
+ canIDeploy,
17
+ } = require('../api/contractsClient');
18
+
19
+ // ─── Helpers ────────────────────────────────────────────────────────────────
20
+
21
+ async function resolveApiToken(opts) {
22
+ return opts.apiToken || process.env.SPECSHIELD_API_KEY || (await getStoredApiKey()) || null;
23
+ }
24
+
25
+ function requireToken(token) {
26
+ if (!token) {
27
+ logger.error('No API token found. Pass --api-token, set SPECSHIELD_API_KEY, or run: specshield login --api-key <KEY>');
28
+ process.exit(2);
29
+ }
30
+ }
31
+
32
+ function fmtDate(iso) {
33
+ if (!iso) return chalk.gray('—');
34
+ try {
35
+ return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', hour12: false })
36
+ .replace(',', '');
37
+ } catch { return iso; }
38
+ }
39
+
40
+ function statusBadge(status) {
41
+ if (!status) return chalk.gray('—');
42
+ const s = String(status).toUpperCase();
43
+ if (s === 'PUBLISHED') return chalk.green(s);
44
+ if (s === 'DEPRECATED') return chalk.yellow(s);
45
+ return chalk.gray(s);
46
+ }
47
+
48
+ function verifyBadge(status) {
49
+ if (!status) return chalk.gray('—');
50
+ const s = String(status).toUpperCase();
51
+ if (s === 'SUCCESS') return chalk.green(s);
52
+ if (s === 'FAILED') return chalk.red(s);
53
+ if (s === 'PENDING') return chalk.yellow(s);
54
+ return chalk.gray(s);
55
+ }
56
+
57
+ function hr() {
58
+ return chalk.gray(' ─────────────────────────────────────────────────────');
59
+ }
60
+
61
+ /** Simple padded column table */
62
+ function printTable(headers, rows) {
63
+ const widths = headers.map((h, i) =>
64
+ Math.max(h.length, ...rows.map(r => stripAnsi(String(r[i] ?? '')).length))
65
+ );
66
+ const headerLine = headers.map((h, i) => chalk.bold(h.padEnd(widths[i]))).join(' ');
67
+ process.stdout.write('\n ' + headerLine + '\n');
68
+ process.stdout.write(' ' + widths.map(w => '─'.repeat(w)).join(' ') + '\n');
69
+ for (const row of rows) {
70
+ const line = row.map((cell, i) => {
71
+ const raw = String(cell ?? '');
72
+ const pad = widths[i] - stripAnsi(raw).length;
73
+ return raw + ' '.repeat(Math.max(0, pad));
74
+ }).join(' ');
75
+ process.stdout.write(' ' + line + '\n');
76
+ }
77
+ process.stdout.write('\n');
78
+ }
79
+
80
+ /** Strip ANSI escape codes for length measurement */
81
+ function stripAnsi(str) {
82
+ return str.replace(/\u001b\[[0-9;]*m/g, '');
83
+ }
84
+
85
+ // ─── Publish ─────────────────────────────────────────────────────────────────
86
+
87
+ const publishCommand = new Command('publish')
88
+ .description('Publish a consumer contract to the registry')
89
+ .requiredOption('--file <path>', 'Path to contract JSON file')
90
+ .option('--org <key>', 'Organization key (overrides file value)')
91
+ .option('--consumer <key>', 'Consumer service key (overrides file value)')
92
+ .option('--provider <key>', 'Provider service key (overrides file value)')
93
+ .option('--consumer-version <ver>', 'Consumer version tag')
94
+ .option('--contract-name <name>', 'Contract name (overrides file value)')
95
+ .option('--tag <tag>', 'Tag / git branch')
96
+ .option('--server <url>', 'SpecShield server URL')
97
+ .option('--api-token <token>', 'API token (overrides env / stored config)')
98
+ .action(async (opts) => {
99
+ const token = await resolveApiToken(opts);
100
+ requireToken(token);
101
+
102
+ // Read and validate file
103
+ const filePath = path.resolve(opts.file);
104
+ if (!(await fsExtra.pathExists(filePath))) {
105
+ logger.error(`Contract file not found: ${filePath}`);
106
+ process.exit(2);
107
+ }
108
+
109
+ let contractDoc;
110
+ try {
111
+ const raw = await fsExtra.readFile(filePath, 'utf8');
112
+ contractDoc = JSON.parse(raw);
113
+ } catch (err) {
114
+ logger.error(`Invalid JSON in contract file: ${err.message}`);
115
+ process.exit(2);
116
+ }
117
+
118
+ // Basic schema validation
119
+ if (!contractDoc.interactions || !Array.isArray(contractDoc.interactions)) {
120
+ logger.error('Contract file must have an "interactions" array.');
121
+ process.exit(2);
122
+ }
123
+
124
+ // Resolve metadata (CLI flags override file values)
125
+ const orgKey = opts.org || contractDoc.orgKey || contractDoc.org;
126
+ const consumerKey = opts.consumer || contractDoc.consumer?.name;
127
+ const providerKey = opts.provider || contractDoc.provider?.name;
128
+ const contractName = opts.contractName || contractDoc.contractName;
129
+ const contractType = contractDoc.contractType || 'HTTP';
130
+
131
+ const missing = [];
132
+ if (!orgKey) missing.push('--org (or "org" in contract file)');
133
+ if (!consumerKey) missing.push('--consumer (or consumer.name in contract file)');
134
+ if (!providerKey) missing.push('--provider (or provider.name in contract file)');
135
+ if (!contractName) missing.push('--contract-name (or contractName in contract file)');
136
+ if (missing.length) {
137
+ logger.error(`Missing required fields:\n ${missing.join('\n ')}`);
138
+ process.exit(2);
139
+ }
140
+
141
+ const spinner = ora('Publishing contract...').start();
142
+
143
+ try {
144
+ const result = await publishContract(opts.server, token, {
145
+ orgKey,
146
+ consumerServiceKey: consumerKey,
147
+ providerServiceKey: providerKey,
148
+ consumerVersion: opts.consumerVersion || contractDoc.consumer?.version || null,
149
+ contractName,
150
+ contractType,
151
+ gitBranch: opts.tag || null,
152
+ verifierName: 'specshield-cli',
153
+ contentJson: contractDoc,
154
+ });
155
+ spinner.stop();
156
+
157
+ process.stdout.write('\n');
158
+ process.stdout.write(chalk.green.bold(' ✔ Contract Published Successfully') + '\n');
159
+ process.stdout.write(hr() + '\n');
160
+ process.stdout.write(` Contract ID : ${chalk.cyan(result.contractId)}\n`);
161
+ process.stdout.write(` Contract Name : ${chalk.white(contractName)}\n`);
162
+ process.stdout.write(` Consumer : ${consumerKey}\n`);
163
+ process.stdout.write(` Provider : ${providerKey}\n`);
164
+ process.stdout.write(` Version : ${chalk.cyan(result.contractVersion)}\n`);
165
+ process.stdout.write(` Status : ${statusBadge(result.status)}\n`);
166
+ if (result.contentHash) {
167
+ process.stdout.write(` Content Hash : ${chalk.gray(result.contentHash.substring(0, 16) + '...')}\n`);
168
+ }
169
+ process.stdout.write(` Published At : ${fmtDate(result.publishedAt)}\n`);
170
+ process.stdout.write('\n');
171
+ process.stdout.write(chalk.gray(` ➜ Run: specshield contracts verify --contract-id ${result.contractId} --base-url <URL>\n`));
172
+ process.stdout.write('\n');
173
+ } catch (err) {
174
+ spinner.fail('Publish failed');
175
+ logger.error(err.message);
176
+ process.exit(1);
177
+ }
178
+ });
179
+
180
+ // ─── List ─────────────────────────────────────────────────────────────────────
181
+
182
+ const listCommand = new Command('list')
183
+ .description('List contracts in the registry')
184
+ .option('--consumer <key>', 'Filter by consumer service key')
185
+ .option('--provider <key>', 'Filter by provider service key')
186
+ .option('--org <key>', 'Filter by organization key')
187
+ .option('--status <status>', 'Filter by status (PUBLISHED | DEPRECATED)')
188
+ .option('--contract-name <name>', 'Filter by contract name')
189
+ .option('--page <n>', 'Page number (0-based)', '0')
190
+ .option('--size <n>', 'Page size', '20')
191
+ .option('--json', 'Output raw JSON')
192
+ .option('--server <url>', 'SpecShield server URL')
193
+ .option('--api-token <token>', 'API token')
194
+ .action(async (opts) => {
195
+ const token = await resolveApiToken(opts);
196
+ requireToken(token);
197
+
198
+ const spinner = opts.json ? null : ora('Fetching contracts...').start();
199
+
200
+ try {
201
+ const page = await listContracts(opts.server, token, {
202
+ org: opts.org,
203
+ consumer: opts.consumer,
204
+ provider: opts.provider,
205
+ contractName: opts.contractName,
206
+ status: opts.status,
207
+ page: parseInt(opts.page, 10) || 0,
208
+ size: parseInt(opts.size, 10) || 20,
209
+ });
210
+ if (spinner) spinner.stop();
211
+
212
+ if (opts.json) {
213
+ process.stdout.write(JSON.stringify(page, null, 2) + '\n');
214
+ return;
215
+ }
216
+
217
+ const items = page.content || [];
218
+ const total = page.totalElements ?? items.length;
219
+
220
+ process.stdout.write('\n');
221
+ process.stdout.write(chalk.bold(' SpecShield Contract Registry') + '\n');
222
+ process.stdout.write(hr() + '\n');
223
+ process.stdout.write(` Showing ${items.length} of ${total} contracts\n`);
224
+
225
+ if (items.length === 0) {
226
+ process.stdout.write(chalk.gray('\n No contracts found matching filters.\n\n'));
227
+ return;
228
+ }
229
+
230
+ printTable(
231
+ ['ID', 'Contract Name', 'Consumer', 'Provider', 'Ver', 'Status', 'Last Verify', 'Published'],
232
+ items.map(c => [
233
+ chalk.cyan(String(c.contractId)),
234
+ c.contractName,
235
+ c.consumerServiceKey,
236
+ c.providerServiceKey,
237
+ c.contractVersion,
238
+ statusBadge(c.status),
239
+ verifyBadge(c.lastVerificationStatus),
240
+ fmtDate(c.publishedAt),
241
+ ])
242
+ );
243
+
244
+ if (page.totalPages > 1) {
245
+ const cur = (page.number ?? 0) + 1;
246
+ process.stdout.write(chalk.gray(` Page ${cur} of ${page.totalPages} · Use --page and --size to navigate\n\n`));
247
+ }
248
+ } catch (err) {
249
+ if (spinner) spinner.fail('List failed');
250
+ logger.error(err.message);
251
+ process.exit(1);
252
+ }
253
+ });
254
+
255
+ // ─── Latest ───────────────────────────────────────────────────────────────────
256
+
257
+ const latestCommand = new Command('latest')
258
+ .description('Get the latest version of a contract')
259
+ .option('--consumer <key>', 'Consumer service key')
260
+ .option('--provider <key>', 'Provider service key')
261
+ .option('--org <key>', 'Organization key')
262
+ .option('--contract-name <name>', 'Contract name')
263
+ .option('--json', 'Print full contract JSON')
264
+ .option('--server <url>', 'SpecShield server URL')
265
+ .option('--api-token <token>', 'API token')
266
+ .action(async (opts) => {
267
+ const token = await resolveApiToken(opts);
268
+ requireToken(token);
269
+
270
+ const spinner = opts.json ? null : ora('Fetching latest contract...').start();
271
+
272
+ try {
273
+ const c = await getLatestContract(opts.server, token, {
274
+ org: opts.org,
275
+ consumer: opts.consumer,
276
+ provider: opts.provider,
277
+ contractName: opts.contractName,
278
+ });
279
+ if (spinner) spinner.stop();
280
+
281
+ if (opts.json) {
282
+ process.stdout.write(JSON.stringify(c, null, 2) + '\n');
283
+ return;
284
+ }
285
+
286
+ process.stdout.write('\n');
287
+ process.stdout.write(chalk.bold(' Latest Contract') + '\n');
288
+ process.stdout.write(hr() + '\n');
289
+ process.stdout.write(` Contract ID : ${chalk.cyan(c.contractId)}\n`);
290
+ process.stdout.write(` Contract Name : ${chalk.white(c.contractName)}\n`);
291
+ process.stdout.write(` Consumer : ${c.consumerServiceKey}\n`);
292
+ process.stdout.write(` Provider : ${c.providerServiceKey}\n`);
293
+ process.stdout.write(` Version : ${chalk.cyan(c.contractVersion)}\n`);
294
+ process.stdout.write(` Type : ${c.contractType || '—'}\n`);
295
+ process.stdout.write(` Status : ${statusBadge(c.status)}\n`);
296
+ if (c.contentHash) {
297
+ process.stdout.write(` Content Hash : ${chalk.gray(c.contentHash.substring(0, 16) + '...')}\n`);
298
+ }
299
+ process.stdout.write(` Published At : ${fmtDate(c.publishedAt)}\n`);
300
+
301
+ if (c.verificationHistory && c.verificationHistory.length) {
302
+ const last = c.verificationHistory[0];
303
+ process.stdout.write('\n');
304
+ process.stdout.write(chalk.bold(' Last Verification') + '\n');
305
+ process.stdout.write(hr() + '\n');
306
+ process.stdout.write(` Verification ID : ${chalk.cyan(last.verificationId)}\n`);
307
+ process.stdout.write(` Status : ${verifyBadge(last.verificationStatus)}\n`);
308
+ process.stdout.write(` Environment : ${last.environment || '—'}\n`);
309
+ process.stdout.write(` Provider Ver : ${last.providerVersion || '—'}\n`);
310
+ process.stdout.write(` Completed At : ${fmtDate(last.completedAt)}\n`);
311
+ }
312
+
313
+ process.stdout.write('\n');
314
+ process.stdout.write(chalk.gray(` ➜ Use --json to see full contract content\n`));
315
+ process.stdout.write(chalk.gray(` ➜ Run: specshield contracts verify --contract-id ${c.contractId} --base-url <URL>\n`));
316
+ process.stdout.write('\n');
317
+ } catch (err) {
318
+ if (spinner) spinner.fail('Fetch failed');
319
+ logger.error(err.message);
320
+ process.exit(1);
321
+ }
322
+ });
323
+
324
+ // ─── Verify ───────────────────────────────────────────────────────────────────
325
+
326
+ const verifyCommand = new Command('verify')
327
+ .description('Verify a contract against a live provider')
328
+ .requiredOption('--contract-id <id>', 'Contract ID to verify')
329
+ .requiredOption('--base-url <url>', 'Provider base URL (e.g. http://localhost:8080)')
330
+ .option('--provider-version <ver>', 'Provider version tag')
331
+ .option('--env <environment>', 'Environment label (e.g. staging, qa)')
332
+ .option('--mode <mode>', 'Verification mode: LIVE | REPLAY', 'LIVE')
333
+ .option('--json', 'Output raw JSON')
334
+ .option('--server <url>', 'SpecShield server URL')
335
+ .option('--api-token <token>', 'API token')
336
+ .action(async (opts) => {
337
+ const token = await resolveApiToken(opts);
338
+ requireToken(token);
339
+
340
+ // Validate base URL
341
+ try { new URL(opts.baseUrl); } catch {
342
+ logger.error(`Invalid base URL: ${opts.baseUrl}`);
343
+ process.exit(2);
344
+ }
345
+
346
+ const contractId = parseInt(opts.contractId, 10);
347
+ if (isNaN(contractId)) {
348
+ logger.error('--contract-id must be a number');
349
+ process.exit(2);
350
+ }
351
+
352
+ const spinner = opts.json ? null : ora(`Verifying contract ${contractId}...`).start();
353
+
354
+ try {
355
+ const result = await verifyContract(opts.server, token, contractId, {
356
+ baseUrl: opts.baseUrl.replace(/\/$/, ''),
357
+ providerVersion: opts.providerVersion || null,
358
+ verificationMode: opts.mode || 'LIVE',
359
+ environment: opts.env || null,
360
+ verifierName: 'specshield-cli',
361
+ });
362
+ if (spinner) spinner.stop();
363
+
364
+ if (opts.json) {
365
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
366
+ return;
367
+ }
368
+
369
+ const summary = result.summary || {};
370
+ const total = summary.total ?? 0;
371
+ const passed = summary.passed ?? 0;
372
+ const failed = summary.failed ?? 0;
373
+ const mismatches = result.mismatches || [];
374
+ const success = result.verificationStatus === 'SUCCESS';
375
+
376
+ process.stdout.write('\n');
377
+ if (success) {
378
+ process.stdout.write(chalk.green.bold(` ✔ Verification PASSED`) + chalk.gray(` (${passed}/${total} interactions)\n`));
379
+ } else {
380
+ process.stdout.write(chalk.red.bold(` ✖ Verification FAILED`) + chalk.gray(` (${passed}/${total} interactions passed, ${failed} failed)\n`));
381
+ }
382
+ process.stdout.write(hr() + '\n');
383
+ process.stdout.write(` Verification ID : ${chalk.cyan(result.verificationId)}\n`);
384
+ process.stdout.write(` Contract ID : ${chalk.cyan(contractId)}\n`);
385
+ process.stdout.write(` Status : ${verifyBadge(result.verificationStatus)}\n`);
386
+ process.stdout.write(` Started At : ${fmtDate(result.startedAt)}\n`);
387
+ process.stdout.write(` Completed At : ${fmtDate(result.completedAt)}\n`);
388
+
389
+ if (mismatches.length > 0) {
390
+ process.stdout.write('\n');
391
+ process.stdout.write(chalk.red.bold(' Mismatches') + '\n');
392
+ process.stdout.write(hr() + '\n');
393
+ for (const m of mismatches) {
394
+ process.stdout.write(` ${chalk.red('●')} ${chalk.bold('[' + (m.interactionKey || '?') + ']')} ${chalk.yellow(m.mismatchType)} at ${chalk.gray(m.path || '$')}\n`);
395
+ if (m.expectedValue !== null && m.expectedValue !== undefined) {
396
+ process.stdout.write(` ${chalk.gray('expected:')} ${chalk.green(m.expectedValue)} ${chalk.gray('→')} ${chalk.red(m.actualValue ?? 'null')}\n`);
397
+ }
398
+ process.stdout.write(` ${chalk.gray(m.message || '')}\n`);
399
+ }
400
+ process.stdout.write('\n');
401
+ }
402
+
403
+ if (success) {
404
+ process.stdout.write(chalk.gray(` ➜ Run: specshield contracts can-i-deploy --provider <NAME> --version <VER>\n`));
405
+ } else {
406
+ process.stdout.write(chalk.gray(` ➜ Run: specshield contracts history --contract-id ${contractId} to inspect past runs\n`));
407
+ }
408
+ process.stdout.write('\n');
409
+
410
+ process.exit(success ? 0 : 1);
411
+ } catch (err) {
412
+ if (spinner) spinner.fail('Verification failed');
413
+ logger.error(err.message);
414
+ process.exit(2);
415
+ }
416
+ });
417
+
418
+ // ─── History ──────────────────────────────────────────────────────────────────
419
+
420
+ const historyCommand = new Command('history')
421
+ .description('Show verification history for a contract')
422
+ .requiredOption('--contract-id <id>', 'Contract ID')
423
+ .option('--json', 'Output raw JSON')
424
+ .option('--server <url>', 'SpecShield server URL')
425
+ .option('--api-token <token>', 'API token')
426
+ .action(async (opts) => {
427
+ const token = await resolveApiToken(opts);
428
+ requireToken(token);
429
+
430
+ const contractId = parseInt(opts.contractId, 10);
431
+ if (isNaN(contractId)) {
432
+ logger.error('--contract-id must be a number');
433
+ process.exit(2);
434
+ }
435
+
436
+ const spinner = opts.json ? null : ora('Fetching verification history...').start();
437
+
438
+ try {
439
+ const history = await getVerificationHistory(opts.server, token, contractId);
440
+ if (spinner) spinner.stop();
441
+
442
+ if (opts.json) {
443
+ process.stdout.write(JSON.stringify(history, null, 2) + '\n');
444
+ return;
445
+ }
446
+
447
+ const items = Array.isArray(history) ? history : (history.content || []);
448
+
449
+ process.stdout.write('\n');
450
+ process.stdout.write(chalk.bold(` Verification History — Contract ${contractId}`) + '\n');
451
+ process.stdout.write(hr() + '\n');
452
+
453
+ if (items.length === 0) {
454
+ process.stdout.write(chalk.gray('\n No verifications found for this contract.\n\n'));
455
+ return;
456
+ }
457
+
458
+ printTable(
459
+ ['ID', 'Status', 'Environment', 'Provider Version', 'Mode', 'Completed At'],
460
+ items.map(v => [
461
+ chalk.cyan(String(v.verificationId ?? v.id ?? '—')),
462
+ verifyBadge(v.verificationStatus),
463
+ v.environment || '—',
464
+ v.providerVersion || '—',
465
+ v.verificationMode || '—',
466
+ fmtDate(v.completedAt),
467
+ ])
468
+ );
469
+
470
+ process.stdout.write(chalk.gray(` ➜ Run: specshield contracts verify --contract-id ${contractId} --base-url <URL> to re-verify\n\n`));
471
+ } catch (err) {
472
+ if (spinner) spinner.fail('Fetch failed');
473
+ logger.error(err.message);
474
+ process.exit(1);
475
+ }
476
+ });
477
+
478
+ // ─── Can-I-Deploy ─────────────────────────────────────────────────────────────
479
+
480
+ const canIDeployCommand = new Command('can-i-deploy')
481
+ .description('Check if a provider version is safe to deploy')
482
+ .requiredOption('--provider <key>', 'Provider service key')
483
+ .requiredOption('--version <ver>', 'Provider version to check')
484
+ .option('--env <environment>', 'Target environment (e.g. qa, staging, production)')
485
+ .option('--json', 'Output raw JSON')
486
+ .option('--server <url>', 'SpecShield server URL')
487
+ .option('--api-token <token>', 'API token')
488
+ .action(async (opts) => {
489
+ const token = await resolveApiToken(opts);
490
+ requireToken(token);
491
+
492
+ const spinner = opts.json ? null : ora(`Checking deployment safety for ${opts.provider}@${opts.version}...`).start();
493
+
494
+ try {
495
+ const results = await canIDeploy(opts.server, token, {
496
+ provider: opts.provider,
497
+ version: opts.version,
498
+ environment: opts.env || null,
499
+ });
500
+ if (spinner) spinner.stop();
501
+
502
+ if (opts.json) {
503
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
504
+ return;
505
+ }
506
+
507
+ const items = Array.isArray(results) ? results : [results];
508
+ const allAllowed = items.every(r => r.allowed);
509
+ const envLabel = opts.env ? ` in ${opts.env}` : '';
510
+
511
+ process.stdout.write('\n');
512
+ if (allAllowed) {
513
+ process.stdout.write(chalk.green.bold(' ✔ PASS') + chalk.white(`: ${opts.provider} v${opts.version} is deployable${envLabel}\n`));
514
+ } else {
515
+ process.stdout.write(chalk.red.bold(' ✖ FAIL') + chalk.white(`: ${opts.provider} v${opts.version} is NOT deployable${envLabel}\n`));
516
+ }
517
+ process.stdout.write(hr() + '\n');
518
+
519
+ if (items.length > 0) {
520
+ process.stdout.write('\n');
521
+ process.stdout.write(chalk.bold(' Contract Decisions') + '\n');
522
+ for (const r of items) {
523
+ const icon = r.allowed ? chalk.green('✔') : chalk.red('✖');
524
+ const status = r.verificationStatus
525
+ ? ` — ${verifyBadge(r.verificationStatus)}`
526
+ : '';
527
+ process.stdout.write(` ${icon} Contract ID ${chalk.cyan(r.contractId)}${status}\n`);
528
+ if (r.reason) {
529
+ process.stdout.write(` ${chalk.gray(r.reason)}\n`);
530
+ }
531
+ }
532
+ process.stdout.write('\n');
533
+ }
534
+
535
+ if (!allAllowed) {
536
+ process.stdout.write(chalk.gray(` ➜ Run: specshield contracts verify --contract-id <ID> --base-url <URL>\n`));
537
+ process.stdout.write(chalk.gray(` ➜ to verify pending contracts before deploying\n`));
538
+ }
539
+ process.stdout.write('\n');
540
+
541
+ process.exit(allAllowed ? 0 : 1);
542
+ } catch (err) {
543
+ if (spinner) spinner.fail('Check failed');
544
+ logger.error(err.message);
545
+ process.exit(2);
546
+ }
547
+ });
548
+
549
+ // ─── Parent contracts command ─────────────────────────────────────────────────
550
+
551
+ const contracts = new Command('contracts')
552
+ .description('Manage and verify consumer-driven contracts');
553
+
554
+ contracts.addCommand(publishCommand);
555
+ contracts.addCommand(listCommand);
556
+ contracts.addCommand(latestCommand);
557
+ contracts.addCommand(verifyCommand);
558
+ contracts.addCommand(historyCommand);
559
+ contracts.addCommand(canIDeployCommand);
560
+
561
+ module.exports = contracts;