@vizzly-testing/cli 0.34.0 → 0.35.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.
Files changed (37) hide show
  1. package/README.md +67 -7
  2. package/dist/api/core.js +18 -4
  3. package/dist/api/endpoints.js +38 -15
  4. package/dist/api/index.js +1 -1
  5. package/dist/cli.js +270 -194
  6. package/dist/client/index.js +62 -21
  7. package/dist/commands/api.js +9 -3
  8. package/dist/commands/preview.js +30 -26
  9. package/dist/commands/project.js +1 -1
  10. package/dist/commands/run.js +25 -10
  11. package/dist/commands/status.js +14 -1
  12. package/dist/commands/tdd-daemon-process.js +5 -1
  13. package/dist/commands/tdd-daemon.js +217 -139
  14. package/dist/commands/tdd.js +26 -5
  15. package/dist/commands/upload.js +31 -13
  16. package/dist/reporter/reporter-bundle.iife.js +6 -6
  17. package/dist/sdk/index.js +23 -9
  18. package/dist/server/handlers/api-handler.js +3 -2
  19. package/dist/server/handlers/tdd-handler.js +5 -1
  20. package/dist/server/routers/screenshot.js +11 -4
  21. package/dist/services/build-manager.js +6 -1
  22. package/dist/tdd/tdd-service.js +6 -2
  23. package/dist/test-runner/core.js +8 -3
  24. package/dist/test-runner/operations.js +19 -3
  25. package/dist/types/client.d.ts +32 -5
  26. package/dist/types/index.d.ts +33 -5
  27. package/dist/uploader/core.js +41 -9
  28. package/dist/uploader/operations.js +19 -17
  29. package/dist/utils/config-loader.js +13 -6
  30. package/dist/utils/context.js +46 -76
  31. package/dist/utils/environment-config.js +20 -0
  32. package/dist/utils/output.js +8 -5
  33. package/dist/utils/screenshot-options.js +60 -10
  34. package/package.json +1 -1
  35. package/skills/vizzly/SKILL.md +12 -4
  36. package/skills/vizzly/references/cli-context.md +8 -2
  37. package/skills/vizzly/references/setup-ci.md +3 -1
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,8 +206,21 @@ 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;
223
+ let tokenArg = null;
210
224
  let verboseMode = false;
211
225
  let logLevelArg = null;
212
226
  let jsonArg = null;
@@ -214,6 +228,11 @@ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
214
228
  if ((argv[i] === '-c' || argv[i] === '--config') && argv[i + 1]) {
215
229
  configPath = argv[i + 1];
216
230
  }
231
+ if (argv[i] === '--token' && argv[i + 1]) {
232
+ tokenArg = argv[i + 1];
233
+ } else if (argv[i].startsWith('--token=')) {
234
+ tokenArg = argv[i].substring('--token='.length);
235
+ }
217
236
  if (argv[i] === '-v' || argv[i] === '--verbose') {
218
237
  verboseMode = true;
219
238
  }
@@ -234,6 +253,7 @@ function extractGlobalOptionsFromArgv(argv, commandNames = null) {
234
253
  }
235
254
  return {
236
255
  configPath,
256
+ tokenArg,
237
257
  verboseMode,
238
258
  logLevelArg,
239
259
  jsonArg
@@ -252,14 +272,139 @@ function normalizeJsonArgv(argv, commandNames) {
252
272
  }
253
273
  return normalizedArgv;
254
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
+ }
255
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({
256
394
  formatHelp
257
395
  });
396
+ program.configureOutput({
397
+ writeErr: writeCommanderError
398
+ });
399
+ program.showHelpAfterError('(Run vizzly --help for available commands)');
400
+ program.showSuggestionAfterError();
401
+ program.exitOverride();
258
402
 
259
403
  // Load plugins before defining commands
260
404
  // We need to manually parse to get the config option early
261
405
  let {
262
406
  configPath,
407
+ tokenArg,
263
408
  verboseMode,
264
409
  logLevelArg,
265
410
  jsonArg
@@ -280,9 +425,11 @@ output.configure({
280
425
  color: colorOverride,
281
426
  json: jsonArg
282
427
  });
283
- const config = await loadConfig(configPath, {});
284
- const services = createServices(config);
285
- const pluginServices = createPluginServices(services);
428
+ let config = await loadConfig(configPath, {
429
+ token: tokenArg
430
+ });
431
+ let services = createServices(config);
432
+ let pluginServices = createPluginServices(services);
286
433
  let plugins = [];
287
434
  try {
288
435
  plugins = await loadPlugins(configPath, config);
@@ -304,34 +451,41 @@ try {
304
451
  output.debug(`Plugin loading failed: ${error.message}`);
305
452
  }
306
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 => {
307
- let globalOptions = program.opts();
454
+ let globalOptions = getGlobalOptions();
308
455
  await init({
309
456
  ...globalOptions,
310
457
  ...options,
311
458
  plugins
312
459
  });
313
460
  });
314
- program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory or file').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('--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) => {
315
- const globalOptions = program.opts();
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) => {
462
+ let globalOptions = getGlobalOptions();
316
463
 
317
464
  // Validate options
318
465
  const validationErrors = validateUploadOptions(path, options);
319
466
  if (validationErrors.length > 0) {
320
- output.error('Validation errors:');
321
- for (let error of validationErrors) {
322
- output.printErr(` - ${error}`);
323
- }
467
+ reportValidationErrors(validationErrors);
468
+ }
469
+ if (!existsSync(path)) {
470
+ output.error(`Path does not exist: ${path}`);
324
471
  process.exit(1);
325
472
  }
326
- await uploadCommand(path, options, globalOptions);
473
+ if (!statSync(path).isDirectory()) {
474
+ output.error(`Path is not a directory: ${path}`);
475
+ process.exit(1);
476
+ }
477
+ let result = await uploadCommand(path, options, globalOptions);
478
+ if (result?.exitCode) {
479
+ process.exit(result.exitCode);
480
+ }
327
481
  });
328
482
 
329
483
  // TDD command with subcommands - local visual review with an interactive dashboard
330
- 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();
331
485
 
332
486
  // TDD Start - Background server
333
- 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 => {
334
- 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();
335
489
 
336
490
  // If this is a daemon child process, run the server directly
337
491
  if (options.daemonChild) {
@@ -340,45 +494,45 @@ tddCmd.command('start').description('Start background TDD server with dashboard'
340
494
  }
341
495
  let validationErrors = validateTddStartOptions(options);
342
496
  if (validationErrors.length > 0) {
343
- output.error('Validation errors:');
344
- for (let error of validationErrors) {
345
- output.printErr(` - ${error}`);
346
- }
347
- process.exit(1);
497
+ reportValidationErrors(validationErrors);
348
498
  }
349
499
  await tddStartCommand(options, globalOptions);
350
500
  });
351
501
 
352
502
  // TDD Stop - Kill background server
353
- tddCmd.command('stop').description('Stop background TDD server').action(async options => {
354
- 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
+ }
355
509
  await tddStopCommand(options, globalOptions);
356
510
  });
357
511
 
358
512
  // TDD Status - Check server status
359
- tddCmd.command('status').description('Check TDD server status').action(async options => {
360
- 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
+ }
361
519
  await tddStatusCommand(options, globalOptions);
362
520
  });
363
521
 
364
522
  // TDD List - List all running servers (for menubar app integration)
365
523
  tddCmd.command('list').description('List all running TDD servers').action(async options => {
366
- const globalOptions = program.opts();
524
+ let globalOptions = getGlobalOptions();
367
525
  await tddListCommand(options, globalOptions);
368
526
  });
369
527
 
370
528
  // TDD Run - One-off test run with ephemeral server (generates static report)
371
- 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) => {
372
- 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();
373
531
 
374
532
  // Validate options
375
533
  const validationErrors = validateTddOptions(command, options);
376
534
  if (validationErrors.length > 0) {
377
- output.error('Validation errors:');
378
- for (let error of validationErrors) {
379
- output.printErr(` - ${error}`);
380
- }
381
- process.exit(1);
535
+ reportValidationErrors(validationErrors);
382
536
  }
383
537
  const {
384
538
  result,
@@ -403,8 +557,8 @@ tddCmd.command('run <command>').description('Run tests once in TDD mode with loc
403
557
  process.once('SIGINT', sigintHandler);
404
558
  process.once('SIGTERM', sigtermHandler);
405
559
 
406
- // If there are comparisons, generate static report
407
- 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;
408
562
  if (hasComparisons) {
409
563
  // Note: Tests have completed at this point, so report-data.json is stable.
410
564
  // The report reflects the final state of all comparisons.
@@ -430,16 +584,12 @@ tddCmd.command('run <command>').description('Run tests once in TDD mode with loc
430
584
  process.exit(result?.exitCode || 0);
431
585
  });
432
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) => {
433
- const globalOptions = program.opts();
587
+ let globalOptions = getGlobalOptions();
434
588
 
435
589
  // Validate options
436
590
  const validationErrors = validateRunOptions(command, options);
437
591
  if (validationErrors.length > 0) {
438
- output.error('Validation errors:');
439
- for (let error of validationErrors) {
440
- output.printErr(` - ${error}`);
441
- }
442
- process.exit(1);
592
+ reportValidationErrors(validationErrors);
443
593
  }
444
594
  try {
445
595
  const result = await runCommand(command, options, globalOptions);
@@ -452,16 +602,12 @@ program.command('run').description('Run tests with Vizzly integration').argument
452
602
  }
453
603
  });
454
604
  program.command('status').description('Check the status of a build').argument('<build-id>', 'Build ID to check status for').action(async (buildId, options) => {
455
- const globalOptions = program.opts();
605
+ let globalOptions = getGlobalOptions();
456
606
 
457
607
  // Validate options
458
608
  const validationErrors = validateStatusOptions(buildId, options);
459
609
  if (validationErrors.length > 0) {
460
- output.error('Validation errors:');
461
- for (let error of validationErrors) {
462
- output.printErr(` - ${error}`);
463
- }
464
- process.exit(1);
610
+ reportValidationErrors(validationErrors);
465
611
  }
466
612
  await statusCommand(buildId, options, globalOptions);
467
613
  });
@@ -476,16 +622,12 @@ Examples:
476
622
  $ vizzly builds -b abc123 --comparisons # Include comparisons
477
623
  $ vizzly builds --json # Output as JSON for scripting
478
624
  `).action(async options => {
479
- const globalOptions = program.opts();
625
+ let globalOptions = getGlobalOptions();
480
626
 
481
627
  // Validate options
482
628
  const validationErrors = validateBuildsOptions(options);
483
629
  if (validationErrors.length > 0) {
484
- output.error('Validation errors:');
485
- for (let error of validationErrors) {
486
- output.printErr(` - ${error}`);
487
- }
488
- process.exit(1);
630
+ reportValidationErrors(validationErrors);
489
631
  }
490
632
  await buildsCommand(options, globalOptions);
491
633
  });
@@ -499,20 +641,16 @@ Examples:
499
641
  $ vizzly comparisons --status changed # Only changed comparisons
500
642
  $ vizzly comparisons --json # Output as JSON for scripting
501
643
  `).action(async options => {
502
- const globalOptions = program.opts();
644
+ let globalOptions = getGlobalOptions();
503
645
 
504
646
  // Validate options
505
647
  const validationErrors = validateComparisonsOptions(options);
506
648
  if (validationErrors.length > 0) {
507
- output.error('Validation errors:');
508
- for (let error of validationErrors) {
509
- output.printErr(` - ${error}`);
510
- }
511
- process.exit(1);
649
+ reportValidationErrors(validationErrors);
512
650
  }
513
651
  await comparisonsCommand(options, globalOptions);
514
652
  });
515
- 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();
516
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', `
517
655
  Examples:
518
656
  $ vizzly context build abc123
@@ -522,14 +660,10 @@ Examples:
522
660
  $ vizzly context build abc123 --agent --json --include diffs,comments
523
661
  $ vizzly context build abc123 --agent --json --full
524
662
  `).action(async (buildId, options) => {
525
- const globalOptions = program.opts();
663
+ let globalOptions = getGlobalOptions();
526
664
  const validationErrors = validateContextBuildOptions(options);
527
665
  if (validationErrors.length > 0) {
528
- output.error('Validation errors:');
529
- for (let error of validationErrors) {
530
- output.printErr(` - ${error}`);
531
- }
532
- process.exit(1);
666
+ reportValidationErrors(validationErrors);
533
667
  }
534
668
  await contextBuildCommand(buildId, options, globalOptions);
535
669
  });
@@ -540,14 +674,10 @@ Examples:
540
674
  $ vizzly context comparison def456 --similar-limit 5 --recent-limit 5
541
675
  $ vizzly context comparison def456 --json
542
676
  `).action(async (comparisonId, options) => {
543
- const globalOptions = program.opts();
677
+ let globalOptions = getGlobalOptions();
544
678
  const validationErrors = validateContextComparisonOptions(options);
545
679
  if (validationErrors.length > 0) {
546
- output.error('Validation errors:');
547
- for (let error of validationErrors) {
548
- output.printErr(` - ${error}`);
549
- }
550
- process.exit(1);
680
+ reportValidationErrors(validationErrors);
551
681
  }
552
682
  await contextComparisonCommand(comparisonId, options, globalOptions);
553
683
  });
@@ -558,14 +688,10 @@ Examples:
558
688
  $ vizzly context screenshot Dashboard --project storybook --org acme
559
689
  $ vizzly context screenshot Dashboard --json
560
690
  `).action(async (name, options) => {
561
- const globalOptions = program.opts();
691
+ let globalOptions = getGlobalOptions();
562
692
  const validationErrors = validateContextScreenshotOptions(options);
563
693
  if (validationErrors.length > 0) {
564
- output.error('Validation errors:');
565
- for (let error of validationErrors) {
566
- output.printErr(` - ${error}`);
567
- }
568
- process.exit(1);
694
+ reportValidationErrors(validationErrors);
569
695
  }
570
696
  await contextScreenshotCommand(name, options, globalOptions);
571
697
  });
@@ -575,14 +701,10 @@ Examples:
575
701
  $ vizzly context similar fp-dashboard --project storybook --org acme
576
702
  $ vizzly context similar fp-dashboard --json
577
703
  `).action(async (fingerprintHash, options) => {
578
- const globalOptions = program.opts();
704
+ let globalOptions = getGlobalOptions();
579
705
  const validationErrors = validateContextSimilarOptions(options);
580
706
  if (validationErrors.length > 0) {
581
- output.error('Validation errors:');
582
- for (let error of validationErrors) {
583
- output.printErr(` - ${error}`);
584
- }
585
- process.exit(1);
707
+ reportValidationErrors(validationErrors);
586
708
  }
587
709
  await contextSimilarCommand(fingerprintHash, options, globalOptions);
588
710
  });
@@ -593,28 +715,20 @@ Examples:
593
715
  $ vizzly context review-queue --limit 10
594
716
  $ vizzly context review-queue --json
595
717
  `).action(async options => {
596
- const globalOptions = program.opts();
718
+ let globalOptions = getGlobalOptions();
597
719
  const validationErrors = validateContextReviewQueueOptions(options);
598
720
  if (validationErrors.length > 0) {
599
- output.error('Validation errors:');
600
- for (let error of validationErrors) {
601
- output.printErr(` - ${error}`);
602
- }
603
- process.exit(1);
721
+ reportValidationErrors(validationErrors);
604
722
  }
605
723
  await contextReviewQueueCommand(options, globalOptions);
606
724
  });
607
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) => {
608
- const globalOptions = program.opts();
726
+ let globalOptions = getGlobalOptions();
609
727
 
610
728
  // Validate options
611
729
  const validationErrors = validateConfigOptions(options);
612
730
  if (validationErrors.length > 0) {
613
- output.error('Validation errors:');
614
- for (let error of validationErrors) {
615
- output.printErr(` - ${error}`);
616
- }
617
- process.exit(1);
731
+ reportValidationErrors(validationErrors);
618
732
  }
619
733
  await configCommand(key, options, globalOptions);
620
734
  });
@@ -627,16 +741,12 @@ Examples:
627
741
 
628
742
  Note: Baselines are stored locally in .vizzly/baselines/ during TDD mode.
629
743
  `).action(async options => {
630
- const globalOptions = program.opts();
744
+ let globalOptions = getGlobalOptions();
631
745
 
632
746
  // Validate options
633
747
  const validationErrors = validateBaselinesOptions(options);
634
748
  if (validationErrors.length > 0) {
635
- output.error('Validation errors:');
636
- for (let error of validationErrors) {
637
- output.printErr(` - ${error}`);
638
- }
639
- process.exit(1);
749
+ reportValidationErrors(validationErrors);
640
750
  }
641
751
  await baselinesCommand(options, globalOptions);
642
752
  });
@@ -651,20 +761,16 @@ Examples:
651
761
  Note: POST is restricted to approve, reject, and comment endpoints.
652
762
  Most operations have dedicated commands (builds, comparisons, approve, etc.).
653
763
  `).action(async (endpoint, options) => {
654
- const globalOptions = program.opts();
764
+ let globalOptions = getGlobalOptions();
655
765
 
656
766
  // Validate options
657
767
  const validationErrors = validateApiOptions(endpoint, options);
658
768
  if (validationErrors.length > 0) {
659
- output.error('Validation errors:');
660
- for (let error of validationErrors) {
661
- output.printErr(` - ${error}`);
662
- }
663
- process.exit(1);
769
+ reportValidationErrors(validationErrors);
664
770
  }
665
771
  await apiCommand(endpoint, options, globalOptions);
666
772
  });
667
- 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', `
668
774
  Examples:
669
775
  $ vizzly approve abc123-def456-7890 # Approve a comparison
670
776
  $ vizzly approve abc123 -m "LGTM" # Approve with comment
@@ -675,18 +781,14 @@ Workflow:
675
781
  2. Review the changes in the web UI or via URLs in the output
676
782
  3. Approve: vizzly approve <comparison-id>
677
783
  `).action(async (comparisonId, options) => {
678
- const globalOptions = program.opts();
784
+ let globalOptions = getGlobalOptions();
679
785
  const validationErrors = validateApproveOptions(comparisonId, options);
680
786
  if (validationErrors.length > 0) {
681
- output.error('Validation errors:');
682
- for (let error of validationErrors) {
683
- output.printErr(` - ${error}`);
684
- }
685
- process.exit(1);
787
+ reportValidationErrors(validationErrors);
686
788
  }
687
789
  await approveCommand(comparisonId, options, globalOptions);
688
790
  });
689
- 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', `
690
792
  Examples:
691
793
  $ vizzly reject abc123 -r "Button color is wrong"
692
794
  $ vizzly reject abc123 --reason "Needs design review"
@@ -697,18 +799,14 @@ Workflow:
697
799
  2. Review the changes in the web UI or via URLs in the output
698
800
  3. Reject with reason: vizzly reject <comparison-id> -r "reason"
699
801
  `).action(async (comparisonId, options) => {
700
- const globalOptions = program.opts();
802
+ let globalOptions = getGlobalOptions();
701
803
  const validationErrors = validateRejectOptions(comparisonId, options);
702
804
  if (validationErrors.length > 0) {
703
- output.error('Validation errors:');
704
- for (let error of validationErrors) {
705
- output.printErr(` - ${error}`);
706
- }
707
- process.exit(1);
805
+ reportValidationErrors(validationErrors);
708
806
  }
709
807
  await rejectCommand(comparisonId, options, globalOptions);
710
808
  });
711
- 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', `
712
810
  Examples:
713
811
  $ vizzly comment abc123 "Looks good overall"
714
812
  $ vizzly comment abc123 "Approved" -t approval
@@ -719,14 +817,10 @@ Workflow:
719
817
  1. Get build ID: vizzly builds --branch main
720
818
  2. Add comment: vizzly comment <build-id> "Your message"
721
819
  `).action(async (buildId, message, options) => {
722
- const globalOptions = program.opts();
820
+ let globalOptions = getGlobalOptions();
723
821
  const validationErrors = validateCommentOptions(buildId, message, options);
724
822
  if (validationErrors.length > 0) {
725
- output.error('Validation errors:');
726
- for (let error of validationErrors) {
727
- output.printErr(` - ${error}`);
728
- }
729
- process.exit(1);
823
+ reportValidationErrors(validationErrors);
730
824
  }
731
825
  await commentCommand(buildId, message, options, globalOptions);
732
826
  });
@@ -738,14 +832,10 @@ Examples:
738
832
  Note: Shows organizations from your user account (via vizzly login)
739
833
  or the single organization for a project token.
740
834
  `).action(async options => {
741
- const globalOptions = program.opts();
835
+ let globalOptions = getGlobalOptions();
742
836
  const validationErrors = validateOrgsOptions(options);
743
837
  if (validationErrors.length > 0) {
744
- output.error('Validation errors:');
745
- for (let error of validationErrors) {
746
- output.printErr(` - ${error}`);
747
- }
748
- process.exit(1);
838
+ reportValidationErrors(validationErrors);
749
839
  }
750
840
  await orgsCommand(options, globalOptions);
751
841
  });
@@ -760,18 +850,14 @@ Workflow:
760
850
  2. List projects: vizzly projects --org <org-slug>
761
851
  3. Query builds: vizzly builds
762
852
  `).action(async options => {
763
- const globalOptions = program.opts();
853
+ let globalOptions = getGlobalOptions();
764
854
  const validationErrors = validateProjectsOptions(options);
765
855
  if (validationErrors.length > 0) {
766
- output.error('Validation errors:');
767
- for (let error of validationErrors) {
768
- output.printErr(` - ${error}`);
769
- }
770
- process.exit(1);
856
+ reportValidationErrors(validationErrors);
771
857
  }
772
858
  await projectsCommand(options, globalOptions);
773
859
  });
774
- 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();
775
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', `
776
862
  Examples:
777
863
  $ vizzly project link vizzly/storybook
@@ -780,111 +866,85 @@ Examples:
780
866
  Note: run "vizzly login" first. The linked credential is project-scoped and is
781
867
  used for cloud uploads; your user login remains separate for review actions.
782
868
  `).action(async (selector, options) => {
783
- const globalOptions = program.opts();
869
+ let globalOptions = getGlobalOptions();
784
870
  const validationErrors = validateProjectLinkOptions(selector, options);
785
871
  if (validationErrors.length > 0) {
786
- output.error('Validation errors:');
787
- for (let error of validationErrors) {
788
- output.printErr(` - ${error}`);
789
- }
790
- process.exit(1);
872
+ reportValidationErrors(validationErrors);
791
873
  }
792
874
  await projectLinkCommand(selector, options, globalOptions);
793
875
  });
794
876
  program.command('finalize').description('Finalize a parallel build after all shards complete').argument('<parallel-id>', 'Parallel ID to finalize').action(async (parallelId, options) => {
795
- const globalOptions = program.opts();
877
+ let globalOptions = getGlobalOptions();
796
878
 
797
879
  // Validate options
798
880
  const validationErrors = validateFinalizeOptions(parallelId, options);
799
881
  if (validationErrors.length > 0) {
800
- output.error('Validation errors:');
801
- for (let error of validationErrors) {
802
- output.printErr(` - ${error}`);
803
- }
804
- process.exit(1);
882
+ reportValidationErrors(validationErrors);
805
883
  }
806
884
  await finalizeCommand(parallelId, options, globalOptions);
807
885
  });
808
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) => {
809
- const globalOptions = program.opts();
887
+ let globalOptions = getGlobalOptions();
810
888
 
811
889
  // Show helpful error if path is missing
812
890
  if (!path) {
813
891
  output.error('Path to static files is required');
814
- output.blank();
815
- output.print(' Upload your build output directory:');
816
- output.blank();
817
- output.print(' vizzly preview ./dist');
818
- output.print(' vizzly preview ./build');
819
- output.print(' vizzly preview ./out');
820
- 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
+ }
821
901
  process.exit(1);
822
902
  }
823
903
 
824
904
  // Validate options
825
905
  const validationErrors = validatePreviewOptions(path, options);
826
906
  if (validationErrors.length > 0) {
827
- output.error('Validation errors:');
828
- for (let error of validationErrors) {
829
- output.printErr(` - ${error}`);
830
- }
831
- process.exit(1);
907
+ reportValidationErrors(validationErrors);
832
908
  }
833
909
  await previewCommand(path, options, globalOptions);
834
910
  });
835
911
  program.command('doctor').description('Run diagnostics to check your environment and configuration').option('--api', 'Include API connectivity checks').action(async options => {
836
- const globalOptions = program.opts();
912
+ let globalOptions = getGlobalOptions();
837
913
 
838
914
  // Validate options
839
915
  const validationErrors = validateDoctorOptions(options);
840
916
  if (validationErrors.length > 0) {
841
- output.error('Validation errors:');
842
- for (let error of validationErrors) {
843
- output.printErr(` - ${error}`);
844
- }
845
- process.exit(1);
917
+ reportValidationErrors(validationErrors);
846
918
  }
847
919
  await doctorCommand(options, globalOptions);
848
920
  });
849
921
  program.command('login').description('Authenticate with your Vizzly account').option('--api-url <url>', 'API URL override').action(async options => {
850
- const globalOptions = program.opts();
922
+ let globalOptions = getGlobalOptions();
851
923
 
852
924
  // Validate options
853
925
  const validationErrors = validateLoginOptions(options);
854
926
  if (validationErrors.length > 0) {
855
- output.error('Validation errors:');
856
- for (let error of validationErrors) {
857
- output.printErr(` - ${error}`);
858
- }
859
- process.exit(1);
927
+ reportValidationErrors(validationErrors);
860
928
  }
861
929
  await loginCommand(options, globalOptions);
862
930
  });
863
931
  program.command('logout').description('Clear stored authentication tokens').option('--api-url <url>', 'API URL override').action(async options => {
864
- const globalOptions = program.opts();
932
+ let globalOptions = getGlobalOptions();
865
933
 
866
934
  // Validate options
867
935
  const validationErrors = validateLogoutOptions(options);
868
936
  if (validationErrors.length > 0) {
869
- output.error('Validation errors:');
870
- for (let error of validationErrors) {
871
- output.printErr(` - ${error}`);
872
- }
873
- process.exit(1);
937
+ reportValidationErrors(validationErrors);
874
938
  }
875
939
  await logoutCommand(options, globalOptions);
876
940
  });
877
941
  program.command('whoami').description('Show current authentication status and user information').option('--api-url <url>', 'API URL override').action(async options => {
878
- const globalOptions = program.opts();
942
+ let globalOptions = getGlobalOptions();
879
943
 
880
944
  // Validate options
881
945
  const validationErrors = validateWhoamiOptions(options);
882
946
  if (validationErrors.length > 0) {
883
- output.error('Validation errors:');
884
- for (let error of validationErrors) {
885
- output.printErr(` - ${error}`);
886
- }
887
- process.exit(1);
947
+ reportValidationErrors(validationErrors);
888
948
  }
889
949
  await whoamiCommand(options, globalOptions);
890
950
  });
@@ -893,6 +953,7 @@ program.command('whoami').description('Show current authentication status and us
893
953
  // This auto-configures the menubar app so it can find package runners/node
894
954
  saveUserPath().catch(() => {});
895
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()))]));
896
957
  let normalizedArgv = normalizeJsonArgv(process.argv, commandNames);
897
958
  let normalizedGlobals = extractGlobalOptionsFromArgv(normalizedArgv, commandNames);
898
959
  output.configure({
@@ -902,4 +963,19 @@ output.configure({
902
963
  json: normalizedGlobals.jsonArg,
903
964
  resetTimer: false
904
965
  });
905
- 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
+ }