specshield 3.2.1 → 3.2.3
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 +75 -0
- package/README.md +468 -568
- package/package.json +4 -1
- package/src/cli.js +2 -0
- package/src/commands/bdct.js +161 -1
- package/src/commands/init.js +98 -16
- package/src/commands/whoami.js +105 -0
- package/src/core/configWriter.js +30 -5
- package/src/core/conformance/index.js +52 -0
- package/src/core/conformance/pathResolver.js +108 -0
- package/src/core/conformance/probeBuilder.js +92 -0
- package/src/core/conformance/responseValidator.js +114 -0
- package/src/core/conformance/runner.js +133 -0
- package/src/core/har/emitOpenapi.js +125 -0
- package/src/core/har/index.js +68 -0
- package/src/core/har/parseHar.js +136 -0
- package/src/core/har/pathTemplate.js +92 -0
- package/src/core/har/schemaInfer.js +100 -0
- package/src/core/projectConfig.js +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specshield",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.3",
|
|
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": {
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"LICENSE"
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
|
+
"@apidevtools/swagger-parser": "^10.1.1",
|
|
58
|
+
"ajv": "^8.20.0",
|
|
59
|
+
"ajv-formats": "^3.0.1",
|
|
57
60
|
"axios": "^1.6.7",
|
|
58
61
|
"chalk": "^4.1.2",
|
|
59
62
|
"commander": "^12.0.0",
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ const logoutCommand = require('./commands/logout');
|
|
|
9
9
|
const bdctCommand = require('./commands/bdct');
|
|
10
10
|
const historyCommand = require('./commands/history');
|
|
11
11
|
const shareCommand = require('./commands/share');
|
|
12
|
+
const whoamiCommand = require('./commands/whoami');
|
|
12
13
|
|
|
13
14
|
const program = new Command();
|
|
14
15
|
|
|
@@ -28,6 +29,7 @@ program.addCommand(logoutCommand);
|
|
|
28
29
|
program.addCommand(bdctCommand);
|
|
29
30
|
program.addCommand(historyCommand);
|
|
30
31
|
program.addCommand(shareCommand);
|
|
32
|
+
program.addCommand(whoamiCommand);
|
|
31
33
|
|
|
32
34
|
program.parseAsync(process.argv).catch((err) => {
|
|
33
35
|
const logger = require('./utils/logger');
|
package/src/commands/bdct.js
CHANGED
|
@@ -41,7 +41,7 @@ function withProjectDefaults(opts, command) {
|
|
|
41
41
|
try {
|
|
42
42
|
applyBdctDefaults(opts, command);
|
|
43
43
|
} catch (err) {
|
|
44
|
-
if (err.code === 'MISSING_REQUIRED_OPTIONS') {
|
|
44
|
+
if (err.code === 'MISSING_REQUIRED_OPTIONS' || err.code === 'UNRESOLVED_PLACEHOLDER') {
|
|
45
45
|
logger.error(err.message);
|
|
46
46
|
process.exit(2);
|
|
47
47
|
}
|
|
@@ -689,6 +689,164 @@ const listConsumersCommand = new Command('list-consumers')
|
|
|
689
689
|
}
|
|
690
690
|
});
|
|
691
691
|
|
|
692
|
+
// ─── capture (Fix 2 — turn recorded traffic into a consumer contract) ────────
|
|
693
|
+
// Reads a HAR file (any browser/Cypress/Playwright/k6 can export one) and
|
|
694
|
+
// emits an OpenAPI 3.0 consumer-contract subset describing only the
|
|
695
|
+
// endpoints/fields the consumer actually called/read. Pure local CLI work —
|
|
696
|
+
// no API token required.
|
|
697
|
+
|
|
698
|
+
const captureFromHarCommand = new Command('from-har')
|
|
699
|
+
.description('Generate a consumer OpenAPI contract from a recorded HAR file')
|
|
700
|
+
.requiredOption('--in <path>', 'Input HAR file (HTTP Archive 1.2)')
|
|
701
|
+
.option('--out <path>', 'Output file (default: write to stdout)')
|
|
702
|
+
.option('--base-url <url>', 'Keep only entries matching this URL prefix (e.g. https://api.acme.com or https://api.acme.com/v1)')
|
|
703
|
+
.option('--method <verbs>', 'Comma-separated methods to include (e.g. GET,POST). Default: all')
|
|
704
|
+
.option('--title <title>', 'OpenAPI info.title', 'Captured consumer contract')
|
|
705
|
+
.option('--version <ver>', 'OpenAPI info.version', '0.1.0')
|
|
706
|
+
.option('--format <fmt>', 'Output format: yaml | json', 'yaml')
|
|
707
|
+
.option('--include-non-json', 'Keep entries with non-JSON bodies (default: drop them)')
|
|
708
|
+
.action(async (opts) => {
|
|
709
|
+
const { captureFromHarFile } = require('../core/har');
|
|
710
|
+
const inputPath = path.resolve(opts.in);
|
|
711
|
+
if (!fsExtra.existsSync(inputPath)) {
|
|
712
|
+
logger.error(`HAR file not found: ${inputPath}`);
|
|
713
|
+
process.exit(2);
|
|
714
|
+
}
|
|
715
|
+
const methods = opts.method
|
|
716
|
+
? opts.method.split(',').map(s => s.trim()).filter(Boolean)
|
|
717
|
+
: undefined;
|
|
718
|
+
|
|
719
|
+
let result;
|
|
720
|
+
try {
|
|
721
|
+
result = captureFromHarFile(inputPath, {
|
|
722
|
+
baseUrl: opts.baseUrl,
|
|
723
|
+
methods,
|
|
724
|
+
onlyJson: !opts.includeNonJson,
|
|
725
|
+
title: opts.title,
|
|
726
|
+
version: opts.version,
|
|
727
|
+
format: opts.format,
|
|
728
|
+
});
|
|
729
|
+
} catch (err) {
|
|
730
|
+
logger.error(err.message);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (opts.out) {
|
|
735
|
+
const outPath = path.resolve(opts.out);
|
|
736
|
+
fsExtra.outputFileSync(outPath, result.text);
|
|
737
|
+
process.stderr.write(chalk.green('✔ ') +
|
|
738
|
+
`Wrote ${chalk.cyan(opts.out)} ` +
|
|
739
|
+
chalk.gray(`(${result.summary.endpoints} endpoints, ${result.summary.operations} ops from ${result.summary.recordsKept}/${result.summary.harEntries} entries)`) + '\n');
|
|
740
|
+
} else {
|
|
741
|
+
process.stdout.write(result.text);
|
|
742
|
+
process.stderr.write(chalk.gray(
|
|
743
|
+
`# ${result.summary.endpoints} endpoints, ${result.summary.operations} ops from ${result.summary.recordsKept}/${result.summary.harEntries} HAR entries\n`
|
|
744
|
+
));
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const captureCommand = new Command('capture')
|
|
749
|
+
.description('Capture a consumer OpenAPI contract from observed traffic (currently: HAR ingest)');
|
|
750
|
+
captureCommand.addCommand(captureFromHarCommand);
|
|
751
|
+
|
|
752
|
+
// ─── verify-provider (Fix 3 — spec-vs-production conformance) ─────────────────
|
|
753
|
+
// Fires probes derived from the OpenAPI spec at the running provider and
|
|
754
|
+
// validates that every response body actually matches its documented schema.
|
|
755
|
+
// Pure local CLI work (calls the customer's service directly); no API token.
|
|
756
|
+
// Safe-by-default: only GET/HEAD/OPTIONS unless --include-mutating.
|
|
757
|
+
|
|
758
|
+
const verifyProviderCommand = new Command('verify-provider')
|
|
759
|
+
.description('Check that a running provider service matches its OpenAPI spec')
|
|
760
|
+
.requiredOption('--spec <path>', 'Path to the provider OpenAPI spec (YAML or JSON)')
|
|
761
|
+
.requiredOption('--base-url <url>', 'Base URL of the running provider, e.g. https://staging.payments.acme.com')
|
|
762
|
+
.option('--include-mutating', 'Also probe POST/PUT/PATCH/DELETE (off by default for safety)')
|
|
763
|
+
.option('--path-params <kvList>', 'Resolve path params: name=val,other=val (overrides spec examples)', collectPathParams, {})
|
|
764
|
+
.option('--header <header>', 'Extra request header to send, e.g. "Authorization: Bearer X" (repeatable)', collectHeaders, {})
|
|
765
|
+
.option('--timeout-ms <ms>', 'Per-request timeout in ms', v => parseInt(v, 10), 8000)
|
|
766
|
+
.option('--json', 'Output raw JSON instead of the human report')
|
|
767
|
+
.action(async (opts) => {
|
|
768
|
+
const { verifyProvider } = require('../core/conformance');
|
|
769
|
+
const specPath = path.resolve(opts.spec);
|
|
770
|
+
if (!fsExtra.existsSync(specPath)) {
|
|
771
|
+
logger.error(`Spec file not found: ${specPath}`);
|
|
772
|
+
process.exit(2);
|
|
773
|
+
}
|
|
774
|
+
const spinner = opts.json ? null : ora('Probing provider…').start();
|
|
775
|
+
let report;
|
|
776
|
+
try {
|
|
777
|
+
report = await verifyProvider({
|
|
778
|
+
spec: specPath,
|
|
779
|
+
baseUrl: opts.baseUrl,
|
|
780
|
+
includeMutating: !!opts.includeMutating,
|
|
781
|
+
pathParams: opts.pathParams,
|
|
782
|
+
headers: opts.header,
|
|
783
|
+
timeoutMs: opts.timeoutMs,
|
|
784
|
+
});
|
|
785
|
+
} catch (err) {
|
|
786
|
+
if (spinner) spinner.fail('Conformance run failed');
|
|
787
|
+
logger.error(err.message);
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
if (spinner) spinner.stop();
|
|
791
|
+
|
|
792
|
+
if (opts.json) {
|
|
793
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
794
|
+
process.exit(report.summary.fail + report.summary.error > 0 ? 1 : 0);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Human report.
|
|
798
|
+
process.stdout.write('\n' + chalk.bold(' Provider conformance — spec vs ') + chalk.cyan(opts.baseUrl) + '\n');
|
|
799
|
+
process.stdout.write(' ' + '─'.repeat(60) + '\n');
|
|
800
|
+
for (const r of report.results) {
|
|
801
|
+
const label = `${r.method.padEnd(6)} ${r.routePath}`;
|
|
802
|
+
const tag =
|
|
803
|
+
r.status === 'PASS' ? chalk.green(' PASS ') :
|
|
804
|
+
r.status === 'FAIL' ? chalk.red(' FAIL ') :
|
|
805
|
+
r.status === 'ERROR' ? chalk.red(' ERROR ') :
|
|
806
|
+
chalk.yellow(' SKIP ');
|
|
807
|
+
process.stdout.write(` ${tag} ${label}` +
|
|
808
|
+
(r.httpStatus ? chalk.gray(` (${r.httpStatus})`) : '') + '\n');
|
|
809
|
+
if (r.status === 'FAIL' && r.mismatches && r.mismatches.length > 0) {
|
|
810
|
+
for (const m of r.mismatches.slice(0, 5)) {
|
|
811
|
+
process.stdout.write(chalk.gray(` ${m.path || '(root)'}: ${m.message}\n`));
|
|
812
|
+
}
|
|
813
|
+
if (r.mismatches.length > 5) {
|
|
814
|
+
process.stdout.write(chalk.gray(` …and ${r.mismatches.length - 5} more\n`));
|
|
815
|
+
}
|
|
816
|
+
} else if (r.status === 'FAIL' && r.reason) {
|
|
817
|
+
process.stdout.write(chalk.gray(` ${r.reason}\n`));
|
|
818
|
+
} else if (r.status === 'ERROR') {
|
|
819
|
+
process.stdout.write(chalk.gray(` ${r.error}\n`));
|
|
820
|
+
} else if (r.status === 'SKIPPED') {
|
|
821
|
+
process.stdout.write(chalk.gray(` ${r.reason}: ${r.skipReason}\n`));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const s = report.summary;
|
|
825
|
+
process.stdout.write(' ' + '─'.repeat(60) + '\n');
|
|
826
|
+
process.stdout.write(` ${s.pass} pass · ${s.fail} fail · ${s.error} error · ${s.skipped} skip (${s.total} probes)\n\n`);
|
|
827
|
+
process.exit(s.fail + s.error > 0 ? 1 : 0);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// Repeatable --header parser: collects into a map.
|
|
831
|
+
function collectHeaders(val, acc) {
|
|
832
|
+
const idx = val.indexOf(':');
|
|
833
|
+
if (idx < 0) return acc;
|
|
834
|
+
const k = val.slice(0, idx).trim();
|
|
835
|
+
const v = val.slice(idx + 1).trim();
|
|
836
|
+
if (k) acc[k] = v;
|
|
837
|
+
return acc;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// --path-params name=val,name=val parser: merges into the accumulator so the
|
|
841
|
+
// flag can be passed multiple times.
|
|
842
|
+
function collectPathParams(val, acc) {
|
|
843
|
+
for (const pair of String(val).split(',')) {
|
|
844
|
+
const [k, ...rest] = pair.split('=');
|
|
845
|
+
if (k && k.trim()) acc[k.trim()] = rest.join('=').trim();
|
|
846
|
+
}
|
|
847
|
+
return acc;
|
|
848
|
+
}
|
|
849
|
+
|
|
692
850
|
// ─── Parent bdct command ──────────────────────────────────────────────────────
|
|
693
851
|
|
|
694
852
|
const bdct = new Command('bdct')
|
|
@@ -702,5 +860,7 @@ bdct.addCommand(listCommand);
|
|
|
702
860
|
bdct.addCommand(matrixCommand);
|
|
703
861
|
bdct.addCommand(listProvidersCommand);
|
|
704
862
|
bdct.addCommand(listConsumersCommand);
|
|
863
|
+
bdct.addCommand(captureCommand);
|
|
864
|
+
bdct.addCommand(verifyProviderCommand);
|
|
705
865
|
|
|
706
866
|
module.exports = bdct;
|
package/src/commands/init.js
CHANGED
|
@@ -25,6 +25,17 @@ const DEFAULT_SERVER = 'https://specshield.io';
|
|
|
25
25
|
|
|
26
26
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
27
27
|
|
|
28
|
+
const PLACEHOLDER = '<replace-me>';
|
|
29
|
+
|
|
30
|
+
// Skippable text fields in the interactive wizard. Empty values for these
|
|
31
|
+
// become PLACEHOLDER tokens in the generated config; non-skippable fields
|
|
32
|
+
// (kind, contractFormat, environment which has a default) are not in this list.
|
|
33
|
+
const PLACEHOLDER_FIELDS = [
|
|
34
|
+
'providerName', 'specPath',
|
|
35
|
+
'consumerName', 'consumerProvider', 'contractPath',
|
|
36
|
+
'org',
|
|
37
|
+
];
|
|
38
|
+
|
|
28
39
|
function abortIfCancelled(answers, keys) {
|
|
29
40
|
// `prompts` returns undefined values when the user hits Ctrl-C.
|
|
30
41
|
for (const k of keys) {
|
|
@@ -35,6 +46,42 @@ function abortIfCancelled(answers, keys) {
|
|
|
35
46
|
}
|
|
36
47
|
}
|
|
37
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Replace empty answers with the PLACEHOLDER token so the generated config
|
|
51
|
+
* makes the gap visible (vs writing an empty string the user might miss).
|
|
52
|
+
* Only operates on the fields listed in PLACEHOLDER_FIELDS — required
|
|
53
|
+
* non-text choices like `kind` are left untouched.
|
|
54
|
+
*/
|
|
55
|
+
function fillPlaceholders(answers) {
|
|
56
|
+
for (const f of PLACEHOLDER_FIELDS) {
|
|
57
|
+
if (answers[f] === '' || answers[f] === null || answers[f] === undefined) continue;
|
|
58
|
+
// Keep the value the user entered.
|
|
59
|
+
}
|
|
60
|
+
for (const f of PLACEHOLDER_FIELDS) {
|
|
61
|
+
if (answers[f] === '' || answers[f] === null) answers[f] = PLACEHOLDER;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk a built config object and return a list of `{ path, value }` for every
|
|
67
|
+
* leaf whose value equals PLACEHOLDER. The `path` is the dotted YAML path
|
|
68
|
+
* (e.g. `bdct.org`, `bdct.provider.spec`) — same form the user sees when
|
|
69
|
+
* editing the .specshield.yml file.
|
|
70
|
+
*/
|
|
71
|
+
function collectPlaceholders(cfg) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const walk = (obj, prefix) => {
|
|
74
|
+
if (!obj || typeof obj !== 'object') return;
|
|
75
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
76
|
+
const p = prefix ? `${prefix}.${k}` : k;
|
|
77
|
+
if (v === PLACEHOLDER) out.push({ path: p, value: v });
|
|
78
|
+
else if (v && typeof v === 'object') walk(v, p);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
walk(cfg, '');
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
38
85
|
async function validateApiKey(server, key) {
|
|
39
86
|
try {
|
|
40
87
|
const res = await axios.post(`${server.replace(/\/$/, '')}/auth/validate-api-key`,
|
|
@@ -156,12 +203,21 @@ async function interactiveFlow(detected, opts) {
|
|
|
156
203
|
const wantsProvider = k.kind === 'provider' || k.kind === 'both';
|
|
157
204
|
const wantsConsumer = k.kind === 'consumer' || k.kind === 'both';
|
|
158
205
|
|
|
206
|
+
// Optional-by-default prompts. Pressing Enter on any text field is OK
|
|
207
|
+
// and produces a "<replace-me>" placeholder in the generated config —
|
|
208
|
+
// surfaced at the end of the wizard and refused by any bdct command
|
|
209
|
+
// that later tries to use it. Lets a user explore the wizard without
|
|
210
|
+
// having to know their org key or provider name up front.
|
|
211
|
+
const skipHint = chalk.gray('(press Enter to skip — fill in later)');
|
|
212
|
+
|
|
159
213
|
if (wantsProvider) {
|
|
160
214
|
const provQs = [
|
|
161
|
-
{ type: 'text', name: 'providerName',
|
|
162
|
-
|
|
163
|
-
initial: detected.
|
|
164
|
-
|
|
215
|
+
{ type: 'text', name: 'providerName',
|
|
216
|
+
message: `Provider name ${skipHint}`,
|
|
217
|
+
initial: detected.serviceName },
|
|
218
|
+
{ type: 'text', name: 'specPath',
|
|
219
|
+
message: `Path to provider OpenAPI spec ${skipHint}`,
|
|
220
|
+
initial: detected.spec || 'openapi.yaml',
|
|
165
221
|
},
|
|
166
222
|
];
|
|
167
223
|
const r = await prompts(provQs);
|
|
@@ -171,15 +227,14 @@ async function interactiveFlow(detected, opts) {
|
|
|
171
227
|
|
|
172
228
|
if (wantsConsumer) {
|
|
173
229
|
const consQs = [
|
|
174
|
-
{ type: 'text', name: 'consumerName',
|
|
230
|
+
{ type: 'text', name: 'consumerName',
|
|
231
|
+
message: `Consumer name ${skipHint}`,
|
|
175
232
|
initial: detected.serviceName },
|
|
176
|
-
{ type: 'text', name: 'consumerProvider',
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
initial: 'contracts/contract.yaml',
|
|
181
|
-
validate: (v) => v ? true : 'Required',
|
|
182
|
-
},
|
|
233
|
+
{ type: 'text', name: 'consumerProvider',
|
|
234
|
+
message: `Provider this consumer talks to ${skipHint}` },
|
|
235
|
+
{ type: 'text', name: 'contractPath',
|
|
236
|
+
message: `Path to consumer contract ${skipHint}`,
|
|
237
|
+
initial: 'contracts/contract.yaml' },
|
|
183
238
|
{ type: 'select', name: 'contractFormat', message: 'Contract format',
|
|
184
239
|
choices: [
|
|
185
240
|
{ title: 'OpenAPI', value: 'OPENAPI' },
|
|
@@ -243,8 +298,8 @@ async function interactiveFlow(detected, opts) {
|
|
|
243
298
|
});
|
|
244
299
|
abortIfCancelled(r, ['org']);
|
|
245
300
|
if (r.org === '__manual__') {
|
|
246
|
-
const m = await prompts({ type: 'text', name: 'org',
|
|
247
|
-
|
|
301
|
+
const m = await prompts({ type: 'text', name: 'org',
|
|
302
|
+
message: `Org key ${skipHint}` });
|
|
248
303
|
abortIfCancelled(m, ['org']);
|
|
249
304
|
answers.org = m.org;
|
|
250
305
|
} else {
|
|
@@ -252,8 +307,8 @@ async function interactiveFlow(detected, opts) {
|
|
|
252
307
|
}
|
|
253
308
|
} else {
|
|
254
309
|
const r = await prompts({
|
|
255
|
-
type: 'text', name: 'org',
|
|
256
|
-
|
|
310
|
+
type: 'text', name: 'org',
|
|
311
|
+
message: `Org key ${skipHint}`,
|
|
257
312
|
});
|
|
258
313
|
abortIfCancelled(r, ['org']);
|
|
259
314
|
answers.org = r.org;
|
|
@@ -409,6 +464,11 @@ const initCommand = new Command('init')
|
|
|
409
464
|
if (answers === null) return; // user said "don't overwrite"
|
|
410
465
|
}
|
|
411
466
|
|
|
467
|
+
// Empty answers from the interactive flow → "<replace-me>" placeholders.
|
|
468
|
+
// The end-of-wizard summary lists every placeholder by path so users
|
|
469
|
+
// don't accidentally commit them.
|
|
470
|
+
fillPlaceholders(answers);
|
|
471
|
+
|
|
412
472
|
const cfg = buildConfig(answers, detected);
|
|
413
473
|
const yaml = render(cfg);
|
|
414
474
|
|
|
@@ -435,10 +495,29 @@ const initCommand = new Command('init')
|
|
|
435
495
|
providerName: answers.providerName,
|
|
436
496
|
consumerName: answers.consumerName,
|
|
437
497
|
providerForConsumer: answers.consumerProvider,
|
|
498
|
+
org: answers.org,
|
|
499
|
+
specPath: answers.specPath,
|
|
500
|
+
contractPath: answers.contractPath,
|
|
501
|
+
environment: answers.environment,
|
|
438
502
|
}, cwd);
|
|
439
503
|
ok(`Wrote ${chalk.white(path.relative(cwd, workflowPath))}`);
|
|
440
504
|
}
|
|
441
505
|
|
|
506
|
+
// Placeholders summary — if the user pressed Enter on any prompt,
|
|
507
|
+
// surface what's still missing so they can fill it in before running
|
|
508
|
+
// any bdct command. Pre-flight checks in bdct.js will block commands
|
|
509
|
+
// that hit a literal "<replace-me>" value at runtime.
|
|
510
|
+
const placeholders = collectPlaceholders(cfg);
|
|
511
|
+
if (placeholders.length > 0) {
|
|
512
|
+
fmtSection('Placeholders to fill in');
|
|
513
|
+
placeholders.forEach(p =>
|
|
514
|
+
warn(`${chalk.yellow(p.path)} = ${chalk.gray('<replace-me>')}`));
|
|
515
|
+
warn(chalk.yellow(
|
|
516
|
+
`Edit ${path.relative(cwd, target) || '.specshield.yml'} ` +
|
|
517
|
+
`to replace ${placeholders.length} placeholder${placeholders.length === 1 ? '' : 's'} ` +
|
|
518
|
+
`before running bdct commands.`));
|
|
519
|
+
}
|
|
520
|
+
|
|
442
521
|
fmtSection('Next steps');
|
|
443
522
|
if (answers.kind !== 'skip') {
|
|
444
523
|
info(`Try: ${chalk.white('specshield bdct list-providers')}`);
|
|
@@ -449,3 +528,6 @@ const initCommand = new Command('init')
|
|
|
449
528
|
});
|
|
450
529
|
|
|
451
530
|
module.exports = initCommand;
|
|
531
|
+
|
|
532
|
+
// Exposed for unit tests — keep usage internal to the init module otherwise.
|
|
533
|
+
module.exports.__test__ = { fillPlaceholders, collectPlaceholders, PLACEHOLDER };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `specshield whoami` — print the active customer's identity and the list of
|
|
5
|
+
* organizations they can use as `--org` values. Reuses /auth/validate-api-key
|
|
6
|
+
* (for the customer summary) and /me/orgs (for the org list) — the same two
|
|
7
|
+
* endpoints the init wizard hits.
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: the dashboard now surfaces orgKey, but terminal-native users
|
|
10
|
+
* and CI engineers shouldn't have to open a browser to discover what their
|
|
11
|
+
* org_key is. This command makes it a one-line lookup.
|
|
12
|
+
*
|
|
13
|
+
* Resolves the API token the same way every other command does:
|
|
14
|
+
* --api-token flag > $SPECSHIELD_API_KEY > stored ~/.specshield/config.json
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { Command } = require('commander');
|
|
18
|
+
const chalk = require('chalk');
|
|
19
|
+
const axios = require('axios');
|
|
20
|
+
const logger = require('../utils/logger');
|
|
21
|
+
const { getStoredApiKey } = require('../config/localConfig');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SERVER = 'https://specshield.io';
|
|
24
|
+
|
|
25
|
+
const whoami = new Command('whoami');
|
|
26
|
+
|
|
27
|
+
whoami
|
|
28
|
+
.description('Show the signed-in customer and the orgKeys you can use as --org')
|
|
29
|
+
.option('--api-token <key>', 'Override stored / env API token for this call')
|
|
30
|
+
.option('--server <url>', 'Override the SpecShield server URL', DEFAULT_SERVER)
|
|
31
|
+
.option('--json', 'Output machine-readable JSON instead of a table')
|
|
32
|
+
.action(async (opts) => {
|
|
33
|
+
const token = opts.apiToken
|
|
34
|
+
|| process.env.SPECSHIELD_API_KEY
|
|
35
|
+
|| (await getStoredApiKey());
|
|
36
|
+
if (!token) {
|
|
37
|
+
logger.error('Not logged in. Run: specshield login --api-key <KEY>');
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const server = (opts.server || DEFAULT_SERVER).replace(/\/$/, '');
|
|
42
|
+
const headers = { 'X-Api-Key': token, 'X-SpecShield-Client': 'cli' };
|
|
43
|
+
|
|
44
|
+
let me, orgs;
|
|
45
|
+
try {
|
|
46
|
+
const meRes = await axios.post(`${server}/auth/validate-api-key`, {}, { headers, timeout: 8000 });
|
|
47
|
+
if (!meRes.data || !meRes.data.valid) {
|
|
48
|
+
logger.error('API key did not validate against ' + server);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
me = meRes.data;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logger.error(err.response
|
|
54
|
+
? `Failed to validate token: ${err.response.status} ${JSON.stringify(err.response.data)}`
|
|
55
|
+
: `Could not reach ${server}: ${err.message}`);
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const orgsRes = await axios.get(`${server}/me/orgs`, { headers, timeout: 8000 });
|
|
61
|
+
orgs = Array.isArray(orgsRes.data) ? orgsRes.data : (orgsRes.data?.orgs || []);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Org fetch is best-effort; the customer info already printed below
|
|
64
|
+
// is the more important part.
|
|
65
|
+
orgs = [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (opts.json) {
|
|
69
|
+
process.stdout.write(JSON.stringify({
|
|
70
|
+
customer: { name: me.name, email: me.email, plan: me.plan, customerId: me.customerId },
|
|
71
|
+
server,
|
|
72
|
+
orgs: orgs.map(o => ({ orgKey: o.orgKey, name: o.name, role: o.myRole || o.role || null })),
|
|
73
|
+
}, null, 2) + '\n');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Human-readable output. Two sections — identity, then orgs table.
|
|
78
|
+
process.stdout.write('\n');
|
|
79
|
+
process.stdout.write(` ${chalk.bold('Logged in as:')} ${me.name || '(no name)'} `);
|
|
80
|
+
if (me.email) process.stdout.write(chalk.gray(`(${me.email})`));
|
|
81
|
+
process.stdout.write(` ${chalk.gray('· plan:')} ${chalk.cyan(me.plan || 'FREE')}\n`);
|
|
82
|
+
process.stdout.write(` ${chalk.gray('Server:')} ${server}\n`);
|
|
83
|
+
process.stdout.write('\n');
|
|
84
|
+
|
|
85
|
+
if (orgs.length === 0) {
|
|
86
|
+
process.stdout.write(chalk.yellow(' You are not a member of any organization yet.\n'));
|
|
87
|
+
process.stdout.write(chalk.gray(` Create one at ${server}/account/team to get an org_key.\n\n`));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
process.stdout.write(` ${chalk.bold('Organizations you can use as --org:')}\n`);
|
|
92
|
+
process.stdout.write(chalk.gray(' ─────────────────────────────────────────────────────\n'));
|
|
93
|
+
const widestKey = Math.max(...orgs.map(o => (o.orgKey || '').length), 8);
|
|
94
|
+
for (const o of orgs) {
|
|
95
|
+
const key = (o.orgKey || '').padEnd(widestKey);
|
|
96
|
+
const role = o.myRole || o.role || '';
|
|
97
|
+
process.stdout.write(
|
|
98
|
+
` ${chalk.cyan(key)} ${o.name || ''}` +
|
|
99
|
+
(role ? ` ${chalk.gray('(' + role + ')')}` : '') + '\n');
|
|
100
|
+
}
|
|
101
|
+
process.stdout.write('\n');
|
|
102
|
+
process.stdout.write(chalk.gray(` Tip: paste an org_key into --org on CLI commands or bdct.org in .specshield.yml\n\n`));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
module.exports = whoami;
|
package/src/core/configWriter.js
CHANGED
|
@@ -137,10 +137,32 @@ function writeProjectConfig(config, cwd = process.cwd()) {
|
|
|
137
137
|
/**
|
|
138
138
|
* Render a starter GitHub Actions workflow that uses
|
|
139
139
|
* `specshield26/bdct-action@v1`. Optional output of the wizard.
|
|
140
|
+
*
|
|
141
|
+
* Every input forwarded to the action MUST be present in the rendered YAML
|
|
142
|
+
* — including org. A missing org renders `--org ""` on the CLI invocation
|
|
143
|
+
* and the action fails with a cryptic "expected value" error from commander.
|
|
144
|
+
*
|
|
145
|
+
* spec/contract paths come from detection (or the user's --spec / --contract
|
|
146
|
+
* flag). They are NOT hardcoded — a previous version assumed every project
|
|
147
|
+
* stored its spec at `api/openapi.yaml`, which broke every project that
|
|
148
|
+
* keeps the spec at the repo root.
|
|
140
149
|
*/
|
|
141
|
-
function renderWorkflow({
|
|
150
|
+
function renderWorkflow({
|
|
151
|
+
kind,
|
|
152
|
+
providerName,
|
|
153
|
+
consumerName,
|
|
154
|
+
providerForConsumer,
|
|
155
|
+
org,
|
|
156
|
+
specPath,
|
|
157
|
+
contractPath,
|
|
158
|
+
environment,
|
|
159
|
+
}) {
|
|
142
160
|
const isProvider = kind === 'provider' || kind === 'both';
|
|
143
161
|
const isConsumer = kind === 'consumer' || kind === 'both';
|
|
162
|
+
const env = environment || 'production';
|
|
163
|
+
const orgLine = org ? ` org: ${org}` : ' org: <replace-me>';
|
|
164
|
+
const spec = specPath || 'openapi.yaml';
|
|
165
|
+
const contract = contractPath || 'contracts/contract.yaml';
|
|
144
166
|
|
|
145
167
|
const lines = [
|
|
146
168
|
'# .github/workflows/specshield-bdct.yml',
|
|
@@ -163,10 +185,11 @@ function renderWorkflow({ kind, providerName, consumerName, providerForConsumer
|
|
|
163
185
|
' - uses: specshield26/bdct-action@v1',
|
|
164
186
|
' with:',
|
|
165
187
|
' command: publish-provider',
|
|
188
|
+
orgLine,
|
|
166
189
|
` provider: ${providerName}`,
|
|
167
190
|
' version: ${{ github.sha }}',
|
|
168
|
-
|
|
169
|
-
|
|
191
|
+
` spec: ${spec}`,
|
|
192
|
+
` env: ${env}`,
|
|
170
193
|
' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
|
|
171
194
|
'',
|
|
172
195
|
' gate:',
|
|
@@ -176,9 +199,10 @@ function renderWorkflow({ kind, providerName, consumerName, providerForConsumer
|
|
|
176
199
|
' - uses: specshield26/bdct-action@v1',
|
|
177
200
|
' with:',
|
|
178
201
|
' command: can-i-deploy',
|
|
202
|
+
orgLine,
|
|
179
203
|
` service: ${providerName}`,
|
|
180
204
|
' version: ${{ github.sha }}',
|
|
181
|
-
|
|
205
|
+
` env: ${env}`,
|
|
182
206
|
' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
|
|
183
207
|
'',
|
|
184
208
|
);
|
|
@@ -193,10 +217,11 @@ function renderWorkflow({ kind, providerName, consumerName, providerForConsumer
|
|
|
193
217
|
' - uses: specshield26/bdct-action@v1',
|
|
194
218
|
' with:',
|
|
195
219
|
' command: publish-consumer',
|
|
220
|
+
orgLine,
|
|
196
221
|
` consumer: ${consumerName}`,
|
|
197
222
|
` provider: ${providerForConsumer}`,
|
|
198
223
|
' version: ${{ github.sha }}',
|
|
199
|
-
|
|
224
|
+
` contract: ${contract}`,
|
|
200
225
|
' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
|
|
201
226
|
'',
|
|
202
227
|
);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-call orchestrator for `specshield bdct verify-provider` — the
|
|
5
|
+
* "spec-vs-production conformance" check (Fix 3 of the BDCT fidelity
|
|
6
|
+
* roadmap).
|
|
7
|
+
*
|
|
8
|
+
* Loads + dereferences an OpenAPI spec, derives a probe list (safe methods
|
|
9
|
+
* only by default), fires the probes at the running provider, validates
|
|
10
|
+
* each response body against the spec's schema for that status, and
|
|
11
|
+
* returns a structured result + summary.
|
|
12
|
+
*
|
|
13
|
+
* Defaults are deliberately conservative — this tool is pointed at REAL
|
|
14
|
+
* customer services (typically staging, sometimes prod), so:
|
|
15
|
+
*
|
|
16
|
+
* - Only GET / HEAD / OPTIONS unless `includeMutating: true`.
|
|
17
|
+
* - Probes whose path params can't be resolved are SKIPPED, not guessed.
|
|
18
|
+
* - Network errors are reported as ERROR results — never thrown — so a
|
|
19
|
+
* single flaky endpoint doesn't kill the whole run.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const axios = require('axios');
|
|
23
|
+
const SwaggerParser = require('@apidevtools/swagger-parser');
|
|
24
|
+
const { buildProbes } = require('./probeBuilder');
|
|
25
|
+
const { runProbes } = require('./runner');
|
|
26
|
+
|
|
27
|
+
async function verifyProvider(opts) {
|
|
28
|
+
const spec = await SwaggerParser.dereference(opts.spec);
|
|
29
|
+
const probes = buildProbes(spec, { includeMutating: !!opts.includeMutating });
|
|
30
|
+
const http = opts.http || defaultHttpAdapter;
|
|
31
|
+
return runProbes(spec, probes, {
|
|
32
|
+
baseUrl: opts.baseUrl,
|
|
33
|
+
pathParams: opts.pathParams,
|
|
34
|
+
headers: opts.headers,
|
|
35
|
+
timeoutMs: opts.timeoutMs,
|
|
36
|
+
http,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Default HTTP adapter (axios). Returns { status, body } and rejects only
|
|
41
|
+
* on connection-level failures — HTTP error statuses come back as data. */
|
|
42
|
+
async function defaultHttpAdapter(method, url, { headers, timeoutMs }) {
|
|
43
|
+
const res = await axios.request({
|
|
44
|
+
method, url, headers, timeout: timeoutMs,
|
|
45
|
+
validateStatus: () => true, // never throw on 4xx/5xx; the validator decides
|
|
46
|
+
responseType: 'json',
|
|
47
|
+
transitional: { silentJSONParsing: true, forcedJSONParsing: true },
|
|
48
|
+
});
|
|
49
|
+
return { status: res.status, body: res.data };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { verifyProvider, defaultHttpAdapter };
|