@vizzly-testing/cli 0.26.2 → 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 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', 'Machine-readable output').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({
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: process.argv.includes('--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
+ }