specshield 3.1.1 → 3.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ # SpecShield CLI changelog
2
+
3
+ ## 3.2.0 — 2026-05-17 — Conversion fixes
4
+
5
+ Three CLI changes designed to make the Cloud features (history, share URLs,
6
+ PR checks, BDCT) visible to the 2,000+ existing CLI users who run local
7
+ `specshield compare` but never discover what signing up unlocks.
8
+
9
+ ### Added
10
+
11
+ - **Post-install welcome banner** — runs once after a fresh `npm install -g specshield`.
12
+ Briefly explains the value progression (Local → Cloud Free → Pro) and points
13
+ to the next command to try. Skipped automatically in CI, in non-TTY shells,
14
+ on update installs, and when `SPECSHIELD_NO_BANNER=1` is set.
15
+
16
+ - **Contextual signup prompt after `specshield compare`** — after 3+ compares
17
+ per week, a soft 3-line nudge appears below the regular output:
18
+ ```
19
+ ● Track these comparisons over time:
20
+ specshield login # 30-sec signup via GitHub / Google · no credit card
21
+ Unlocks: compare history, shareable report URLs, PR badge
22
+ ```
23
+ Throttled to once per week so it never spams. Suppressed entirely for
24
+ logged-in users, `--json` output, CI environments, and on opt-out. The
25
+ prompt copy escalates at 10 and 25 compares per window.
26
+
27
+ - **`specshield history`** — new command to list recent comparisons saved
28
+ in your Cloud account. Surfaces in `specshield --help` so local-only
29
+ users discover the feature exists.
30
+
31
+ - **`specshield share <report-id | base.yaml target.yaml>`** — generate a
32
+ public shareable URL for a comparison. Designed for pasting diffs into
33
+ Slack, PR comments, or Jira. Cloud account required.
34
+
35
+ Both new commands print a friendly "Get started in 30 seconds" message
36
+ with a `specshield login` deep link when run without credentials.
37
+
38
+ ### Why
39
+
40
+ Background: in May 2026 the CLI had 2,000+ active monthly users (npm download
41
+ estimate; real human count likely 200-500) but the SpecShield Cloud signup
42
+ rate from the CLI was effectively zero. Local compare was so capable that
43
+ users got 100% of their immediate value without ever needing an account.
44
+
45
+ These changes don't remove any free functionality — local compare is still
46
+ fully usable without signup — they just make the gap between local and
47
+ cloud visible at the moments when a user is most engaged (post-install,
48
+ post-compare, on `--help`).
49
+
50
+ ### Tests
51
+
52
+ - Added `tests/core/conversionPrompt.test.js` covering all skip conditions,
53
+ threshold logic, escalation, and 7-day window reset (10 tests).
54
+ - All 134 existing tests still pass.
55
+
56
+ ### Opting out
57
+
58
+ If you don't want the banner or contextual prompts:
59
+
60
+ ```sh
61
+ export SPECSHIELD_NO_BANNER=1 # disable banner + post-compare nudge
62
+ ```
63
+
64
+ Or just sign up — logged-in users never see either.
package/README.md CHANGED
@@ -137,6 +137,15 @@ specshield bdct can-i-deploy --version $GITHUB_SHA
137
137
 
138
138
  See [§ specshield init](#specshield-init--first-run-setup-wizard) below.
139
139
 
140
+ > **Quiet install for CI / Docker images:**
141
+ > The post-install welcome banner auto-detects CI environments (`CI`,
142
+ > `GITHUB_ACTIONS`, `BUILDKITE`, `CIRCLECI`, `GITLAB_CI`, `JENKINS_URL`,
143
+ > `TRAVIS`, `TF_BUILD`) and skips itself there — so your CI logs stay clean.
144
+ > To silence it on a workstation too:
145
+ > ```bash
146
+ > export SPECSHIELD_NO_BANNER=1
147
+ > ```
148
+
140
149
  ---
141
150
 
142
151
  ## 🚀 Create Your Free Account
@@ -160,7 +169,9 @@ See [§ specshield init](#specshield-init--first-run-setup-wizard) below.
160
169
  | Breaking change detection | ✅ | ✅ | ✅ |
161
170
  | JSON / human output | ✅ | ✅ | ✅ |
162
171
  | Fail CI on breaking change | ✅ | ✅ | ✅ |
163
- | **Compare history & dashboard** | ❌ | ✅ | ✅ |
172
+ | **`specshield history` compare timeline** | ❌ | ✅ | ✅ |
173
+ | **`specshield share` — public report URLs** | ❌ | ✅ | ✅ |
174
+ | **Dashboard** | ❌ | ✅ | ✅ |
164
175
  | **GitHub App PR checks** | ❌ | ✅ | ✅ |
165
176
  | **BDCT bi-directional contracts** | ❌ | ❌ | ✅ |
166
177
  | **BDCT can-i-deploy gating** | ❌ | ❌ | ✅ |
@@ -268,7 +279,16 @@ Done. Your token is stored in `~/.specshield/config.json` — no need to pass it
268
279
  export SPECSHIELD_API_KEY=ss_your_token_here
269
280
  ```
270
281
 
271
- Token resolution order: `--api-key flag` `SPECSHIELD_API_KEY` env var stored config → `.specshield.yml`
282
+ This single env var works for every command `compare`, `login`, and every
283
+ `bdct` subcommand all read it.
284
+
285
+ Token resolution order:
286
+ - `compare` / `login`: `--api-key` flag → `SPECSHIELD_API_KEY` env var → stored config → `.specshield.yml`
287
+ - `bdct ...` subcommands: `--api-token` flag → `SPECSHIELD_API_KEY` env var → stored config
288
+
289
+ > **Why two flag names?** `compare` and `login` were built first and used
290
+ > `--api-key`. The newer `bdct` commands use `--api-token` to keep the word
291
+ > "key" reserved for the stored config concept. The env var unifies both.
272
292
 
273
293
  ---
274
294
 
@@ -313,6 +333,63 @@ specshield compare base.yaml target.yaml --remote --json --output result.json
313
333
 
314
334
  ---
315
335
 
336
+ ## Comparison History
337
+
338
+ Every `specshield compare --remote` is saved to your SpecShield account.
339
+ List the recent comparisons your account has run from any machine — useful
340
+ for tracking API drift over time across CI pipelines + local runs.
341
+
342
+ ```bash
343
+ specshield history # last 20 comparisons
344
+ specshield history --limit 50 # show more
345
+ specshield history --json # machine-readable for scripts
346
+ ```
347
+
348
+ ```
349
+ Your recent comparisons
350
+ ─────────────────────────────────────────────────────
351
+ 482 3 breaking 2026-05-17 14:30 payment-v1.yaml → payment-v2.yaml
352
+ 481 0 breaking 2026-05-17 11:02 user-api.yaml → user-api-updated.yaml
353
+ 480 7 breaking 2026-05-16 18:55 billing-v3.yaml → billing-v4.yaml
354
+ ```
355
+
356
+ Account required — run `specshield login` to set up (free, no credit card).
357
+
358
+ ---
359
+
360
+ ## Share a Comparison
361
+
362
+ Generate a public, tokenized URL for any comparison report. Anyone with
363
+ the link can view the diff — no SpecShield account needed. Great for
364
+ pasting into Slack threads, PR comments, or Jira tickets.
365
+
366
+ ```bash
367
+ # Share an existing report by ID (from `specshield history`)
368
+ specshield share 482
369
+
370
+ # Compare two specs and share the result in one step
371
+ specshield share base.yaml target.yaml
372
+
373
+ # Time-limited link — expires in 30 days
374
+ specshield share 482 --expires 30
375
+ ```
376
+
377
+ ```
378
+ ✔ Share link ready
379
+ ─────────────────────────────────────────────────────
380
+ https://specshield.io/r/_Ru8OVubxY3r9zHOsylESaULphCqBYH5jTPYldSMU88
381
+ Expires: 2026-06-16T12:34:56Z
382
+
383
+ Anyone with this link can view the diff — no SpecShield account required.
384
+ ```
385
+
386
+ Links use a 256-bit random token, so they can't be guessed by enumeration.
387
+ Revoke any time from your dashboard at [specshield.io](https://specshield.io).
388
+
389
+ Account required — `specshield login` to set up.
390
+
391
+ ---
392
+
316
393
  ## GitHub Integration
317
394
 
318
395
  **Automatic API contract checks on every pull request — no workflow YAML required.**
@@ -421,6 +498,7 @@ from any subdirectory of your project.
421
498
  ### Non-interactive (scriptable) mode
422
499
 
423
500
  ```bash
501
+ # Provider-only project
424
502
  specshield init --no-interactive \
425
503
  --kind provider \
426
504
  --org acme-pay \
@@ -428,16 +506,36 @@ specshield init --no-interactive \
428
506
  --spec api/openapi.yaml \
429
507
  --env staging \
430
508
  --write-workflow
509
+
510
+ # Consumer-only project
511
+ specshield init --no-interactive \
512
+ --kind consumer \
513
+ --org acme-pay \
514
+ --consumer checkout-ui \
515
+ --consumer-provider payment-service \
516
+ --contract contracts/payment-service.yaml \
517
+ --format OPENAPI \
518
+ --env staging
431
519
  ```
432
520
 
433
- ### Other useful flags
521
+ ### All `init` flags
434
522
 
435
523
  | Flag | Purpose |
436
524
  | --- | --- |
437
- | `--print` | Detect everything, print the proposed YAML, write nothing. Good for `--dry-run` review in CI. |
525
+ | `--no-interactive` | Run without prompts; all required fields must come from flags or `package.json`/git autodetection. |
526
+ | `--print` | Detect everything, print the proposed YAML to stdout, write nothing. Good for `--dry-run` review in CI. |
438
527
  | `--force` | Skip the overwrite-confirmation if `.specshield.yml` already exists. |
439
- | `--server <url>` | Use a non-default SpecShield endpoint (self-hosted / staging). |
440
- | `--write-workflow` | Also write a starter GitHub Actions workflow under `.github/workflows/`. |
528
+ | `--server <url>` | Use a non-default SpecShield endpoint (self-hosted / staging). Default: `https://specshield.io`. |
529
+ | `--kind <kind>` | `provider`, `consumer`, `both`, or `skip`. Required in `--no-interactive`. |
530
+ | `--org <key>` | Organization key. |
531
+ | `--provider <name>` | Provider service name (used when `--kind=provider|both`). |
532
+ | `--spec <path>` | Path to the provider OpenAPI spec. |
533
+ | `--consumer <name>` | Consumer service name (used when `--kind=consumer|both`). |
534
+ | `--consumer-provider <name>` | The provider this consumer talks to (used when `--kind=consumer|both`). |
535
+ | `--contract <path>` | Path to the consumer contract (OpenAPI or Pact JSON). |
536
+ | `--format <fmt>` | Consumer contract format: `OPENAPI` (default) or `PACT`. |
537
+ | `--env <environment>` | Default environment for BDCT operations. |
538
+ | `--write-workflow` | Also write a starter GitHub Actions workflow under `.github/workflows/specshield-bdct.yml`. |
441
539
 
442
540
  > **What is _not_ written into `.specshield.yml`:** your API key. It is
443
541
  > stored in `~/.specshield/config.json` (set by `specshield login`) or
@@ -471,17 +569,25 @@ specshield bdct publish-provider \
471
569
  --provider payment-service \
472
570
  --version v2.1.0 \
473
571
  --spec ./api/openapi.yaml \
474
- --env production
572
+ --env production \
573
+ --branch main
475
574
  ```
476
575
 
477
576
  ```
478
- ✔ Provider spec published
479
- Provider : payment-service
480
- Version : v2.1.0
481
- Env : production
482
- Auto-verifications triggered: 3
577
+ ✔ Provider Spec Published
578
+ ─────────────────────────────────────────────────────
579
+ ID : 1842
580
+ Provider : payment-service
581
+ Version : v2.1.0
582
+ Environment : production
583
+ Published At: 2026-05-13 14:30:12
584
+ Verifications triggered: 3
483
585
  ```
484
586
 
587
+ `--branch` is optional — when present it's stamped on the published spec so you
588
+ can correlate a spec version with the git branch it came from in
589
+ `bdct list-providers`.
590
+
485
591
  ### Publish a Consumer Contract
486
592
 
487
593
  The consumer contract is an OpenAPI spec that describes only the endpoints the consumer uses:
@@ -522,19 +628,30 @@ specshield bdct publish-consumer \
522
628
  --consumer checkout-ui \
523
629
  --provider payment-service \
524
630
  --version 2.0.0 \
525
- --contract ./contracts/checkout-ui-payment.yaml
631
+ --contract ./contracts/checkout-ui-payment.yaml \
632
+ --format OPENAPI
526
633
  ```
527
634
 
528
635
  ```
529
- ✔ Consumer contract published
530
- Consumer : checkout-ui @ 2.0.0
531
- Provider : payment-service
532
- Compatibility: COMPATIBLE
636
+ ✔ Consumer Contract Published
637
+ ─────────────────────────────────────────────────────
638
+ ID : 942
639
+ Consumer : checkout-ui
640
+ Provider : payment-service
641
+ Version : 2.0.0
642
+ Format : OPENAPI
643
+ Published At: 2026-05-13 14:31:05
644
+ Verifications triggered: 1
645
+
646
+ ➜ Run: specshield bdct verify --consumer checkout-ui --provider payment-service
533
647
  ```
534
648
 
535
- If the provider spec is already published, compatibility is checked immediately.
649
+ If the provider spec is already published, compatibility is verified
650
+ automatically — the "Verifications triggered" count reflects that.
536
651
 
537
- **Pact JSON contracts are also accepted** — SpecShield auto-converts them:
652
+ **Pact JSON contracts are also supported** — pass `--format PACT` and point
653
+ `--contract` at a Pact file. The backend stores the format and runs the same
654
+ compatibility engine against your provider's OpenAPI spec:
538
655
 
539
656
  ```bash
540
657
  specshield bdct publish-consumer \
@@ -542,9 +659,12 @@ specshield bdct publish-consumer \
542
659
  --consumer checkout-ui \
543
660
  --provider payment-service \
544
661
  --version 2.0.0 \
545
- --contract ./pacts/checkout-ui-payment-service.json
662
+ --contract ./pacts/checkout-ui-payment-service.json \
663
+ --format PACT
546
664
  ```
547
665
 
666
+ `--format` defaults to `OPENAPI` if omitted.
667
+
548
668
  ### Verify Compatibility
549
669
 
550
670
  Manually trigger a verification between a specific consumer/provider pair:
@@ -596,18 +716,36 @@ specshield bdct can-i-deploy \
596
716
  --env production
597
717
  ```
598
718
 
719
+ Deployable output:
720
+
599
721
  ```
600
- DEPLOYABLE
722
+ PASS: payment-service v2.1.0 is deployable in production
723
+ ─────────────────────────────────────────────────────
724
+
725
+ Consumer Verifications
601
726
 
602
- payment-service v2.1.0 is COMPATIBLE with all 3 consumer(s)
727
+ Consumer Version Status Verified At
728
+ ─────────── ─────── ────────── ───────────────
729
+ checkout-ui 2.0.0 COMPATIBLE 2026-05-13 14:32
730
+ mobile-app 1.5.0 COMPATIBLE 2026-05-13 14:32
731
+ partner-sdk 3.2.1 COMPATIBLE 2026-05-13 14:32
603
732
  ```
604
733
 
734
+ Blocked output:
735
+
605
736
  ```
606
- ✖ NOT DEPLOYABLE
737
+ FAIL: payment-service v2.1.0 is NOT deployable in production
738
+ ─────────────────────────────────────────────────────
739
+
740
+ Consumer Verifications
741
+
742
+ Consumer Version Status Verified At
743
+ ─────────── ─────── ──────────── ───────────────
744
+ checkout-ui 2.0.0 INCOMPATIBLE 2026-05-13 14:32
745
+ mobile-app 1.5.0 INCOMPATIBLE 2026-05-13 14:32
607
746
 
608
- payment-service v2.1.0 is INCOMPATIBLE with:
609
- checkout-ui@2.0.0 (INCOMPATIBLE)
610
- mobile-app@1.5.0 (INCOMPATIBLE)
747
+ ➜ Run: specshield bdct verify --consumer <NAME> --provider payment-service
748
+ ➜ to identify and resolve incompatibilities
611
749
  ```
612
750
 
613
751
  Exit codes: `0` = deployable · `1` = blocked · `2` = error
@@ -621,12 +759,17 @@ specshield bdct matrix --org acme-store --env production
621
759
  ```
622
760
 
623
761
  ```
624
- Compatibility Matrix (env: production)
762
+ BDCT Compatibility Matrix
763
+ Environment: production
764
+ ─────────────────────────────────────────────────────
625
765
 
626
- payment-service order-service
627
- checkout-ui COMPATIBLE COMPATIBLE
628
- mobile-app INCOMPATIBLE COMPATIBLE
629
- partner-sdk COMPATIBLE UNKNOWN
766
+ Consumer \ Provider payment-service order-service
767
+ ─────────────────── ─────────────── ─────────────
768
+ checkout-ui COMPATIBLE COMPATIBLE
769
+ mobile-app INCOMPATIBLE COMPATIBLE
770
+ partner-sdk COMPATIBLE UNKNOWN
771
+
772
+ ■ COMPATIBLE ■ INCOMPATIBLE ■ UNKNOWN
630
773
  ```
631
774
 
632
775
  ### List Provider Specs
@@ -640,11 +783,15 @@ specshield bdct list-providers --org acme-store --provider payment-service
640
783
  ```
641
784
 
642
785
  ```
643
- Provider Specs
644
-
645
- payment-service v2.1.0 production 2025-05-01
646
- payment-service v2.0.0 staging 2025-04-20
647
- order-service v1.3.0 production 2025-04-28
786
+ Published Provider Specs
787
+ ─────────────────────────────────────────────────────
788
+ Showing 3 of 3 specs
789
+
790
+ ID Provider Version Environment Branch Published At
791
+ ──── ─────────────── ─────── ─────────── ────── ───────────────
792
+ 1842 payment-service v2.1.0 production main 2026-05-13 14:30
793
+ 1799 payment-service v2.0.0 staging main 2026-04-20 09:11
794
+ 1772 order-service v1.3.0 production main 2026-04-28 11:02
648
795
  ```
649
796
 
650
797
  ### List Consumer Contracts
@@ -670,11 +817,14 @@ specshield bdct list \
670
817
  ```
671
818
 
672
819
  ```
673
- BDCT Verifications
674
-
675
- Consumer Consumer Ver Provider Provider Ver Env Status Verified At
676
- checkout-ui 2.0.0 payment-service v2.1.0 production COMPATIBLE 2025-05-01 14:30
677
- mobile-app 1.5.0 payment-service v2.1.0 production INCOMPATIBLE 2025-05-01 14:30
820
+ BDCT Verification History
821
+ ─────────────────────────────────────────────────────
822
+ Showing 2 of 2 verifications
823
+
824
+ ID Consumer Provider Cons Ver Prov Ver Status Environment Verified At
825
+ ──── ─────────── ─────────────── ──────── ──────── ──────────── ─────────── ───────────────
826
+ 3081 checkout-ui payment-service 2.0.0 v2.1.0 COMPATIBLE production 2026-05-13 14:32
827
+ 3082 mobile-app payment-service 1.5.0 v2.1.0 INCOMPATIBLE production 2026-05-13 14:32
678
828
  ```
679
829
 
680
830
  ### BDCT JSON Output
@@ -831,28 +981,60 @@ jobs:
831
981
 
832
982
  ## Config File
833
983
 
834
- Create `.specshield.yml` in your project root:
984
+ Run [`specshield init`](#specshield-init--first-run-setup-wizard) to generate
985
+ `.specshield.yml` automatically, or hand-write it. Either way, every
986
+ `specshield ...` invocation reads the file from the project root (or any
987
+ parent directory) and uses its values as defaults — CLI flags always win.
988
+
989
+ The full schema supports both local-compare defaults and BDCT defaults:
835
990
 
836
991
  ```yaml
837
- failOnBreaking: true
838
- severity: error
992
+ schemaVersion: 1
839
993
 
994
+ # Local-compare defaults (used by `specshield compare`).
995
+ failOnBreaking: true
996
+ severity: error # info | warning | error
840
997
  ignore:
841
- - "DELETE /admin removed"
998
+ - "DELETE /admin removed" # match by substring; repeatable
842
999
 
1000
+ # Hosted compare (set --remote on the CLI to override).
843
1001
  remote:
844
1002
  enabled: false
845
- url: "https://specshield.io/compare"
1003
+ url: https://specshield.io/compare
846
1004
  timeout: 10000
847
- # apiKey: "" ← use env var instead
1005
+ # apiKey is intentionally NOT set here. Use SPECSHIELD_API_KEY in CI
1006
+ # or `specshield login` locally. Never commit a key to a file.
848
1007
 
1008
+ # BDCT defaults (used by every `specshield bdct ...` subcommand).
1009
+ bdct:
1010
+ org: acme-pay
1011
+ environment: staging
1012
+ # server: https://specshield.io # only set for self-hosted / staging
1013
+
1014
+ provider:
1015
+ name: payment-service
1016
+ spec: api/openapi.yaml # paths are relative to this file
1017
+ # branch: main # informational tag on each publish
1018
+
1019
+ # consumer:
1020
+ # name: checkout-ui
1021
+ # provider: payment-service
1022
+ # contract: contracts/payment-service.yaml
1023
+ # format: OPENAPI # OPENAPI | PACT
1024
+
1025
+ # GitHub App + bdct-action defaults.
849
1026
  github:
850
1027
  specPath: api/openapi.yaml
851
1028
  failOnBreaking: true
852
1029
  commentOnPr: true
853
1030
  ```
854
1031
 
855
- CLI flags always override config file values.
1032
+ When this file is present, BDCT commands collapse to just `--version`:
1033
+
1034
+ ```bash
1035
+ specshield bdct publish-provider --version $GITHUB_SHA
1036
+ specshield bdct can-i-deploy --version $GITHUB_SHA
1037
+ ```
856
1038
 
857
1039
  ---
858
1040
 
@@ -875,6 +1057,25 @@ specshield compare <base> <target> [options]
875
1057
  | `--config <path>` | Path to `.specshield.yml` |
876
1058
  | `--timeout <ms>` | Request timeout for remote mode |
877
1059
 
1060
+ ```bash
1061
+ specshield history [options]
1062
+ ```
1063
+
1064
+ | Option | Description |
1065
+ |---|---|
1066
+ | `--limit <n>` | Number of comparisons to list (default 20) |
1067
+ | `--json` | Machine-readable JSON output |
1068
+ | `--api-key <key>` | Override stored API key |
1069
+
1070
+ ```bash
1071
+ specshield share <reportId | base.yaml target.yaml> [options]
1072
+ ```
1073
+
1074
+ | Option | Description |
1075
+ |---|---|
1076
+ | `--expires <days>` | Make the link expire after N days (default: never) |
1077
+ | `--api-key <key>` | Override stored API key |
1078
+
878
1079
  ```bash
879
1080
  specshield bdct <subcommand> [options]
880
1081
  ```
@@ -885,12 +1086,32 @@ specshield bdct <subcommand> [options]
885
1086
  | `publish-consumer` | Publish a consumer contract (OpenAPI subset or Pact JSON) |
886
1087
  | `verify` | Manually trigger verification for a consumer/provider pair |
887
1088
  | `can-i-deploy` | Check if a service version is safe to deploy |
888
- | `matrix` | View compatibility matrix across all pairs |
1089
+ | `matrix` | Compatibility matrix across all consumer/provider pairs |
889
1090
  | `list-providers` | List published provider specs |
890
1091
  | `list-consumers` | List published consumer contracts |
891
1092
  | `list` | List verification history |
892
1093
 
893
- All `bdct` subcommands support `--json` for machine-readable output.
1094
+ Every `bdct` subcommand accepts these flags in common:
1095
+
1096
+ | Flag | Purpose |
1097
+ |---|---|
1098
+ | `--org <key>` | Organization key (or read from `.specshield.yml`'s `bdct.org`) |
1099
+ | `--json` | Machine-readable JSON output |
1100
+ | `--server <url>` | Override SpecShield server URL (self-hosted / staging) |
1101
+ | `--api-token <token>` | API token (overrides env / stored config) |
1102
+
1103
+ Per-subcommand flags:
1104
+
1105
+ | Subcommand | Flags |
1106
+ |---|---|
1107
+ | `publish-provider` | `--spec <path>`, `--provider <name>`, `--version <ver>`, `--env <env>`, `--branch <branch>` |
1108
+ | `publish-consumer` | `--contract <path>`, `--consumer <name>`, `--provider <name>`, `--version <ver>`, `--format OPENAPI\|PACT` |
1109
+ | `verify` | `--consumer <name>`, `--provider <name>`, `--consumer-version <ver>`, `--provider-version <ver>`, `--env <env>` |
1110
+ | `can-i-deploy` | `--service <name>`, `--version <ver>`, `--env <env>` |
1111
+ | `matrix` | `--env <env>` |
1112
+ | `list-providers` | `--provider <name>` (filter) |
1113
+ | `list-consumers` | `--consumer <name>` (filter), `--provider <name>` (filter) |
1114
+ | `list` | `--consumer <name>`, `--provider <name>`, `--env <env>`, `--page <n>`, `--size <n>` |
894
1115
 
895
1116
  ---
896
1117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specshield",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "CLI for OpenAPI breaking change detection and bi-directional contract verification — with can-i-deploy gating, GitHub PR checks, and a first-run setup wizard.",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -10,7 +10,8 @@
10
10
  "start": "node bin/specshield.js",
11
11
  "test": "jest --coverage",
12
12
  "test:watch": "jest --watch",
13
- "lint": "eslint src tests --ext .js"
13
+ "lint": "eslint src tests --ext .js",
14
+ "postinstall": "node scripts/welcome.js || true"
14
15
  },
15
16
  "keywords": [
16
17
  "openapi",
@@ -47,7 +48,9 @@
47
48
  "files": [
48
49
  "bin",
49
50
  "src",
51
+ "scripts",
50
52
  "README.md",
53
+ "CHANGELOG.md",
51
54
  "LICENSE"
52
55
  ],
53
56
  "dependencies": {
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-install welcome banner.
4
+ *
5
+ * Runs once via the `postinstall` hook in package.json after a fresh
6
+ * `npm install -g specshield`. Prints a brief value-progression banner so
7
+ * users discover the cloud features that exist beyond local compare —
8
+ * historically users would install, run compare, get value, and never look
9
+ * at the README to find out signup unlocks more.
10
+ *
11
+ * We bail out silently in environments where a banner would be noise or
12
+ * could break automation:
13
+ * - CI environment variables present (CI, GITHUB_ACTIONS, BUILDKITE,
14
+ * CIRCLECI, GITLAB_CI, JENKINS_URL, TRAVIS, TF_BUILD)
15
+ * - npm_config_loglevel is `silent` or `error` (user opted out of noise)
16
+ * - SPECSHIELD_NO_BANNER=1 (explicit opt-out)
17
+ * - Not running under npm (`npm_command` unset)
18
+ * - Update install rather than fresh install (npm_command !== "install")
19
+ *
20
+ * No state is written; we let the user's terminal scroll and move on.
21
+ */
22
+ 'use strict';
23
+
24
+ function shouldSkip() {
25
+ const env = process.env;
26
+ if (env.SPECSHIELD_NO_BANNER === '1') return true;
27
+ if (env.CI || env.GITHUB_ACTIONS || env.BUILDKITE || env.CIRCLECI ||
28
+ env.GITLAB_CI || env.JENKINS_URL || env.TRAVIS || env.TF_BUILD) {
29
+ return true;
30
+ }
31
+ const loglevel = env.npm_config_loglevel;
32
+ if (loglevel === 'silent' || loglevel === 'error') return true;
33
+ // Only show on the user-initiated "install" command. Things like
34
+ // `npm ci`, `npm update`, transitive dep installs all set npm_command
35
+ // to something other than 'install'.
36
+ if (env.npm_command !== 'install') return true;
37
+ return false;
38
+ }
39
+
40
+ if (shouldSkip()) {
41
+ process.exit(0);
42
+ }
43
+
44
+ // ANSI color helpers — kept local to avoid pulling in chalk during a
45
+ // post-install script (chalk requires node_modules to be fully populated,
46
+ // which is racy during installs).
47
+ const isTTY = !!process.stdout.isTTY;
48
+ const c = (code, s) => (isTTY ? `[${code}m${s}` : s);
49
+ const bold = (s) => c('1', s);
50
+ const dim = (s) => c('2', s);
51
+ const cyan = (s) => c('36', s);
52
+ const grn = (s) => c('32', s);
53
+
54
+ const banner = `
55
+ ${bold('SpecShield installed')} ${dim('— OpenAPI breaking-change detection + BDCT')}
56
+
57
+ ${bold('Get started')}
58
+ ${cyan('specshield compare')} base.yaml target.yaml --fail-on-breaking
59
+ ${cyan('specshield init')} ${dim('# project setup wizard')}
60
+
61
+ ${bold('Your usage tier')}
62
+ ${grn('●')} ${bold('Local (free, no account)')} spec compare, breaking-change detection
63
+ ${dim('○')} Cloud Free ${dim('+ compare history, PR badge, share URLs')}
64
+ ${dim('○')} Pro ${dim('+ BDCT, can-i-deploy, GitHub PR checks, team')}
65
+
66
+ ${dim('Sign in when you want history & sharing:')} ${cyan('specshield login')}
67
+ ${dim('Docs:')} ${cyan('https://specshield.io/docs')}
68
+ `;
69
+
70
+ process.stdout.write(banner + '\n');
package/src/cli.js CHANGED
@@ -7,6 +7,8 @@ const initCommand = require('./commands/init');
7
7
  const loginCommand = require('./commands/login');
8
8
  const logoutCommand = require('./commands/logout');
9
9
  const bdctCommand = require('./commands/bdct');
10
+ const historyCommand = require('./commands/history');
11
+ const shareCommand = require('./commands/share');
10
12
 
11
13
  const program = new Command();
12
14
 
@@ -24,6 +26,8 @@ program.addCommand(initCommand);
24
26
  program.addCommand(loginCommand);
25
27
  program.addCommand(logoutCommand);
26
28
  program.addCommand(bdctCommand);
29
+ program.addCommand(historyCommand);
30
+ program.addCommand(shareCommand);
27
31
 
28
32
  program.parseAsync(process.argv).catch((err) => {
29
33
  const logger = require('./utils/logger');
@@ -12,6 +12,7 @@ const { classifyChanges, filterBySeverity } = require('../core/classifyChanges')
12
12
  const { formatHuman, formatJson } = require('../core/outputFormatter');
13
13
  const { loadConfig } = require('../core/configLoader');
14
14
  const { resolveExitCode } = require('../core/exitCode');
15
+ const { recordCompareAndMaybeRender } = require('../core/conversionPrompt');
15
16
  const logger = require('../utils/logger');
16
17
  const fsExtra = require('fs-extra');
17
18
  const { getStoredApiKey } = require('../config/localConfig');
@@ -92,6 +93,22 @@ compare
92
93
  }
93
94
  }
94
95
 
96
+ // Contextual signup nudge — runs only when the user isn't logged in,
97
+ // output is human-readable, not in CI, and they've crossed a usage
98
+ // threshold this week. The recordCompareAndMaybeRender function is
99
+ // best-effort; any failure (disk read/write, etc.) is silently
100
+ // swallowed so the conversion path never blocks compare.
101
+ try {
102
+ const loggedIn = !!(options.resolvedApiKey ||
103
+ process.env.SPECSHIELD_API_KEY ||
104
+ await getStoredApiKey());
105
+ const prompt = await recordCompareAndMaybeRender({
106
+ loggedIn,
107
+ jsonOutput: !!options.json,
108
+ });
109
+ if (prompt) process.stdout.write('\n' + prompt + '\n');
110
+ } catch { /* never block on the nudge */ }
111
+
95
112
  // Exit code
96
113
  const code = resolveExitCode(result, options);
97
114
  process.exit(code);
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `specshield history` — list recent comparisons saved in the user's
5
+ * SpecShield Cloud account.
6
+ *
7
+ * This command's primary value isn't itself — it's that it surfaces a
8
+ * concrete Cloud-only feature in `specshield --help`, giving local-only
9
+ * users a visible "what am I missing?" signal. When run without a stored
10
+ * API key, it prints a brief sign-up nudge rather than failing silently.
11
+ */
12
+
13
+ const { Command } = require('commander');
14
+ const chalk = require('chalk');
15
+ const ora = require('ora');
16
+ const axios = require('axios');
17
+ const logger = require('../utils/logger');
18
+ const { getStoredApiKey } = require('../config/localConfig');
19
+
20
+ const HOSTED_API_URL = 'https://specshield.io';
21
+
22
+ const history = new Command('history');
23
+
24
+ history
25
+ .description('Show your recent comparisons (Cloud account required)')
26
+ .option('--limit <n>', 'How many comparisons to show', '20')
27
+ .option('--json', 'Machine-readable JSON output')
28
+ .option('--api-key <key>', 'API key (overrides env / stored config)')
29
+ .option('--api-url <url>', 'Override hosted API base URL', HOSTED_API_URL)
30
+ .action(async (opts) => {
31
+ const apiKey = opts.apiKey
32
+ || process.env.SPECSHIELD_API_KEY
33
+ || (await getStoredApiKey())
34
+ || null;
35
+
36
+ if (!apiKey) {
37
+ printSignupNudge();
38
+ process.exit(2);
39
+ }
40
+
41
+ const spinner = opts.json ? null : ora('Fetching your comparison history...').start();
42
+ try {
43
+ const size = parseInt(opts.limit, 10) || 20;
44
+ const response = await axios.get(`${opts.apiUrl}/me/compare-history`, {
45
+ // Backend uses Spring Data Page conventions: page (0-indexed) + size.
46
+ params: { page: 0, size },
47
+ headers: { 'X-Api-Key': apiKey, 'X-SpecShield-Client': 'cli' },
48
+ timeout: 10000,
49
+ });
50
+ if (spinner) spinner.stop();
51
+
52
+ const items = response.data?.content
53
+ || response.data?.items
54
+ || (Array.isArray(response.data) ? response.data : []);
55
+
56
+ if (opts.json) {
57
+ process.stdout.write(JSON.stringify(items, null, 2) + '\n');
58
+ return;
59
+ }
60
+
61
+ if (items.length === 0) {
62
+ console.log(chalk.gray('\n No comparisons in your history yet. Run:'));
63
+ console.log(chalk.cyan(' specshield compare a.yaml b.yaml --remote\n'));
64
+ return;
65
+ }
66
+
67
+ console.log('\n ' + chalk.bold('Your recent comparisons'));
68
+ console.log(chalk.gray(' ─────────────────────────────────────────────────────'));
69
+ for (const item of items) {
70
+ const id = item.id || item.reportId || '—';
71
+ const created = item.createdAt || item.timestamp || '—';
72
+ const breaks = item.breakingCount ?? item.breakingChanges?.length ?? '—';
73
+ // CompareHistorySummaryDto exposes base + target spec names — combine
74
+ // for a "a.yaml → b.yaml" summary that matches what the user typed.
75
+ const summary = item.baseSpecName && item.targetSpecName
76
+ ? `${item.baseSpecName} → ${item.targetSpecName}`
77
+ : (item.summary || item.baseSpecName || '');
78
+ const breakBadge = breaks === 0 || breaks === '—'
79
+ ? chalk.green(`${breaks} breaking`)
80
+ : chalk.red(`${breaks} breaking`);
81
+ console.log(` ${chalk.cyan(String(id).padEnd(10))} ${breakBadge.padEnd(18)} ${chalk.gray(String(created))} ${summary}`);
82
+ }
83
+ console.log();
84
+ } catch (err) {
85
+ if (spinner) spinner.fail('Could not fetch history');
86
+ const msg = err.response
87
+ ? `${err.response.status}: ${JSON.stringify(err.response.data)}`
88
+ : err.message;
89
+ logger.error(`Failed to fetch history: ${msg}`);
90
+ process.exit(2);
91
+ }
92
+ });
93
+
94
+ function printSignupNudge() {
95
+ console.log('');
96
+ console.log(chalk.bold(' specshield history') + chalk.gray(' — Cloud feature'));
97
+ console.log('');
98
+ console.log(' Comparison history is part of your SpecShield Cloud account.');
99
+ console.log(' Every ' + chalk.cyan('specshield compare --remote') + ' you run is saved with the diff,');
100
+ console.log(' date, and a shareable report URL.');
101
+ console.log('');
102
+ console.log(chalk.bold(' Get started in 30 seconds:'));
103
+ console.log(' ' + chalk.cyan('specshield login') + chalk.gray(' # API key from https://specshield.io'));
104
+ console.log(' ' + chalk.gray('or visit https://specshield.io and sign in with GitHub / Google'));
105
+ console.log('');
106
+ console.log(' ' + chalk.gray('Cloud is free for personal use.'));
107
+ console.log('');
108
+ }
109
+
110
+ module.exports = history;
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `specshield share` — generate a public shareable URL for a comparison
5
+ * report, so users can paste a SpecShield-hosted link into Slack, GitHub
6
+ * PR comments, or Jira tickets.
7
+ *
8
+ * Like the history command, the bigger purpose is making a concrete
9
+ * Cloud-only feature discoverable from `specshield --help`. Anyone reading
10
+ * the help output sees "share" listed and discovers there's value behind
11
+ * signing up.
12
+ *
13
+ * Usage:
14
+ * specshield share <report-id> # share an existing remote compare
15
+ * specshield share <base.yaml> <target> # compare-and-share in one step
16
+ */
17
+
18
+ const { Command } = require('commander');
19
+ const chalk = require('chalk');
20
+ const ora = require('ora');
21
+ const axios = require('axios');
22
+ const logger = require('../utils/logger');
23
+ const { getStoredApiKey } = require('../config/localConfig');
24
+ const { loadSpec } = require('../core/loadSpec');
25
+
26
+ const HOSTED_API_URL = 'https://specshield.io';
27
+
28
+ const share = new Command('share');
29
+
30
+ share
31
+ .description('Generate a public shareable URL for a comparison (Cloud account required)')
32
+ .argument('[reportOrBase]', 'Existing report ID, or path to base spec when comparing inline')
33
+ .argument('[target]', 'Path to target spec when comparing inline (optional)')
34
+ .option('--expires <days>', 'Make the share link expire after N days (default: never)', null)
35
+ .option('--api-key <key>', 'API key (overrides env / stored config)')
36
+ .option('--api-url <url>', 'Override hosted API base URL', HOSTED_API_URL)
37
+ .action(async (reportOrBase, target, opts) => {
38
+ const apiKey = opts.apiKey
39
+ || process.env.SPECSHIELD_API_KEY
40
+ || (await getStoredApiKey())
41
+ || null;
42
+
43
+ if (!apiKey) {
44
+ printSignupNudge();
45
+ process.exit(2);
46
+ }
47
+
48
+ if (!reportOrBase) {
49
+ logger.error('Usage:\n specshield share <report-id>\n specshield share base.yaml target.yaml');
50
+ process.exit(2);
51
+ }
52
+
53
+ const spinner = ora('Generating share link...').start();
54
+ try {
55
+ const headers = { 'X-Api-Key': apiKey, 'X-SpecShield-Client': 'cli' };
56
+ const expiresInDays = parseInt(opts.expires, 10) || null;
57
+
58
+ let reportId;
59
+ if (target) {
60
+ // Inline-compare path: run a /compare with --remote semantics to
61
+ // persist the result, then immediately share the resulting history
62
+ // row. Two API calls but keeps the backend share endpoint single-purpose.
63
+ spinner.text = 'Running remote comparison...';
64
+ const baseSpec = await loadSpec(reportOrBase);
65
+ const targetSpec = await loadSpec(target);
66
+ const compareResp = await axios.post(`${opts.apiUrl}/compare`,
67
+ { baseSpec, targetSpec },
68
+ { headers, timeout: 30000 });
69
+ // The /compare endpoint returns the diff; the persisted history ID is
70
+ // on the response (added by CompareHistoryService).
71
+ reportId = compareResp.data?.historyId || compareResp.data?.reportId;
72
+ if (!reportId) {
73
+ throw new Error('Comparison succeeded but no history ID was returned — cannot create share link.');
74
+ }
75
+ spinner.text = 'Generating share link...';
76
+ } else {
77
+ reportId = reportOrBase;
78
+ }
79
+
80
+ const response = await axios.post(`${opts.apiUrl}/me/share-links`,
81
+ { reportId, expiresInDays },
82
+ { headers, timeout: 15000 });
83
+ spinner.stop();
84
+
85
+ const url = response.data?.url || response.data?.shareUrl;
86
+ const expiresAt = response.data?.expiresAt || null;
87
+
88
+ console.log('');
89
+ console.log(chalk.green(' ✔ Share link ready'));
90
+ console.log(' ─────────────────────────────────────────────────────');
91
+ console.log(' ' + chalk.cyan(url));
92
+ if (expiresAt) console.log(' ' + chalk.gray('Expires: ' + expiresAt));
93
+ console.log('');
94
+ console.log(chalk.gray(' Anyone with this link can view the diff — no SpecShield account required.'));
95
+ console.log('');
96
+ } catch (err) {
97
+ spinner.fail('Could not generate share link');
98
+ const msg = err.response
99
+ ? `${err.response.status}: ${JSON.stringify(err.response.data)}`
100
+ : err.message;
101
+ logger.error(`Share failed: ${msg}`);
102
+ process.exit(2);
103
+ }
104
+ });
105
+
106
+ function printSignupNudge() {
107
+ console.log('');
108
+ console.log(chalk.bold(' specshield share') + chalk.gray(' — Cloud feature'));
109
+ console.log('');
110
+ console.log(' Shareable report URLs let you paste a diff into Slack, GitHub PR comments,');
111
+ console.log(' or Jira tickets — anyone can view without a SpecShield account.');
112
+ console.log('');
113
+ console.log(chalk.bold(' Get started in 30 seconds:'));
114
+ console.log(' ' + chalk.cyan('specshield login') + chalk.gray(' # API key from https://specshield.io'));
115
+ console.log(' ' + chalk.gray('or visit https://specshield.io and sign in with GitHub / Google'));
116
+ console.log('');
117
+ console.log(' ' + chalk.gray('Cloud is free for personal use.'));
118
+ console.log('');
119
+ }
120
+
121
+ module.exports = share;
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Contextual signup nudge after a successful `specshield compare`.
5
+ *
6
+ * Lives in its own module so the heuristic is easy to read, test, and tune
7
+ * without touching the compare command. The default state file path is
8
+ * `~/.specshield/state.json` — same directory as the stored API key so the
9
+ * "is the user logged in?" check is co-located.
10
+ *
11
+ * Trigger conditions (ALL must be true):
12
+ * - User is NOT logged in
13
+ * - Not in CI (env CI/GITHUB_ACTIONS/etc)
14
+ * - Output isn't `--json` (machine-readable)
15
+ * - User has run >= 3 compares in the last 7 days
16
+ * - We haven't shown a prompt in the last 7 days (don't spam)
17
+ *
18
+ * The state file is best-effort; if the disk read or write fails for any
19
+ * reason we silently skip the prompt — we will never block or fail the
20
+ * compare command for the sake of a marketing nudge.
21
+ */
22
+
23
+ const path = require('path');
24
+ const os = require('os');
25
+ const fsExtra = require('fs-extra');
26
+ const chalk = require('chalk');
27
+
28
+ // Path is overridable for tests. Production never sets the env var, so the
29
+ // real home-directory state file is used. Tests point this at a temp dir
30
+ // because os.homedir() on POSIX ignores HOME and reads from /etc/passwd.
31
+ const STATE_PATH = process.env.SPECSHIELD_STATE_PATH
32
+ || path.join(os.homedir(), '.specshield', 'state.json');
33
+ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
34
+ const COMPARE_THRESHOLD = 3;
35
+
36
+ /**
37
+ * Hand-tuned variant strings keyed by the user's compare count tier. We
38
+ * escalate the value-prop as they prove sustained usage — first prompts
39
+ * are gentle (just history); deeper-use prompts are richer (BDCT, team).
40
+ */
41
+ const VARIANTS = [
42
+ {
43
+ minCount: 3,
44
+ body: [
45
+ `${chalk.cyan('●')} ${chalk.bold('Track these comparisons over time:')}`,
46
+ ` ${chalk.cyan('specshield login')} ${chalk.gray('# 30-sec signup via GitHub / Google · no credit card')}`,
47
+ ` ${chalk.gray('Unlocks: compare history, shareable report URLs, PR badge')}`,
48
+ ],
49
+ },
50
+ {
51
+ minCount: 10,
52
+ body: [
53
+ `${chalk.cyan('●')} ${chalk.bold('You ran 10+ compares this week.')} ${chalk.gray('Your team can collaborate on this:')}`,
54
+ ` ${chalk.cyan('specshield login')} ${chalk.gray('# free Cloud account · GitHub PR checks · BDCT · team dashboard')}`,
55
+ ],
56
+ },
57
+ {
58
+ minCount: 25,
59
+ body: [
60
+ `${chalk.cyan('●')} ${chalk.bold('Heavy usage detected.')} ${chalk.gray("Time to gate deploys with can-i-deploy:")}`,
61
+ ` ${chalk.cyan('specshield login')} ${chalk.gray('# unlocks BDCT bi-directional contracts · can-i-deploy · audit log')}`,
62
+ ` ${chalk.gray('Pricing: free for personal use · $29/mo Solo · $89/mo Team (10 seats)')}`,
63
+ ],
64
+ },
65
+ ];
66
+
67
+ function isCi() {
68
+ const e = process.env;
69
+ return !!(e.CI || e.GITHUB_ACTIONS || e.BUILDKITE || e.CIRCLECI ||
70
+ e.GITLAB_CI || e.JENKINS_URL || e.TRAVIS || e.TF_BUILD);
71
+ }
72
+
73
+ async function readState() {
74
+ try {
75
+ return await fsExtra.readJson(STATE_PATH);
76
+ } catch {
77
+ return {};
78
+ }
79
+ }
80
+
81
+ async function writeState(state) {
82
+ try {
83
+ await fsExtra.outputJson(STATE_PATH, state, { spaces: 2 });
84
+ } catch {
85
+ // Best-effort — never throw from the conversion path.
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Record a successful compare and return the variant string to print
91
+ * after the regular output (or null to skip the prompt entirely).
92
+ *
93
+ * @param {object} opts
94
+ * @param {boolean} opts.loggedIn Whether the user has a stored API key
95
+ * @param {boolean} opts.jsonOutput True if --json was passed (suppress prompts)
96
+ * @returns {Promise<string|null>}
97
+ */
98
+ async function recordCompareAndMaybeRender({ loggedIn, jsonOutput }) {
99
+ if (loggedIn) return null;
100
+ if (jsonOutput) return null;
101
+ if (isCi()) return null;
102
+ // Honor the same opt-out as the post-install banner.
103
+ if (process.env.SPECSHIELD_NO_BANNER === '1') return null;
104
+
105
+ const now = Date.now();
106
+ const state = await readState();
107
+
108
+ // Window the compare count: reset the counter once a week so a heavy
109
+ // burst followed by a long pause doesn't trigger a prompt months later.
110
+ if (!state.windowStartedAt || (now - state.windowStartedAt) > ONE_WEEK_MS) {
111
+ state.windowStartedAt = now;
112
+ state.compareCount = 0;
113
+ }
114
+ state.compareCount = (state.compareCount || 0) + 1;
115
+
116
+ let prompt = null;
117
+ if (state.compareCount >= COMPARE_THRESHOLD) {
118
+ const sinceLast = state.lastPromptAt ? (now - state.lastPromptAt) : Infinity;
119
+ if (sinceLast >= ONE_WEEK_MS) {
120
+ prompt = pickVariant(state.compareCount);
121
+ state.lastPromptAt = now;
122
+ state.promptsShown = (state.promptsShown || 0) + 1;
123
+ }
124
+ }
125
+
126
+ await writeState(state);
127
+ return prompt;
128
+ }
129
+
130
+ function pickVariant(count) {
131
+ // Linear scan from most-aggressive to least-aggressive — pick the
132
+ // highest-min-count variant the user qualifies for.
133
+ const eligible = VARIANTS.filter(v => count >= v.minCount).sort((a, b) => b.minCount - a.minCount);
134
+ if (eligible.length === 0) return null;
135
+ return eligible[0].body.join('\n');
136
+ }
137
+
138
+ module.exports = { recordCompareAndMaybeRender, STATE_PATH };