@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.
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Comparisons command - query and search comparisons
3
+ */
4
+
5
+ import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild, getComparison as defaultGetComparison, searchComparisons as defaultSearchComparisons } 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
+ * Comparisons command - query comparisons with various filters
11
+ * @param {Object} options - Command options
12
+ * @param {Object} globalOptions - Global CLI options
13
+ * @param {Object} deps - Dependencies for testing
14
+ */
15
+ export async function comparisonsCommand(options = {}, globalOptions = {}, deps = {}) {
16
+ let {
17
+ loadConfig = defaultLoadConfig,
18
+ createApiClient = defaultCreateApiClient,
19
+ getBuild = defaultGetBuild,
20
+ getComparison = defaultGetComparison,
21
+ searchComparisons = defaultSearchComparisons,
22
+ output = defaultOutput,
23
+ exit = code => process.exit(code)
24
+ } = deps;
25
+ output.configure({
26
+ json: globalOptions.json,
27
+ verbose: globalOptions.verbose,
28
+ color: !globalOptions.noColor
29
+ });
30
+ try {
31
+ // Load configuration with CLI overrides
32
+ let allOptions = {
33
+ ...globalOptions,
34
+ ...options
35
+ };
36
+ let config = await loadConfig(globalOptions.config, allOptions);
37
+
38
+ // Validate API token
39
+ if (!config.apiKey) {
40
+ output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
41
+ exit(1);
42
+ return;
43
+ }
44
+ let client = createApiClient({
45
+ baseUrl: config.apiUrl,
46
+ token: config.apiKey,
47
+ command: 'comparisons'
48
+ });
49
+
50
+ // Get a specific comparison by ID
51
+ if (options.id) {
52
+ output.startSpinner('Fetching comparison...');
53
+ let comparison = await getComparison(client, options.id);
54
+ output.stopSpinner();
55
+ if (globalOptions.json) {
56
+ output.data(formatComparisonForJson(comparison));
57
+ output.cleanup();
58
+ return;
59
+ }
60
+ displayComparison(output, comparison, globalOptions.verbose);
61
+ output.cleanup();
62
+ return;
63
+ }
64
+
65
+ // Get comparisons for a specific build
66
+ if (options.build) {
67
+ output.startSpinner('Fetching comparisons for build...');
68
+ let response = await getBuild(client, options.build, {
69
+ include: 'comparisons'
70
+ });
71
+ output.stopSpinner();
72
+ let build = response.build || response;
73
+ let comparisons = build.comparisons || [];
74
+
75
+ // Apply status filter if provided
76
+ if (options.status) {
77
+ comparisons = comparisons.filter(c => c.status === options.status);
78
+ }
79
+
80
+ // Apply name filter if provided
81
+ if (options.name) {
82
+ let pattern = options.name.replace(/\*/g, '.*');
83
+ let regex = new RegExp(pattern, 'i');
84
+ comparisons = comparisons.filter(c => regex.test(c.name));
85
+ }
86
+ if (globalOptions.json) {
87
+ output.data({
88
+ buildId: build.id,
89
+ buildName: build.name,
90
+ comparisons: comparisons.map(formatComparisonForJson),
91
+ summary: {
92
+ total: comparisons.length,
93
+ passed: comparisons.filter(c => c.status === 'identical').length,
94
+ failed: comparisons.filter(c => c.status === 'changed').length,
95
+ new: comparisons.filter(c => c.status === 'new').length
96
+ }
97
+ });
98
+ output.cleanup();
99
+ return;
100
+ }
101
+ displayBuildComparisons(output, build, comparisons, globalOptions.verbose);
102
+ output.cleanup();
103
+ return;
104
+ }
105
+
106
+ // Search comparisons by name across builds
107
+ if (options.name) {
108
+ output.startSpinner('Searching comparisons...');
109
+ let filters = {
110
+ branch: options.branch,
111
+ limit: options.limit || 50,
112
+ offset: options.offset || 0
113
+ };
114
+ let response = await searchComparisons(client, options.name, filters);
115
+ output.stopSpinner();
116
+ let comparisons = response.comparisons || [];
117
+ let pagination = response.pagination || {
118
+ total: comparisons.length,
119
+ hasMore: false
120
+ };
121
+ if (globalOptions.json) {
122
+ output.data({
123
+ comparisons: comparisons.map(formatComparisonForJson),
124
+ pagination: {
125
+ total: pagination.total,
126
+ limit: filters.limit,
127
+ offset: filters.offset,
128
+ hasMore: pagination.hasMore
129
+ }
130
+ });
131
+ output.cleanup();
132
+ return;
133
+ }
134
+ displaySearchResults(output, comparisons, options.name, pagination, globalOptions.verbose);
135
+ output.cleanup();
136
+ return;
137
+ }
138
+
139
+ // No valid query provided
140
+ output.error('Specify --build <id>, --id <comparison-id>, or --name <pattern> to query comparisons');
141
+ output.hint('Examples:');
142
+ output.hint(' vizzly comparisons --build <build-id>');
143
+ output.hint(' vizzly comparisons --name "button-*"');
144
+ output.hint(' vizzly comparisons --id <comparison-id>');
145
+ exit(1);
146
+ } catch (error) {
147
+ output.stopSpinner();
148
+ output.error('Failed to fetch comparisons', error);
149
+ output.cleanup();
150
+ exit(1);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Format a comparison for JSON output
156
+ */
157
+ function formatComparisonForJson(comparison) {
158
+ return {
159
+ id: comparison.id,
160
+ name: comparison.name,
161
+ status: comparison.status,
162
+ diffPercentage: comparison.diff_percentage ?? null,
163
+ approvalStatus: comparison.approval_status,
164
+ viewport: comparison.viewport_width ? {
165
+ width: comparison.viewport_width,
166
+ height: comparison.viewport_height
167
+ } : null,
168
+ browser: comparison.browser || null,
169
+ urls: {
170
+ baseline: comparison.baseline_screenshot?.original_url || null,
171
+ current: comparison.current_screenshot?.original_url || null,
172
+ diff: comparison.diff_image?.url || null
173
+ },
174
+ buildId: comparison.build_id,
175
+ buildName: comparison.build_name,
176
+ buildBranch: comparison.build_branch,
177
+ createdAt: comparison.created_at
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Display a single comparison in detail
183
+ */
184
+ function displayComparison(output, comparison, verbose) {
185
+ let _colors = output.getColors();
186
+ output.header('comparison', comparison.status);
187
+ output.keyValue({
188
+ Name: comparison.name,
189
+ Status: comparison.status?.toUpperCase(),
190
+ 'Diff %': comparison.diff_percentage != null ? `${(comparison.diff_percentage * 100).toFixed(2)}%` : 'N/A',
191
+ Approval: comparison.approval_status || 'pending'
192
+ });
193
+ output.blank();
194
+ if (comparison.viewport_width) {
195
+ output.labelValue('Viewport', `${comparison.viewport_width}×${comparison.viewport_height}`);
196
+ }
197
+ if (comparison.browser) {
198
+ output.labelValue('Browser', comparison.browser);
199
+ }
200
+ output.blank();
201
+
202
+ // Build context
203
+ if (comparison.build_name || comparison.build_id) {
204
+ output.labelValue('Build', comparison.build_name || comparison.build_id);
205
+ }
206
+ if (comparison.build_branch) {
207
+ output.labelValue('Branch', comparison.build_branch);
208
+ }
209
+ if (comparison.build_commit_sha) {
210
+ output.labelValue('Commit', comparison.build_commit_sha.substring(0, 8));
211
+ }
212
+
213
+ // URLs in verbose mode
214
+ if (verbose) {
215
+ output.blank();
216
+ output.labelValue('URLs', '');
217
+ if (comparison.baseline_screenshot?.original_url) {
218
+ output.print(` Baseline: ${comparison.baseline_screenshot.original_url}`);
219
+ }
220
+ if (comparison.current_screenshot?.original_url) {
221
+ output.print(` Current: ${comparison.current_screenshot.original_url}`);
222
+ }
223
+ if (comparison.diff_image?.url) {
224
+ output.print(` Diff: ${comparison.diff_image.url}`);
225
+ }
226
+ }
227
+ if (comparison.created_at) {
228
+ output.blank();
229
+ output.hint(`Created ${new Date(comparison.created_at).toLocaleString()}`);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Display comparisons for a build
235
+ */
236
+ function displayBuildComparisons(output, build, comparisons, verbose) {
237
+ let colors = output.getColors();
238
+ output.header('comparisons');
239
+ output.labelValue('Build', build.name || build.id);
240
+ output.blank();
241
+ if (comparisons.length === 0) {
242
+ output.print(' No comparisons found');
243
+ output.cleanup();
244
+ return;
245
+ }
246
+
247
+ // Summary
248
+ let passed = comparisons.filter(c => c.status === 'identical').length;
249
+ let failed = comparisons.filter(c => c.status === 'changed').length;
250
+ let newCount = comparisons.filter(c => c.status === 'new').length;
251
+ let stats = [];
252
+ if (passed > 0) stats.push(`${colors.brand.success(passed)} identical`);
253
+ if (failed > 0) stats.push(`${colors.brand.warning(failed)} changed`);
254
+ if (newCount > 0) stats.push(`${colors.brand.info(newCount)} new`);
255
+ output.labelValue('Summary', stats.join(colors.dim(' · ')));
256
+ output.blank();
257
+
258
+ // List comparisons
259
+ for (let comp of comparisons.slice(0, verbose ? 100 : 20)) {
260
+ let icon = getStatusIcon(colors, comp.status);
261
+ let diffInfo = comp.diff_percentage != null ? colors.dim(` (${(comp.diff_percentage * 100).toFixed(1)}%)`) : '';
262
+ output.print(` ${icon} ${comp.name}${diffInfo}`);
263
+ }
264
+ if (comparisons.length > (verbose ? 100 : 20)) {
265
+ output.blank();
266
+ output.hint(`... and ${comparisons.length - (verbose ? 100 : 20)} more. Use --verbose to see all.`);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Display search results
272
+ */
273
+ function displaySearchResults(output, comparisons, searchPattern, pagination, verbose) {
274
+ let colors = output.getColors();
275
+ output.header('comparisons');
276
+ output.labelValue('Search', searchPattern);
277
+ output.blank();
278
+ if (comparisons.length === 0) {
279
+ output.print(' No comparisons found');
280
+ return;
281
+ }
282
+
283
+ // Group by build for better display
284
+ let byBuild = new Map();
285
+ for (let comp of comparisons) {
286
+ let key = comp.build_id;
287
+ if (!byBuild.has(key)) {
288
+ byBuild.set(key, {
289
+ buildName: comp.build_name,
290
+ buildBranch: comp.build_branch,
291
+ comparisons: []
292
+ });
293
+ }
294
+ byBuild.get(key).comparisons.push(comp);
295
+ }
296
+ for (let [buildId, group] of byBuild) {
297
+ output.print(` ${colors.bold(group.buildName || buildId)}`);
298
+ if (group.buildBranch) {
299
+ output.print(` ${colors.dim(group.buildBranch)}`);
300
+ }
301
+ for (let comp of group.comparisons.slice(0, verbose ? 10 : 3)) {
302
+ let icon = getStatusIcon(colors, comp.status);
303
+ output.print(` ${icon} ${comp.name}`);
304
+ }
305
+ if (group.comparisons.length > (verbose ? 10 : 3)) {
306
+ output.print(` ${colors.dim(`... and ${group.comparisons.length - (verbose ? 10 : 3)} more`)}`);
307
+ }
308
+ output.blank();
309
+ }
310
+ if (pagination.hasMore) {
311
+ output.hint(`Showing ${comparisons.length} of ${pagination.total}. ` + `Use --offset ${pagination.offset + pagination.limit} to see more.`);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Get icon for comparison status
317
+ */
318
+ function getStatusIcon(colors, status) {
319
+ switch (status) {
320
+ case 'identical':
321
+ return colors.brand.success('✓');
322
+ case 'new':
323
+ return colors.brand.info('●');
324
+ case 'changed':
325
+ return colors.brand.warning('◐');
326
+ case 'failed':
327
+ return colors.brand.error('✗');
328
+ default:
329
+ return colors.dim('○');
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Validate comparisons command options
335
+ */
336
+ export function validateComparisonsOptions(options = {}) {
337
+ let errors = [];
338
+ if (options.limit && (Number.isNaN(options.limit) || options.limit < 1 || options.limit > 250)) {
339
+ errors.push('--limit must be a number between 1 and 250');
340
+ }
341
+ if (options.offset && (Number.isNaN(options.offset) || options.offset < 0)) {
342
+ errors.push('--offset must be a non-negative number');
343
+ }
344
+ return errors;
345
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Config command - query and display configuration
3
+ */
4
+
5
+ import { resolve } from 'node:path';
6
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
7
+ import { getProjectMapping as defaultGetProjectMapping } from '../utils/global-config.js';
8
+ import * as defaultOutput from '../utils/output.js';
9
+
10
+ /**
11
+ * Config command - display current configuration
12
+ * @param {string|null} key - Optional specific key to get (dot notation)
13
+ * @param {Object} options - Command options
14
+ * @param {Object} globalOptions - Global CLI options
15
+ * @param {Object} deps - Dependencies for testing
16
+ */
17
+ export async function configCommand(key = null, options = {}, globalOptions = {}, deps = {}) {
18
+ let {
19
+ loadConfig = defaultLoadConfig,
20
+ getProjectMapping = defaultGetProjectMapping,
21
+ output = defaultOutput,
22
+ exit = code => process.exit(code)
23
+ } = deps;
24
+ output.configure({
25
+ json: globalOptions.json,
26
+ verbose: globalOptions.verbose,
27
+ color: !globalOptions.noColor
28
+ });
29
+ try {
30
+ // Load configuration
31
+ let config = await loadConfig(globalOptions.config, {
32
+ ...globalOptions,
33
+ ...options
34
+ });
35
+ let configFile = config._configPath || null;
36
+
37
+ // Get project mapping if available
38
+ let currentDir = resolve(process.cwd());
39
+ let projectMapping = await getProjectMapping(currentDir);
40
+
41
+ // Build the config object to display
42
+ let displayConfig = {
43
+ server: config.server || {
44
+ port: 47392,
45
+ timeout: 30000
46
+ },
47
+ build: config.build || {},
48
+ upload: config.upload || {},
49
+ comparison: config.comparison || {
50
+ threshold: 2.0
51
+ },
52
+ tdd: config.tdd || {}
53
+ };
54
+
55
+ // Add API config (without exposing full token)
56
+ if (config.apiKey) {
57
+ displayConfig.api = {
58
+ url: config.apiUrl || config.baseUrl,
59
+ tokenConfigured: true,
60
+ tokenPrefix: `${config.apiKey.substring(0, 8)}...`
61
+ };
62
+ }
63
+
64
+ // If a specific key is requested, extract it
65
+ if (key) {
66
+ let value = getNestedValue(displayConfig, key);
67
+ if (value === undefined) {
68
+ output.error(`Configuration key "${key}" not found`);
69
+ output.hint('Use "vizzly config" without arguments to see all available keys');
70
+ exit(1);
71
+ return;
72
+ }
73
+ if (globalOptions.json) {
74
+ output.data({
75
+ key,
76
+ value
77
+ });
78
+ output.cleanup();
79
+ return;
80
+ }
81
+
82
+ // Simple human output for specific key
83
+ output.header('config');
84
+ output.labelValue(key, formatValue(value));
85
+ output.cleanup();
86
+ return;
87
+ }
88
+
89
+ // JSON output for full config
90
+ if (globalOptions.json) {
91
+ output.data({
92
+ configFile,
93
+ config: displayConfig,
94
+ project: projectMapping ? {
95
+ name: projectMapping.projectName,
96
+ slug: projectMapping.projectSlug,
97
+ organization: projectMapping.organizationSlug
98
+ } : null
99
+ });
100
+ output.cleanup();
101
+ return;
102
+ }
103
+
104
+ // Human-readable output
105
+ output.header('config');
106
+
107
+ // Config file location
108
+ if (configFile) {
109
+ output.labelValue('Config file', configFile);
110
+ } else {
111
+ output.labelValue('Config file', 'Using defaults (no config file found)');
112
+ }
113
+ output.blank();
114
+
115
+ // Project context if available
116
+ if (projectMapping) {
117
+ output.labelValue('Project', `${projectMapping.projectName} (${projectMapping.projectSlug})`);
118
+ output.labelValue('Organization', projectMapping.organizationSlug);
119
+ output.blank();
120
+ }
121
+
122
+ // Display configuration sections
123
+ displaySection(output, 'Server', displayConfig.server);
124
+ displaySection(output, 'Comparison', displayConfig.comparison);
125
+ displaySection(output, 'TDD', displayConfig.tdd);
126
+ if (globalOptions.verbose) {
127
+ displaySection(output, 'Build', displayConfig.build);
128
+ displaySection(output, 'Upload', displayConfig.upload);
129
+ if (displayConfig.api) {
130
+ displaySection(output, 'API', displayConfig.api);
131
+ }
132
+ } else {
133
+ output.hint('Use --verbose to see all configuration options');
134
+ }
135
+ output.cleanup();
136
+ } catch (error) {
137
+ output.error('Failed to load configuration', error);
138
+ output.cleanup();
139
+ exit(1);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Display a configuration section
145
+ */
146
+ function displaySection(output, title, section) {
147
+ if (!section || Object.keys(section).length === 0) return;
148
+ output.labelValue(title, '');
149
+ for (let [key, value] of Object.entries(section)) {
150
+ output.print(` ${key}: ${formatValue(value)}`);
151
+ }
152
+ output.blank();
153
+ }
154
+
155
+ /**
156
+ * Format a value for display
157
+ */
158
+ function formatValue(value) {
159
+ if (value === null || value === undefined) return 'not set';
160
+ if (typeof value === 'boolean') return value ? 'yes' : 'no';
161
+ if (typeof value === 'object') return JSON.stringify(value);
162
+ return String(value);
163
+ }
164
+
165
+ /**
166
+ * Get a nested value using dot notation
167
+ */
168
+ function getNestedValue(obj, path) {
169
+ let parts = path.split('.');
170
+ let current = obj;
171
+ for (let part of parts) {
172
+ if (current === null || current === undefined) return undefined;
173
+ if (typeof current !== 'object') return undefined;
174
+ current = current[part];
175
+ }
176
+ return current;
177
+ }
178
+
179
+ /**
180
+ * Validate config command options
181
+ */
182
+ export function validateConfigOptions() {
183
+ return [];
184
+ }
@@ -15,31 +15,69 @@ export class InitCommand {
15
15
  this.plugins = plugins;
16
16
  }
17
17
  async run(options = {}) {
18
- output.header('init');
19
- try {
20
- // Check for existing config
21
- let configPath = path.join(process.cwd(), 'vizzly.config.js');
22
- let hasConfig = await this.fileExists(configPath);
23
- if (hasConfig && !options.force) {
24
- output.warn('A vizzly.config.js file already exists');
25
- output.hint('Use --force to overwrite');
26
- return;
18
+ // Check for existing config
19
+ let configPath = path.join(process.cwd(), 'vizzly.config.js');
20
+ let hasConfig = await this.fileExists(configPath);
21
+ if (hasConfig && !options.force) {
22
+ // JSON output for skipped
23
+ if (options.json) {
24
+ output.data({
25
+ status: 'skipped',
26
+ reason: 'config_exists',
27
+ configPath,
28
+ message: 'A vizzly.config.js file already exists. Use --force to overwrite.'
29
+ });
30
+ return {
31
+ status: 'skipped',
32
+ configPath
33
+ };
27
34
  }
28
-
35
+ output.header('init');
36
+ output.warn('A vizzly.config.js file already exists');
37
+ output.hint('Use --force to overwrite');
38
+ return {
39
+ status: 'skipped',
40
+ configPath
41
+ };
42
+ }
43
+ try {
29
44
  // Generate config file with defaults
30
- await this.generateConfigFile(configPath);
45
+ await this.generateConfigFile(configPath, options);
46
+
47
+ // Get plugins with config for JSON output
48
+ let pluginsWithConfig = this.plugins.filter(p => p.configSchema);
49
+ let pluginNames = pluginsWithConfig.map(p => p.name);
50
+
51
+ // JSON output for success
52
+ if (options.json) {
53
+ output.data({
54
+ status: 'created',
55
+ configPath,
56
+ plugins: pluginNames
57
+ });
58
+ return {
59
+ status: 'created',
60
+ configPath,
61
+ plugins: pluginNames
62
+ };
63
+ }
31
64
 
32
65
  // Show next steps
33
66
  this.showNextSteps();
34
67
  output.blank();
35
68
  output.complete('Vizzly CLI setup complete');
69
+ return {
70
+ status: 'created',
71
+ configPath,
72
+ plugins: pluginNames
73
+ };
36
74
  } catch (error) {
37
75
  throw new VizzlyError('Failed to initialize Vizzly configuration', 'INIT_FAILED', {
38
76
  error: error.message
39
77
  });
40
78
  }
41
79
  }
42
- async generateConfigFile(configPath) {
80
+ async generateConfigFile(configPath, options = {}) {
43
81
  let coreConfig = `export default {
44
82
  // Server configuration (for run command)
45
83
  server: {
@@ -77,6 +115,10 @@ export class InitCommand {
77
115
  }
78
116
  coreConfig += '\n};\n';
79
117
  await fs.writeFile(configPath, coreConfig, 'utf8');
118
+
119
+ // Skip human-readable output in JSON mode
120
+ if (options.json) return;
121
+ output.header('init');
80
122
  output.complete('Created vizzly.config.js');
81
123
 
82
124
  // Log discovered plugins
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Organizations command - List organizations the user has access to
3
+ */
4
+
5
+ import { createApiClient } from '../api/client.js';
6
+ import { loadConfig } from '../utils/config-loader.js';
7
+ import { getApiUrl } from '../utils/environment-config.js';
8
+ import * as output from '../utils/output.js';
9
+
10
+ /**
11
+ * Organizations command implementation
12
+ * @param {Object} options - Command options
13
+ * @param {Object} globalOptions - Global CLI options
14
+ */
15
+ export async function orgsCommand(_options = {}, globalOptions = {}) {
16
+ output.configure({
17
+ json: globalOptions.json,
18
+ verbose: globalOptions.verbose,
19
+ color: !globalOptions.noColor
20
+ });
21
+ try {
22
+ let config = await loadConfig(globalOptions.config, globalOptions);
23
+ if (!config.apiKey) {
24
+ output.error('API token required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"');
25
+ output.cleanup();
26
+ process.exit(1);
27
+ }
28
+ let client = createApiClient({
29
+ baseUrl: config.apiUrl || getApiUrl(),
30
+ token: config.apiKey
31
+ });
32
+ output.startSpinner('Fetching organizations...');
33
+ let response = await client.request('/api/sdk/organizations');
34
+ output.stopSpinner();
35
+ let orgs = response.organizations || [];
36
+ if (globalOptions.json) {
37
+ output.data({
38
+ organizations: orgs.map(org => ({
39
+ id: org.id,
40
+ name: org.name,
41
+ slug: org.slug,
42
+ role: org.role,
43
+ projectCount: org.projectCount,
44
+ createdAt: org.created_at
45
+ })),
46
+ count: orgs.length
47
+ });
48
+ } else {
49
+ output.header('orgs');
50
+ let colors = output.getColors();
51
+ if (orgs.length === 0) {
52
+ output.print(' No organizations found');
53
+ } else {
54
+ output.labelValue('Count', String(orgs.length));
55
+ output.blank();
56
+ for (let org of orgs) {
57
+ let roleLabel = org.role === 'token' ? 'via token' : org.role;
58
+ output.print(` ${colors.bold(org.name)} ${colors.dim(`@${org.slug}`)}`);
59
+ output.print(` ${colors.dim(`${org.projectCount} projects · ${roleLabel}`)}`);
60
+ }
61
+ }
62
+ }
63
+ output.cleanup();
64
+ } catch (error) {
65
+ output.stopSpinner();
66
+ output.error('Failed to fetch organizations', error);
67
+ output.cleanup();
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Validate orgs options
74
+ * @param {Object} _options - Command options
75
+ * @returns {string[]} Validation errors
76
+ */
77
+ export function validateOrgsOptions(_options = {}) {
78
+ return [];
79
+ }