specshield 1.0.13 → 2.0.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,648 @@
1
+ 'use strict';
2
+
3
+ const { Command } = require('commander');
4
+ const chalk = require('chalk');
5
+ const ora = require('ora');
6
+ const path = require('path');
7
+ const fsExtra = require('fs-extra');
8
+ const logger = require('../utils/logger');
9
+ const { getStoredApiKey } = require('../config/localConfig');
10
+ const {
11
+ publishProviderSpec,
12
+ publishConsumerContract,
13
+ verify,
14
+ listVerifications,
15
+ canIDeploy,
16
+ getMatrix,
17
+ listProviderSpecs,
18
+ listConsumerContracts,
19
+ } = require('../api/bdctClient');
20
+
21
+ // ─── Helpers ────────────────────────────────────────────────────────────────
22
+
23
+ async function resolveApiToken(opts) {
24
+ return opts.apiToken || process.env.SPECSHIELD_API_KEY || (await getStoredApiKey()) || null;
25
+ }
26
+
27
+ function requireToken(token) {
28
+ if (!token) {
29
+ logger.error('No API token found. Pass --api-token, set SPECSHIELD_API_KEY, or run: specshield login --api-key <KEY>');
30
+ process.exit(2);
31
+ }
32
+ }
33
+
34
+ function fmtDate(iso) {
35
+ if (!iso) return chalk.gray('—');
36
+ try {
37
+ return new Date(iso).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', hour12: false })
38
+ .replace(',', '');
39
+ } catch { return iso; }
40
+ }
41
+
42
+ function compatBadge(status) {
43
+ if (!status) return chalk.gray('UNKNOWN');
44
+ const s = String(status).toUpperCase();
45
+ if (s === 'COMPATIBLE') return chalk.green(s);
46
+ if (s === 'INCOMPATIBLE') return chalk.red(s);
47
+ return chalk.gray(s);
48
+ }
49
+
50
+ function hr() {
51
+ return chalk.gray(' ─────────────────────────────────────────────────────');
52
+ }
53
+
54
+ /** Strip ANSI escape codes for length measurement */
55
+ function stripAnsi(str) {
56
+ return str.replace(/\[[0-9;]*m/g, '');
57
+ }
58
+
59
+ /** Simple padded column table */
60
+ function printTable(headers, rows) {
61
+ const widths = headers.map((h, i) =>
62
+ Math.max(h.length, ...rows.map(r => stripAnsi(String(r[i] ?? '')).length))
63
+ );
64
+ const headerLine = headers.map((h, i) => chalk.bold(h.padEnd(widths[i]))).join(' ');
65
+ process.stdout.write('\n ' + headerLine + '\n');
66
+ process.stdout.write(' ' + widths.map(w => '─'.repeat(w)).join(' ') + '\n');
67
+ for (const row of rows) {
68
+ const line = row.map((cell, i) => {
69
+ const raw = String(cell ?? '');
70
+ const pad = widths[i] - stripAnsi(raw).length;
71
+ return raw + ' '.repeat(Math.max(0, pad));
72
+ }).join(' ');
73
+ process.stdout.write(' ' + line + '\n');
74
+ }
75
+ process.stdout.write('\n');
76
+ }
77
+
78
+ // ─── publish-provider ────────────────────────────────────────────────────────
79
+
80
+ const publishProviderCommand = new Command('publish-provider')
81
+ .description('Publish a provider OpenAPI spec to the BDCT registry')
82
+ .requiredOption('--spec <path>', 'Path to provider spec file (YAML or JSON)')
83
+ .requiredOption('--provider <name>', 'Provider service name')
84
+ .requiredOption('--version <ver>', 'Provider version tag')
85
+ .option('--env <environment>', 'Environment label (e.g. staging, production)')
86
+ .option('--org <key>', 'Organization key')
87
+ .option('--branch <branch>', 'Git branch name')
88
+ .option('--json', 'Output raw JSON')
89
+ .option('--server <url>', 'SpecShield server URL')
90
+ .option('--api-token <token>', 'API token (overrides env / stored config)')
91
+ .action(async (opts) => {
92
+ const token = await resolveApiToken(opts);
93
+ requireToken(token);
94
+
95
+ const filePath = path.resolve(opts.spec);
96
+ if (!(await fsExtra.pathExists(filePath))) {
97
+ logger.error(`Spec file not found: ${filePath}`);
98
+ process.exit(2);
99
+ }
100
+
101
+ let specContent;
102
+ try {
103
+ specContent = await fsExtra.readFile(filePath, 'utf8');
104
+ } catch (err) {
105
+ logger.error(`Failed to read spec file: ${err.message}`);
106
+ process.exit(2);
107
+ }
108
+
109
+ const spinner = opts.json ? null : ora('Publishing provider spec...').start();
110
+
111
+ try {
112
+ const result = await publishProviderSpec(opts.server, token, {
113
+ provider: opts.provider,
114
+ version: opts.version,
115
+ specContent,
116
+ env: opts.env || null,
117
+ orgKey: opts.org || null,
118
+ branch: opts.branch || null,
119
+ });
120
+ if (spinner) spinner.stop();
121
+
122
+ if (opts.json) {
123
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
124
+ return;
125
+ }
126
+
127
+ process.stdout.write('\n');
128
+ process.stdout.write(chalk.green.bold(' ✔ Provider Spec Published') + '\n');
129
+ process.stdout.write(hr() + '\n');
130
+ if (result.id) process.stdout.write(` ID : ${chalk.cyan(result.id)}\n`);
131
+ process.stdout.write(` Provider : ${chalk.white(opts.provider)}\n`);
132
+ process.stdout.write(` Version : ${chalk.cyan(opts.version)}\n`);
133
+ if (opts.env) process.stdout.write(` Environment : ${opts.env}\n`);
134
+ if (result.publishedAt) process.stdout.write(` Published At: ${fmtDate(result.publishedAt)}\n`);
135
+ process.stdout.write('\n');
136
+ } catch (err) {
137
+ if (spinner) spinner.fail('Publish failed');
138
+ logger.error(err.message);
139
+ process.exit(1);
140
+ }
141
+ });
142
+
143
+ // ─── publish-consumer ────────────────────────────────────────────────────────
144
+
145
+ const publishConsumerCommand = new Command('publish-consumer')
146
+ .description('Publish a consumer contract to the BDCT registry')
147
+ .requiredOption('--contract <path>', 'Path to consumer contract file (OpenAPI YAML/JSON or Pact JSON)')
148
+ .requiredOption('--consumer <name>', 'Consumer service name')
149
+ .requiredOption('--provider <name>', 'Provider service name')
150
+ .requiredOption('--version <ver>', 'Consumer version tag')
151
+ .option('--org <key>', 'Organization key')
152
+ .option('--format <fmt>', 'Contract format: OPENAPI | PACT', 'OPENAPI')
153
+ .option('--json', 'Output raw JSON')
154
+ .option('--server <url>', 'SpecShield server URL')
155
+ .option('--api-token <token>', 'API token (overrides env / stored config)')
156
+ .action(async (opts) => {
157
+ const token = await resolveApiToken(opts);
158
+ requireToken(token);
159
+
160
+ const filePath = path.resolve(opts.contract);
161
+ if (!(await fsExtra.pathExists(filePath))) {
162
+ logger.error(`Contract file not found: ${filePath}`);
163
+ process.exit(2);
164
+ }
165
+
166
+ let contractContent;
167
+ try {
168
+ contractContent = await fsExtra.readFile(filePath, 'utf8');
169
+ } catch (err) {
170
+ logger.error(`Failed to read contract file: ${err.message}`);
171
+ process.exit(2);
172
+ }
173
+
174
+ const spinner = opts.json ? null : ora('Publishing consumer contract...').start();
175
+
176
+ try {
177
+ const result = await publishConsumerContract(opts.server, token, {
178
+ consumer: opts.consumer,
179
+ provider: opts.provider,
180
+ version: opts.version,
181
+ contractContent,
182
+ orgKey: opts.org || null,
183
+ format: opts.format || 'OPENAPI',
184
+ });
185
+ if (spinner) spinner.stop();
186
+
187
+ if (opts.json) {
188
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
189
+ return;
190
+ }
191
+
192
+ process.stdout.write('\n');
193
+ process.stdout.write(chalk.green.bold(' ✔ Consumer Contract Published') + '\n');
194
+ process.stdout.write(hr() + '\n');
195
+ if (result.id) process.stdout.write(` ID : ${chalk.cyan(result.id)}\n`);
196
+ process.stdout.write(` Consumer : ${chalk.white(opts.consumer)}\n`);
197
+ process.stdout.write(` Provider : ${chalk.white(opts.provider)}\n`);
198
+ process.stdout.write(` Version : ${chalk.cyan(opts.version)}\n`);
199
+ process.stdout.write(` Format : ${opts.format || 'OPENAPI'}\n`);
200
+ if (result.publishedAt) process.stdout.write(` Published At: ${fmtDate(result.publishedAt)}\n`);
201
+ process.stdout.write('\n');
202
+ process.stdout.write(chalk.gray(` ➜ Run: specshield bdct verify --consumer ${opts.consumer} --provider ${opts.provider}\n`));
203
+ process.stdout.write('\n');
204
+ } catch (err) {
205
+ if (spinner) spinner.fail('Publish failed');
206
+ logger.error(err.message);
207
+ process.exit(1);
208
+ }
209
+ });
210
+
211
+ // ─── verify ──────────────────────────────────────────────────────────────────
212
+
213
+ const verifyCommand = new Command('verify')
214
+ .description('Verify consumer-provider contract compatibility')
215
+ .requiredOption('--consumer <name>', 'Consumer service name')
216
+ .requiredOption('--provider <name>', 'Provider service name')
217
+ .option('--consumer-version <ver>', 'Consumer version to verify')
218
+ .option('--provider-version <ver>', 'Provider version to verify against')
219
+ .option('--env <environment>', 'Environment label')
220
+ .option('--org <key>', 'Organization key')
221
+ .option('--json', 'Output raw JSON')
222
+ .option('--server <url>', 'SpecShield server URL')
223
+ .option('--api-token <token>', 'API token')
224
+ .action(async (opts) => {
225
+ const token = await resolveApiToken(opts);
226
+ requireToken(token);
227
+
228
+ const spinner = opts.json ? null : ora(`Verifying ${opts.consumer} → ${opts.provider}...`).start();
229
+
230
+ try {
231
+ const result = await verify(opts.server, token, {
232
+ consumer: opts.consumer,
233
+ provider: opts.provider,
234
+ consumerVersion: opts.consumerVersion || null,
235
+ providerVersion: opts.providerVersion || null,
236
+ env: opts.env || null,
237
+ orgKey: opts.org || null,
238
+ });
239
+ if (spinner) spinner.stop();
240
+
241
+ if (opts.json) {
242
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
243
+ return;
244
+ }
245
+
246
+ const status = String(result.status || result.result || '').toUpperCase();
247
+ const success = status === 'COMPATIBLE';
248
+
249
+ process.stdout.write('\n');
250
+ if (success) {
251
+ process.stdout.write(chalk.green.bold(' ✔ COMPATIBLE') + '\n');
252
+ } else {
253
+ process.stdout.write(chalk.red.bold(' ✖ INCOMPATIBLE') + '\n');
254
+ }
255
+ process.stdout.write(hr() + '\n');
256
+ if (result.id) process.stdout.write(` Verification ID : ${chalk.cyan(result.id)}\n`);
257
+ process.stdout.write(` Consumer : ${opts.consumer}${opts.consumerVersion ? '@' + opts.consumerVersion : ''}\n`);
258
+ process.stdout.write(` Provider : ${opts.provider}${opts.providerVersion ? '@' + opts.providerVersion : ''}\n`);
259
+ if (opts.env) process.stdout.write(` Environment : ${opts.env}\n`);
260
+ process.stdout.write(` Result : ${compatBadge(status)}\n`);
261
+ if (result.verifiedAt || result.completedAt) {
262
+ process.stdout.write(` Verified At : ${fmtDate(result.verifiedAt || result.completedAt)}\n`);
263
+ }
264
+
265
+ const issues = result.issues || result.mismatches || [];
266
+ if (issues.length > 0) {
267
+ process.stdout.write('\n');
268
+ process.stdout.write(chalk.red.bold(' Issues') + '\n');
269
+ process.stdout.write(hr() + '\n');
270
+ for (const issue of issues) {
271
+ process.stdout.write(` ${chalk.red('●')} ${chalk.bold(issue.type || issue.mismatchType || 'MISMATCH')} at ${chalk.gray(issue.path || '$')}\n`);
272
+ if (issue.message) process.stdout.write(` ${chalk.gray(issue.message)}\n`);
273
+ }
274
+ process.stdout.write('\n');
275
+ }
276
+
277
+ process.stdout.write('\n');
278
+ process.exit(success ? 0 : 1);
279
+ } catch (err) {
280
+ if (spinner) spinner.fail('Verification failed');
281
+ logger.error(err.message);
282
+ process.exit(2);
283
+ }
284
+ });
285
+
286
+ // ─── can-i-deploy ─────────────────────────────────────────────────────────────
287
+
288
+ const canIDeployCommand = new Command('can-i-deploy')
289
+ .description('Check if a service version is safe to deploy')
290
+ .requiredOption('--service <name>', 'Service name (consumer or provider)')
291
+ .requiredOption('--version <ver>', 'Service version to check')
292
+ .option('--env <environment>', 'Target environment (e.g. qa, staging, production)')
293
+ .option('--org <key>', 'Organization key')
294
+ .option('--json', 'Output raw JSON')
295
+ .option('--server <url>', 'SpecShield server URL')
296
+ .option('--api-token <token>', 'API token')
297
+ .action(async (opts) => {
298
+ const token = await resolveApiToken(opts);
299
+ requireToken(token);
300
+
301
+ const spinner = opts.json ? null : ora(`Checking deployment safety for ${opts.service}@${opts.version}...`).start();
302
+
303
+ try {
304
+ const result = await canIDeploy(opts.server, token, {
305
+ service: opts.service,
306
+ version: opts.version,
307
+ env: opts.env || null,
308
+ org: opts.org || null,
309
+ });
310
+ if (spinner) spinner.stop();
311
+
312
+ if (opts.json) {
313
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
314
+ return;
315
+ }
316
+
317
+ const deployable = result.deployable ?? result.allowed ?? false;
318
+ const envLabel = opts.env ? ` in ${opts.env}` : '';
319
+
320
+ process.stdout.write('\n');
321
+ if (deployable) {
322
+ process.stdout.write(chalk.green.bold(' ✔ PASS') + chalk.white(`: ${opts.service} v${opts.version} is deployable${envLabel}\n`));
323
+ } else {
324
+ process.stdout.write(chalk.red.bold(' ✖ FAIL') + chalk.white(`: ${opts.service} v${opts.version} is NOT deployable${envLabel}\n`));
325
+ }
326
+ process.stdout.write(hr() + '\n');
327
+
328
+ const consumers = result.consumers || result.verifications || [];
329
+ if (consumers.length > 0) {
330
+ process.stdout.write('\n');
331
+ process.stdout.write(chalk.bold(' Consumer Verifications') + '\n');
332
+ printTable(
333
+ ['Consumer', 'Version', 'Status', 'Verified At'],
334
+ consumers.map(c => [
335
+ c.consumer || c.consumerName || '—',
336
+ c.consumerVersion || c.version || '—',
337
+ compatBadge(c.status || c.result),
338
+ fmtDate(c.verifiedAt || c.completedAt),
339
+ ])
340
+ );
341
+ }
342
+
343
+ if (!deployable) {
344
+ process.stdout.write(chalk.gray(` ➜ Run: specshield bdct verify --consumer <NAME> --provider ${opts.service}\n`));
345
+ process.stdout.write(chalk.gray(` ➜ to identify and resolve incompatibilities\n`));
346
+ process.stdout.write('\n');
347
+ process.exit(1);
348
+ } else {
349
+ process.stdout.write('\n');
350
+ process.exit(0);
351
+ }
352
+ } catch (err) {
353
+ if (spinner) spinner.fail('Check failed');
354
+ logger.error(err.message);
355
+ process.exit(2);
356
+ }
357
+ });
358
+
359
+ // ─── list ─────────────────────────────────────────────────────────────────────
360
+
361
+ const listCommand = new Command('list')
362
+ .description('List BDCT verification history')
363
+ .option('--org <key>', 'Filter by organization key')
364
+ .option('--consumer <name>', 'Filter by consumer service name')
365
+ .option('--provider <name>', 'Filter by provider service name')
366
+ .option('--env <environment>', 'Filter by environment')
367
+ .option('--page <n>', 'Page number (0-based)', '0')
368
+ .option('--size <n>', 'Page size', '20')
369
+ .option('--json', 'Output raw JSON')
370
+ .option('--server <url>', 'SpecShield server URL')
371
+ .option('--api-token <token>', 'API token')
372
+ .action(async (opts) => {
373
+ const token = await resolveApiToken(opts);
374
+ requireToken(token);
375
+
376
+ const spinner = opts.json ? null : ora('Fetching verification history...').start();
377
+
378
+ try {
379
+ const page = await listVerifications(opts.server, token, {
380
+ org: opts.org,
381
+ consumer: opts.consumer,
382
+ provider: opts.provider,
383
+ env: opts.env,
384
+ page: parseInt(opts.page, 10) || 0,
385
+ size: parseInt(opts.size, 10) || 20,
386
+ });
387
+ if (spinner) spinner.stop();
388
+
389
+ if (opts.json) {
390
+ process.stdout.write(JSON.stringify(page, null, 2) + '\n');
391
+ return;
392
+ }
393
+
394
+ const items = page.content || (Array.isArray(page) ? page : []);
395
+ const total = page.totalElements ?? items.length;
396
+
397
+ process.stdout.write('\n');
398
+ process.stdout.write(chalk.bold(' BDCT Verification History') + '\n');
399
+ process.stdout.write(hr() + '\n');
400
+ process.stdout.write(` Showing ${items.length} of ${total} verifications\n`);
401
+
402
+ if (items.length === 0) {
403
+ process.stdout.write(chalk.gray('\n No verifications found matching filters.\n\n'));
404
+ return;
405
+ }
406
+
407
+ printTable(
408
+ ['ID', 'Consumer', 'Provider', 'Cons Ver', 'Prov Ver', 'Status', 'Environment', 'Verified At'],
409
+ items.map(v => [
410
+ chalk.cyan(String(v.id ?? v.verificationId ?? '—')),
411
+ v.consumer || v.consumerName || '—',
412
+ v.provider || v.providerName || '—',
413
+ v.consumerVersion || '—',
414
+ v.providerVersion || '—',
415
+ compatBadge(v.status || v.result),
416
+ v.env || v.environment || '—',
417
+ fmtDate(v.verifiedAt || v.completedAt),
418
+ ])
419
+ );
420
+
421
+ if (page.totalPages > 1) {
422
+ const cur = (page.number ?? 0) + 1;
423
+ process.stdout.write(chalk.gray(` Page ${cur} of ${page.totalPages} · Use --page and --size to navigate\n\n`));
424
+ }
425
+ } catch (err) {
426
+ if (spinner) spinner.fail('List failed');
427
+ logger.error(err.message);
428
+ process.exit(1);
429
+ }
430
+ });
431
+
432
+ // ─── matrix ──────────────────────────────────────────────────────────────────
433
+
434
+ const matrixCommand = new Command('matrix')
435
+ .description('Show ASCII compatibility matrix of consumers vs providers')
436
+ .option('--org <key>', 'Organization key')
437
+ .option('--env <environment>', 'Environment label')
438
+ .option('--json', 'Output raw JSON')
439
+ .option('--server <url>', 'SpecShield server URL')
440
+ .option('--api-token <token>', 'API token')
441
+ .action(async (opts) => {
442
+ const token = await resolveApiToken(opts);
443
+ requireToken(token);
444
+
445
+ const spinner = opts.json ? null : ora('Fetching compatibility matrix...').start();
446
+
447
+ try {
448
+ const matrix = await getMatrix(opts.server, token, {
449
+ org: opts.org,
450
+ env: opts.env,
451
+ });
452
+ if (spinner) spinner.stop();
453
+
454
+ if (opts.json) {
455
+ process.stdout.write(JSON.stringify(matrix, null, 2) + '\n');
456
+ return;
457
+ }
458
+
459
+ // Expected matrix shape: { consumers: string[], providers: string[], cells: { [consumer]: { [provider]: string } } }
460
+ const consumers = matrix.consumers || [];
461
+ const providers = matrix.providers || [];
462
+ const cells = matrix.cells || {};
463
+
464
+ process.stdout.write('\n');
465
+ process.stdout.write(chalk.bold(' BDCT Compatibility Matrix') + '\n');
466
+ if (opts.env) process.stdout.write(chalk.gray(` Environment: ${opts.env}\n`));
467
+ process.stdout.write(hr() + '\n');
468
+
469
+ if (consumers.length === 0 || providers.length === 0) {
470
+ process.stdout.write(chalk.gray('\n No data available. Publish provider specs and consumer contracts first.\n\n'));
471
+ return;
472
+ }
473
+
474
+ // Build table: first column = consumer label, remaining = providers
475
+ const headers = ['Consumer \\ Provider', ...providers];
476
+ const rows = consumers.map(consumer => {
477
+ const providerCells = providers.map(provider => {
478
+ const status = (cells[consumer] && cells[consumer][provider]) || 'UNKNOWN';
479
+ return compatBadge(status);
480
+ });
481
+ return [chalk.white(consumer), ...providerCells];
482
+ });
483
+
484
+ printTable(headers, rows);
485
+
486
+ process.stdout.write(chalk.green(' ■') + chalk.gray(' COMPATIBLE ') +
487
+ chalk.red('■') + chalk.gray(' INCOMPATIBLE ') +
488
+ chalk.gray('■ UNKNOWN') + '\n\n');
489
+ } catch (err) {
490
+ if (spinner) spinner.fail('Fetch failed');
491
+ logger.error(err.message);
492
+ process.exit(1);
493
+ }
494
+ });
495
+
496
+ // ─── list-providers ──────────────────────────────────────────────────────────
497
+
498
+ const listProvidersCommand = new Command('list-providers')
499
+ .description('List published provider specs')
500
+ .option('--org <key>', 'Filter by organization key')
501
+ .option('--provider <name>', 'Filter by provider service name')
502
+ .option('--env <environment>', 'Filter by environment')
503
+ .option('--page <n>', 'Page number (0-based)', '0')
504
+ .option('--size <n>', 'Page size', '20')
505
+ .option('--json', 'Output raw JSON')
506
+ .option('--server <url>', 'SpecShield server URL')
507
+ .option('--api-token <token>', 'API token')
508
+ .action(async (opts) => {
509
+ const token = await resolveApiToken(opts);
510
+ requireToken(token);
511
+
512
+ const spinner = opts.json ? null : ora('Fetching provider specs...').start();
513
+
514
+ try {
515
+ const page = await listProviderSpecs(opts.server, token, {
516
+ org: opts.org,
517
+ provider: opts.provider,
518
+ env: opts.env,
519
+ page: parseInt(opts.page, 10) || 0,
520
+ size: parseInt(opts.size, 10) || 20,
521
+ });
522
+ if (spinner) spinner.stop();
523
+
524
+ if (opts.json) {
525
+ process.stdout.write(JSON.stringify(page, null, 2) + '\n');
526
+ return;
527
+ }
528
+
529
+ const items = page.content || (Array.isArray(page) ? page : []);
530
+ const total = page.totalElements ?? items.length;
531
+
532
+ process.stdout.write('\n');
533
+ process.stdout.write(chalk.bold(' Published Provider Specs') + '\n');
534
+ process.stdout.write(hr() + '\n');
535
+ process.stdout.write(` Showing ${items.length} of ${total} specs\n`);
536
+
537
+ if (items.length === 0) {
538
+ process.stdout.write(chalk.gray('\n No provider specs found matching filters.\n\n'));
539
+ return;
540
+ }
541
+
542
+ printTable(
543
+ ['ID', 'Provider', 'Version', 'Environment', 'Branch', 'Published At'],
544
+ items.map(s => [
545
+ chalk.cyan(String(s.id ?? '—')),
546
+ s.provider || s.providerName || '—',
547
+ chalk.cyan(s.version || '—'),
548
+ s.env || s.environment || '—',
549
+ chalk.gray(s.branch || '—'),
550
+ fmtDate(s.publishedAt),
551
+ ])
552
+ );
553
+
554
+ if (page.totalPages > 1) {
555
+ const cur = (page.number ?? 0) + 1;
556
+ process.stdout.write(chalk.gray(` Page ${cur} of ${page.totalPages} · Use --page and --size to navigate\n\n`));
557
+ }
558
+ } catch (err) {
559
+ if (spinner) spinner.fail('List failed');
560
+ logger.error(err.message);
561
+ process.exit(1);
562
+ }
563
+ });
564
+
565
+ // ─── list-consumers ──────────────────────────────────────────────────────────
566
+
567
+ const listConsumersCommand = new Command('list-consumers')
568
+ .description('List published consumer contracts')
569
+ .option('--org <key>', 'Filter by organization key')
570
+ .option('--consumer <name>', 'Filter by consumer service name')
571
+ .option('--provider <name>', 'Filter by provider service name')
572
+ .option('--page <n>', 'Page number (0-based)', '0')
573
+ .option('--size <n>', 'Page size', '20')
574
+ .option('--json', 'Output raw JSON')
575
+ .option('--server <url>', 'SpecShield server URL')
576
+ .option('--api-token <token>', 'API token')
577
+ .action(async (opts) => {
578
+ const token = await resolveApiToken(opts);
579
+ requireToken(token);
580
+
581
+ const spinner = opts.json ? null : ora('Fetching consumer contracts...').start();
582
+
583
+ try {
584
+ const page = await listConsumerContracts(opts.server, token, {
585
+ org: opts.org,
586
+ consumer: opts.consumer,
587
+ provider: opts.provider,
588
+ page: parseInt(opts.page, 10) || 0,
589
+ size: parseInt(opts.size, 10) || 20,
590
+ });
591
+ if (spinner) spinner.stop();
592
+
593
+ if (opts.json) {
594
+ process.stdout.write(JSON.stringify(page, null, 2) + '\n');
595
+ return;
596
+ }
597
+
598
+ const items = page.content || (Array.isArray(page) ? page : []);
599
+ const total = page.totalElements ?? items.length;
600
+
601
+ process.stdout.write('\n');
602
+ process.stdout.write(chalk.bold(' Published Consumer Contracts') + '\n');
603
+ process.stdout.write(hr() + '\n');
604
+ process.stdout.write(` Showing ${items.length} of ${total} contracts\n`);
605
+
606
+ if (items.length === 0) {
607
+ process.stdout.write(chalk.gray('\n No consumer contracts found matching filters.\n\n'));
608
+ return;
609
+ }
610
+
611
+ printTable(
612
+ ['ID', 'Consumer', 'Provider', 'Version', 'Format', 'Published At'],
613
+ items.map(c => [
614
+ chalk.cyan(String(c.id ?? '—')),
615
+ c.consumer || c.consumerName || '—',
616
+ c.provider || c.providerName || '—',
617
+ chalk.cyan(c.version || '—'),
618
+ c.format || '—',
619
+ fmtDate(c.publishedAt),
620
+ ])
621
+ );
622
+
623
+ if (page.totalPages > 1) {
624
+ const cur = (page.number ?? 0) + 1;
625
+ process.stdout.write(chalk.gray(` Page ${cur} of ${page.totalPages} · Use --page and --size to navigate\n\n`));
626
+ }
627
+ } catch (err) {
628
+ if (spinner) spinner.fail('List failed');
629
+ logger.error(err.message);
630
+ process.exit(1);
631
+ }
632
+ });
633
+
634
+ // ─── Parent bdct command ──────────────────────────────────────────────────────
635
+
636
+ const bdct = new Command('bdct')
637
+ .description('Bi-Directional Contract Testing (BDCT) — publish specs, verify compatibility, check deployability');
638
+
639
+ bdct.addCommand(publishProviderCommand);
640
+ bdct.addCommand(publishConsumerCommand);
641
+ bdct.addCommand(verifyCommand);
642
+ bdct.addCommand(canIDeployCommand);
643
+ bdct.addCommand(listCommand);
644
+ bdct.addCommand(matrixCommand);
645
+ bdct.addCommand(listProvidersCommand);
646
+ bdct.addCommand(listConsumersCommand);
647
+
648
+ module.exports = bdct;