@vizzly-testing/cli 0.26.1 → 0.27.0
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 +250 -5
- package/dist/commands/api.js +190 -0
- package/dist/commands/baselines.js +265 -0
- package/dist/commands/builds.js +274 -0
- package/dist/commands/comparisons.js +345 -0
- package/dist/commands/config-cmd.js +184 -0
- package/dist/commands/init.js +54 -12
- package/dist/commands/orgs.js +79 -0
- package/dist/commands/preview.js +13 -3
- package/dist/commands/project.js +69 -15
- package/dist/commands/projects.js +96 -0
- package/dist/commands/review.js +319 -0
- package/dist/commands/run.js +142 -2
- package/dist/commands/tdd-daemon.js +78 -12
- package/dist/commands/tdd.js +61 -2
- package/dist/commands/upload.js +94 -2
- package/dist/server/handlers/tdd-handler.js +23 -2
- package/dist/utils/config-loader.js +13 -1
- package/dist/utils/output.js +107 -4
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import 'dotenv/config';
|
|
3
3
|
import { program } from 'commander';
|
|
4
|
+
import { apiCommand, validateApiOptions } from './commands/api.js';
|
|
5
|
+
import { baselinesCommand, validateBaselinesOptions } from './commands/baselines.js';
|
|
6
|
+
import { buildsCommand, validateBuildsOptions } from './commands/builds.js';
|
|
7
|
+
import { comparisonsCommand, validateComparisonsOptions } from './commands/comparisons.js';
|
|
8
|
+
import { configCommand, validateConfigOptions } from './commands/config-cmd.js';
|
|
4
9
|
import { doctorCommand, validateDoctorOptions } from './commands/doctor.js';
|
|
5
10
|
import { finalizeCommand, validateFinalizeOptions } from './commands/finalize.js';
|
|
6
11
|
import { init } from './commands/init.js';
|
|
7
12
|
import { loginCommand, validateLoginOptions } from './commands/login.js';
|
|
8
13
|
import { logoutCommand, validateLogoutOptions } from './commands/logout.js';
|
|
14
|
+
import { orgsCommand, validateOrgsOptions } from './commands/orgs.js';
|
|
9
15
|
import { previewCommand, validatePreviewOptions } from './commands/preview.js';
|
|
10
16
|
import { projectListCommand, projectRemoveCommand, projectSelectCommand, projectTokenCommand, validateProjectOptions } from './commands/project.js';
|
|
17
|
+
import { projectsCommand, validateProjectsOptions } from './commands/projects.js';
|
|
18
|
+
import { approveCommand, commentCommand, rejectCommand, validateApproveOptions, validateCommentOptions, validateRejectOptions } from './commands/review.js';
|
|
11
19
|
import { runCommand, validateRunOptions } from './commands/run.js';
|
|
12
20
|
import { statusCommand, validateStatusOptions } from './commands/status.js';
|
|
13
21
|
import { tddCommand, validateTddOptions } from './commands/tdd.js';
|
|
@@ -64,17 +72,27 @@ const formatHelp = (cmd, helper) => {
|
|
|
64
72
|
key: 'core',
|
|
65
73
|
icon: '▸',
|
|
66
74
|
title: 'Core',
|
|
67
|
-
names: ['run', 'tdd', 'upload', 'status', 'finalize', 'preview']
|
|
75
|
+
names: ['run', 'tdd', 'upload', 'status', 'finalize', 'preview', 'builds', 'comparisons']
|
|
76
|
+
}, {
|
|
77
|
+
key: 'review',
|
|
78
|
+
icon: '▸',
|
|
79
|
+
title: 'Review',
|
|
80
|
+
names: ['approve', 'reject', 'comment']
|
|
68
81
|
}, {
|
|
69
82
|
key: 'setup',
|
|
70
83
|
icon: '▸',
|
|
71
84
|
title: 'Setup',
|
|
72
|
-
names: ['init', 'doctor']
|
|
85
|
+
names: ['init', 'doctor', 'config', 'baselines']
|
|
86
|
+
}, {
|
|
87
|
+
key: 'advanced',
|
|
88
|
+
icon: '▸',
|
|
89
|
+
title: 'Advanced',
|
|
90
|
+
names: ['api']
|
|
73
91
|
}, {
|
|
74
92
|
key: 'auth',
|
|
75
93
|
icon: '▸',
|
|
76
94
|
title: 'Account',
|
|
77
|
-
names: ['login', 'logout', 'whoami']
|
|
95
|
+
names: ['login', 'logout', 'whoami', 'orgs', 'projects']
|
|
78
96
|
}, {
|
|
79
97
|
key: 'project',
|
|
80
98
|
icon: '▸',
|
|
@@ -83,7 +101,9 @@ const formatHelp = (cmd, helper) => {
|
|
|
83
101
|
}];
|
|
84
102
|
let grouped = {
|
|
85
103
|
core: [],
|
|
104
|
+
review: [],
|
|
86
105
|
setup: [],
|
|
106
|
+
advanced: [],
|
|
87
107
|
auth: [],
|
|
88
108
|
project: [],
|
|
89
109
|
other: []
|
|
@@ -189,7 +209,7 @@ const formatHelp = (cmd, helper) => {
|
|
|
189
209
|
lines.push('');
|
|
190
210
|
return lines.join('\n');
|
|
191
211
|
};
|
|
192
|
-
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', '
|
|
212
|
+
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({
|
|
193
213
|
formatHelp
|
|
194
214
|
});
|
|
195
215
|
|
|
@@ -198,6 +218,7 @@ program.name('vizzly').description('Vizzly CLI for visual regression testing').v
|
|
|
198
218
|
let configPath = null;
|
|
199
219
|
let verboseMode = false;
|
|
200
220
|
let logLevelArg = null;
|
|
221
|
+
let jsonArg = null;
|
|
201
222
|
for (let i = 0; i < process.argv.length; i++) {
|
|
202
223
|
if ((process.argv[i] === '-c' || process.argv[i] === '--config') && process.argv[i + 1]) {
|
|
203
224
|
configPath = process.argv[i + 1];
|
|
@@ -208,6 +229,19 @@ for (let i = 0; i < process.argv.length; i++) {
|
|
|
208
229
|
if (process.argv[i] === '--log-level' && process.argv[i + 1]) {
|
|
209
230
|
logLevelArg = process.argv[i + 1];
|
|
210
231
|
}
|
|
232
|
+
// Handle --json with optional field selection
|
|
233
|
+
// --json (no value) = true, --json=fields or --json fields = "fields"
|
|
234
|
+
if (process.argv[i] === '--json') {
|
|
235
|
+
let nextArg = process.argv[i + 1];
|
|
236
|
+
// If next arg exists and doesn't start with -, it's the fields value
|
|
237
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
238
|
+
jsonArg = nextArg;
|
|
239
|
+
} else {
|
|
240
|
+
jsonArg = true;
|
|
241
|
+
}
|
|
242
|
+
} else if (process.argv[i].startsWith('--json=')) {
|
|
243
|
+
jsonArg = process.argv[i].substring('--json='.length);
|
|
244
|
+
}
|
|
211
245
|
}
|
|
212
246
|
|
|
213
247
|
// Configure output early
|
|
@@ -223,7 +257,7 @@ output.configure({
|
|
|
223
257
|
logLevel: logLevelArg,
|
|
224
258
|
verbose: verboseMode,
|
|
225
259
|
color: colorOverride,
|
|
226
|
-
json:
|
|
260
|
+
json: jsonArg
|
|
227
261
|
});
|
|
228
262
|
const config = await loadConfig(configPath, {});
|
|
229
263
|
const services = createServices(config);
|
|
@@ -405,6 +439,217 @@ program.command('status').description('Check the status of a build').argument('<
|
|
|
405
439
|
}
|
|
406
440
|
await statusCommand(buildId, options, globalOptions);
|
|
407
441
|
});
|
|
442
|
+
program.command('builds').description('List and query builds').option('-b, --build <id>', 'Get a specific build by ID').option('--branch <branch>', 'Filter by branch').option('--status <status>', 'Filter by status (created, pending, processing, completed, failed)').option('--environment <env>', 'Filter by environment').option('--limit <n>', 'Maximum results to return (1-250)', val => parseInt(val, 10), 20).option('--offset <n>', 'Skip first N results', val => parseInt(val, 10), 0).option('--comparisons', 'Include comparisons when fetching a specific build').addHelpText('after', `
|
|
443
|
+
Examples:
|
|
444
|
+
$ vizzly builds # List recent builds
|
|
445
|
+
$ vizzly builds --branch main # Filter by branch
|
|
446
|
+
$ vizzly builds --status completed # Filter by status
|
|
447
|
+
$ vizzly builds -b abc123-def456 # Get specific build by ID
|
|
448
|
+
$ vizzly builds -b abc123 --comparisons # Include comparisons
|
|
449
|
+
$ vizzly builds --json # Output as JSON for scripting
|
|
450
|
+
`).action(async options => {
|
|
451
|
+
const globalOptions = program.opts();
|
|
452
|
+
|
|
453
|
+
// Validate options
|
|
454
|
+
const validationErrors = validateBuildsOptions(options);
|
|
455
|
+
if (validationErrors.length > 0) {
|
|
456
|
+
output.error('Validation errors:');
|
|
457
|
+
for (let error of validationErrors) {
|
|
458
|
+
output.printErr(` - ${error}`);
|
|
459
|
+
}
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
await buildsCommand(options, globalOptions);
|
|
463
|
+
});
|
|
464
|
+
program.command('comparisons').description('Query and search comparisons').option('-b, --build <id>', 'Get comparisons for a specific build').option('--id <id>', 'Get a specific comparison by ID').option('--name <pattern>', 'Search comparisons by name (supports wildcards)').option('--status <status>', 'Filter by status (identical, new, changed)').option('--branch <branch>', 'Filter by branch (for name search)').option('--limit <n>', 'Maximum results to return (1-250)', val => parseInt(val, 10), 50).option('--offset <n>', 'Skip first N results', val => parseInt(val, 10), 0).addHelpText('after', `
|
|
465
|
+
Examples:
|
|
466
|
+
$ vizzly comparisons -b abc123 # List comparisons for a build
|
|
467
|
+
$ vizzly comparisons --id def456 # Get specific comparison by ID
|
|
468
|
+
$ vizzly comparisons --name "Button" # Search by screenshot name
|
|
469
|
+
$ vizzly comparisons --name "Login*" # Wildcard search
|
|
470
|
+
$ vizzly comparisons --status changed # Only changed comparisons
|
|
471
|
+
$ vizzly comparisons --json # Output as JSON for scripting
|
|
472
|
+
`).action(async options => {
|
|
473
|
+
const globalOptions = program.opts();
|
|
474
|
+
|
|
475
|
+
// Validate options
|
|
476
|
+
const validationErrors = validateComparisonsOptions(options);
|
|
477
|
+
if (validationErrors.length > 0) {
|
|
478
|
+
output.error('Validation errors:');
|
|
479
|
+
for (let error of validationErrors) {
|
|
480
|
+
output.printErr(` - ${error}`);
|
|
481
|
+
}
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
await comparisonsCommand(options, globalOptions);
|
|
485
|
+
});
|
|
486
|
+
program.command('config').description('Display current configuration').argument('[key]', 'Specific config key to get (dot notation, e.g., comparison.threshold)').action(async (key, options) => {
|
|
487
|
+
const globalOptions = program.opts();
|
|
488
|
+
|
|
489
|
+
// Validate options
|
|
490
|
+
const validationErrors = validateConfigOptions(options);
|
|
491
|
+
if (validationErrors.length > 0) {
|
|
492
|
+
output.error('Validation errors:');
|
|
493
|
+
for (let error of validationErrors) {
|
|
494
|
+
output.printErr(` - ${error}`);
|
|
495
|
+
}
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
await configCommand(key, options, globalOptions);
|
|
499
|
+
});
|
|
500
|
+
program.command('baselines').description('List and query local TDD baselines').option('--name <pattern>', 'Filter baselines by name (supports wildcards)').option('--info <name>', 'Get detailed info for a specific baseline').addHelpText('after', `
|
|
501
|
+
Examples:
|
|
502
|
+
$ vizzly baselines # List all local baselines
|
|
503
|
+
$ vizzly baselines --name "Button*" # Filter by name pattern
|
|
504
|
+
$ vizzly baselines --info "homepage" # Get details for specific baseline
|
|
505
|
+
$ vizzly baselines --json # Output as JSON for scripting
|
|
506
|
+
|
|
507
|
+
Note: Baselines are stored locally in .vizzly/baselines/ during TDD mode.
|
|
508
|
+
`).action(async options => {
|
|
509
|
+
const globalOptions = program.opts();
|
|
510
|
+
|
|
511
|
+
// Validate options
|
|
512
|
+
const validationErrors = validateBaselinesOptions(options);
|
|
513
|
+
if (validationErrors.length > 0) {
|
|
514
|
+
output.error('Validation errors:');
|
|
515
|
+
for (let error of validationErrors) {
|
|
516
|
+
output.printErr(` - ${error}`);
|
|
517
|
+
}
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
await baselinesCommand(options, globalOptions);
|
|
521
|
+
});
|
|
522
|
+
program.command('api').description('Make raw API requests (for power users)').argument('<endpoint>', 'API endpoint (e.g., /api/sdk/builds)').option('-X, --method <method>', 'HTTP method (GET or POST for approve/reject/comment)', 'GET').option('-d, --data <json>', 'Request body (JSON)').option('-H, --header <header>', 'Add header (key:value), can be repeated', (val, prev) => prev ? [...prev, val] : [val]).option('-q, --query <param>', 'Add query param (key=value), can be repeated', (val, prev) => prev ? [...prev, val] : [val]).addHelpText('after', `
|
|
523
|
+
Examples:
|
|
524
|
+
$ vizzly api /api/sdk/builds # List builds
|
|
525
|
+
$ vizzly api /api/sdk/builds -q limit=5 # With query params
|
|
526
|
+
$ vizzly api /api/sdk/builds/abc123 # Get specific build
|
|
527
|
+
$ vizzly api /api/sdk/comparisons/abc123/approve -X POST
|
|
528
|
+
$ vizzly api /api/sdk/builds/abc123/comments -X POST -d '{"content":"Nice!"}'
|
|
529
|
+
|
|
530
|
+
Note: POST is restricted to approve, reject, and comment endpoints.
|
|
531
|
+
Most operations have dedicated commands (builds, comparisons, approve, etc.).
|
|
532
|
+
`).action(async (endpoint, options) => {
|
|
533
|
+
const globalOptions = program.opts();
|
|
534
|
+
|
|
535
|
+
// Validate options
|
|
536
|
+
const validationErrors = validateApiOptions(endpoint, options);
|
|
537
|
+
if (validationErrors.length > 0) {
|
|
538
|
+
output.error('Validation errors:');
|
|
539
|
+
for (let error of validationErrors) {
|
|
540
|
+
output.printErr(` - ${error}`);
|
|
541
|
+
}
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
await apiCommand(endpoint, options, globalOptions);
|
|
545
|
+
});
|
|
546
|
+
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', `
|
|
547
|
+
Examples:
|
|
548
|
+
$ vizzly approve abc123-def456-7890 # Approve a comparison
|
|
549
|
+
$ vizzly approve abc123 -m "LGTM" # Approve with comment
|
|
550
|
+
$ vizzly approve abc123 --json # Output as JSON for scripting
|
|
551
|
+
|
|
552
|
+
Workflow:
|
|
553
|
+
1. List comparisons: vizzly comparisons -b <build-id>
|
|
554
|
+
2. Review the changes in the web UI or via URLs in the output
|
|
555
|
+
3. Approve: vizzly approve <comparison-id>
|
|
556
|
+
`).action(async (comparisonId, options) => {
|
|
557
|
+
const globalOptions = program.opts();
|
|
558
|
+
const validationErrors = validateApproveOptions(comparisonId, options);
|
|
559
|
+
if (validationErrors.length > 0) {
|
|
560
|
+
output.error('Validation errors:');
|
|
561
|
+
for (let error of validationErrors) {
|
|
562
|
+
output.printErr(` - ${error}`);
|
|
563
|
+
}
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
await approveCommand(comparisonId, options, globalOptions);
|
|
567
|
+
});
|
|
568
|
+
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', `
|
|
569
|
+
Examples:
|
|
570
|
+
$ vizzly reject abc123 -r "Button color is wrong"
|
|
571
|
+
$ vizzly reject abc123 --reason "Needs design review"
|
|
572
|
+
$ vizzly reject abc123 -r "Regression" --json
|
|
573
|
+
|
|
574
|
+
Workflow:
|
|
575
|
+
1. List comparisons: vizzly comparisons -b <build-id>
|
|
576
|
+
2. Review the changes in the web UI or via URLs in the output
|
|
577
|
+
3. Reject with reason: vizzly reject <comparison-id> -r "reason"
|
|
578
|
+
`).action(async (comparisonId, options) => {
|
|
579
|
+
const globalOptions = program.opts();
|
|
580
|
+
const validationErrors = validateRejectOptions(comparisonId, options);
|
|
581
|
+
if (validationErrors.length > 0) {
|
|
582
|
+
output.error('Validation errors:');
|
|
583
|
+
for (let error of validationErrors) {
|
|
584
|
+
output.printErr(` - ${error}`);
|
|
585
|
+
}
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
await rejectCommand(comparisonId, options, globalOptions);
|
|
589
|
+
});
|
|
590
|
+
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', `
|
|
591
|
+
Examples:
|
|
592
|
+
$ vizzly comment abc123 "Looks good overall"
|
|
593
|
+
$ vizzly comment abc123 "Approved" -t approval
|
|
594
|
+
$ vizzly comment abc123 "Please fix the header" -t rejection
|
|
595
|
+
$ vizzly comment abc123 "CI feedback" --json
|
|
596
|
+
|
|
597
|
+
Workflow:
|
|
598
|
+
1. Get build ID: vizzly builds --branch main
|
|
599
|
+
2. Add comment: vizzly comment <build-id> "Your message"
|
|
600
|
+
`).action(async (buildId, message, options) => {
|
|
601
|
+
const globalOptions = program.opts();
|
|
602
|
+
const validationErrors = validateCommentOptions(buildId, message, options);
|
|
603
|
+
if (validationErrors.length > 0) {
|
|
604
|
+
output.error('Validation errors:');
|
|
605
|
+
for (let error of validationErrors) {
|
|
606
|
+
output.printErr(` - ${error}`);
|
|
607
|
+
}
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
await commentCommand(buildId, message, options, globalOptions);
|
|
611
|
+
});
|
|
612
|
+
program.command('orgs').description('List organizations you have access to').addHelpText('after', `
|
|
613
|
+
Examples:
|
|
614
|
+
$ vizzly orgs # List all organizations
|
|
615
|
+
$ vizzly orgs --json # Output as JSON for scripting
|
|
616
|
+
|
|
617
|
+
Note: Shows organizations from your user account (via vizzly login)
|
|
618
|
+
or the single organization for a project token.
|
|
619
|
+
`).action(async options => {
|
|
620
|
+
const globalOptions = program.opts();
|
|
621
|
+
const validationErrors = validateOrgsOptions(options);
|
|
622
|
+
if (validationErrors.length > 0) {
|
|
623
|
+
output.error('Validation errors:');
|
|
624
|
+
for (let error of validationErrors) {
|
|
625
|
+
output.printErr(` - ${error}`);
|
|
626
|
+
}
|
|
627
|
+
process.exit(1);
|
|
628
|
+
}
|
|
629
|
+
await orgsCommand(options, globalOptions);
|
|
630
|
+
});
|
|
631
|
+
program.command('projects').description('List projects you have access to').option('--org <slug>', 'Filter by organization slug').option('--limit <n>', 'Maximum results to return (1-250)', val => parseInt(val, 10), 50).option('--offset <n>', 'Skip first N results', val => parseInt(val, 10), 0).addHelpText('after', `
|
|
632
|
+
Examples:
|
|
633
|
+
$ vizzly projects # List all projects
|
|
634
|
+
$ vizzly projects --org my-company # Filter by organization
|
|
635
|
+
$ vizzly projects --json # Output as JSON for scripting
|
|
636
|
+
|
|
637
|
+
Workflow:
|
|
638
|
+
1. List orgs: vizzly orgs
|
|
639
|
+
2. List projects: vizzly projects --org <org-slug>
|
|
640
|
+
3. Query builds: vizzly builds
|
|
641
|
+
`).action(async options => {
|
|
642
|
+
const globalOptions = program.opts();
|
|
643
|
+
const validationErrors = validateProjectsOptions(options);
|
|
644
|
+
if (validationErrors.length > 0) {
|
|
645
|
+
output.error('Validation errors:');
|
|
646
|
+
for (let error of validationErrors) {
|
|
647
|
+
output.printErr(` - ${error}`);
|
|
648
|
+
}
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
await projectsCommand(options, globalOptions);
|
|
652
|
+
});
|
|
408
653
|
program.command('finalize').description('Finalize a parallel build after all shards complete').argument('<parallel-id>', 'Parallel ID to finalize').action(async (parallelId, options) => {
|
|
409
654
|
const globalOptions = program.opts();
|
|
410
655
|
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API command - raw API access for power users
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createApiClient as defaultCreateApiClient } from '../api/index.js';
|
|
6
|
+
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
7
|
+
import * as defaultOutput from '../utils/output.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* API command - make raw API requests
|
|
11
|
+
* @param {string} endpoint - API endpoint (e.g., /sdk/builds)
|
|
12
|
+
* @param {Object} options - Command options
|
|
13
|
+
* @param {Object} globalOptions - Global CLI options
|
|
14
|
+
* @param {Object} deps - Dependencies for testing
|
|
15
|
+
*/
|
|
16
|
+
export async function apiCommand(endpoint, options = {}, globalOptions = {}, deps = {}) {
|
|
17
|
+
let {
|
|
18
|
+
loadConfig = defaultLoadConfig,
|
|
19
|
+
createApiClient = defaultCreateApiClient,
|
|
20
|
+
output = defaultOutput,
|
|
21
|
+
exit = code => process.exit(code)
|
|
22
|
+
} = deps;
|
|
23
|
+
output.configure({
|
|
24
|
+
json: globalOptions.json,
|
|
25
|
+
verbose: globalOptions.verbose,
|
|
26
|
+
color: !globalOptions.noColor
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
// Load configuration
|
|
30
|
+
let allOptions = {
|
|
31
|
+
...globalOptions,
|
|
32
|
+
...options
|
|
33
|
+
};
|
|
34
|
+
let config = await loadConfig(globalOptions.config, allOptions);
|
|
35
|
+
|
|
36
|
+
// Validate API token
|
|
37
|
+
if (!config.apiKey) {
|
|
38
|
+
output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
|
|
39
|
+
exit(1);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Normalize endpoint
|
|
44
|
+
let normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
45
|
+
if (!normalizedEndpoint.startsWith('/api/')) {
|
|
46
|
+
normalizedEndpoint = `/api${normalizedEndpoint}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Build request options
|
|
50
|
+
let method = (options.method || 'GET').toUpperCase();
|
|
51
|
+
|
|
52
|
+
// Validate method and endpoint combination
|
|
53
|
+
if (method === 'POST' && !isAllowedPostEndpoint(normalizedEndpoint)) {
|
|
54
|
+
output.error(`POST not allowed for ${normalizedEndpoint}. Only approve, reject, and comment endpoints support POST.`);
|
|
55
|
+
output.hint('Use GET for queries, or use dedicated commands (vizzly approve, vizzly reject, vizzly comment)');
|
|
56
|
+
exit(1);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
60
|
+
output.error(`Method ${method} not allowed. Use GET for queries.`);
|
|
61
|
+
exit(1);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let requestOptions = {
|
|
65
|
+
method
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Add headers
|
|
69
|
+
let headers = {};
|
|
70
|
+
if (options.header) {
|
|
71
|
+
let headerList = Array.isArray(options.header) ? options.header : [options.header];
|
|
72
|
+
for (let h of headerList) {
|
|
73
|
+
let [key, ...valueParts] = h.split(':');
|
|
74
|
+
if (key && valueParts.length > 0) {
|
|
75
|
+
headers[key.trim()] = valueParts.join(':').trim();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Add body for POST/PUT/PATCH
|
|
81
|
+
if (options.data && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
82
|
+
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
83
|
+
requestOptions.body = options.data;
|
|
84
|
+
}
|
|
85
|
+
if (Object.keys(headers).length > 0) {
|
|
86
|
+
requestOptions.headers = headers;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add query parameters
|
|
90
|
+
if (options.query) {
|
|
91
|
+
let params = new URLSearchParams();
|
|
92
|
+
let queryList = Array.isArray(options.query) ? options.query : [options.query];
|
|
93
|
+
for (let q of queryList) {
|
|
94
|
+
let [key, ...valueParts] = q.split('=');
|
|
95
|
+
if (key && valueParts.length > 0) {
|
|
96
|
+
params.append(key.trim(), valueParts.join('=').trim());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let queryString = params.toString();
|
|
100
|
+
if (queryString) {
|
|
101
|
+
normalizedEndpoint += (normalizedEndpoint.includes('?') ? '&' : '?') + queryString;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Make the request
|
|
106
|
+
output.startSpinner(`${method} ${normalizedEndpoint}`);
|
|
107
|
+
let client = createApiClient({
|
|
108
|
+
baseUrl: config.apiUrl,
|
|
109
|
+
token: config.apiKey,
|
|
110
|
+
command: 'api'
|
|
111
|
+
});
|
|
112
|
+
let response = await client.request(normalizedEndpoint, requestOptions);
|
|
113
|
+
output.stopSpinner();
|
|
114
|
+
|
|
115
|
+
// Output response
|
|
116
|
+
if (globalOptions.json) {
|
|
117
|
+
output.data({
|
|
118
|
+
endpoint: normalizedEndpoint,
|
|
119
|
+
method,
|
|
120
|
+
response
|
|
121
|
+
});
|
|
122
|
+
output.cleanup();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Pretty print response for humans
|
|
127
|
+
output.header('api');
|
|
128
|
+
output.labelValue('Endpoint', normalizedEndpoint);
|
|
129
|
+
output.labelValue('Method', method);
|
|
130
|
+
output.blank();
|
|
131
|
+
|
|
132
|
+
// Format response
|
|
133
|
+
if (typeof response === 'object') {
|
|
134
|
+
output.print(JSON.stringify(response, null, 2));
|
|
135
|
+
} else {
|
|
136
|
+
output.print(String(response));
|
|
137
|
+
}
|
|
138
|
+
output.cleanup();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
output.stopSpinner();
|
|
141
|
+
if (globalOptions.json) {
|
|
142
|
+
output.data({
|
|
143
|
+
endpoint,
|
|
144
|
+
method: options.method || 'GET',
|
|
145
|
+
error: {
|
|
146
|
+
message: error.message,
|
|
147
|
+
code: error.code,
|
|
148
|
+
status: error.context?.status
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
output.cleanup();
|
|
152
|
+
exit(1);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
output.error('API request failed', error);
|
|
156
|
+
output.cleanup();
|
|
157
|
+
exit(1);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Allowed POST endpoints (whitelist for mutations)
|
|
163
|
+
* Most mutations should use dedicated commands, but these are allowed for raw API access
|
|
164
|
+
*/
|
|
165
|
+
const ALLOWED_POST_ENDPOINTS = [/^\/api\/sdk\/comparisons\/[^/]+\/approve$/, /^\/api\/sdk\/comparisons\/[^/]+\/reject$/, /^\/api\/sdk\/builds\/[^/]+\/comments$/];
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check if a POST endpoint is allowed
|
|
169
|
+
*/
|
|
170
|
+
function isAllowedPostEndpoint(endpoint) {
|
|
171
|
+
return ALLOWED_POST_ENDPOINTS.some(pattern => pattern.test(endpoint));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Validate API command options
|
|
176
|
+
*/
|
|
177
|
+
export function validateApiOptions(endpoint, options = {}) {
|
|
178
|
+
let errors = [];
|
|
179
|
+
if (!endpoint || endpoint.trim() === '') {
|
|
180
|
+
errors.push('Endpoint is required');
|
|
181
|
+
}
|
|
182
|
+
let method = (options.method || 'GET').toUpperCase();
|
|
183
|
+
|
|
184
|
+
// Only GET is allowed by default
|
|
185
|
+
// POST is allowed only for whitelisted endpoints
|
|
186
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
187
|
+
errors.push(`Method ${method} not allowed. Use GET for queries or POST for approve/reject/comment.`);
|
|
188
|
+
}
|
|
189
|
+
return errors;
|
|
190
|
+
}
|