specshield 2.0.1 → 3.1.1

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.
@@ -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;
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Render `.specshield.yml` from a structured config object.
5
+ *
6
+ * Hand-rolled YAML so we can interleave comments. The shape is small and
7
+ * stable; using `js-yaml.dump` would give us a clean file but no comments —
8
+ * comments are 80% of the value of the file for users reading it later.
9
+ *
10
+ * Schema (all keys optional except where noted):
11
+ *
12
+ * {
13
+ * schemaVersion: 1,
14
+ * failOnBreaking: true,
15
+ * severity: 'error',
16
+ * bdct: {
17
+ * org: 'acme-pay', // required
18
+ * server: 'https://specshield.io',
19
+ * environment: 'staging',
20
+ * provider: { name, spec, branch },
21
+ * consumer: { name, provider, contract, format }
22
+ * },
23
+ * github: { specPath, failOnBreaking, commentOnPr }
24
+ * }
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const HEADER = [
31
+ '# .specshield.yml — generated by `specshield init`',
32
+ '#',
33
+ '# Picked up automatically by every `specshield` invocation in this directory.',
34
+ '# CLI flags always override values in this file.',
35
+ '# Safe to commit. Never put your API key here — keep it in ~/.specshield/config.json',
36
+ '# or set SPECSHIELD_API_KEY in CI.',
37
+ '',
38
+ ];
39
+
40
+ function quoteIfNeeded(v) {
41
+ if (v === null || v === undefined) return null;
42
+ const s = String(v);
43
+ if (/^[\w./@:-]+$/.test(s)) return s; // safe bare scalar
44
+ return JSON.stringify(s); // double-quoted, JSON is YAML-safe
45
+ }
46
+
47
+ function emit(lines, indent, key, value) {
48
+ if (value === null || value === undefined || value === '') return;
49
+ lines.push(`${' '.repeat(indent)}${key}: ${quoteIfNeeded(value)}`);
50
+ }
51
+
52
+ function emitBoolean(lines, indent, key, value) {
53
+ if (value === null || value === undefined) return;
54
+ lines.push(`${' '.repeat(indent)}${key}: ${value ? 'true' : 'false'}`);
55
+ }
56
+
57
+ function emitComment(lines, indent, text) {
58
+ lines.push(`${' '.repeat(indent)}# ${text}`);
59
+ }
60
+
61
+ function blank(lines) {
62
+ if (lines[lines.length - 1] !== '') lines.push('');
63
+ }
64
+
65
+ /**
66
+ * Build the YAML string. Returns the final document including a trailing newline.
67
+ */
68
+ function render(config = {}) {
69
+ const lines = [...HEADER];
70
+
71
+ emit(lines, 0, 'schemaVersion', config.schemaVersion ?? 1);
72
+ blank(lines);
73
+
74
+ // ── Local compare defaults ────────────────────────────────────────────
75
+ emitComment(lines, 0, 'Local compare defaults — used by `specshield compare`.');
76
+ emitBoolean(lines, 0, 'failOnBreaking', config.failOnBreaking ?? true);
77
+ emit (lines, 0, 'severity', config.severity ?? 'error');
78
+ blank(lines);
79
+
80
+ // ── BDCT section ──────────────────────────────────────────────────────
81
+ if (config.bdct && Object.keys(config.bdct).length > 0) {
82
+ emitComment(lines, 0, 'BDCT defaults — used by every `specshield bdct ...` command.');
83
+ lines.push('bdct:');
84
+ emit(lines, 2, 'org', config.bdct.org);
85
+ if (config.bdct.server && config.bdct.server !== 'https://specshield.io') {
86
+ emit(lines, 2, 'server', config.bdct.server);
87
+ }
88
+ emit(lines, 2, 'environment', config.bdct.environment);
89
+
90
+ if (config.bdct.provider) {
91
+ blank(lines);
92
+ emitComment(lines, 2, 'This project publishes a provider spec.');
93
+ lines.push(' provider:');
94
+ emit(lines, 4, 'name', config.bdct.provider.name);
95
+ emit(lines, 4, 'spec', config.bdct.provider.spec);
96
+ emit(lines, 4, 'branch', config.bdct.provider.branch);
97
+ }
98
+
99
+ if (config.bdct.consumer) {
100
+ blank(lines);
101
+ emitComment(lines, 2, 'This project publishes a consumer contract.');
102
+ lines.push(' consumer:');
103
+ emit(lines, 4, 'name', config.bdct.consumer.name);
104
+ emit(lines, 4, 'provider', config.bdct.consumer.provider);
105
+ emit(lines, 4, 'contract', config.bdct.consumer.contract);
106
+ emit(lines, 4, 'format', config.bdct.consumer.format ?? 'OPENAPI');
107
+ }
108
+ blank(lines);
109
+ }
110
+
111
+ // ── GitHub App defaults ───────────────────────────────────────────────
112
+ if (config.github && Object.keys(config.github).length > 0) {
113
+ emitComment(lines, 0, 'GitHub App + bdct-action defaults.');
114
+ lines.push('github:');
115
+ emit (lines, 2, 'specPath', config.github.specPath);
116
+ emitBoolean(lines, 2, 'failOnBreaking', config.github.failOnBreaking);
117
+ emitBoolean(lines, 2, 'commentOnPr', config.github.commentOnPr);
118
+ blank(lines);
119
+ }
120
+
121
+ // Trim trailing blank lines, ensure exactly one trailing newline.
122
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
123
+ return lines.join('\n') + '\n';
124
+ }
125
+
126
+ /**
127
+ * Write the rendered YAML to `<cwd>/.specshield.yml`. Creates parent dirs
128
+ * if needed (it shouldn't, but defensive). Returns the absolute path written.
129
+ */
130
+ function writeProjectConfig(config, cwd = process.cwd()) {
131
+ const target = path.join(cwd, '.specshield.yml');
132
+ fs.mkdirSync(path.dirname(target), { recursive: true });
133
+ fs.writeFileSync(target, render(config), 'utf8');
134
+ return target;
135
+ }
136
+
137
+ /**
138
+ * Render a starter GitHub Actions workflow that uses
139
+ * `specshield26/bdct-action@v1`. Optional output of the wizard.
140
+ */
141
+ function renderWorkflow({ kind, providerName, consumerName, providerForConsumer }) {
142
+ const isProvider = kind === 'provider' || kind === 'both';
143
+ const isConsumer = kind === 'consumer' || kind === 'both';
144
+
145
+ const lines = [
146
+ '# .github/workflows/specshield-bdct.yml',
147
+ '# Generated by `specshield init`. Edit freely.',
148
+ 'name: SpecShield BDCT',
149
+ '',
150
+ 'on:',
151
+ ' push:',
152
+ ' branches: [main]',
153
+ '',
154
+ 'jobs:',
155
+ ];
156
+
157
+ if (isProvider) {
158
+ lines.push(
159
+ ' publish-provider:',
160
+ ' runs-on: ubuntu-latest',
161
+ ' steps:',
162
+ ' - uses: actions/checkout@v4',
163
+ ' - uses: specshield26/bdct-action@v1',
164
+ ' with:',
165
+ ' command: publish-provider',
166
+ ` provider: ${providerName}`,
167
+ ' version: ${{ github.sha }}',
168
+ ' spec: api/openapi.yaml',
169
+ ' env: production',
170
+ ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
171
+ '',
172
+ ' gate:',
173
+ ' needs: publish-provider',
174
+ ' runs-on: ubuntu-latest',
175
+ ' steps:',
176
+ ' - uses: specshield26/bdct-action@v1',
177
+ ' with:',
178
+ ' command: can-i-deploy',
179
+ ` service: ${providerName}`,
180
+ ' version: ${{ github.sha }}',
181
+ ' env: production',
182
+ ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
183
+ '',
184
+ );
185
+ }
186
+
187
+ if (isConsumer) {
188
+ lines.push(
189
+ ' publish-consumer:',
190
+ ' runs-on: ubuntu-latest',
191
+ ' steps:',
192
+ ' - uses: actions/checkout@v4',
193
+ ' - uses: specshield26/bdct-action@v1',
194
+ ' with:',
195
+ ' command: publish-consumer',
196
+ ` consumer: ${consumerName}`,
197
+ ` provider: ${providerForConsumer}`,
198
+ ' version: ${{ github.sha }}',
199
+ ' contract: contracts/contract.yaml',
200
+ ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
201
+ '',
202
+ );
203
+ }
204
+
205
+ return lines.join('\n');
206
+ }
207
+
208
+ function writeWorkflow(spec, cwd = process.cwd()) {
209
+ const dir = path.join(cwd, '.github', 'workflows');
210
+ const target = path.join(dir, 'specshield-bdct.yml');
211
+ fs.mkdirSync(dir, { recursive: true });
212
+ fs.writeFileSync(target, renderWorkflow(spec), 'utf8');
213
+ return target;
214
+ }
215
+
216
+ module.exports = {
217
+ render,
218
+ writeProjectConfig,
219
+ renderWorkflow,
220
+ writeWorkflow,
221
+ };