specshield 3.2.1 → 3.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specshield",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
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": {
@@ -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
  }
@@ -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', 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',
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', message: 'Consumer name',
230
+ { type: 'text', name: 'consumerName',
231
+ message: `Consumer name ${skipHint}`,
175
232
  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
- },
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', message: 'Org key',
247
- validate: (v) => v ? true : 'Required' });
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', message: 'Org key',
256
- validate: (v) => v ? true : 'Required',
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 };
@@ -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({ kind, providerName, consumerName, providerForConsumer }) {
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
- ' spec: api/openapi.yaml',
169
- ' env: production',
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
- ' env: production',
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
- ' contract: contracts/contract.yaml',
224
+ ` contract: ${contract}`,
200
225
  ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
201
226
  '',
202
227
  );
@@ -157,6 +157,25 @@ function applyBdctDefaults(opts, command, { cwd = process.cwd() } = {}) {
157
157
  opts[f] = (f === 'spec' || f === 'contract') ? resolvePath(def) : def;
158
158
  }
159
159
 
160
+ // Placeholder check — `<replace-me>` is the marker `specshield init` writes
161
+ // for fields the user skipped during the wizard. Letting it flow through
162
+ // would send the literal string to the backend, producing confusing 4xx
163
+ // errors. Refuse with a message that points at the file and field.
164
+ const placeholders = FIELDS
165
+ .filter(f => opts[f] === '<replace-me>')
166
+ .map(f => '--' + f.replace(/[A-Z]/g, m => '-' + m.toLowerCase()));
167
+ if (placeholders.length > 0) {
168
+ const where = cfg._file ? cfg._file : '(no config file found)';
169
+ const err = new Error(
170
+ `The following value${placeholders.length === 1 ? ' is' : 's are'} still set to ` +
171
+ `the "<replace-me>" placeholder written by \`specshield init\`: ` +
172
+ placeholders.join(', ') + '\n' +
173
+ `Edit ${where} (or pass real values as CLI flags) before re-running.`);
174
+ err.code = 'UNRESOLVED_PLACEHOLDER';
175
+ err.placeholders = placeholders;
176
+ throw err;
177
+ }
178
+
160
179
  // Required-field check.
161
180
  const required = REQUIRED_FIELDS[command] || [];
162
181
  const missing = required.filter(k => !opts[k]);