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 +558 -14
- package/package.json +32 -24
- package/src/api/contractsClient.js +88 -0
- package/src/cli.js +2 -0
- package/src/commands/contracts.js +561 -0
package/README.md
CHANGED
|
@@ -5,7 +5,89 @@
|
|
|
5
5
|
[](#license)
|
|
6
6
|
[](https://nodejs.org)
|
|
7
7
|
|
|
8
|
-
|
|
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
|
|
122
|
+
SpecShield CLI is a developer-first **OpenAPI diff tool** and
|
|
123
|
+
**contract testing solution** that helps prevent breaking API changes.
|
|
31
124
|
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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;
|