specshield 3.2.3 → 3.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -486,63 +486,362 @@ specshield bdct can-i-deploy --org acme-store --service payment-service --versio
486
486
 
487
487
  ---
488
488
 
489
- # `bdct capture from-har` — record real traffic, get a consumer contract
489
+ # `bdct capture from-har` — turn real traffic into a consumer contract
490
490
 
491
- **The Pact alternative for teams that don't want to adopt a DSL.**
491
+ **This is the feature that makes BDCT actually work for normal teams.**
492
+ Pact requires every consumer team to learn a DSL, instrument their tests,
493
+ and run a broker. SpecShield asks for something they already have: a
494
+ recording of an HTTP test run, in the universal HAR format that every
495
+ browser, every test framework, and every recording proxy already produces.
492
496
 
493
- `from-har` reads a [HAR file](https://en.wikipedia.org/wiki/HAR_(file_format)) (HTTP Archive — any browser, Cypress, Playwright, k6, Insomnia, or Charles Proxy can export one), filters to your provider's host, infers an OpenAPI 3.0 consumer-contract subset from what your tests actually called, and writes it to a file you can publish with `bdct publish-consumer`.
497
+ A hand-written consumer contract drifts the moment you forget a field. A
498
+ HAR-derived contract reflects what your code **actually called**, every
499
+ time you re-record. It's the difference between "we documented the
500
+ integration" and "we proved the integration."
494
501
 
495
- Why this matters: a hand-written consumer subset can be wrong (you forget the `currency` field your code reads, and the provider can remove it without anyone noticing). A HAR recording **captures what your code actually does** — without the Pact DSL, language-agnostic, runs anywhere.
502
+ ### The 3-step mental model
496
503
 
497
- ### Generate a contract from a recorded test run
504
+ ```
505
+ ┌─────────────────────────────────────────────────────────────────────────┐
506
+ │ 1. RECORD 2. CAPTURE 3. GATE │
507
+ │ │
508
+ │ Your existing test run → specshield bdct → publish-consumer │
509
+ │ → traffic.har capture from-har verify │
510
+ │ (browser / Playwright / → consumer-contract.yaml can-i-deploy │
511
+ │ Cypress / mitmproxy / │
512
+ │ Postman / k6 / …) ← language-agnostic ← every provider │
513
+ │ CLI does the OpenAPI PR re-checks │
514
+ │ inference every consumer │
515
+ └─────────────────────────────────────────────────────────────────────────┘
516
+ ```
517
+
518
+ Step 1 already happens in your team — you're just saving the file. Step 2
519
+ is one CLI command. Step 3 is the BDCT registry you publish to.
520
+
521
+ ---
522
+
523
+ ## Step 1 — record a HAR file
524
+
525
+ Pick the recorder that matches **how the consumer is exercised** in your
526
+ project. You almost never have to write recorder code yourself.
527
+
528
+ | Recorder | Use when the consumer is… | Effort |
529
+ |---|---|---|
530
+ | **[Browser DevTools](https://developer.chrome.com/docs/devtools/network/reference#save)** (Chrome / Firefox / Edge / Safari) | A frontend SPA, a Swagger UI, or any browser-driven app | Zero code |
531
+ | **[Playwright](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har)** with `recordHar` | Exercised by Playwright UI/integration tests | **One config field** |
532
+ | **[Cypress](https://github.com/NeuraLegion/cypress-har-generator)** + `cypress-har-generator` | Exercised by Cypress UI tests | One plugin |
533
+ | **[mitmproxy](https://docs.mitmproxy.org/stable/addons-examples/#har-dump)** | A backend service-to-service caller in a test env | One brew install, no consumer changes |
534
+ | **[Postman](https://learning.postman.com/docs/getting-started/importing-and-exporting/exporting-postman-data/#exporting-as-har)** / Insomnia / Bruno | Manually exercised during exploratory testing | Right-click → export |
535
+ | **[k6](https://grafana.com/docs/k6/latest/results-output/real-time/json/#har)** load tests | Already running k6 against the provider | One `--out har=…` flag |
536
+
537
+ **Recipes follow** — keep reading for the exact commands per tool, then jump
538
+ to Step 2 (the capture command, which is identical regardless of recorder).
539
+
540
+ ### 1a. Chrome / Firefox / Edge DevTools (zero code — best for frontend)
541
+
542
+ 1. Open the page that calls your API in the browser.
543
+ 2. Open DevTools (`F12` / `Cmd+Opt+I`) → **Network** tab.
544
+ 3. Click the 🚫 button to clear, then reload the page and click through the
545
+ flows you want to record.
546
+ 4. Right-click anywhere in the request list → **Save all as HAR with
547
+ content** → save as `traffic.har`.
548
+
549
+ That's the entire recording. The file is byte-compatible with everything
550
+ SpecShield does.
551
+
552
+ > **Safari note:** Safari's "Export HAR" is under the Develop menu →
553
+ > *Export…* in the Network tab; it produces the same format.
554
+
555
+ ### 1b. Playwright (best for teams with UI tests)
556
+
557
+ Add one option to your `playwright.config.js`:
558
+
559
+ ```js
560
+ // playwright.config.js
561
+ const path = require('path');
562
+ module.exports = {
563
+ testDir: './tests',
564
+ use: {
565
+ baseURL: 'https://staging.acme.com',
566
+ recordHar: { path: path.resolve(__dirname, 'traffic.har'), mode: 'full', content: 'embed' },
567
+ },
568
+ };
569
+ ```
570
+
571
+ Run your tests as usual:
572
+
573
+ ```bash
574
+ npx playwright test
575
+ # → writes traffic.har on context close
576
+ ```
577
+
578
+ The HAR will contain every HTTP call the browser made during the test —
579
+ HTML, JS, images, and the API JSON. The `--onlyJson` default in Step 2
580
+ filters out the noise automatically.
581
+
582
+ > Need the HAR file flushed **per test**? Create the context explicitly in
583
+ > your test and call `await context.close()` at the end. A runnable
584
+ > copy-paste starter lives in [`examples/playwright-har/`](examples/playwright-har/)
585
+ > of this repo — clone, `npm install`, `npx playwright install chromium`,
586
+ > `npm test` produces `traffic.har` against the public JSONPlaceholder
587
+ > sandbox so you can verify the toolchain works before pointing it at your
588
+ > own provider.
589
+
590
+ ### 1c. Cypress
591
+
592
+ ```bash
593
+ npm i -D @neuralegion/cypress-har-generator
594
+ ```
595
+
596
+ ```js
597
+ // cypress.config.js
598
+ const { install } = require('@neuralegion/cypress-har-generator');
599
+ module.exports = { e2e: { setupNodeEvents(on, config) { install(on); return config; } } };
600
+
601
+ // cypress/support/e2e.js
602
+ beforeEach(() => cy.recordHar());
603
+ afterEach(() => cy.saveHar({ outDir: './har-out' }));
604
+ ```
605
+
606
+ Run `npx cypress run` and the HAR for each spec lands in `har-out/`.
607
+
608
+ ### 1d. mitmproxy (best for backend service-to-service)
609
+
610
+ When the consumer is a backend service (no browser), point it through
611
+ mitmproxy in your integration-test env. No consumer code change.
498
612
 
499
613
  ```bash
500
- # Record a HAR (one of many ways)
501
- # - Chrome DevTools Network right-click "Save all as HAR"
502
- # - Playwright: page.on('request')... or use BROWSER_TOOLS_HAR
503
- # - Cypress: cy.intercept(...) + plugins like cypress-har-generator
504
- # - k6: k6 run --out har=run.har script.js
614
+ # Install once
615
+ brew install mitmproxy # macOS also available via pip and apt
505
616
 
617
+ # Start the proxy and tell it to dump HAR
618
+ mitmdump --set hardump=traffic.har --listen-port 8080
619
+
620
+ # In another terminal: run your tests with HTTPS_PROXY pointing at it
621
+ HTTPS_PROXY=http://localhost:8080 HTTP_PROXY=http://localhost:8080 \
622
+ ./run-integration-tests.sh
623
+
624
+ # Ctrl-C mitmdump when done — traffic.har is on disk
625
+ ```
626
+
627
+ The `hardump` add-on is built into mitmproxy ≥ 9.0. For TLS hosts you'll
628
+ need to trust mitmproxy's CA in your test JVM/runtime (see the
629
+ [mitmproxy CA docs](https://docs.mitmproxy.org/stable/concepts-certificates/));
630
+ in many test environments it's a single env var
631
+ (`NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, or a
632
+ JVM `cacerts` import).
633
+
634
+ ### 1e. Postman / Insomnia / Bruno
635
+
636
+ Run the request (or a whole collection runner). Right-click the response
637
+ → **Save Response as HAR**. Repeat for each request you want included,
638
+ then either keep them as separate `.har` files (pass `--in` once per file
639
+ via a loop in CI) or merge them with any HAR-merge tool.
640
+
641
+ ### 1f. k6 load test (bonus — recording while load-testing)
642
+
643
+ ```bash
644
+ k6 run --out har=traffic.har script.js
645
+ ```
646
+
647
+ k6 emits a HAR alongside its load metrics. Same file works with
648
+ `bdct capture from-har`.
649
+
650
+ ---
651
+
652
+ ## Step 2 — turn the HAR into a consumer contract
653
+
654
+ This is the one CLI command. The same command works regardless of which
655
+ recorder produced the HAR.
656
+
657
+ ```bash
506
658
  specshield bdct capture from-har \
507
- --in tests/checkout-run.har \
659
+ --in traffic.har \
508
660
  --base-url https://api.acme.com \
509
- --out contracts/checkout-ui-payment.yaml
661
+ --out consumer-contract.yaml
510
662
  ```
511
663
 
664
+ Expected output:
665
+
512
666
  ```
513
- ✔ Wrote contracts/checkout-ui-payment.yaml (2 endpoints, 3 ops from 17/24 entries)
667
+ ✔ Wrote consumer-contract.yaml (4 endpoints, 6 ops from 23/41 entries)
514
668
  ```
515
669
 
516
- ### Options
670
+ The `23/41` summary means **23 entries** survived the filters (right host,
671
+ JSON body) out of **41 total** the recorder captured. Open the YAML to see
672
+ exactly what your code talks to — endpoint by endpoint, field by field.
517
673
 
518
- | Flag | Purpose |
519
- |---|---|
520
- | `--in <path>` | **Required.** Input HAR file (HTTP Archive 1.2). |
521
- | `--out <path>` | Output file (default: stdout). |
522
- | `--base-url <url>` | Keep only entries matching this URL prefix (e.g. `https://api.acme.com` or `https://api.acme.com/v1`). |
523
- | `--method <verbs>` | Comma-separated methods to include (e.g. `GET,POST`). Default: all. |
524
- | `--title <title>` | OpenAPI `info.title`. Default: `Captured consumer contract`. |
525
- | `--version <ver>` | OpenAPI `info.version`. Default: `0.1.0`. |
526
- | `--format <fmt>` | Output format: `yaml` (default) or `json`. |
527
- | `--include-non-json` | Keep entries with non-JSON bodies (default: drop them — schemas can't be inferred). |
674
+ ### All options
528
675
 
529
- ### What you get
676
+ | Flag | Purpose | Default |
677
+ |---|---|---|
678
+ | `--in <path>` | **Required.** Input HAR file (HAR 1.2). | — |
679
+ | `--out <path>` | Output file. If omitted, writes to stdout. | stdout |
680
+ | `--base-url <url>` | Keep only entries matching this URL prefix. Critical when the consumer talks to multiple backends — see "multi-host" below. | (no filter — keeps every host) |
681
+ | `--method <verbs>` | Comma-separated methods to include, e.g. `GET,POST`. | all methods |
682
+ | `--title <title>` | OpenAPI `info.title`. | `Captured consumer contract` |
683
+ | `--version <ver>` | OpenAPI `info.version` — usually your git SHA in CI. | `0.1.0` |
684
+ | `--format <fmt>` | `yaml` or `json`. | `yaml` |
685
+ | `--include-non-json` | Keep entries with non-JSON bodies (HTML, images, binary). Schemas can't be inferred but the endpoints will appear. | off |
686
+
687
+ ### What the engine actually does
688
+
689
+ For every HAR entry that passes the filters:
690
+
691
+ - **Path templating.** `/users/123/orders/abc-2026` becomes
692
+ `/users/{userId}/orders/{orderId}`. Parameter names come from the
693
+ preceding noun in the URL — *not* a generic `{id}` — so the resulting
694
+ OpenAPI matches the parameter names your provider likely already uses
695
+ (`{userId}`, `{orderId}`, `{accountId}`, …).
696
+ - **Per-status schema merging.** If three `GET /users/{userId}` samples
697
+ return slightly different shapes, the emitted schema is the *merger*:
698
+ fields seen in **every** sample stay `required`; fields seen in **some**
699
+ samples become optional; `integer` + `number` widens to `number`; a
700
+ type conflict falls back to `string`.
701
+ - **Format detection.** UUIDs, RFC 3339 date-times, and email addresses
702
+ get `format: uuid` / `format: date-time` / `format: email`.
703
+ - **Status-code coverage.** A 404 sample produces its own response schema
704
+ alongside the 200 — so the contract documents the error shapes your code
705
+ handles, not just the happy path.
706
+ - **JSON-only by default.** Entries with non-JSON bodies are dropped; the
707
+ HTML page load from a Playwright test never ends up in the contract.
530
708
 
531
- - **Path templating:** concrete paths like `/users/123/orders/abc-2026` are turned into `/users/{userId}/orders/{orderId}` (the param is named from the preceding noun, not a generic `{id}`).
532
- - **Per-status schema merging:** if two recorded GETs return slightly different shapes, the emitted schema is the merger — fields seen in EVERY sample stay `required`, fields seen in only SOME become optional, integer + number widens to number.
533
- - **Format detection:** UUIDs, RFC 3339 date-times, and emails get `format: uuid|date-time|email`.
534
- - **JSON-only by default:** non-JSON bodies are dropped (schemas can't be inferred from binary/HTML); override with `--include-non-json` for debugging.
709
+ ---
535
710
 
536
- ### Use the captured contract in BDCT
711
+ ## Step 3 publish + gate
537
712
 
538
713
  ```bash
714
+ # 1. Publish the captured contract (run on every consumer PR)
539
715
  specshield bdct publish-consumer \
540
- --org acme-store --consumer checkout-ui \
541
- --provider payment-service --version 2.0.0 \
542
- --contract contracts/checkout-ui-payment.yaml \
543
- --format OPENAPI
716
+ --org acme-store \
717
+ --consumer checkout-ui \
718
+ --provider payment-service \
719
+ --version "$GIT_SHA" \
720
+ --format OPENAPI \
721
+ --contract consumer-contract.yaml
722
+
723
+ # 2. Gate the deploy (run before promoting the consumer)
724
+ specshield bdct can-i-deploy \
725
+ --org acme-store \
726
+ --service checkout-ui --version "$GIT_SHA" --env staging
727
+ # exit 0 = safe; exit 1 = a verification says NO and the deploy is blocked
728
+ ```
729
+
730
+ Full BDCT command reference is in the [Bi-Directional Contract Testing](#bi-directional-contract-testing-bdct)
731
+ section above.
732
+
733
+ ---
734
+
735
+ ## Operational concerns (read this before going live)
736
+
737
+ ### Scrubbing auth tokens, cookies, and PII
738
+
739
+ A raw HAR can contain `Authorization` headers, session cookies, and real
740
+ PII in request/response bodies. **Scrub before publishing.** Two patterns:
741
+
742
+ **Quick scrub with jq** (zero deps if you already have jq):
743
+
744
+ ```bash
745
+ jq 'del(.. | .headers? | .[]? | select(.name | ascii_downcase | IN("authorization","cookie","set-cookie","x-api-key")))' \
746
+ traffic.har > scrubbed.har
747
+ ```
748
+
749
+ **Heavier scrub via `har-sanitizer`** (npm tool — supports body redaction
750
+ patterns, allowlists, etc.):
751
+
752
+ ```bash
753
+ npx har-sanitizer --input traffic.har --output scrubbed.har --scrub-words 'email,ssn,creditCard'
754
+ ```
755
+
756
+ The contract that `bdct capture from-har` emits only carries field *names
757
+ and types* — not values — so once the HAR is scrubbed of secrets, the
758
+ published contract is safe to share with the provider team.
759
+
760
+ ### Multi-host filtering (consumer talks to several backends)
761
+
762
+ `--base-url` is a prefix match, so it doubles as a host filter:
763
+
764
+ ```bash
765
+ # Keep only calls to the payment service
766
+ specshield bdct capture from-har \
767
+ --in traffic.har --base-url https://payment.acme.com \
768
+ --out contracts/checkout-ui-payment.yaml
769
+
770
+ # Same HAR, different provider — keep only calls to inventory
771
+ specshield bdct capture from-har \
772
+ --in traffic.har --base-url https://inventory.acme.com \
773
+ --out contracts/checkout-ui-inventory.yaml
774
+ ```
775
+
776
+ One test run → one HAR → N consumer contracts, one per provider.
777
+
778
+ ### Keeping contracts fresh in CI
779
+
780
+ The whole pattern is "record from your existing tests, publish from CI." A
781
+ typical `.github/workflows/contract-test.yml`:
782
+
783
+ ```yaml
784
+ - name: Run integration tests (produces traffic.har)
785
+ run: npx playwright test # or ./mvnw verify, or whatever you use
786
+
787
+ - name: HAR → consumer contract
788
+ run: |
789
+ specshield bdct capture from-har \
790
+ --in traffic.har \
791
+ --base-url ${{ vars.PROVIDER_BASE_URL }} \
792
+ --out consumer-contract.yaml
793
+
794
+ - name: Publish contract
795
+ env:
796
+ SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
797
+ run: |
798
+ specshield bdct publish-consumer \
799
+ --org ${{ vars.SPECSHIELD_ORG }} \
800
+ --consumer ${{ vars.SERVICE_NAME }} \
801
+ --provider ${{ vars.PROVIDER_NAME }} \
802
+ --version ${{ github.sha }} \
803
+ --format OPENAPI \
804
+ --contract consumer-contract.yaml
805
+
806
+ - name: Gate the deploy
807
+ env:
808
+ SPECSHIELD_API_KEY: ${{ secrets.SPECSHIELD_API_KEY }}
809
+ run: |
810
+ specshield bdct can-i-deploy \
811
+ --org ${{ vars.SPECSHIELD_ORG }} \
812
+ --service ${{ vars.SERVICE_NAME }} \
813
+ --version ${{ github.sha }} \
814
+ --env staging
544
815
  ```
545
816
 
817
+ Identical pattern on GitLab, CircleCI, Jenkins — only the secret-injection
818
+ syntax changes.
819
+
820
+ ### Common pitfalls
821
+
822
+ | Symptom | Cause | Fix |
823
+ |---|---|---|
824
+ | `0 endpoints, 0 ops from 0/N entries` | `--base-url` doesn't match any host in the HAR | Drop `--base-url` to see what hosts ARE in the HAR; re-run with the right prefix. |
825
+ | Contract is missing your POST | Recorder captured a 4xx for that POST | Fix the test so the POST succeeds, OR keep the 4xx (it'll document the error path). |
826
+ | Path got templated when you didn't want it to | A literal path segment looked like a numeric/UUID id | Path segments are templated when **multiple** entries share a prefix but differ on that segment. A single entry stays literal. |
827
+ | Playwright HAR file is empty / not written | `context.close()` didn't run | Create the context explicitly with `chromium.launch()` → `browser.newContext({ recordHar })` and `await context.close()` at the end of the test. |
828
+ | Backend rejects POST when mitmproxy is in the path | TLS cert not trusted by the consumer | See the mitmproxy CA-cert link above; one env var or one JKS import. |
829
+
830
+ ---
831
+
832
+ ## Why this is the most valuable piece of the CLI
833
+
834
+ Every other contract-testing product on the market asks the consumer team
835
+ to **change their code** — adopt a DSL, instrument their tests, run a
836
+ broker. That tax is why most teams that "should" be doing contract testing
837
+ aren't.
838
+
839
+ `bdct capture from-har` removes the tax. Your team's existing test run is
840
+ already the contract; the CLI just converts the format. The HAR-capture →
841
+ publish → can-i-deploy loop is what turns "we have OpenAPI specs in a
842
+ folder" into "no provider PR merges if it would break a deployed consumer"
843
+ — with measurable enforcement, in one CI step, and no per-language SDK.
844
+
546
845
  ---
547
846
 
548
847
  # `bdct verify-provider` — does your live provider actually match its spec?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specshield",
3
- "version": "3.2.3",
3
+ "version": "3.2.5",
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": {
@@ -70,6 +70,8 @@ function hr() {
70
70
  return chalk.gray(' ─────────────────────────────────────────────────────');
71
71
  }
72
72
 
73
+ const { stripVersionPrefix } = require('../util/versionStrip');
74
+
73
75
  /**
74
76
  * Flatten a verification result's mismatches.
75
77
  * Current backend returns `resultJson` (JSON string of [{endpoint,status,mismatches:[...]}]).
@@ -275,6 +277,11 @@ const verifyCommand = new Command('verify')
275
277
  const token = await resolveApiToken(opts);
276
278
  requireToken(token);
277
279
 
280
+ // Tolerate a leading `v` on either version (see can-i-deploy for the
281
+ // full rationale — readers paste back display values like `v1.0.0`).
282
+ opts.consumerVersion = stripVersionPrefix(opts.consumerVersion);
283
+ opts.providerVersion = stripVersionPrefix(opts.providerVersion);
284
+
278
285
  const spinner = opts.json ? null : ora(`Verifying ${opts.consumer} → ${opts.provider}...`).start();
279
286
 
280
287
  try {
@@ -361,6 +368,13 @@ const canIDeployCommand = new Command('can-i-deploy')
361
368
  const token = await resolveApiToken(opts);
362
369
  requireToken(token);
363
370
 
371
+ // Both the UI and the CLI render versions as `v<version>` for readability.
372
+ // When a user reads that and pastes it back into `--version`, the query
373
+ // silently matches nothing (the stored value never has a leading `v`).
374
+ // Strip a `v` that's followed by a digit, then use the cleaned version
375
+ // for the network call AND the human display so we never print `vv…`.
376
+ opts.version = stripVersionPrefix(opts.version);
377
+
364
378
  const spinner = opts.json ? null : ora(`Checking deployment safety for ${opts.service}@${opts.version}...`).start();
365
379
 
366
380
  try {
@@ -380,11 +394,15 @@ const canIDeployCommand = new Command('can-i-deploy')
380
394
  process.exit(deployable ? 0 : 1);
381
395
  }
382
396
 
397
+ // Idempotent `v` prefix on display — don't double it when the stored
398
+ // version legitimately starts with `v` (e.g. `vendor-tag-99`). Mirrors
399
+ // the UI pill at `BdctCanIDeploy.jsx:392`.
400
+ const vDisplay = /^v/i.test(opts.version) ? opts.version : `v${opts.version}`;
383
401
  process.stdout.write('\n');
384
402
  if (deployable) {
385
- process.stdout.write(chalk.green.bold(' ✔ PASS') + chalk.white(`: ${opts.service} v${opts.version} is deployable${envLabel}\n`));
403
+ process.stdout.write(chalk.green.bold(' ✔ PASS') + chalk.white(`: ${opts.service} ${vDisplay} is deployable${envLabel}\n`));
386
404
  } else {
387
- process.stdout.write(chalk.red.bold(' ✖ FAIL') + chalk.white(`: ${opts.service} v${opts.version} is NOT deployable${envLabel}\n`));
405
+ process.stdout.write(chalk.red.bold(' ✖ FAIL') + chalk.white(`: ${opts.service} ${vDisplay} is NOT deployable${envLabel}\n`));
388
406
  }
389
407
  process.stdout.write(hr() + '\n');
390
408
 
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tolerate a leading `v` on user-supplied versions.
5
+ *
6
+ * The UI pill and several CLI displays render versions as `v<version>` for
7
+ * readability; readers routinely paste those back into lookup flags
8
+ * (`--version`, `--consumer-version`, `--provider-version`) where the leading
9
+ * `v` then silently makes the query miss every record because the stored
10
+ * value never has one.
11
+ *
12
+ * Strip a `v` (case-insensitive) ONLY when it sits in front of a digit, so
13
+ * legitimate strings that start with `v` followed by a letter (`vendor-tag`,
14
+ * `vNext`) pass through untouched.
15
+ *
16
+ * Applied at the entry of every LOOKUP action (`verify`, `can-i-deploy`).
17
+ * NOT applied to publish actions — the publisher's version is whatever they
18
+ * chose to store, including a literal `v` prefix if they want one.
19
+ */
20
+ function stripVersionPrefix(v) {
21
+ return typeof v === 'string' ? v.replace(/^v(?=\d)/i, '') : v;
22
+ }
23
+
24
+ module.exports = { stripVersionPrefix };