@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 +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/utils/config-loader.js +13 -1
- package/dist/utils/output.js +107 -4
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -15,31 +15,69 @@ export class InitCommand {
|
|
|
15
15
|
this.plugins = plugins;
|
|
16
16
|
}
|
|
17
17
|
async run(options = {}) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
output.
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|