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.
@@ -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
- .requiredOption('--spec <path>', 'Path to provider spec file (YAML or JSON)')
103
- .requiredOption('--provider <name>', 'Provider service name')
104
- .requiredOption('--version <ver>', 'Provider version tag')
105
- .requiredOption('--org <key>', 'Organization key')
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', 'Output raw JSON')
109
- .option('--server <url>', 'SpecShield 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
- .requiredOption('--contract <path>', 'Path to consumer contract file (OpenAPI YAML/JSON or Pact JSON)')
172
- .requiredOption('--consumer <name>', 'Consumer service name')
173
- .requiredOption('--provider <name>', 'Provider service name')
174
- .requiredOption('--version <ver>', 'Consumer version tag')
175
- .requiredOption('--org <key>', 'Organization key')
176
- .option('--format <fmt>', 'Contract format: OPENAPI | PACT', 'OPENAPI')
177
- .option('--json', 'Output raw JSON')
178
- .option('--server <url>', 'SpecShield 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
- .requiredOption('--consumer <name>', 'Consumer service name')
244
- .requiredOption('--provider <name>', 'Provider service name')
245
- .requiredOption('--consumer-version <ver>', 'Consumer version to verify')
246
- .requiredOption('--provider-version <ver>', 'Provider version to verify against')
247
- .requiredOption('--org <key>', 'Organization key')
248
- .option('--env <environment>', 'Environment label')
249
- .option('--json', 'Output raw JSON')
250
- .option('--server <url>', 'SpecShield server URL')
251
- .option('--api-token <token>', 'API 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
- return;
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
- .requiredOption('--service <name>', 'Service name (consumer or provider)')
331
- .requiredOption('--version <ver>', 'Service version to check')
332
- .requiredOption('--org <key>', 'Organization key')
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', 'Output raw JSON')
335
- .option('--server <url>', 'SpecShield 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
- return;
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
- .requiredOption('--org <key>', 'Organization key')
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
- .requiredOption('--org <key>', 'Organization key')
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
- .requiredOption('--org <key>', 'Organization key')
566
+ .option('--org <key>', 'Organization key')
542
567
  .option('--provider <name>', 'Filter by provider service name')
543
- .option('--json', 'Output raw JSON')
544
- .option('--server <url>', 'SpecShield 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
- .requiredOption('--org <key>', 'Organization key')
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', 'Output raw JSON')
608
- .option('--server <url>', 'SpecShield 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;