specshield 1.0.6 → 1.0.8

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
@@ -5,7 +5,89 @@
5
5
  [![license](https://img.shields.io/badge/license-MIT-blue)](#license)
6
6
  [![node](https://img.shields.io/badge/node-%3E%3D18-green)](https://nodejs.org)
7
7
 
8
- Compare OpenAPI and Swagger specs, detect breaking changes, and fail CI before incompatible API changes reach production.
8
+ ## 🚀 Prevent Breaking API Changes Before They Reach Production
9
+
10
+ **SpecShield is a Pact alternative for OpenAPI** that helps developers
11
+ **detect breaking API changes in CI/CD** and act as a powerful
12
+ **OpenAPI diff tool for modern backend teams.**
13
+
14
+ ---
15
+
16
+ ## 👀 See it in action
17
+
18
+ ```bash
19
+ $ specshield compare base.yaml target.yaml --fail-on-breaking
20
+
21
+ ✖ BREAKING CHANGES DETECTED
22
+
23
+ 1. DELETE /users endpoint removed
24
+ 2. POST /payments request field "amount" changed from optional to required
25
+ 3. GET /orders/{id} response field "status" type changed: string -> object
26
+
27
+ Summary
28
+ - Breaking changes : 3
29
+ - Modifications : 1
30
+ - Additions : 2
31
+ - Warnings : 0
32
+
33
+ CI Result: FAILED
34
+ Exit Code: 1
35
+ ```
36
+
37
+ These are the kinds of changes that break clients in production.
38
+ SpecShield catches them early so your CI can block unsafe deployments.
39
+
40
+ ### Safe change example
41
+
42
+ ```bash
43
+ $ specshield compare base.yaml target.yaml --fail-on-breaking
44
+
45
+ ✔ NO BREAKING CHANGES FOUND
46
+
47
+ Summary
48
+ - Breaking changes : 0
49
+ - Modifications : 1
50
+ - Additions : 3
51
+ - Warnings : 1
52
+
53
+ CI Result: PASSED
54
+ Exit Code: 0
55
+ ```
56
+
57
+ ---
58
+
59
+ ## ⚡ 30-second quick start
60
+
61
+ ```bash
62
+ npm install -g specshield
63
+ specshield compare base.yaml target.yaml --fail-on-breaking
64
+ ```
65
+
66
+ 👉 No account required for local compare
67
+
68
+ ---
69
+
70
+ ## 💡 Useful for
71
+
72
+ - Preventing accidental API breakage in pull requests
73
+ - Failing CI when breaking changes are introduced
74
+ - Tracking API drift across versions
75
+ - Enforcing contracts between consumer and provider services
76
+
77
+ ---
78
+
79
+ ## 🌐 Upgrade to Remote
80
+
81
+ Local compare is free and unlimited.
82
+
83
+ Remote mode unlocks:
84
+
85
+ - 📊 API change history
86
+ - 👥 Team collaboration
87
+ - 🚦 Deployment gating (`can-i-deploy`)
88
+ - 🔗 Contract testing across services
89
+
90
+ 👉 https://specshield.io
9
91
 
10
92
  ---
11
93
 
@@ -15,8 +97,18 @@ Compare OpenAPI and Swagger specs, detect breaking changes, and fail CI before i
15
97
  - [Installation](#installation)
16
98
  - [Local Compare](#local-compare)
17
99
  - [Authentication](#authentication)
18
- - [Generate an API Token](#generate-an-api-token)
100
+ - [Generate an API Token](#generate-an-api-token)
19
101
  - [Remote Compare](#remote-compare)
102
+ - [Contracts — Consumer-Driven Testing](#contracts--consumer-driven-testing)
103
+ - [Contract File Format](#contract-file-format)
104
+ - [Publish a Contract](#publish-a-contract)
105
+ - [List Contracts](#list-contracts)
106
+ - [Get Latest Contract](#get-latest-contract)
107
+ - [Verify a Contract](#verify-a-contract)
108
+ - [Verification History](#verification-history)
109
+ - [Can I Deploy?](#can-i-deploy)
110
+ - [Full Publish → Verify → Deploy Workflow](#full-publish--verify--deploy-workflow)
111
+ - [Contracts in CI/CD](#contracts-in-cicd)
20
112
  - [Config File](#config-file)
21
113
  - [All Options](#all-options)
22
114
  - [Exit Codes](#exit-codes)
@@ -27,12 +119,11 @@ Compare OpenAPI and Swagger specs, detect breaking changes, and fail CI before i
27
119
 
28
120
  ## What is SpecShield CLI?
29
121
 
30
- SpecShield CLI is a command-line tool for comparing two OpenAPI/Swagger specifications and detecting what changed between them. It classifies changes into:
122
+ SpecShield CLI is a developer-first **OpenAPI diff tool** and
123
+ **contract testing solution** that helps prevent breaking API changes.
31
124
 
32
- - **Breaking changes** removed endpoints, changed required fields, incompatible type changes
33
- - **Modifications** changed behavior that may or may not break clients
34
- - **Additions** — new endpoints or fields
35
- - **Warnings** — low-severity notices
125
+ It works as a lightweight **Pact alternative for OpenAPI**, designed for
126
+ modern microservices and CI/CD pipelines.
36
127
 
37
128
  It works in two modes:
38
129
 
@@ -121,7 +212,7 @@ This validates the token against the SpecShield API and saves it to `~/.specshie
121
212
 
122
213
  **Example output:**
123
214
 
124
- ```
215
+ ```text
125
216
  ✔ Logged in successfully.
126
217
 
127
218
  Customer: Jane Smith
@@ -188,12 +279,465 @@ specshield compare base.yaml target.yaml --remote --json --output result.json
188
279
 
189
280
  The CLI reads your API token (from flag, env var, or stored config) and sends it as an `X-Api-Key` header with each request. If no token is found, the command exits with an error:
190
281
 
191
- ```
282
+ ```text
192
283
  Error: No API key found. Run: specshield login --api-key <KEY>
193
284
  ```
194
285
 
195
286
  ---
196
287
 
288
+ ## Contracts — Consumer-Driven Testing
289
+
290
+ SpecShield Contracts lets consumer teams publish API expectations (contracts) to a central registry. Provider teams then verify their service satisfies those contracts before deploying.
291
+
292
+ **Why contract testing?**
293
+ - Catch provider-side regressions before they reach consumers
294
+ - Enforce an agreed-upon API shape across services
295
+ - Gate deployments on verified contracts with `can-i-deploy`
296
+
297
+ All contract commands require an API token. See [Authentication](#authentication).
298
+
299
+ ### Contract File Format
300
+
301
+ Create a `.json` file describing the expected API interactions:
302
+
303
+ ```json
304
+ {
305
+ "consumer": {
306
+ "name": "checkout-ui",
307
+ "version": "1.2.0"
308
+ },
309
+ "provider": {
310
+ "name": "payment-service"
311
+ },
312
+ "contractName": "create-payment",
313
+ "contractType": "HTTP",
314
+ "interactions": [
315
+ {
316
+ "description": "create payment",
317
+ "request": {
318
+ "method": "POST",
319
+ "path": "/payments",
320
+ "headers": {
321
+ "Content-Type": "application/json"
322
+ },
323
+ "body": {
324
+ "orderId": "ORD-123",
325
+ "amount": 100,
326
+ "currency": "INR"
327
+ }
328
+ },
329
+ "expectedResponse": {
330
+ "status": 201,
331
+ "headers": {
332
+ "Content-Type": "application/json"
333
+ },
334
+ "body": {
335
+ "paymentId": "PAY-123",
336
+ "status": "CREATED"
337
+ }
338
+ }
339
+ }
340
+ ],
341
+ "metadata": {
342
+ "generatedBy": "specshield-cli",
343
+ "contractFormatVersion": "1.0"
344
+ }
345
+ }
346
+ ```
347
+
348
+ 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.
349
+
350
+ ### Publish a Contract
351
+
352
+ ```bash
353
+ specshield contracts publish \\
354
+ --file ./contracts/create-payment.json \\
355
+ --org acme
356
+ ```
357
+
358
+ With all flags explicitly set (flags override file values):
359
+
360
+ ```bash
361
+ specshield contracts publish \\
362
+ --file ./contracts/create-payment.json \\
363
+ --org acme \\
364
+ --consumer checkout-ui \\
365
+ --provider payment-service \\
366
+ --contract-name create-payment \\
367
+ --consumer-version 1.2.0 \\
368
+ --tag main
369
+ ```
370
+
371
+ **Example output:**
372
+
373
+ ```text
374
+ ✔ Contract Published Successfully
375
+ ─────────────────────────────────────────────────────
376
+ Contract ID : 42
377
+ Contract Name : create-payment
378
+ Consumer : checkout-ui
379
+ Provider : payment-service
380
+ Version : 3
381
+ Status : PUBLISHED
382
+ Content Hash : a3f9c1d2e7b8...
383
+ Published At : 06/04/2026 14:30:00
384
+
385
+ ➜ Run: specshield contracts verify --contract-id 42 --base-url http://localhost:8080
386
+ ```
387
+
388
+ **Options:**
389
+
390
+ | Flag | Description |
391
+ |---|---|
392
+ | `--file <path>` | Path to contract JSON file (required) |
393
+ | `--org <key>` | Organization key |
394
+ | `--consumer <key>` | Consumer service key (overrides file) |
395
+ | `--provider <key>` | Provider service key (overrides file) |
396
+ | `--consumer-version <ver>` | Consumer version tag |
397
+ | `--contract-name <name>` | Contract name (overrides file) |
398
+ | `--tag <tag>` | Git branch or release tag |
399
+ | `--server <url>` | SpecShield server URL (default: `https://specshield.io`) |
400
+ | `--api-token <token>` | API token |
401
+
402
+ ### List Contracts
403
+
404
+ ```bash
405
+ specshield contracts list
406
+ ```
407
+
408
+ Filter by provider:
409
+
410
+ ```bash
411
+ specshield contracts list --provider payment-service
412
+ ```
413
+
414
+ Filter by consumer, org, status:
415
+
416
+ ```bash
417
+ specshield contracts list \\
418
+ --consumer checkout-ui \\
419
+ --provider payment-service \\
420
+ --status PUBLISHED
421
+ ```
422
+
423
+ Output raw JSON:
424
+
425
+ ```bash
426
+ specshield contracts list --json
427
+ ```
428
+
429
+ **Example output:**
430
+
431
+ ```text
432
+ SpecShield Contract Registry
433
+ ─────────────────────────────────────────────────────
434
+ Showing 2 of 2 contracts
435
+
436
+ ID Contract Name Consumer Provider Ver Status Last Verify Published
437
+ ── ─────────────── ──────────── ─────────────── ─── ───────── ─────────── ─────────────────────
438
+ 42 create-payment checkout-ui payment-service 3 PUBLISHED SUCCESS 06/04/2026 14:30:00
439
+ 41 get-order-status order-ui order-service 1 PUBLISHED FAILED 05/04/2026 09:15:00
440
+ ```
441
+
442
+ **Options:**
443
+
444
+ | Flag | Description |
445
+ |---|---|
446
+ | `--consumer <key>` | Filter by consumer |
447
+ | `--provider <key>` | Filter by provider |
448
+ | `--org <key>` | Filter by organization |
449
+ | `--status <status>` | Filter by status (`PUBLISHED` / `DEPRECATED`) |
450
+ | `--contract-name <name>` | Filter by contract name |
451
+ | `--page <n>` | Page number (0-based, default: `0`) |
452
+ | `--size <n>` | Page size (default: `20`) |
453
+ | `--json` | Output raw JSON |
454
+
455
+ ### Get Latest Contract
456
+
457
+ ```bash
458
+ specshield contracts latest \\
459
+ --consumer checkout-ui \\
460
+ --provider payment-service \\
461
+ --contract-name create-payment
462
+ ```
463
+
464
+ Print the full contract content (interactions, body, etc.):
465
+
466
+ ```bash
467
+ specshield contracts latest \\
468
+ --consumer checkout-ui \\
469
+ --provider payment-service \\
470
+ --json
471
+ ```
472
+
473
+ **Options:**
474
+
475
+ | Flag | Description |
476
+ |---|---|
477
+ | `--consumer <key>` | Consumer service key |
478
+ | `--provider <key>` | Provider service key |
479
+ | `--org <key>` | Organization key |
480
+ | `--contract-name <name>` | Contract name |
481
+ | `--json` | Print full contract JSON |
482
+
483
+ ### Verify a Contract
484
+
485
+ Run the contract against a live provider. SpecShield replays each interaction against the `--base-url` and compares the response to the expected values.
486
+
487
+ ```bash
488
+ specshield contracts verify \\
489
+ --contract-id 42 \\
490
+ --base-url http://localhost:8080
491
+ ```
492
+
493
+ With version and environment tags:
494
+
495
+ ```bash
496
+ specshield contracts verify \\
497
+ --contract-id 42 \\
498
+ --base-url https://payment-service.staging.internal \\
499
+ --provider-version v2.1.0 \\
500
+ --env staging
501
+ ```
502
+
503
+ Output raw JSON (for CI parsing):
504
+
505
+ ```bash
506
+ specshield contracts verify \\
507
+ --contract-id 42 \\
508
+ --base-url http://localhost:8080 \\
509
+ --json
510
+ ```
511
+
512
+ **Example output (pass):**
513
+
514
+ ```text
515
+ ✔ Verification PASSED (1/1 interactions)
516
+ ─────────────────────────────────────────────────────
517
+ Verification ID : 101
518
+ Contract ID : 42
519
+ Status : SUCCESS
520
+ Started At : 06/04/2026 14:35:00
521
+ Completed At : 06/04/2026 14:35:01
522
+
523
+ ➜ Run: specshield contracts can-i-deploy --provider payment-service --version v2.1.0
524
+ ```
525
+
526
+ **Example output (fail):**
527
+
528
+ ```text
529
+ ✖ Verification FAILED (0/1 interactions passed, 1 failed)
530
+ ─────────────────────────────────────────────────────
531
+ Verification ID : 102
532
+ Contract ID : 42
533
+ Status : FAILED
534
+
535
+ Mismatches
536
+ ─────────────────────────────────────────────────────
537
+ ● [create payment] STATUS_CODE_MISMATCH at $.status
538
+ expected: 201 → actual: 200
539
+ Expected status 201 but got 200
540
+
541
+ ➜ Run: specshield contracts history --contract-id 42 to inspect previous runs
542
+ ```
543
+
544
+ **Exit codes:** `0` = PASSED, `1` = FAILED, `2` = error
545
+
546
+ **Options:**
547
+
548
+ | Flag | Description |
549
+ |---|---|
550
+ | `--contract-id <id>` | Contract ID to verify (required) |
551
+ | `--base-url <url>` | Provider base URL (required, e.g. `http://localhost:8080`) |
552
+ | `--provider-version <ver>` | Provider version tag |
553
+ | `--env <environment>` | Environment label (`staging`, `qa`, `production`) |
554
+ | `--mode <mode>` | `LIVE` or `REPLAY` (default: `LIVE`) |
555
+ | `--json` | Output raw JSON |
556
+
557
+ ### Verification History
558
+
559
+ ```bash
560
+ specshield contracts history --contract-id 42
561
+ ```
562
+
563
+ **Example output:**
564
+
565
+ ```text
566
+ Verification History — Contract 42
567
+ ─────────────────────────────────────────────────────
568
+
569
+ ID Status Environment Provider Version Mode Completed At
570
+ ─── ─────── ─────────── ──────────────── ──── ─────────────────────
571
+ 102 FAILED staging v2.1.0 LIVE 06/04/2026 14:35:01
572
+ 101 SUCCESS staging v2.0.0 LIVE 05/04/2026 10:00:00
573
+ 98 SUCCESS qa v1.9.0 LIVE 01/04/2026 08:30:00
574
+ ```
575
+
576
+ **Options:**
577
+
578
+ | Flag | Description |
579
+ |---|---|
580
+ | `--contract-id <id>` | Contract ID (required) |
581
+ | `--json` | Output raw JSON |
582
+
583
+ ### Can I Deploy?
584
+
585
+ Check whether a provider version has passing verifications for all associated contracts:
586
+
587
+ ```bash
588
+ specshield contracts can-i-deploy \\
589
+ --provider payment-service \\
590
+ --version v2.1.0
591
+ ```
592
+
593
+ Scoped to a specific environment:
594
+
595
+ ```bash
596
+ specshield contracts can-i-deploy \\
597
+ --provider payment-service \\
598
+ --version v2.1.0 \\
599
+ --env production
600
+ ```
601
+
602
+ **Example output (allowed):**
603
+
604
+ ```text
605
+ ✔ PASS: payment-service v2.1.0 is deployable in production
606
+ ─────────────────────────────────────────────────────
607
+
608
+ Contract Decisions
609
+ ✔ Contract ID 42 — SUCCESS
610
+ All contracts verified
611
+ ```
612
+
613
+ **Example output (blocked):**
614
+
615
+ ```text
616
+ ✖ FAIL: payment-service v2.1.0 is NOT deployable in production
617
+ ─────────────────────────────────────────────────────
618
+
619
+ Contract Decisions
620
+ ✖ Contract ID 43 — FAILED
621
+ Unverified or failed contracts found
622
+
623
+ ➜ Run: specshield contracts verify --contract-id 43 --base-url <URL>
624
+ ➜ to verify pending contracts before deploying
625
+ ```
626
+
627
+ **Exit codes:** `0` = deployable, `1` = blocked, `2` = error
628
+
629
+ **Options:**
630
+
631
+ | Flag | Description |
632
+ |---|---|
633
+ | `--provider <key>` | Provider service key (required) |
634
+ | `--version <ver>` | Provider version to check (required) |
635
+ | `--env <environment>` | Target environment |
636
+ | `--json` | Output raw JSON |
637
+
638
+ ### Full Publish → Verify → Deploy Workflow
639
+
640
+ ```bash
641
+ # 1. Consumer team publishes a contract
642
+ specshield contracts publish \\
643
+ --file ./contracts/create-payment.json \\
644
+ --org acme
645
+
646
+ # 2. Provider team starts their service locally
647
+ ./gradlew bootRun &
648
+
649
+ # 3. Provider team verifies the contract
650
+ specshield contracts verify \\
651
+ --contract-id 42 \\
652
+ --base-url http://localhost:8080 \\
653
+ --provider-version v2.1.0 \\
654
+ --env staging
655
+
656
+ # 4. Gate the deployment
657
+ specshield contracts can-i-deploy \\
658
+ --provider payment-service \\
659
+ --version v2.1.0 \\
660
+ --env staging
661
+ ```
662
+
663
+ ### Contracts in CI/CD
664
+
665
+ #### GitHub Actions — Publish on consumer change
666
+
667
+ ```yaml
668
+ name: Publish Contract
669
+
670
+ on:
671
+ push:
672
+ branches: [main]
673
+ paths:
674
+ - 'contracts/**'
675
+
676
+ jobs:
677
+ publish-contract:
678
+ runs-on: ubuntu-latest
679
+ steps:
680
+ - uses: actions/checkout@v4
681
+ - uses: actions/setup-node@v4
682
+ with:
683
+ node-version: '20'
684
+ - run: npm install -g specshield
685
+ - name: Publish contract
686
+ env:
687
+ SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
688
+ run: |
689
+ specshield contracts publish \\
690
+ --file ./contracts/create-payment.json \\
691
+ --org acme \\
692
+ --tag ${{ github.ref_name }}
693
+ ```
694
+
695
+ #### GitHub Actions — Verify + can-i-deploy on provider change
696
+
697
+ ```yaml
698
+ name: Contract Verification
699
+
700
+ on:
701
+ push:
702
+ branches: [main]
703
+
704
+ jobs:
705
+ verify-contracts:
706
+ runs-on: ubuntu-latest
707
+ services:
708
+ payment-service:
709
+ image: myorg/payment-service:${{ github.sha }}
710
+ ports:
711
+ - 8080:8080
712
+ steps:
713
+ - uses: actions/checkout@v4
714
+ - uses: actions/setup-node@v4
715
+ with:
716
+ node-version: '20'
717
+ - run: npm install -g specshield
718
+
719
+ - name: Verify contract
720
+ env:
721
+ SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
722
+ run: |
723
+ specshield contracts verify \\
724
+ --contract-id ${{ vars.PAYMENT_CONTRACT_ID }} \\
725
+ --base-url http://localhost:8080 \\
726
+ --provider-version ${{ github.sha }} \\
727
+ --env staging
728
+
729
+ - name: Can I deploy?
730
+ env:
731
+ SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
732
+ run: |
733
+ specshield contracts can-i-deploy \\
734
+ --provider payment-service \\
735
+ --version ${{ github.sha }} \\
736
+ --env staging
737
+ ```
738
+
739
+ ---
740
+
197
741
  ## Config File
198
742
 
199
743
  Create `.specshield.yml` in your project root to set default behavior:
@@ -280,8 +824,8 @@ jobs:
280
824
 
281
825
  - name: Compare specs
282
826
  run: |
283
- specshield compare /tmp/base-spec.yaml api/openapi.yaml \
284
- --fail-on-breaking \
827
+ specshield compare /tmp/base-spec.yaml api/openapi.yaml \\
828
+ --fail-on-breaking \\
285
829
  --output spec-diff.json
286
830
 
287
831
  - name: Upload diff report
@@ -301,9 +845,9 @@ Add your API token as a GitHub Actions secret named `SPECSHIELD_API_KEY`, then:
301
845
  env:
302
846
  SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
303
847
  run: |
304
- specshield compare /tmp/base-spec.yaml api/openapi.yaml \
305
- --remote \
306
- --fail-on-breaking \
848
+ specshield compare /tmp/base-spec.yaml api/openapi.yaml \\
849
+ --remote \\
850
+ --fail-on-breaking \\
307
851
  --output spec-diff.json
308
852
  ```
309
853
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specshield",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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": {
@@ -13,29 +13,37 @@
13
13
  "lint": "eslint src tests --ext .js"
14
14
  },
15
15
  "keywords": [
16
- "openapi",
17
- "swagger",
18
- "openapi-diff",
19
- "swagger-diff",
20
- "api-diff",
21
- "api-breaking-changes",
22
- "breaking-change-detection",
23
- "contract-testing",
24
- "api-contract",
25
- "api-versioning",
26
- "api-governance",
27
- "ci-cd",
28
- "devops",
29
- "github-actions",
30
- "automation",
31
- "cli",
32
- "developer-tools",
33
- "yaml",
34
- "json",
35
- "schema-diff",
36
- "rest-api",
37
- "openapi-cli"
38
- ],
16
+ "openapi",
17
+ "swagger",
18
+ "api-diff",
19
+ "openapi-diff",
20
+ "swagger-diff",
21
+ "api-diff-tool",
22
+ "breaking-change-detection",
23
+ "api-breaking-changes",
24
+ "prevent-breaking-api",
25
+ "api-regression",
26
+ "api-compatibility",
27
+ "contract-testing",
28
+ "consumer-driven-contract",
29
+ "api-contract",
30
+ "contract-verification",
31
+ "can-i-deploy",
32
+ "ci-cd",
33
+ "devops",
34
+ "github-actions",
35
+ "cli",
36
+ "developer-tools",
37
+ "openapi-compare",
38
+ "swagger-compare",
39
+ "pact-alternative",
40
+ "api-contract-testing",
41
+ "microservices",
42
+ "api-quality",
43
+ "api-governance",
44
+ "yaml",
45
+ "json"
46
+ ],
39
47
  "license": "MIT",
40
48
  "files": [
41
49
  "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;