specshield 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +483 -0
- package/package.json +1 -1
- package/src/api/contractsClient.js +88 -0
- package/src/cli.js +2 -0
- package/src/commands/contracts.js +561 -0
package/README.md
CHANGED
|
@@ -17,6 +17,16 @@ Compare OpenAPI and Swagger specs, detect breaking changes, and fail CI before i
|
|
|
17
17
|
- [Authentication](#authentication)
|
|
18
18
|
- [Generate an API Token](#generate-an-api-token)
|
|
19
19
|
- [Remote Compare](#remote-compare)
|
|
20
|
+
- [Contracts — Consumer-Driven Testing](#contracts--consumer-driven-testing)
|
|
21
|
+
- [Contract File Format](#contract-file-format)
|
|
22
|
+
- [Publish a Contract](#publish-a-contract)
|
|
23
|
+
- [List Contracts](#list-contracts)
|
|
24
|
+
- [Get Latest Contract](#get-latest-contract)
|
|
25
|
+
- [Verify a Contract](#verify-a-contract)
|
|
26
|
+
- [Verification History](#verification-history)
|
|
27
|
+
- [Can I Deploy?](#can-i-deploy)
|
|
28
|
+
- [Full Publish → Verify → Deploy Workflow](#full-publish--verify--deploy-workflow)
|
|
29
|
+
- [Contracts in CI/CD](#contracts-in-cicd)
|
|
20
30
|
- [Config File](#config-file)
|
|
21
31
|
- [All Options](#all-options)
|
|
22
32
|
- [Exit Codes](#exit-codes)
|
|
@@ -194,6 +204,479 @@ Error: No API key found. Run: specshield login --api-key <KEY>
|
|
|
194
204
|
|
|
195
205
|
---
|
|
196
206
|
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Contracts — Consumer-Driven Testing
|
|
210
|
+
|
|
211
|
+
SpecShield Contracts lets consumer teams publish API expectations (contracts) to a central registry. Provider teams then verify their service satisfies those contracts before deploying.
|
|
212
|
+
|
|
213
|
+
**Why contract testing?**
|
|
214
|
+
- Catch provider-side regressions before they reach consumers
|
|
215
|
+
- Enforce an agreed-upon API shape across services
|
|
216
|
+
- Gate deployments on verified contracts with `can-i-deploy`
|
|
217
|
+
|
|
218
|
+
All contract commands require an API token. See [Authentication](#authentication).
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
### Contract File Format
|
|
223
|
+
|
|
224
|
+
Create a `.json` file describing the expected API interactions:
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"consumer": {
|
|
229
|
+
"name": "checkout-ui",
|
|
230
|
+
"version": "1.2.0"
|
|
231
|
+
},
|
|
232
|
+
"provider": {
|
|
233
|
+
"name": "payment-service"
|
|
234
|
+
},
|
|
235
|
+
"contractName": "create-payment",
|
|
236
|
+
"contractType": "HTTP",
|
|
237
|
+
"interactions": [
|
|
238
|
+
{
|
|
239
|
+
"description": "create payment",
|
|
240
|
+
"request": {
|
|
241
|
+
"method": "POST",
|
|
242
|
+
"path": "/payments",
|
|
243
|
+
"headers": {
|
|
244
|
+
"Content-Type": "application/json"
|
|
245
|
+
},
|
|
246
|
+
"body": {
|
|
247
|
+
"orderId": "ORD-123",
|
|
248
|
+
"amount": 100,
|
|
249
|
+
"currency": "INR"
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
"expectedResponse": {
|
|
253
|
+
"status": 201,
|
|
254
|
+
"headers": {
|
|
255
|
+
"Content-Type": "application/json"
|
|
256
|
+
},
|
|
257
|
+
"body": {
|
|
258
|
+
"paymentId": "PAY-123",
|
|
259
|
+
"status": "CREATED"
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
"metadata": {
|
|
265
|
+
"generatedBy": "specshield-cli",
|
|
266
|
+
"contractFormatVersion": "1.0"
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The `consumer.name`, `provider.name`, and `contractName` fields are used to identify the contract in the registry. CLI flags (`--consumer`, `--provider`, `--contract-name`) override file values if provided.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
### Publish a Contract
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
specshield contracts publish \
|
|
279
|
+
--file ./contracts/create-payment.json \
|
|
280
|
+
--org acme
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
With all flags explicitly set (flags override file values):
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
specshield contracts publish \
|
|
287
|
+
--file ./contracts/create-payment.json \
|
|
288
|
+
--org acme \
|
|
289
|
+
--consumer checkout-ui \
|
|
290
|
+
--provider payment-service \
|
|
291
|
+
--contract-name create-payment \
|
|
292
|
+
--consumer-version 1.2.0 \
|
|
293
|
+
--tag main
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**Example output:**
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
✔ Contract Published Successfully
|
|
300
|
+
─────────────────────────────────────────────────────
|
|
301
|
+
Contract ID : 42
|
|
302
|
+
Contract Name : create-payment
|
|
303
|
+
Consumer : checkout-ui
|
|
304
|
+
Provider : payment-service
|
|
305
|
+
Version : 3
|
|
306
|
+
Status : PUBLISHED
|
|
307
|
+
Content Hash : a3f9c1d2e7b8...
|
|
308
|
+
Published At : 06/04/2026 14:30:00
|
|
309
|
+
|
|
310
|
+
➜ Run: specshield contracts verify --contract-id 42 --base-url http://localhost:8080
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**Options:**
|
|
314
|
+
|
|
315
|
+
| Flag | Description |
|
|
316
|
+
|---|---|
|
|
317
|
+
| `--file <path>` | Path to contract JSON file (required) |
|
|
318
|
+
| `--org <key>` | Organization key |
|
|
319
|
+
| `--consumer <key>` | Consumer service key (overrides file) |
|
|
320
|
+
| `--provider <key>` | Provider service key (overrides file) |
|
|
321
|
+
| `--consumer-version <ver>` | Consumer version tag |
|
|
322
|
+
| `--contract-name <name>` | Contract name (overrides file) |
|
|
323
|
+
| `--tag <tag>` | Git branch or release tag |
|
|
324
|
+
| `--server <url>` | SpecShield server URL (default: `https://specshield.io`) |
|
|
325
|
+
| `--api-token <token>` | API token |
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
### List Contracts
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
specshield contracts list
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Filter by provider:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
specshield contracts list --provider payment-service
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Filter by consumer, org, status:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
specshield contracts list \
|
|
345
|
+
--consumer checkout-ui \
|
|
346
|
+
--provider payment-service \
|
|
347
|
+
--status PUBLISHED
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Output raw JSON:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
specshield contracts list --json
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Example output:**
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
SpecShield Contract Registry
|
|
360
|
+
─────────────────────────────────────────────────────
|
|
361
|
+
Showing 2 of 2 contracts
|
|
362
|
+
|
|
363
|
+
ID Contract Name Consumer Provider Ver Status Last Verify Published
|
|
364
|
+
── ─────────────── ──────────── ─────────────── ─── ───────── ─────────── ─────────────────────
|
|
365
|
+
42 create-payment checkout-ui payment-service 3 PUBLISHED SUCCESS 06/04/2026 14:30:00
|
|
366
|
+
41 get-order-status order-ui order-service 1 PUBLISHED FAILED 05/04/2026 09:15:00
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Options:**
|
|
370
|
+
|
|
371
|
+
| Flag | Description |
|
|
372
|
+
|---|---|
|
|
373
|
+
| `--consumer <key>` | Filter by consumer |
|
|
374
|
+
| `--provider <key>` | Filter by provider |
|
|
375
|
+
| `--org <key>` | Filter by organization |
|
|
376
|
+
| `--status <status>` | Filter by status (`PUBLISHED` / `DEPRECATED`) |
|
|
377
|
+
| `--contract-name <name>` | Filter by contract name |
|
|
378
|
+
| `--page <n>` | Page number (0-based, default: `0`) |
|
|
379
|
+
| `--size <n>` | Page size (default: `20`) |
|
|
380
|
+
| `--json` | Output raw JSON |
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
### Get Latest Contract
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
specshield contracts latest \
|
|
388
|
+
--consumer checkout-ui \
|
|
389
|
+
--provider payment-service \
|
|
390
|
+
--contract-name create-payment
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Print the full contract content (interactions, body, etc.):
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
specshield contracts latest \
|
|
397
|
+
--consumer checkout-ui \
|
|
398
|
+
--provider payment-service \
|
|
399
|
+
--json
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**Options:**
|
|
403
|
+
|
|
404
|
+
| Flag | Description |
|
|
405
|
+
|---|---|
|
|
406
|
+
| `--consumer <key>` | Consumer service key |
|
|
407
|
+
| `--provider <key>` | Provider service key |
|
|
408
|
+
| `--org <key>` | Organization key |
|
|
409
|
+
| `--contract-name <name>` | Contract name |
|
|
410
|
+
| `--json` | Print full contract JSON |
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
### Verify a Contract
|
|
415
|
+
|
|
416
|
+
Run the contract against a live provider. SpecShield replays each interaction against the `--base-url` and compares the response to the expected values.
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
specshield contracts verify \
|
|
420
|
+
--contract-id 42 \
|
|
421
|
+
--base-url http://localhost:8080
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
With version and environment tags:
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
specshield contracts verify \
|
|
428
|
+
--contract-id 42 \
|
|
429
|
+
--base-url https://payment-service.staging.internal \
|
|
430
|
+
--provider-version v2.1.0 \
|
|
431
|
+
--env staging
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Output raw JSON (for CI parsing):
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
specshield contracts verify \
|
|
438
|
+
--contract-id 42 \
|
|
439
|
+
--base-url http://localhost:8080 \
|
|
440
|
+
--json
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
**Example output (pass):**
|
|
444
|
+
|
|
445
|
+
```
|
|
446
|
+
✔ Verification PASSED (1/1 interactions)
|
|
447
|
+
─────────────────────────────────────────────────────
|
|
448
|
+
Verification ID : 101
|
|
449
|
+
Contract ID : 42
|
|
450
|
+
Status : SUCCESS
|
|
451
|
+
Started At : 06/04/2026 14:35:00
|
|
452
|
+
Completed At : 06/04/2026 14:35:01
|
|
453
|
+
|
|
454
|
+
➜ Run: specshield contracts can-i-deploy --provider payment-service --version v2.1.0
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
**Example output (fail):**
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
✖ Verification FAILED (0/1 interactions passed, 1 failed)
|
|
461
|
+
─────────────────────────────────────────────────────
|
|
462
|
+
Verification ID : 102
|
|
463
|
+
Contract ID : 42
|
|
464
|
+
Status : FAILED
|
|
465
|
+
|
|
466
|
+
Mismatches
|
|
467
|
+
─────────────────────────────────────────────────────
|
|
468
|
+
● [create payment] STATUS_CODE_MISMATCH at $.status
|
|
469
|
+
expected: 201 → actual: 200
|
|
470
|
+
Expected status 201 but got 200
|
|
471
|
+
|
|
472
|
+
➜ Run: specshield contracts history --contract-id 42 to inspect previous runs
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
**Exit codes:** `0` = PASSED, `1` = FAILED, `2` = error
|
|
476
|
+
|
|
477
|
+
**Options:**
|
|
478
|
+
|
|
479
|
+
| Flag | Description |
|
|
480
|
+
|---|---|
|
|
481
|
+
| `--contract-id <id>` | Contract ID to verify (required) |
|
|
482
|
+
| `--base-url <url>` | Provider base URL (required, e.g. `http://localhost:8080`) |
|
|
483
|
+
| `--provider-version <ver>` | Provider version tag |
|
|
484
|
+
| `--env <environment>` | Environment label (`staging`, `qa`, `production`) |
|
|
485
|
+
| `--mode <mode>` | `LIVE` or `REPLAY` (default: `LIVE`) |
|
|
486
|
+
| `--json` | Output raw JSON |
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
### Verification History
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
specshield contracts history --contract-id 42
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
**Example output:**
|
|
497
|
+
|
|
498
|
+
```
|
|
499
|
+
Verification History — Contract 42
|
|
500
|
+
─────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
ID Status Environment Provider Version Mode Completed At
|
|
503
|
+
─── ─────── ─────────── ──────────────── ──── ─────────────────────
|
|
504
|
+
102 FAILED staging v2.1.0 LIVE 06/04/2026 14:35:01
|
|
505
|
+
101 SUCCESS staging v2.0.0 LIVE 05/04/2026 10:00:00
|
|
506
|
+
98 SUCCESS qa v1.9.0 LIVE 01/04/2026 08:30:00
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
**Options:**
|
|
510
|
+
|
|
511
|
+
| Flag | Description |
|
|
512
|
+
|---|---|
|
|
513
|
+
| `--contract-id <id>` | Contract ID (required) |
|
|
514
|
+
| `--json` | Output raw JSON |
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
### Can I Deploy?
|
|
519
|
+
|
|
520
|
+
Check whether a provider version has passing verifications for all associated contracts:
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
specshield contracts can-i-deploy \
|
|
524
|
+
--provider payment-service \
|
|
525
|
+
--version v2.1.0
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Scoped to a specific environment:
|
|
529
|
+
|
|
530
|
+
```bash
|
|
531
|
+
specshield contracts can-i-deploy \
|
|
532
|
+
--provider payment-service \
|
|
533
|
+
--version v2.1.0 \
|
|
534
|
+
--env production
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
**Example output (allowed):**
|
|
538
|
+
|
|
539
|
+
```
|
|
540
|
+
✔ PASS: payment-service v2.1.0 is deployable in production
|
|
541
|
+
─────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
Contract Decisions
|
|
544
|
+
✔ Contract ID 42 — SUCCESS
|
|
545
|
+
All contracts verified
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
**Example output (blocked):**
|
|
549
|
+
|
|
550
|
+
```
|
|
551
|
+
✖ FAIL: payment-service v2.1.0 is NOT deployable in production
|
|
552
|
+
─────────────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
Contract Decisions
|
|
555
|
+
✖ Contract ID 43 — FAILED
|
|
556
|
+
Unverified or failed contracts found
|
|
557
|
+
|
|
558
|
+
➜ Run: specshield contracts verify --contract-id 43 --base-url <URL>
|
|
559
|
+
➜ to verify pending contracts before deploying
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
**Exit codes:** `0` = deployable, `1` = blocked, `2` = error
|
|
563
|
+
|
|
564
|
+
**Options:**
|
|
565
|
+
|
|
566
|
+
| Flag | Description |
|
|
567
|
+
|---|---|
|
|
568
|
+
| `--provider <key>` | Provider service key (required) |
|
|
569
|
+
| `--version <ver>` | Provider version to check (required) |
|
|
570
|
+
| `--env <environment>` | Target environment |
|
|
571
|
+
| `--json` | Output raw JSON |
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
### Full Publish → Verify → Deploy Workflow
|
|
576
|
+
|
|
577
|
+
```bash
|
|
578
|
+
# 1. Consumer team publishes a contract
|
|
579
|
+
specshield contracts publish \
|
|
580
|
+
--file ./contracts/create-payment.json \
|
|
581
|
+
--org acme
|
|
582
|
+
|
|
583
|
+
# 2. Provider team starts their service locally
|
|
584
|
+
./gradlew bootRun &
|
|
585
|
+
|
|
586
|
+
# 3. Provider team verifies the contract
|
|
587
|
+
specshield contracts verify \
|
|
588
|
+
--contract-id 42 \
|
|
589
|
+
--base-url http://localhost:8080 \
|
|
590
|
+
--provider-version v2.1.0 \
|
|
591
|
+
--env staging
|
|
592
|
+
|
|
593
|
+
# 4. Gate the deployment
|
|
594
|
+
specshield contracts can-i-deploy \
|
|
595
|
+
--provider payment-service \
|
|
596
|
+
--version v2.1.0 \
|
|
597
|
+
--env staging
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
### Contracts in CI/CD
|
|
603
|
+
|
|
604
|
+
#### GitHub Actions — Publish on consumer change
|
|
605
|
+
|
|
606
|
+
```yaml
|
|
607
|
+
name: Publish Contract
|
|
608
|
+
|
|
609
|
+
on:
|
|
610
|
+
push:
|
|
611
|
+
branches: [main]
|
|
612
|
+
paths:
|
|
613
|
+
- 'contracts/**'
|
|
614
|
+
|
|
615
|
+
jobs:
|
|
616
|
+
publish-contract:
|
|
617
|
+
runs-on: ubuntu-latest
|
|
618
|
+
steps:
|
|
619
|
+
- uses: actions/checkout@v4
|
|
620
|
+
- uses: actions/setup-node@v4
|
|
621
|
+
with:
|
|
622
|
+
node-version: '20'
|
|
623
|
+
- run: npm install -g specshield
|
|
624
|
+
- name: Publish contract
|
|
625
|
+
env:
|
|
626
|
+
SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
|
|
627
|
+
run: |
|
|
628
|
+
specshield contracts publish \
|
|
629
|
+
--file ./contracts/create-payment.json \
|
|
630
|
+
--org acme \
|
|
631
|
+
--tag ${{ github.ref_name }}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
#### GitHub Actions — Verify + can-i-deploy on provider change
|
|
635
|
+
|
|
636
|
+
```yaml
|
|
637
|
+
name: Contract Verification
|
|
638
|
+
|
|
639
|
+
on:
|
|
640
|
+
push:
|
|
641
|
+
branches: [main]
|
|
642
|
+
|
|
643
|
+
jobs:
|
|
644
|
+
verify-contracts:
|
|
645
|
+
runs-on: ubuntu-latest
|
|
646
|
+
services:
|
|
647
|
+
payment-service:
|
|
648
|
+
image: myorg/payment-service:${{ github.sha }}
|
|
649
|
+
ports:
|
|
650
|
+
- 8080:8080
|
|
651
|
+
steps:
|
|
652
|
+
- uses: actions/checkout@v4
|
|
653
|
+
- uses: actions/setup-node@v4
|
|
654
|
+
with:
|
|
655
|
+
node-version: '20'
|
|
656
|
+
- run: npm install -g specshield
|
|
657
|
+
|
|
658
|
+
- name: Verify contract
|
|
659
|
+
env:
|
|
660
|
+
SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
|
|
661
|
+
run: |
|
|
662
|
+
specshield contracts verify \
|
|
663
|
+
--contract-id ${{ vars.PAYMENT_CONTRACT_ID }} \
|
|
664
|
+
--base-url http://localhost:8080 \
|
|
665
|
+
--provider-version ${{ github.sha }} \
|
|
666
|
+
--env staging
|
|
667
|
+
|
|
668
|
+
- name: Can I deploy?
|
|
669
|
+
env:
|
|
670
|
+
SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
|
|
671
|
+
run: |
|
|
672
|
+
specshield contracts can-i-deploy \
|
|
673
|
+
--provider payment-service \
|
|
674
|
+
--version ${{ github.sha }} \
|
|
675
|
+
--env staging
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
---
|
|
679
|
+
|
|
197
680
|
## Config File
|
|
198
681
|
|
|
199
682
|
Create `.specshield.yml` in your project root to set default behavior:
|
package/package.json
CHANGED
|
@@ -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;
|