@vizzly-testing/cli 0.35.0 → 0.35.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/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import 'dotenv/config';
3
- import { program } from 'commander';
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import { Option, program } from 'commander';
4
5
  import { apiCommand, validateApiOptions } from './commands/api.js';
5
6
  import { baselinesCommand, validateBaselinesOptions } from './commands/baselines.js';
6
7
  import { buildsCommand, validateBuildsOptions } from './commands/builds.js';
@@ -52,7 +53,7 @@ const formatHelp = (cmd, helper) => {
52
53
  lines.push(` ${c.gray('Visual regression testing from your terminal')}`);
53
54
  } else {
54
55
  // Compact header for subcommands
55
- lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.white(cmd.name())}`);
56
+ lines.push(` ${c.brand.amber(c.bold(commandPath(cmd)))}`);
56
57
  let desc = cmd.description();
57
58
  if (desc) {
58
59
  lines.push(` ${c.gray(desc)}`);
@@ -84,7 +85,7 @@ const formatHelp = (cmd, helper) => {
84
85
  key: 'setup',
85
86
  icon: '▸',
86
87
  title: 'Setup',
87
- names: ['init', 'doctor', 'config', 'baselines']
88
+ names: ['init', 'doctor', 'config', 'baselines', 'project']
88
89
  }, {
89
90
  key: 'advanced',
90
91
  icon: '▸',
@@ -174,7 +175,7 @@ const formatHelp = (cmd, helper) => {
174
175
  lines.push(` ${c.brand.amber('▸')} ${c.bold('Quick Start')}`);
175
176
  lines.push('');
176
177
  lines.push(` ${c.dim('# Local visual review')}`);
177
- lines.push(` ${c.gray('$')} ${c.white('vizzly tdd start')}`);
178
+ lines.push(` ${c.gray('$')} ${c.white('vizzly tdd start --open')}`);
178
179
  lines.push('');
179
180
  lines.push(` ${c.dim('# CI pipeline')}`);
180
181
  lines.push(` ${c.gray('$')} ${c.white('vizzly run "pnpm test" --wait')}`);
@@ -205,6 +206,18 @@ const formatHelp = (cmd, helper) => {
205
206
  lines.push('');
206
207
  return lines.join('\n');
207
208
  };
209
+ function commandPath(cmd) {
210
+ let names = [];
211
+ let current = cmd;
212
+ while (current) {
213
+ let name = current.name();
214
+ if (name) {
215
+ names.unshift(name);
216
+ }
217
+ current = current.parent;
218
+ }
219
+ return names.join(' ');
220
+ }
208
221
  function extractGlobalOptionsFromArgv(argv, commandNames = null) {
209
222
  let configPath = null;
210
223
  let tokenArg = null;
@@ -259,9 +272,133 @@ function normalizeJsonArgv(argv, commandNames) {
259
272
  }
260
273
  return normalizedArgv;
261
274
  }
275
+ function findNestedCommandRequest(args, startIndex, subcommandNames, parentName) {
276
+ if (!subcommandNames?.size) {
277
+ return null;
278
+ }
279
+ let valueOptions = new Set(['-c', '--config', '--token', '--log-level']);
280
+ for (let i = startIndex; i < args.length; i++) {
281
+ let arg = args[i];
282
+ if (arg === '--') {
283
+ return null;
284
+ }
285
+ if (valueOptions.has(arg)) {
286
+ i++;
287
+ continue;
288
+ }
289
+ if (arg.startsWith('--')) {
290
+ continue;
291
+ }
292
+ if (arg.startsWith('-')) {
293
+ continue;
294
+ }
295
+ return {
296
+ name: arg,
297
+ known: subcommandNames.has(arg),
298
+ helpCommand: `vizzly ${parentName} --help`
299
+ };
300
+ }
301
+ return null;
302
+ }
303
+ function findRequestedCommand(argv, commandNames, nestedCommandNames = new Map()) {
304
+ let valueOptions = new Set(['-c', '--config', '--token', '--log-level']);
305
+ let args = argv.slice(2);
306
+ let jsonValueCandidate = null;
307
+ for (let i = 0; i < args.length; i++) {
308
+ let arg = args[i];
309
+ if (arg === '--') {
310
+ return null;
311
+ }
312
+ if (arg === '--json') {
313
+ let nextArg = args[i + 1];
314
+ if (nextArg && !nextArg.startsWith('-') && !commandNames.has(nextArg)) {
315
+ jsonValueCandidate = jsonValueCandidate || nextArg;
316
+ i++;
317
+ }
318
+ continue;
319
+ }
320
+ if (valueOptions.has(arg)) {
321
+ i++;
322
+ continue;
323
+ }
324
+ if (arg.startsWith('--json=')) {
325
+ continue;
326
+ }
327
+ if (arg.startsWith('--')) {
328
+ continue;
329
+ }
330
+ if (arg.startsWith('-')) {
331
+ continue;
332
+ }
333
+ let known = commandNames.has(arg);
334
+ if (known) {
335
+ let nestedRequest = findNestedCommandRequest(args, i + 1, nestedCommandNames.get(arg), arg);
336
+ if (nestedRequest && !nestedRequest.known) {
337
+ return nestedRequest;
338
+ }
339
+ }
340
+ return {
341
+ name: arg,
342
+ known
343
+ };
344
+ }
345
+ if (jsonValueCandidate) {
346
+ return {
347
+ name: jsonValueCandidate,
348
+ known: false
349
+ };
350
+ }
351
+ return null;
352
+ }
353
+ function getGlobalOptions() {
354
+ let options = program.opts();
355
+ return {
356
+ ...options,
357
+ noColor: options.noColor || options.color === false
358
+ };
359
+ }
360
+ function reportValidationErrors(errors) {
361
+ if (output.isJson()) {
362
+ output.error('Validation errors', null, {
363
+ errors
364
+ });
365
+ } else {
366
+ output.error('Validation errors:');
367
+ for (let error of errors) {
368
+ output.printErr(` - ${error}`);
369
+ }
370
+ }
371
+ process.exit(1);
372
+ }
373
+ function formatCommanderError(message) {
374
+ return message.replace(/^error:\s*/, '').trim();
375
+ }
376
+ function writeCommanderError(text) {
377
+ let message = formatCommanderError(text);
378
+ if (!message) {
379
+ return;
380
+ }
381
+ if (output.isJson()) {
382
+ if (message.startsWith('(')) {
383
+ return;
384
+ }
385
+ output.error(message);
386
+ } else {
387
+ process.stderr.write(text);
388
+ if (!text.endsWith('\n')) {
389
+ process.stderr.write('\n');
390
+ }
391
+ }
392
+ }
262
393
  program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)').option('--log-level <level>', 'Log level: debug, info, warn, error (default: info, or VIZZLY_LOG_LEVEL env var)').option('--json [fields]', 'JSON output, optionally specify fields (e.g., --json id,status,branch)').option('--color', 'Force colored output (even in non-TTY)').option('--no-color', 'Disable colored output').option('--strict', 'Fail on any error (default: be resilient, warn on non-critical issues)').configureHelp({
263
394
  formatHelp
264
395
  });
396
+ program.configureOutput({
397
+ writeErr: writeCommanderError
398
+ });
399
+ program.showHelpAfterError('(Run vizzly --help for available commands)');
400
+ program.showSuggestionAfterError();
401
+ program.exitOverride();
265
402
 
266
403
  // Load plugins before defining commands
267
404
  // We need to manually parse to get the config option early
@@ -314,7 +451,7 @@ try {
314
451
  output.debug(`Plugin loading failed: ${error.message}`);
315
452
  }
316
453
  program.command('init').description('Initialize Vizzly in your project').option('--force', 'Overwrite existing configuration').option('--agent-skill', 'Install the repo-local Vizzly agent skill').option('--agent-guidance', 'Add Vizzly guidance to this project AGENTS.md and install the agent skill').option('--skip-agent-skill', 'Skip the Vizzly agent skill prompt').action(async options => {
317
- let globalOptions = program.opts();
454
+ let globalOptions = getGlobalOptions();
318
455
  await init({
319
456
  ...globalOptions,
320
457
  ...options,
@@ -322,15 +459,19 @@ program.command('init').description('Initialize Vizzly in your project').option(
322
459
  });
323
460
  });
324
461
  program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', Number).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', Number).option('--branch <branch>', 'Git branch').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (path, options) => {
325
- let globalOptions = program.opts();
462
+ let globalOptions = getGlobalOptions();
326
463
 
327
464
  // Validate options
328
465
  const validationErrors = validateUploadOptions(path, options);
329
466
  if (validationErrors.length > 0) {
330
- output.error('Validation errors:');
331
- for (let error of validationErrors) {
332
- output.printErr(` - ${error}`);
333
- }
467
+ reportValidationErrors(validationErrors);
468
+ }
469
+ if (!existsSync(path)) {
470
+ output.error(`Path does not exist: ${path}`);
471
+ process.exit(1);
472
+ }
473
+ if (!statSync(path).isDirectory()) {
474
+ output.error(`Path is not a directory: ${path}`);
334
475
  process.exit(1);
335
476
  }
336
477
  let result = await uploadCommand(path, options, globalOptions);
@@ -340,11 +481,11 @@ program.command('upload').description('Upload screenshots to Vizzly').argument('
340
481
  });
341
482
 
342
483
  // TDD command with subcommands - local visual review with an interactive dashboard
343
- const tddCmd = program.command('tdd').description('Run tests in TDD mode with local visual review');
484
+ const tddCmd = program.command('tdd').description('Run tests in TDD mode with local visual review').showHelpAfterError('(Run vizzly tdd --help for available commands)').showSuggestionAfterError();
344
485
 
345
486
  // TDD Start - Background server
346
- tddCmd.command('start').description('Start background TDD server with dashboard').option('--port <port>', 'Port for TDD server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--token <token>', 'API token override').option('--daemon-child', 'Internal: run as daemon child process').action(async options => {
347
- const globalOptions = program.opts();
487
+ tddCmd.command('start').description('Start background TDD server with dashboard').option('--port <port>', 'Port for TDD server').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--token <token>', 'API token override').addOption(new Option('--daemon-child', 'Internal: run as daemon child process').hideHelp()).action(async options => {
488
+ let globalOptions = getGlobalOptions();
348
489
 
349
490
  // If this is a daemon child process, run the server directly
350
491
  if (options.daemonChild) {
@@ -353,45 +494,45 @@ tddCmd.command('start').description('Start background TDD server with dashboard'
353
494
  }
354
495
  let validationErrors = validateTddStartOptions(options);
355
496
  if (validationErrors.length > 0) {
356
- output.error('Validation errors:');
357
- for (let error of validationErrors) {
358
- output.printErr(` - ${error}`);
359
- }
360
- process.exit(1);
497
+ reportValidationErrors(validationErrors);
361
498
  }
362
499
  await tddStartCommand(options, globalOptions);
363
500
  });
364
501
 
365
502
  // TDD Stop - Kill background server
366
- tddCmd.command('stop').description('Stop background TDD server').action(async options => {
367
- const globalOptions = program.opts();
503
+ tddCmd.command('stop').description('Stop background TDD server').option('--port <port>', 'Port for TDD server').action(async options => {
504
+ let globalOptions = getGlobalOptions();
505
+ let validationErrors = validateTddStartOptions(options);
506
+ if (validationErrors.length > 0) {
507
+ reportValidationErrors(validationErrors);
508
+ }
368
509
  await tddStopCommand(options, globalOptions);
369
510
  });
370
511
 
371
512
  // TDD Status - Check server status
372
- tddCmd.command('status').description('Check TDD server status').action(async options => {
373
- const globalOptions = program.opts();
513
+ tddCmd.command('status').description('Check TDD server status').option('--port <port>', 'Port for TDD server').action(async options => {
514
+ let globalOptions = getGlobalOptions();
515
+ let validationErrors = validateTddStartOptions(options);
516
+ if (validationErrors.length > 0) {
517
+ reportValidationErrors(validationErrors);
518
+ }
374
519
  await tddStatusCommand(options, globalOptions);
375
520
  });
376
521
 
377
522
  // TDD List - List all running servers (for menubar app integration)
378
523
  tddCmd.command('list').description('List all running TDD servers').action(async options => {
379
- const globalOptions = program.opts();
524
+ let globalOptions = getGlobalOptions();
380
525
  await tddListCommand(options, globalOptions);
381
526
  });
382
527
 
383
528
  // TDD Run - One-off test run with ephemeral server (generates static report)
384
- tddCmd.command('run <command>').description('Run tests once in TDD mode with local visual review').option('--port <port>', 'Port for TDD server', '47392').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--no-open', 'Skip opening report in browser').action(async (command, options) => {
385
- const globalOptions = program.opts();
529
+ tddCmd.command('run <command>').description('Run tests once in TDD mode with local visual review').option('--port <port>', 'Port for TDD server').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').option('--fail-on-diff', 'Fail tests when visual differences are detected').option('--no-open', 'Skip opening report in browser').action(async (command, options) => {
530
+ let globalOptions = getGlobalOptions();
386
531
 
387
532
  // Validate options
388
533
  const validationErrors = validateTddOptions(command, options);
389
534
  if (validationErrors.length > 0) {
390
- output.error('Validation errors:');
391
- for (let error of validationErrors) {
392
- output.printErr(` - ${error}`);
393
- }
394
- process.exit(1);
535
+ reportValidationErrors(validationErrors);
395
536
  }
396
537
  const {
397
538
  result,
@@ -416,8 +557,8 @@ tddCmd.command('run <command>').description('Run tests once in TDD mode with loc
416
557
  process.once('SIGINT', sigintHandler);
417
558
  process.once('SIGTERM', sigtermHandler);
418
559
 
419
- // If there are comparisons, generate static report
420
- const hasComparisons = result?.comparisons?.length > 0;
560
+ // If the run captured screenshots, generate a static report for review.
561
+ let hasComparisons = result?.screenshotsCaptured > 0 || result?.comparisons?.length > 0 || result?.summary?.total > 0;
421
562
  if (hasComparisons) {
422
563
  // Note: Tests have completed at this point, so report-data.json is stable.
423
564
  // The report reflects the final state of all comparisons.
@@ -443,16 +584,12 @@ tddCmd.command('run <command>').description('Run tests once in TDD mode with loc
443
584
  process.exit(result?.exitCode || 0);
444
585
  });
445
586
  program.command('run').description('Run tests with Vizzly integration').argument('<command>', 'Test command to run').option('--port <port>', 'Port for screenshot server', '47392').option('-b, --build-name <name>', 'Custom build name').option('--branch <branch>', 'Git branch override').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', Number).option('--min-cluster-size <pixels>', 'Minimum changed-pixel cluster size', Number).option('--batch-size <n>', 'Upload batch size', Number).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', Number).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--allow-no-token', 'Allow running without API token').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (command, options) => {
446
- const globalOptions = program.opts();
587
+ let globalOptions = getGlobalOptions();
447
588
 
448
589
  // Validate options
449
590
  const validationErrors = validateRunOptions(command, options);
450
591
  if (validationErrors.length > 0) {
451
- output.error('Validation errors:');
452
- for (let error of validationErrors) {
453
- output.printErr(` - ${error}`);
454
- }
455
- process.exit(1);
592
+ reportValidationErrors(validationErrors);
456
593
  }
457
594
  try {
458
595
  const result = await runCommand(command, options, globalOptions);
@@ -465,16 +602,12 @@ program.command('run').description('Run tests with Vizzly integration').argument
465
602
  }
466
603
  });
467
604
  program.command('status').description('Check the status of a build').argument('<build-id>', 'Build ID to check status for').action(async (buildId, options) => {
468
- const globalOptions = program.opts();
605
+ let globalOptions = getGlobalOptions();
469
606
 
470
607
  // Validate options
471
608
  const validationErrors = validateStatusOptions(buildId, options);
472
609
  if (validationErrors.length > 0) {
473
- output.error('Validation errors:');
474
- for (let error of validationErrors) {
475
- output.printErr(` - ${error}`);
476
- }
477
- process.exit(1);
610
+ reportValidationErrors(validationErrors);
478
611
  }
479
612
  await statusCommand(buildId, options, globalOptions);
480
613
  });
@@ -489,16 +622,12 @@ Examples:
489
622
  $ vizzly builds -b abc123 --comparisons # Include comparisons
490
623
  $ vizzly builds --json # Output as JSON for scripting
491
624
  `).action(async options => {
492
- const globalOptions = program.opts();
625
+ let globalOptions = getGlobalOptions();
493
626
 
494
627
  // Validate options
495
628
  const validationErrors = validateBuildsOptions(options);
496
629
  if (validationErrors.length > 0) {
497
- output.error('Validation errors:');
498
- for (let error of validationErrors) {
499
- output.printErr(` - ${error}`);
500
- }
501
- process.exit(1);
630
+ reportValidationErrors(validationErrors);
502
631
  }
503
632
  await buildsCommand(options, globalOptions);
504
633
  });
@@ -512,20 +641,16 @@ Examples:
512
641
  $ vizzly comparisons --status changed # Only changed comparisons
513
642
  $ vizzly comparisons --json # Output as JSON for scripting
514
643
  `).action(async options => {
515
- const globalOptions = program.opts();
644
+ let globalOptions = getGlobalOptions();
516
645
 
517
646
  // Validate options
518
647
  const validationErrors = validateComparisonsOptions(options);
519
648
  if (validationErrors.length > 0) {
520
- output.error('Validation errors:');
521
- for (let error of validationErrors) {
522
- output.printErr(` - ${error}`);
523
- }
524
- process.exit(1);
649
+ reportValidationErrors(validationErrors);
525
650
  }
526
651
  await comparisonsCommand(options, globalOptions);
527
652
  });
528
- let contextCmd = program.command('context').description('Fetch build and diff context');
653
+ let contextCmd = program.command('context').description('Fetch build and diff context').showHelpAfterError('(Run vizzly context --help for available commands)').showSuggestionAfterError();
529
654
  contextCmd.command('build').description('Fetch build context').argument('<build-id>', 'Build ID to fetch context for').option('--source <source>', 'Context source: auto, cloud, or local', 'auto').option('--agent', 'Output compact context for LLM agents').option('--full', 'Return the full build context payload with --agent --json').option('--include <items>', 'Add detail to compact agent JSON: screenshots,diffs,comments').addHelpText('after', `
530
655
  Examples:
531
656
  $ vizzly context build abc123
@@ -535,14 +660,10 @@ Examples:
535
660
  $ vizzly context build abc123 --agent --json --include diffs,comments
536
661
  $ vizzly context build abc123 --agent --json --full
537
662
  `).action(async (buildId, options) => {
538
- const globalOptions = program.opts();
663
+ let globalOptions = getGlobalOptions();
539
664
  const validationErrors = validateContextBuildOptions(options);
540
665
  if (validationErrors.length > 0) {
541
- output.error('Validation errors:');
542
- for (let error of validationErrors) {
543
- output.printErr(` - ${error}`);
544
- }
545
- process.exit(1);
666
+ reportValidationErrors(validationErrors);
546
667
  }
547
668
  await contextBuildCommand(buildId, options, globalOptions);
548
669
  });
@@ -553,14 +674,10 @@ Examples:
553
674
  $ vizzly context comparison def456 --similar-limit 5 --recent-limit 5
554
675
  $ vizzly context comparison def456 --json
555
676
  `).action(async (comparisonId, options) => {
556
- const globalOptions = program.opts();
677
+ let globalOptions = getGlobalOptions();
557
678
  const validationErrors = validateContextComparisonOptions(options);
558
679
  if (validationErrors.length > 0) {
559
- output.error('Validation errors:');
560
- for (let error of validationErrors) {
561
- output.printErr(` - ${error}`);
562
- }
563
- process.exit(1);
680
+ reportValidationErrors(validationErrors);
564
681
  }
565
682
  await contextComparisonCommand(comparisonId, options, globalOptions);
566
683
  });
@@ -571,14 +688,10 @@ Examples:
571
688
  $ vizzly context screenshot Dashboard --project storybook --org acme
572
689
  $ vizzly context screenshot Dashboard --json
573
690
  `).action(async (name, options) => {
574
- const globalOptions = program.opts();
691
+ let globalOptions = getGlobalOptions();
575
692
  const validationErrors = validateContextScreenshotOptions(options);
576
693
  if (validationErrors.length > 0) {
577
- output.error('Validation errors:');
578
- for (let error of validationErrors) {
579
- output.printErr(` - ${error}`);
580
- }
581
- process.exit(1);
694
+ reportValidationErrors(validationErrors);
582
695
  }
583
696
  await contextScreenshotCommand(name, options, globalOptions);
584
697
  });
@@ -588,14 +701,10 @@ Examples:
588
701
  $ vizzly context similar fp-dashboard --project storybook --org acme
589
702
  $ vizzly context similar fp-dashboard --json
590
703
  `).action(async (fingerprintHash, options) => {
591
- const globalOptions = program.opts();
704
+ let globalOptions = getGlobalOptions();
592
705
  const validationErrors = validateContextSimilarOptions(options);
593
706
  if (validationErrors.length > 0) {
594
- output.error('Validation errors:');
595
- for (let error of validationErrors) {
596
- output.printErr(` - ${error}`);
597
- }
598
- process.exit(1);
707
+ reportValidationErrors(validationErrors);
599
708
  }
600
709
  await contextSimilarCommand(fingerprintHash, options, globalOptions);
601
710
  });
@@ -606,28 +715,20 @@ Examples:
606
715
  $ vizzly context review-queue --limit 10
607
716
  $ vizzly context review-queue --json
608
717
  `).action(async options => {
609
- const globalOptions = program.opts();
718
+ let globalOptions = getGlobalOptions();
610
719
  const validationErrors = validateContextReviewQueueOptions(options);
611
720
  if (validationErrors.length > 0) {
612
- output.error('Validation errors:');
613
- for (let error of validationErrors) {
614
- output.printErr(` - ${error}`);
615
- }
616
- process.exit(1);
721
+ reportValidationErrors(validationErrors);
617
722
  }
618
723
  await contextReviewQueueCommand(options, globalOptions);
619
724
  });
620
725
  program.command('config').description('Display current configuration').argument('[key]', 'Specific config key to get (dot notation, e.g., comparison.threshold)').action(async (key, options) => {
621
- const globalOptions = program.opts();
726
+ let globalOptions = getGlobalOptions();
622
727
 
623
728
  // Validate options
624
729
  const validationErrors = validateConfigOptions(options);
625
730
  if (validationErrors.length > 0) {
626
- output.error('Validation errors:');
627
- for (let error of validationErrors) {
628
- output.printErr(` - ${error}`);
629
- }
630
- process.exit(1);
731
+ reportValidationErrors(validationErrors);
631
732
  }
632
733
  await configCommand(key, options, globalOptions);
633
734
  });
@@ -640,16 +741,12 @@ Examples:
640
741
 
641
742
  Note: Baselines are stored locally in .vizzly/baselines/ during TDD mode.
642
743
  `).action(async options => {
643
- const globalOptions = program.opts();
744
+ let globalOptions = getGlobalOptions();
644
745
 
645
746
  // Validate options
646
747
  const validationErrors = validateBaselinesOptions(options);
647
748
  if (validationErrors.length > 0) {
648
- output.error('Validation errors:');
649
- for (let error of validationErrors) {
650
- output.printErr(` - ${error}`);
651
- }
652
- process.exit(1);
749
+ reportValidationErrors(validationErrors);
653
750
  }
654
751
  await baselinesCommand(options, globalOptions);
655
752
  });
@@ -664,20 +761,16 @@ Examples:
664
761
  Note: POST is restricted to approve, reject, and comment endpoints.
665
762
  Most operations have dedicated commands (builds, comparisons, approve, etc.).
666
763
  `).action(async (endpoint, options) => {
667
- const globalOptions = program.opts();
764
+ let globalOptions = getGlobalOptions();
668
765
 
669
766
  // Validate options
670
767
  const validationErrors = validateApiOptions(endpoint, options);
671
768
  if (validationErrors.length > 0) {
672
- output.error('Validation errors:');
673
- for (let error of validationErrors) {
674
- output.printErr(` - ${error}`);
675
- }
676
- process.exit(1);
769
+ reportValidationErrors(validationErrors);
677
770
  }
678
771
  await apiCommand(endpoint, options, globalOptions);
679
772
  });
680
- program.command('approve').description('Approve a comparison').argument('<comparison-id>', 'Comparison ID to approve (UUID format)').option('-m, --comment <message>', 'Optional comment explaining the approval').addHelpText('after', `
773
+ program.command('approve').description('Approve a comparison').argument('<comparison-id>', 'Comparison ID to approve').option('-m, --comment <message>', 'Optional comment explaining the approval').addHelpText('after', `
681
774
  Examples:
682
775
  $ vizzly approve abc123-def456-7890 # Approve a comparison
683
776
  $ vizzly approve abc123 -m "LGTM" # Approve with comment
@@ -688,18 +781,14 @@ Workflow:
688
781
  2. Review the changes in the web UI or via URLs in the output
689
782
  3. Approve: vizzly approve <comparison-id>
690
783
  `).action(async (comparisonId, options) => {
691
- const globalOptions = program.opts();
784
+ let globalOptions = getGlobalOptions();
692
785
  const validationErrors = validateApproveOptions(comparisonId, options);
693
786
  if (validationErrors.length > 0) {
694
- output.error('Validation errors:');
695
- for (let error of validationErrors) {
696
- output.printErr(` - ${error}`);
697
- }
698
- process.exit(1);
787
+ reportValidationErrors(validationErrors);
699
788
  }
700
789
  await approveCommand(comparisonId, options, globalOptions);
701
790
  });
702
- program.command('reject').description('Reject a comparison').argument('<comparison-id>', 'Comparison ID to reject (UUID format)').option('-r, --reason <message>', 'Required reason for rejection').addHelpText('after', `
791
+ program.command('reject').description('Reject a comparison').argument('<comparison-id>', 'Comparison ID to reject').option('-r, --reason <message>', 'Required reason for rejection').addHelpText('after', `
703
792
  Examples:
704
793
  $ vizzly reject abc123 -r "Button color is wrong"
705
794
  $ vizzly reject abc123 --reason "Needs design review"
@@ -710,18 +799,14 @@ Workflow:
710
799
  2. Review the changes in the web UI or via URLs in the output
711
800
  3. Reject with reason: vizzly reject <comparison-id> -r "reason"
712
801
  `).action(async (comparisonId, options) => {
713
- const globalOptions = program.opts();
802
+ let globalOptions = getGlobalOptions();
714
803
  const validationErrors = validateRejectOptions(comparisonId, options);
715
804
  if (validationErrors.length > 0) {
716
- output.error('Validation errors:');
717
- for (let error of validationErrors) {
718
- output.printErr(` - ${error}`);
719
- }
720
- process.exit(1);
805
+ reportValidationErrors(validationErrors);
721
806
  }
722
807
  await rejectCommand(comparisonId, options, globalOptions);
723
808
  });
724
- program.command('comment').description('Add a comment to a build').argument('<build-id>', 'Build ID to comment on (UUID format)').argument('<message>', 'Comment message').option('-t, --type <type>', 'Comment type: general, approval, rejection', 'general').addHelpText('after', `
809
+ program.command('comment').description('Add a comment to a build').argument('<build-id>', 'Build ID to comment on').argument('<message>', 'Comment message').option('-t, --type <type>', 'Comment type: general, approval, rejection', 'general').addHelpText('after', `
725
810
  Examples:
726
811
  $ vizzly comment abc123 "Looks good overall"
727
812
  $ vizzly comment abc123 "Approved" -t approval
@@ -732,14 +817,10 @@ Workflow:
732
817
  1. Get build ID: vizzly builds --branch main
733
818
  2. Add comment: vizzly comment <build-id> "Your message"
734
819
  `).action(async (buildId, message, options) => {
735
- const globalOptions = program.opts();
820
+ let globalOptions = getGlobalOptions();
736
821
  const validationErrors = validateCommentOptions(buildId, message, options);
737
822
  if (validationErrors.length > 0) {
738
- output.error('Validation errors:');
739
- for (let error of validationErrors) {
740
- output.printErr(` - ${error}`);
741
- }
742
- process.exit(1);
823
+ reportValidationErrors(validationErrors);
743
824
  }
744
825
  await commentCommand(buildId, message, options, globalOptions);
745
826
  });
@@ -751,14 +832,10 @@ Examples:
751
832
  Note: Shows organizations from your user account (via vizzly login)
752
833
  or the single organization for a project token.
753
834
  `).action(async options => {
754
- const globalOptions = program.opts();
835
+ let globalOptions = getGlobalOptions();
755
836
  const validationErrors = validateOrgsOptions(options);
756
837
  if (validationErrors.length > 0) {
757
- output.error('Validation errors:');
758
- for (let error of validationErrors) {
759
- output.printErr(` - ${error}`);
760
- }
761
- process.exit(1);
838
+ reportValidationErrors(validationErrors);
762
839
  }
763
840
  await orgsCommand(options, globalOptions);
764
841
  });
@@ -773,18 +850,14 @@ Workflow:
773
850
  2. List projects: vizzly projects --org <org-slug>
774
851
  3. Query builds: vizzly builds
775
852
  `).action(async options => {
776
- const globalOptions = program.opts();
853
+ let globalOptions = getGlobalOptions();
777
854
  const validationErrors = validateProjectsOptions(options);
778
855
  if (validationErrors.length > 0) {
779
- output.error('Validation errors:');
780
- for (let error of validationErrors) {
781
- output.printErr(` - ${error}`);
782
- }
783
- process.exit(1);
856
+ reportValidationErrors(validationErrors);
784
857
  }
785
858
  await projectsCommand(options, globalOptions);
786
859
  });
787
- let projectCommand = program.command('project').description('Manage the project linked to this local checkout');
860
+ let projectCommand = program.command('project').description('Manage the project linked to this local checkout').showHelpAfterError('(Run vizzly project --help for available commands)').showSuggestionAfterError();
788
861
  projectCommand.command('link [selector]').description('Link a Vizzly project for cloud uploads').option('--org <slug>', 'Organization slug').option('--project <slug>', 'Project slug').option('--name <name>', 'Credential name shown in Vizzly').option('--expires-at <iso>', 'Optional token expiration timestamp').addHelpText('after', `
789
862
  Examples:
790
863
  $ vizzly project link vizzly/storybook
@@ -793,111 +866,85 @@ Examples:
793
866
  Note: run "vizzly login" first. The linked credential is project-scoped and is
794
867
  used for cloud uploads; your user login remains separate for review actions.
795
868
  `).action(async (selector, options) => {
796
- const globalOptions = program.opts();
869
+ let globalOptions = getGlobalOptions();
797
870
  const validationErrors = validateProjectLinkOptions(selector, options);
798
871
  if (validationErrors.length > 0) {
799
- output.error('Validation errors:');
800
- for (let error of validationErrors) {
801
- output.printErr(` - ${error}`);
802
- }
803
- process.exit(1);
872
+ reportValidationErrors(validationErrors);
804
873
  }
805
874
  await projectLinkCommand(selector, options, globalOptions);
806
875
  });
807
876
  program.command('finalize').description('Finalize a parallel build after all shards complete').argument('<parallel-id>', 'Parallel ID to finalize').action(async (parallelId, options) => {
808
- const globalOptions = program.opts();
877
+ let globalOptions = getGlobalOptions();
809
878
 
810
879
  // Validate options
811
880
  const validationErrors = validateFinalizeOptions(parallelId, options);
812
881
  if (validationErrors.length > 0) {
813
- output.error('Validation errors:');
814
- for (let error of validationErrors) {
815
- output.printErr(` - ${error}`);
816
- }
817
- process.exit(1);
882
+ reportValidationErrors(validationErrors);
818
883
  }
819
884
  await finalizeCommand(parallelId, options, globalOptions);
820
885
  });
821
886
  program.command('preview').description('Upload static files as a preview for a build').argument('[path]', 'Path to static files (dist/, build/, out/)').option('-b, --build <id>', 'Build ID to attach preview to').option('--base <path>', 'Override auto-detected base path').option('--open', 'Open preview URL in browser after upload').option('--dry-run', 'Show what would be uploaded without uploading').option('--public-link', 'Acknowledge that preview URL grants access to anyone with the link (required for private projects)').option('-x, --exclude <pattern>', 'Exclude files/dirs (repeatable, e.g. -x "*.log" -x "temp/")', (val, prev) => prev ? [...prev, val] : [val]).option('-i, --include <pattern>', 'Override default exclusions (repeatable, e.g. -i package.json -i tests/)', (val, prev) => prev ? [...prev, val] : [val]).action(async (path, options) => {
822
- const globalOptions = program.opts();
887
+ let globalOptions = getGlobalOptions();
823
888
 
824
889
  // Show helpful error if path is missing
825
890
  if (!path) {
826
891
  output.error('Path to static files is required');
827
- output.blank();
828
- output.print(' Upload your build output directory:');
829
- output.blank();
830
- output.print(' vizzly preview ./dist');
831
- output.print(' vizzly preview ./build');
832
- output.print(' vizzly preview ./out');
833
- output.blank();
892
+ if (!globalOptions.json) {
893
+ output.blank();
894
+ output.print(' Upload your build output directory:');
895
+ output.blank();
896
+ output.print(' vizzly preview ./dist');
897
+ output.print(' vizzly preview ./build');
898
+ output.print(' vizzly preview ./out');
899
+ output.blank();
900
+ }
834
901
  process.exit(1);
835
902
  }
836
903
 
837
904
  // Validate options
838
905
  const validationErrors = validatePreviewOptions(path, options);
839
906
  if (validationErrors.length > 0) {
840
- output.error('Validation errors:');
841
- for (let error of validationErrors) {
842
- output.printErr(` - ${error}`);
843
- }
844
- process.exit(1);
907
+ reportValidationErrors(validationErrors);
845
908
  }
846
909
  await previewCommand(path, options, globalOptions);
847
910
  });
848
911
  program.command('doctor').description('Run diagnostics to check your environment and configuration').option('--api', 'Include API connectivity checks').action(async options => {
849
- const globalOptions = program.opts();
912
+ let globalOptions = getGlobalOptions();
850
913
 
851
914
  // Validate options
852
915
  const validationErrors = validateDoctorOptions(options);
853
916
  if (validationErrors.length > 0) {
854
- output.error('Validation errors:');
855
- for (let error of validationErrors) {
856
- output.printErr(` - ${error}`);
857
- }
858
- process.exit(1);
917
+ reportValidationErrors(validationErrors);
859
918
  }
860
919
  await doctorCommand(options, globalOptions);
861
920
  });
862
921
  program.command('login').description('Authenticate with your Vizzly account').option('--api-url <url>', 'API URL override').action(async options => {
863
- const globalOptions = program.opts();
922
+ let globalOptions = getGlobalOptions();
864
923
 
865
924
  // Validate options
866
925
  const validationErrors = validateLoginOptions(options);
867
926
  if (validationErrors.length > 0) {
868
- output.error('Validation errors:');
869
- for (let error of validationErrors) {
870
- output.printErr(` - ${error}`);
871
- }
872
- process.exit(1);
927
+ reportValidationErrors(validationErrors);
873
928
  }
874
929
  await loginCommand(options, globalOptions);
875
930
  });
876
931
  program.command('logout').description('Clear stored authentication tokens').option('--api-url <url>', 'API URL override').action(async options => {
877
- const globalOptions = program.opts();
932
+ let globalOptions = getGlobalOptions();
878
933
 
879
934
  // Validate options
880
935
  const validationErrors = validateLogoutOptions(options);
881
936
  if (validationErrors.length > 0) {
882
- output.error('Validation errors:');
883
- for (let error of validationErrors) {
884
- output.printErr(` - ${error}`);
885
- }
886
- process.exit(1);
937
+ reportValidationErrors(validationErrors);
887
938
  }
888
939
  await logoutCommand(options, globalOptions);
889
940
  });
890
941
  program.command('whoami').description('Show current authentication status and user information').option('--api-url <url>', 'API URL override').action(async options => {
891
- const globalOptions = program.opts();
942
+ let globalOptions = getGlobalOptions();
892
943
 
893
944
  // Validate options
894
945
  const validationErrors = validateWhoamiOptions(options);
895
946
  if (validationErrors.length > 0) {
896
- output.error('Validation errors:');
897
- for (let error of validationErrors) {
898
- output.printErr(` - ${error}`);
899
- }
900
- process.exit(1);
947
+ reportValidationErrors(validationErrors);
901
948
  }
902
949
  await whoamiCommand(options, globalOptions);
903
950
  });
@@ -906,6 +953,7 @@ program.command('whoami').description('Show current authentication status and us
906
953
  // This auto-configures the menubar app so it can find package runners/node
907
954
  saveUserPath().catch(() => {});
908
955
  let commandNames = new Set(program.commands.map(command => command.name()));
956
+ let nestedCommandNames = new Map(program.commands.map(command => [command.name(), new Set(command.commands.map(subcommand => subcommand.name()))]));
909
957
  let normalizedArgv = normalizeJsonArgv(process.argv, commandNames);
910
958
  let normalizedGlobals = extractGlobalOptionsFromArgv(normalizedArgv, commandNames);
911
959
  output.configure({
@@ -915,4 +963,19 @@ output.configure({
915
963
  json: normalizedGlobals.jsonArg,
916
964
  resetTimer: false
917
965
  });
918
- program.parse(normalizedArgv);
966
+ let requestedCommand = findRequestedCommand(normalizedArgv, commandNames, nestedCommandNames);
967
+ if (requestedCommand && !requestedCommand.known) {
968
+ output.error(`unknown command '${requestedCommand.name}'`);
969
+ if (!output.isJson() && requestedCommand.helpCommand) {
970
+ output.printErr(`(Run ${requestedCommand.helpCommand} for available commands)`);
971
+ }
972
+ process.exit(1);
973
+ }
974
+ try {
975
+ program.parse(normalizedArgv);
976
+ } catch (error) {
977
+ if (error.exitCode == null) {
978
+ output.error('Unexpected CLI error', error);
979
+ }
980
+ process.exit(error.exitCode ?? 1);
981
+ }