specshield 3.0.0 → 3.1.2
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 +277 -46
- package/package.json +34 -33
- package/src/cli.js +2 -0
- package/src/commands/bdct.js +71 -44
- package/src/commands/init.js +399 -0
- package/src/core/configWriter.js +221 -0
- package/src/core/projectConfig.js +189 -0
- package/src/core/projectDetect.js +180 -0
package/src/commands/bdct.js
CHANGED
|
@@ -7,6 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const fsExtra = require('fs-extra');
|
|
8
8
|
const logger = require('../utils/logger');
|
|
9
9
|
const { getStoredApiKey } = require('../config/localConfig');
|
|
10
|
+
const { applyBdctDefaults } = require('../core/projectConfig');
|
|
10
11
|
const {
|
|
11
12
|
publishProviderSpec,
|
|
12
13
|
publishConsumerContract,
|
|
@@ -31,6 +32,24 @@ function requireToken(token) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Fill missing CLI options from `.specshield.yml` if one is present, then
|
|
37
|
+
* verify every required field for `command` is set. Exits 2 with a friendly
|
|
38
|
+
* message if anything is missing.
|
|
39
|
+
*/
|
|
40
|
+
function withProjectDefaults(opts, command) {
|
|
41
|
+
try {
|
|
42
|
+
applyBdctDefaults(opts, command);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.code === 'MISSING_REQUIRED_OPTIONS') {
|
|
45
|
+
logger.error(err.message);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
return opts;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
function fmtDate(iso) {
|
|
35
54
|
if (!iso) return chalk.gray('—');
|
|
36
55
|
try {
|
|
@@ -99,16 +118,17 @@ function printTable(headers, rows) {
|
|
|
99
118
|
|
|
100
119
|
const publishProviderCommand = new Command('publish-provider')
|
|
101
120
|
.description('Publish a provider OpenAPI spec to the BDCT registry')
|
|
102
|
-
.
|
|
103
|
-
.
|
|
104
|
-
.
|
|
105
|
-
.
|
|
121
|
+
.option('--spec <path>', 'Path to provider spec file (YAML or JSON)')
|
|
122
|
+
.option('--provider <name>', 'Provider service name')
|
|
123
|
+
.option('--version <ver>', 'Provider version tag')
|
|
124
|
+
.option('--org <key>', 'Organization key')
|
|
106
125
|
.option('--env <environment>', 'Environment label (e.g. staging, production)')
|
|
107
126
|
.option('--branch <branch>', 'Git branch name')
|
|
108
|
-
.option('--json',
|
|
109
|
-
.option('--server <url>',
|
|
127
|
+
.option('--json', 'Output raw JSON')
|
|
128
|
+
.option('--server <url>', 'SpecShield server URL')
|
|
110
129
|
.option('--api-token <token>', 'API token (overrides env / stored config)')
|
|
111
130
|
.action(async (opts) => {
|
|
131
|
+
withProjectDefaults(opts, 'publish-provider');
|
|
112
132
|
const token = await resolveApiToken(opts);
|
|
113
133
|
requireToken(token);
|
|
114
134
|
|
|
@@ -168,16 +188,17 @@ const publishProviderCommand = new Command('publish-provider')
|
|
|
168
188
|
|
|
169
189
|
const publishConsumerCommand = new Command('publish-consumer')
|
|
170
190
|
.description('Publish a consumer contract to the BDCT registry')
|
|
171
|
-
.
|
|
172
|
-
.
|
|
173
|
-
.
|
|
174
|
-
.
|
|
175
|
-
.
|
|
176
|
-
.option('--format <fmt>',
|
|
177
|
-
.option('--json',
|
|
178
|
-
.option('--server <url>',
|
|
191
|
+
.option('--contract <path>', 'Path to consumer contract file (OpenAPI YAML/JSON or Pact JSON)')
|
|
192
|
+
.option('--consumer <name>', 'Consumer service name')
|
|
193
|
+
.option('--provider <name>', 'Provider service name')
|
|
194
|
+
.option('--version <ver>', 'Consumer version tag')
|
|
195
|
+
.option('--org <key>', 'Organization key')
|
|
196
|
+
.option('--format <fmt>', 'Contract format: OPENAPI | PACT', 'OPENAPI')
|
|
197
|
+
.option('--json', 'Output raw JSON')
|
|
198
|
+
.option('--server <url>', 'SpecShield server URL')
|
|
179
199
|
.option('--api-token <token>', 'API token (overrides env / stored config)')
|
|
180
200
|
.action(async (opts) => {
|
|
201
|
+
withProjectDefaults(opts, 'publish-consumer');
|
|
181
202
|
const token = await resolveApiToken(opts);
|
|
182
203
|
requireToken(token);
|
|
183
204
|
|
|
@@ -240,16 +261,17 @@ const publishConsumerCommand = new Command('publish-consumer')
|
|
|
240
261
|
|
|
241
262
|
const verifyCommand = new Command('verify')
|
|
242
263
|
.description('Verify consumer-provider contract compatibility')
|
|
243
|
-
.
|
|
244
|
-
.
|
|
245
|
-
.
|
|
246
|
-
.
|
|
247
|
-
.
|
|
248
|
-
.option('--env <environment>',
|
|
249
|
-
.option('--json',
|
|
250
|
-
.option('--server <url>',
|
|
251
|
-
.option('--api-token <token>',
|
|
264
|
+
.option('--consumer <name>', 'Consumer service name')
|
|
265
|
+
.option('--provider <name>', 'Provider service name')
|
|
266
|
+
.option('--consumer-version <ver>', 'Consumer version to verify')
|
|
267
|
+
.option('--provider-version <ver>', 'Provider version to verify against')
|
|
268
|
+
.option('--org <key>', 'Organization key')
|
|
269
|
+
.option('--env <environment>', 'Environment label')
|
|
270
|
+
.option('--json', 'Output raw JSON')
|
|
271
|
+
.option('--server <url>', 'SpecShield server URL')
|
|
272
|
+
.option('--api-token <token>', 'API token')
|
|
252
273
|
.action(async (opts) => {
|
|
274
|
+
withProjectDefaults(opts, 'verify');
|
|
253
275
|
const token = await resolveApiToken(opts);
|
|
254
276
|
requireToken(token);
|
|
255
277
|
|
|
@@ -266,14 +288,14 @@ const verifyCommand = new Command('verify')
|
|
|
266
288
|
});
|
|
267
289
|
if (spinner) spinner.stop();
|
|
268
290
|
|
|
291
|
+
const status = String(result.status || result.result || '').toUpperCase();
|
|
292
|
+
const success = status === 'COMPATIBLE';
|
|
293
|
+
|
|
269
294
|
if (opts.json) {
|
|
270
295
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
271
|
-
|
|
296
|
+
process.exit(success ? 0 : 1);
|
|
272
297
|
}
|
|
273
298
|
|
|
274
|
-
const status = String(result.status || result.result || '').toUpperCase();
|
|
275
|
-
const success = status === 'COMPATIBLE';
|
|
276
|
-
|
|
277
299
|
process.stdout.write('\n');
|
|
278
300
|
if (success) {
|
|
279
301
|
process.stdout.write(chalk.green.bold(' ✔ COMPATIBLE') + '\n');
|
|
@@ -327,14 +349,15 @@ const verifyCommand = new Command('verify')
|
|
|
327
349
|
|
|
328
350
|
const canIDeployCommand = new Command('can-i-deploy')
|
|
329
351
|
.description('Check if a service version is safe to deploy')
|
|
330
|
-
.
|
|
331
|
-
.
|
|
332
|
-
.
|
|
352
|
+
.option('--service <name>', 'Service name (consumer or provider)')
|
|
353
|
+
.option('--version <ver>', 'Service version to check')
|
|
354
|
+
.option('--org <key>', 'Organization key')
|
|
333
355
|
.option('--env <environment>', 'Target environment (e.g. qa, staging, production)')
|
|
334
|
-
.option('--json',
|
|
335
|
-
.option('--server <url>',
|
|
356
|
+
.option('--json', 'Output raw JSON')
|
|
357
|
+
.option('--server <url>', 'SpecShield server URL')
|
|
336
358
|
.option('--api-token <token>', 'API token')
|
|
337
359
|
.action(async (opts) => {
|
|
360
|
+
withProjectDefaults(opts, 'can-i-deploy');
|
|
338
361
|
const token = await resolveApiToken(opts);
|
|
339
362
|
requireToken(token);
|
|
340
363
|
|
|
@@ -349,14 +372,14 @@ const canIDeployCommand = new Command('can-i-deploy')
|
|
|
349
372
|
});
|
|
350
373
|
if (spinner) spinner.stop();
|
|
351
374
|
|
|
375
|
+
const deployable = result.deployable ?? result.allowed ?? false;
|
|
376
|
+
const envLabel = opts.env ? ` in ${opts.env}` : '';
|
|
377
|
+
|
|
352
378
|
if (opts.json) {
|
|
353
379
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
354
|
-
|
|
380
|
+
process.exit(deployable ? 0 : 1);
|
|
355
381
|
}
|
|
356
382
|
|
|
357
|
-
const deployable = result.deployable ?? result.allowed ?? false;
|
|
358
|
-
const envLabel = opts.env ? ` in ${opts.env}` : '';
|
|
359
|
-
|
|
360
383
|
process.stdout.write('\n');
|
|
361
384
|
if (deployable) {
|
|
362
385
|
process.stdout.write(chalk.green.bold(' ✔ PASS') + chalk.white(`: ${opts.service} v${opts.version} is deployable${envLabel}\n`));
|
|
@@ -400,7 +423,7 @@ const canIDeployCommand = new Command('can-i-deploy')
|
|
|
400
423
|
|
|
401
424
|
const listCommand = new Command('list')
|
|
402
425
|
.description('List BDCT verification history')
|
|
403
|
-
.
|
|
426
|
+
.option('--org <key>', 'Organization key')
|
|
404
427
|
.option('--consumer <name>', 'Filter by consumer service name')
|
|
405
428
|
.option('--provider <name>', 'Filter by provider service name')
|
|
406
429
|
.option('--env <environment>', 'Filter by environment')
|
|
@@ -410,6 +433,7 @@ const listCommand = new Command('list')
|
|
|
410
433
|
.option('--server <url>', 'SpecShield server URL')
|
|
411
434
|
.option('--api-token <token>', 'API token')
|
|
412
435
|
.action(async (opts) => {
|
|
436
|
+
withProjectDefaults(opts, 'list');
|
|
413
437
|
const token = await resolveApiToken(opts);
|
|
414
438
|
requireToken(token);
|
|
415
439
|
|
|
@@ -473,12 +497,13 @@ const listCommand = new Command('list')
|
|
|
473
497
|
|
|
474
498
|
const matrixCommand = new Command('matrix')
|
|
475
499
|
.description('Show ASCII compatibility matrix of consumers vs providers')
|
|
476
|
-
.
|
|
500
|
+
.option('--org <key>', 'Organization key')
|
|
477
501
|
.option('--env <environment>', 'Environment label')
|
|
478
502
|
.option('--json', 'Output raw JSON')
|
|
479
503
|
.option('--server <url>', 'SpecShield server URL')
|
|
480
504
|
.option('--api-token <token>', 'API token')
|
|
481
505
|
.action(async (opts) => {
|
|
506
|
+
withProjectDefaults(opts, 'matrix');
|
|
482
507
|
const token = await resolveApiToken(opts);
|
|
483
508
|
requireToken(token);
|
|
484
509
|
|
|
@@ -538,12 +563,13 @@ const matrixCommand = new Command('matrix')
|
|
|
538
563
|
|
|
539
564
|
const listProvidersCommand = new Command('list-providers')
|
|
540
565
|
.description('List published provider specs')
|
|
541
|
-
.
|
|
566
|
+
.option('--org <key>', 'Organization key')
|
|
542
567
|
.option('--provider <name>', 'Filter by provider service name')
|
|
543
|
-
.option('--json',
|
|
544
|
-
.option('--server <url>',
|
|
568
|
+
.option('--json', 'Output raw JSON')
|
|
569
|
+
.option('--server <url>', 'SpecShield server URL')
|
|
545
570
|
.option('--api-token <token>', 'API token')
|
|
546
571
|
.action(async (opts) => {
|
|
572
|
+
withProjectDefaults(opts, 'list-providers');
|
|
547
573
|
const token = await resolveApiToken(opts);
|
|
548
574
|
requireToken(token);
|
|
549
575
|
|
|
@@ -601,13 +627,14 @@ const listProvidersCommand = new Command('list-providers')
|
|
|
601
627
|
|
|
602
628
|
const listConsumersCommand = new Command('list-consumers')
|
|
603
629
|
.description('List published consumer contracts')
|
|
604
|
-
.
|
|
630
|
+
.option('--org <key>', 'Organization key')
|
|
605
631
|
.option('--consumer <name>', 'Filter by consumer service name')
|
|
606
632
|
.option('--provider <name>', 'Filter by provider service name')
|
|
607
|
-
.option('--json',
|
|
608
|
-
.option('--server <url>',
|
|
633
|
+
.option('--json', 'Output raw JSON')
|
|
634
|
+
.option('--server <url>', 'SpecShield server URL')
|
|
609
635
|
.option('--api-token <token>', 'API token')
|
|
610
636
|
.action(async (opts) => {
|
|
637
|
+
withProjectDefaults(opts, 'list-consumers');
|
|
611
638
|
const token = await resolveApiToken(opts);
|
|
612
639
|
requireToken(token);
|
|
613
640
|
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `specshield init` — interactive (or scriptable) wizard that detects the
|
|
5
|
+
* project context, asks the user a few questions, and writes
|
|
6
|
+
* `.specshield.yml` plus an optional starter GitHub Actions workflow.
|
|
7
|
+
*
|
|
8
|
+
* Modes:
|
|
9
|
+
* specshield init interactive (default)
|
|
10
|
+
* specshield init --no-interactive ... scriptable; fails fast on missing fields
|
|
11
|
+
* specshield init --print detect everything, print proposed YAML to stdout, write nothing
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { Command } = require('commander');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const chalk = require('chalk');
|
|
17
|
+
const prompts = require('prompts');
|
|
18
|
+
const axios = require('axios');
|
|
19
|
+
const logger = require('../utils/logger');
|
|
20
|
+
const { getStoredApiKey, setStoredApiKey } = require('../config/localConfig');
|
|
21
|
+
const { detectAll } = require('../core/projectDetect');
|
|
22
|
+
const { render, writeProjectConfig, writeWorkflow } = require('../core/configWriter');
|
|
23
|
+
|
|
24
|
+
const DEFAULT_SERVER = 'https://specshield.io';
|
|
25
|
+
|
|
26
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function abortIfCancelled(answers, keys) {
|
|
29
|
+
// `prompts` returns undefined values when the user hits Ctrl-C.
|
|
30
|
+
for (const k of keys) {
|
|
31
|
+
if (answers[k] === undefined) {
|
|
32
|
+
logger.error('Aborted.');
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function validateApiKey(server, key) {
|
|
39
|
+
try {
|
|
40
|
+
const res = await axios.post(`${server.replace(/\/$/, '')}/auth/validate-api-key`,
|
|
41
|
+
null, { headers: { 'X-Api-Key': key }, timeout: 8000 });
|
|
42
|
+
return res.data && res.data.valid ? res.data : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchOrgs(server, key) {
|
|
49
|
+
try {
|
|
50
|
+
const res = await axios.get(`${server.replace(/\/$/, '')}/me/orgs`,
|
|
51
|
+
{ headers: { 'X-Api-Key': key }, timeout: 8000 });
|
|
52
|
+
return Array.isArray(res.data) ? res.data : (res.data?.orgs || []);
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fmtSection(title) {
|
|
59
|
+
process.stdout.write('\n' + chalk.bold(title) + '\n');
|
|
60
|
+
process.stdout.write(chalk.gray(' ─────────────────────────────────────────────────────') + '\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ok(msg) { process.stdout.write(` ${chalk.green('✔')} ${msg}\n`); }
|
|
64
|
+
function info(msg) { process.stdout.write(` ${chalk.cyan('•')} ${msg}\n`); }
|
|
65
|
+
function warn(msg) { process.stdout.write(` ${chalk.yellow('!')} ${msg}\n`); }
|
|
66
|
+
|
|
67
|
+
// ─── Build the config object ───────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function buildConfig(answers, detected) {
|
|
70
|
+
const cfg = {
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
failOnBreaking: true,
|
|
73
|
+
severity: 'error',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const wantsBdct = answers.kind && answers.kind !== 'skip';
|
|
77
|
+
if (wantsBdct) {
|
|
78
|
+
cfg.bdct = {
|
|
79
|
+
org: answers.org,
|
|
80
|
+
environment: answers.environment || 'staging',
|
|
81
|
+
};
|
|
82
|
+
if (answers.server && answers.server !== DEFAULT_SERVER) {
|
|
83
|
+
cfg.bdct.server = answers.server;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (answers.kind === 'provider' || answers.kind === 'both') {
|
|
87
|
+
cfg.bdct.provider = {
|
|
88
|
+
name: answers.providerName,
|
|
89
|
+
spec: answers.specPath,
|
|
90
|
+
};
|
|
91
|
+
if (detected.branch) cfg.bdct.provider.branch = detected.branch;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (answers.kind === 'consumer' || answers.kind === 'both') {
|
|
95
|
+
cfg.bdct.consumer = {
|
|
96
|
+
name: answers.consumerName,
|
|
97
|
+
provider: answers.consumerProvider,
|
|
98
|
+
contract: answers.contractPath,
|
|
99
|
+
format: answers.contractFormat || 'OPENAPI',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (answers.specPath) {
|
|
105
|
+
cfg.github = {
|
|
106
|
+
specPath: answers.specPath,
|
|
107
|
+
failOnBreaking: true,
|
|
108
|
+
commentOnPr: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return cfg;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Interactive flow ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
async function interactiveFlow(detected, opts) {
|
|
118
|
+
fmtSection('SpecShield CLI · setup wizard');
|
|
119
|
+
|
|
120
|
+
if (detected.git.remote) ok(`Detected git repo: ${chalk.white(detected.git.remote)}`);
|
|
121
|
+
if (detected.spec) ok(`Found OpenAPI spec: ${chalk.white(detected.spec)}`);
|
|
122
|
+
if (detected.serviceName) {
|
|
123
|
+
ok(`Detected service name from ${detected.service.source}: ${chalk.white(detected.serviceName)}`);
|
|
124
|
+
}
|
|
125
|
+
if (detected.existing) warn('A .specshield.yml already exists in this directory.');
|
|
126
|
+
|
|
127
|
+
const answers = {};
|
|
128
|
+
|
|
129
|
+
if (detected.existing) {
|
|
130
|
+
const r = await prompts({
|
|
131
|
+
type: 'confirm', name: 'overwrite',
|
|
132
|
+
message: 'Overwrite the existing .specshield.yml?',
|
|
133
|
+
initial: false,
|
|
134
|
+
});
|
|
135
|
+
abortIfCancelled(r, ['overwrite']);
|
|
136
|
+
if (!r.overwrite) {
|
|
137
|
+
info('No changes written. Exiting.');
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const k = await prompts({
|
|
143
|
+
type: 'select', name: 'kind',
|
|
144
|
+
message: 'What does this project own?',
|
|
145
|
+
choices: [
|
|
146
|
+
{ title: 'Provider service (publishes a spec consumers depend on)', value: 'provider' },
|
|
147
|
+
{ title: 'Consumer integration (calls a provider\'s API)', value: 'consumer' },
|
|
148
|
+
{ title: 'Both', value: 'both' },
|
|
149
|
+
{ title: 'Skip BDCT — local compare only', value: 'skip' },
|
|
150
|
+
],
|
|
151
|
+
initial: detected.spec ? 0 : 3,
|
|
152
|
+
});
|
|
153
|
+
abortIfCancelled(k, ['kind']);
|
|
154
|
+
answers.kind = k.kind;
|
|
155
|
+
|
|
156
|
+
const wantsProvider = k.kind === 'provider' || k.kind === 'both';
|
|
157
|
+
const wantsConsumer = k.kind === 'consumer' || k.kind === 'both';
|
|
158
|
+
|
|
159
|
+
if (wantsProvider) {
|
|
160
|
+
const provQs = [
|
|
161
|
+
{ type: 'text', name: 'providerName', message: 'Provider name', initial: detected.serviceName },
|
|
162
|
+
{ type: 'text', name: 'specPath', message: 'Path to provider OpenAPI spec',
|
|
163
|
+
initial: detected.spec || 'api/openapi.yaml',
|
|
164
|
+
validate: (v) => v ? true : 'Required',
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
const r = await prompts(provQs);
|
|
168
|
+
abortIfCancelled(r, ['providerName', 'specPath']);
|
|
169
|
+
Object.assign(answers, r);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (wantsConsumer) {
|
|
173
|
+
const consQs = [
|
|
174
|
+
{ type: 'text', name: 'consumerName', message: 'Consumer name',
|
|
175
|
+
initial: detected.serviceName },
|
|
176
|
+
{ type: 'text', name: 'consumerProvider', message: 'Provider this consumer talks to',
|
|
177
|
+
validate: (v) => v ? true : 'Required',
|
|
178
|
+
},
|
|
179
|
+
{ type: 'text', name: 'contractPath', message: 'Path to consumer contract',
|
|
180
|
+
initial: 'contracts/contract.yaml',
|
|
181
|
+
validate: (v) => v ? true : 'Required',
|
|
182
|
+
},
|
|
183
|
+
{ type: 'select', name: 'contractFormat', message: 'Contract format',
|
|
184
|
+
choices: [
|
|
185
|
+
{ title: 'OpenAPI', value: 'OPENAPI' },
|
|
186
|
+
{ title: 'Pact JSON', value: 'PACT' },
|
|
187
|
+
],
|
|
188
|
+
initial: 0,
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
const r = await prompts(consQs);
|
|
192
|
+
abortIfCancelled(r, ['consumerName', 'consumerProvider', 'contractPath', 'contractFormat']);
|
|
193
|
+
Object.assign(answers, r);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (k.kind !== 'skip') {
|
|
197
|
+
// Server first (so we can validate the API key against it)
|
|
198
|
+
answers.server = opts.server || DEFAULT_SERVER;
|
|
199
|
+
|
|
200
|
+
// Authenticate / pick org
|
|
201
|
+
let token = await getStoredApiKey();
|
|
202
|
+
let validated = token ? await validateApiKey(answers.server, token) : null;
|
|
203
|
+
|
|
204
|
+
if (validated) {
|
|
205
|
+
const r = await prompts({
|
|
206
|
+
type: 'confirm', name: 'reuse',
|
|
207
|
+
message: `Use existing API key for ${chalk.white(validated.name || validated.customerId)} (${validated.plan})?`,
|
|
208
|
+
initial: true,
|
|
209
|
+
});
|
|
210
|
+
abortIfCancelled(r, ['reuse']);
|
|
211
|
+
if (!r.reuse) { token = null; validated = null; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!token) {
|
|
215
|
+
info(`Generate an API key at ${chalk.white(answers.server + '/account/keys')}`);
|
|
216
|
+
const r = await prompts({
|
|
217
|
+
type: 'password', name: 'key',
|
|
218
|
+
message: 'Paste your SpecShield API key',
|
|
219
|
+
validate: (v) => (v && v.startsWith('ss_')) ? true : 'API keys start with ss_',
|
|
220
|
+
});
|
|
221
|
+
abortIfCancelled(r, ['key']);
|
|
222
|
+
validated = await validateApiKey(answers.server, r.key);
|
|
223
|
+
if (!validated) {
|
|
224
|
+
logger.error('That API key did not validate against ' + answers.server);
|
|
225
|
+
process.exit(2);
|
|
226
|
+
}
|
|
227
|
+
await setStoredApiKey(r.key);
|
|
228
|
+
ok(`Stored API key in ~/.specshield/config.json`);
|
|
229
|
+
token = r.key;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Org key — try to autocomplete from /me/orgs
|
|
233
|
+
const orgs = await fetchOrgs(answers.server, token);
|
|
234
|
+
if (orgs.length > 0) {
|
|
235
|
+
const r = await prompts({
|
|
236
|
+
type: 'select', name: 'org',
|
|
237
|
+
message: 'Pick an organisation',
|
|
238
|
+
choices: orgs.map(o => ({
|
|
239
|
+
title: `${o.name || o.orgKey} ${chalk.gray('(' + o.orgKey + ')')}`,
|
|
240
|
+
value: o.orgKey,
|
|
241
|
+
})).concat([{ title: chalk.gray('Other (enter manually)'), value: '__manual__' }]),
|
|
242
|
+
initial: 0,
|
|
243
|
+
});
|
|
244
|
+
abortIfCancelled(r, ['org']);
|
|
245
|
+
if (r.org === '__manual__') {
|
|
246
|
+
const m = await prompts({ type: 'text', name: 'org', message: 'Org key',
|
|
247
|
+
validate: (v) => v ? true : 'Required' });
|
|
248
|
+
abortIfCancelled(m, ['org']);
|
|
249
|
+
answers.org = m.org;
|
|
250
|
+
} else {
|
|
251
|
+
answers.org = r.org;
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
const r = await prompts({
|
|
255
|
+
type: 'text', name: 'org', message: 'Org key',
|
|
256
|
+
validate: (v) => v ? true : 'Required',
|
|
257
|
+
});
|
|
258
|
+
abortIfCancelled(r, ['org']);
|
|
259
|
+
answers.org = r.org;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Default environment
|
|
263
|
+
const e = await prompts({
|
|
264
|
+
type: 'text', name: 'environment',
|
|
265
|
+
message: 'Default environment',
|
|
266
|
+
initial: detected.environment || 'staging',
|
|
267
|
+
});
|
|
268
|
+
abortIfCancelled(e, ['environment']);
|
|
269
|
+
answers.environment = e.environment;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Optional: write the starter workflow
|
|
273
|
+
if (k.kind !== 'skip') {
|
|
274
|
+
const w = await prompts({
|
|
275
|
+
type: 'confirm', name: 'workflow',
|
|
276
|
+
message: 'Also write a starter GitHub Actions workflow at .github/workflows/specshield-bdct.yml?',
|
|
277
|
+
initial: true,
|
|
278
|
+
});
|
|
279
|
+
abortIfCancelled(w, ['workflow']);
|
|
280
|
+
answers.writeWorkflow = w.workflow;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return answers;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Non-interactive flow ──────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function nonInteractiveFlow(detected, opts) {
|
|
289
|
+
const need = (name) => {
|
|
290
|
+
if (!opts[name] && !detected[name]) {
|
|
291
|
+
logger.error(`Missing --${name.replace(/[A-Z]/g, m => '-' + m.toLowerCase())} (required in --no-interactive mode).`);
|
|
292
|
+
process.exit(2);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (!opts.kind) {
|
|
297
|
+
logger.error('Missing --kind (provider | consumer | both | skip) in --no-interactive mode.');
|
|
298
|
+
process.exit(2);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const answers = {
|
|
302
|
+
kind: opts.kind,
|
|
303
|
+
server: opts.server || DEFAULT_SERVER,
|
|
304
|
+
org: opts.org,
|
|
305
|
+
environment: opts.env || detected.environment || 'staging',
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (opts.kind === 'provider' || opts.kind === 'both') {
|
|
309
|
+
answers.providerName = opts.provider || detected.serviceName;
|
|
310
|
+
answers.specPath = opts.spec || detected.spec;
|
|
311
|
+
if (!answers.providerName) need('provider');
|
|
312
|
+
if (!answers.specPath) need('spec');
|
|
313
|
+
}
|
|
314
|
+
if (opts.kind === 'consumer' || opts.kind === 'both') {
|
|
315
|
+
answers.consumerName = opts.consumer || detected.serviceName;
|
|
316
|
+
answers.consumerProvider = opts.consumerProvider;
|
|
317
|
+
answers.contractPath = opts.contract;
|
|
318
|
+
answers.contractFormat = opts.format || 'OPENAPI';
|
|
319
|
+
if (!answers.consumerName) need('consumer');
|
|
320
|
+
if (!answers.consumerProvider) need('consumerProvider');
|
|
321
|
+
if (!answers.contractPath) need('contract');
|
|
322
|
+
}
|
|
323
|
+
if (opts.kind !== 'skip' && !answers.org) need('org');
|
|
324
|
+
|
|
325
|
+
answers.writeWorkflow = !!opts.writeWorkflow;
|
|
326
|
+
return answers;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Command ───────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
const initCommand = new Command('init')
|
|
332
|
+
.description('Detect the project, write .specshield.yml and an optional GitHub workflow')
|
|
333
|
+
.option('--no-interactive', 'Run without prompts; all fields must be passed as flags')
|
|
334
|
+
.option('--print', 'Print the proposed config and exit; do not write any files')
|
|
335
|
+
.option('--force', 'Skip the overwrite confirmation if .specshield.yml exists')
|
|
336
|
+
.option('--server <url>', 'SpecShield server URL', 'https://specshield.io')
|
|
337
|
+
.option('--kind <kind>', 'provider | consumer | both | skip')
|
|
338
|
+
.option('--org <key>', 'Organization key')
|
|
339
|
+
.option('--provider <name>', 'Provider service name (when --kind=provider|both)')
|
|
340
|
+
.option('--spec <path>', 'Path to provider OpenAPI spec')
|
|
341
|
+
.option('--consumer <name>', 'Consumer service name (when --kind=consumer|both)')
|
|
342
|
+
.option('--consumer-provider <name>', 'Provider this consumer talks to')
|
|
343
|
+
.option('--contract <path>', 'Path to consumer contract')
|
|
344
|
+
.option('--format <fmt>', 'Consumer contract format: OPENAPI | PACT', 'OPENAPI')
|
|
345
|
+
.option('--env <environment>', 'Default environment')
|
|
346
|
+
.option('--write-workflow', 'Also write .github/workflows/specshield-bdct.yml')
|
|
347
|
+
.action(async (opts) => {
|
|
348
|
+
const cwd = process.cwd();
|
|
349
|
+
const detected = detectAll(cwd);
|
|
350
|
+
|
|
351
|
+
let answers;
|
|
352
|
+
if (opts.interactive === false) {
|
|
353
|
+
// In non-interactive mode, refuse to overwrite an existing config unless
|
|
354
|
+
// --force is passed. Prevents a CI script from silently clobbering a
|
|
355
|
+
// hand-edited .specshield.yml that has settings the wizard wouldn't
|
|
356
|
+
// regenerate (custom branch, different provider name, etc.).
|
|
357
|
+
if (detected.existing && !opts.force && !opts.print) {
|
|
358
|
+
logger.error(
|
|
359
|
+
'.specshield.yml already exists. Pass --force to overwrite, or remove the file first.');
|
|
360
|
+
process.exit(2);
|
|
361
|
+
}
|
|
362
|
+
answers = nonInteractiveFlow(detected, opts);
|
|
363
|
+
} else {
|
|
364
|
+
answers = await interactiveFlow(detected, opts);
|
|
365
|
+
if (answers === null) return; // user said "don't overwrite"
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const cfg = buildConfig(answers, detected);
|
|
369
|
+
const yaml = render(cfg);
|
|
370
|
+
|
|
371
|
+
if (opts.print) {
|
|
372
|
+
process.stdout.write('\n' + yaml);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
fmtSection('Writing files');
|
|
377
|
+
const target = writeProjectConfig(cfg, cwd);
|
|
378
|
+
ok(`Wrote ${chalk.white(path.relative(cwd, target) || '.specshield.yml')}`);
|
|
379
|
+
|
|
380
|
+
if (answers.writeWorkflow && answers.kind !== 'skip') {
|
|
381
|
+
const workflowPath = writeWorkflow({
|
|
382
|
+
kind: answers.kind,
|
|
383
|
+
providerName: answers.providerName,
|
|
384
|
+
consumerName: answers.consumerName,
|
|
385
|
+
providerForConsumer: answers.consumerProvider,
|
|
386
|
+
}, cwd);
|
|
387
|
+
ok(`Wrote ${chalk.white(path.relative(cwd, workflowPath))}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
fmtSection('Next steps');
|
|
391
|
+
if (answers.kind !== 'skip') {
|
|
392
|
+
info(`Try: ${chalk.white('specshield bdct list-providers')}`);
|
|
393
|
+
info(`Or: ${chalk.white('specshield bdct can-i-deploy --version $(git rev-parse HEAD)')}`);
|
|
394
|
+
}
|
|
395
|
+
info(`Docs: ${chalk.white('https://specshield.io/docs')}`);
|
|
396
|
+
process.stdout.write('\n');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
module.exports = initCommand;
|