@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.
@@ -597,11 +597,21 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
597
597
  success: true,
598
598
  buildId,
599
599
  previewUrl: result.previewUrl,
600
- files: result.uploaded,
601
- totalBytes: result.totalBytes,
600
+ files: result.uploaded || fileCount,
601
+ bytes: totalSize,
602
+ compressedBytes: zipBuffer.length,
603
+ compressionRatio: parseFloat(compressionRatio) / 100,
602
604
  newBytes: result.newBytes,
603
- deduplicationRatio: result.deduplicationRatio
605
+ reusedBlobs: result.reusedBlobs || 0,
606
+ deduplicationRatio: result.deduplicationRatio,
607
+ basePath: result.basePath || null,
608
+ expiresAt: result.expiresAt || null
604
609
  });
610
+ output.cleanup();
611
+ return {
612
+ success: true,
613
+ result
614
+ };
605
615
  } else {
606
616
  output.complete('Preview uploaded');
607
617
  output.blank();
@@ -107,6 +107,25 @@ export async function projectSelectCommand(options = {}, globalOptions = {}) {
107
107
  projectName: selectedProject.name,
108
108
  organizationSlug: selectedOrg.slug
109
109
  });
110
+
111
+ // JSON output for success
112
+ if (globalOptions.json) {
113
+ output.data({
114
+ status: 'configured',
115
+ project: {
116
+ name: selectedProject.name,
117
+ slug: selectedProject.slug
118
+ },
119
+ organization: {
120
+ name: selectedOrg.name,
121
+ slug: selectedOrg.slug
122
+ },
123
+ directory: currentDir,
124
+ tokenCreated: true
125
+ });
126
+ output.cleanup();
127
+ return;
128
+ }
110
129
  output.complete('Project configured');
111
130
  output.blank();
112
131
  output.keyValue({
@@ -136,9 +155,13 @@ export async function projectListCommand(_options = {}, globalOptions = {}) {
136
155
  try {
137
156
  let mappings = await getProjectMappings();
138
157
  let paths = Object.keys(mappings);
158
+ let currentDir = resolve(process.cwd());
139
159
  if (paths.length === 0) {
140
160
  if (globalOptions.json) {
141
- output.data({});
161
+ output.data({
162
+ projects: [],
163
+ current: null
164
+ });
142
165
  } else {
143
166
  output.header('project:list');
144
167
  output.print(' No projects configured');
@@ -149,13 +172,29 @@ export async function projectListCommand(_options = {}, globalOptions = {}) {
149
172
  return;
150
173
  }
151
174
  if (globalOptions.json) {
152
- output.data(mappings);
175
+ let projects = paths.map(path => {
176
+ let mapping = mappings[path];
177
+ return {
178
+ directory: path,
179
+ isCurrent: path === currentDir,
180
+ project: {
181
+ name: mapping.projectName,
182
+ slug: mapping.projectSlug
183
+ },
184
+ organization: mapping.organizationSlug,
185
+ createdAt: mapping.createdAt
186
+ };
187
+ });
188
+ let current = projects.find(p => p.isCurrent) || null;
189
+ output.data({
190
+ projects,
191
+ current
192
+ });
153
193
  output.cleanup();
154
194
  return;
155
195
  }
156
196
  output.header('project:list');
157
197
  let colors = output.getColors();
158
- let currentDir = resolve(process.cwd());
159
198
  for (let path of paths) {
160
199
  let mapping = mappings[path];
161
200
  let isCurrent = path === currentDir;
@@ -211,8 +250,13 @@ export async function projectTokenCommand(_options = {}, globalOptions = {}) {
211
250
  if (globalOptions.json) {
212
251
  output.data({
213
252
  token: tokenStr,
214
- projectSlug: mapping.projectSlug,
215
- organizationSlug: mapping.organizationSlug
253
+ directory: currentDir,
254
+ project: {
255
+ name: mapping.projectName,
256
+ slug: mapping.projectSlug
257
+ },
258
+ organization: mapping.organizationSlug,
259
+ createdAt: mapping.createdAt
216
260
  });
217
261
  output.cleanup();
218
262
  return;
@@ -304,7 +348,23 @@ export async function projectRemoveCommand(_options = {}, globalOptions = {}) {
304
348
  return;
305
349
  }
306
350
 
307
- // Confirm removal
351
+ // In JSON mode, skip confirmation (for scripting)
352
+ if (globalOptions.json) {
353
+ await deleteProjectMapping(currentDir);
354
+ output.data({
355
+ removed: true,
356
+ directory: currentDir,
357
+ project: {
358
+ name: mapping.projectName,
359
+ slug: mapping.projectSlug
360
+ },
361
+ organization: mapping.organizationSlug
362
+ });
363
+ output.cleanup();
364
+ return;
365
+ }
366
+
367
+ // Confirm removal (interactive mode only)
308
368
  output.header('project:remove');
309
369
  output.labelValue('Current configuration', '');
310
370
  output.keyValue({
@@ -320,15 +380,9 @@ export async function projectRemoveCommand(_options = {}, globalOptions = {}) {
320
380
  return;
321
381
  }
322
382
  await deleteProjectMapping(currentDir);
323
- if (globalOptions.json) {
324
- output.data({
325
- removed: true
326
- });
327
- } else {
328
- output.complete('Project configuration removed');
329
- output.blank();
330
- output.hint('Run "vizzly project:select" to configure a different project');
331
- }
383
+ output.complete('Project configuration removed');
384
+ output.blank();
385
+ output.hint('Run "vizzly project:select" to configure a different project');
332
386
  output.cleanup();
333
387
  } catch (error) {
334
388
  output.error('Failed to remove project configuration', error);
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Projects command - List projects 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
+ * Projects command implementation
12
+ * @param {Object} options - Command options
13
+ * @param {Object} globalOptions - Global CLI options
14
+ */
15
+ export async function projectsCommand(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
+
33
+ // Build query params
34
+ let params = new URLSearchParams();
35
+ if (options.org) params.set('organization', options.org);
36
+ if (options.limit) params.set('limit', String(options.limit));
37
+ if (options.offset) params.set('offset', String(options.offset));
38
+ let queryString = params.toString();
39
+ let endpoint = `/api/sdk/projects${queryString ? `?${queryString}` : ''}`;
40
+ output.startSpinner('Fetching projects...');
41
+ let response = await client.request(endpoint);
42
+ output.stopSpinner();
43
+ let projects = response.projects || [];
44
+ let pagination = response.pagination || {};
45
+ if (globalOptions.json) {
46
+ output.data({
47
+ projects: projects.map(p => ({
48
+ id: p.id,
49
+ name: p.name,
50
+ slug: p.slug,
51
+ organizationName: p.organizationName,
52
+ organizationSlug: p.organizationSlug,
53
+ buildCount: p.buildCount,
54
+ createdAt: p.created_at,
55
+ updatedAt: p.updated_at
56
+ })),
57
+ pagination
58
+ });
59
+ } else {
60
+ output.header('projects');
61
+ let colors = output.getColors();
62
+ if (projects.length === 0) {
63
+ output.print(' No projects found');
64
+ if (options.org) {
65
+ output.hint(`No projects in organization "${options.org}"`);
66
+ }
67
+ } else {
68
+ output.labelValue('Showing', `${projects.length} of ${pagination.total}`);
69
+ output.blank();
70
+ for (let project of projects) {
71
+ output.print(` ${colors.bold(project.name)} ${colors.dim(`@${project.organizationSlug}/${project.slug}`)}`);
72
+ output.print(` ${colors.dim(`${project.buildCount} builds`)}`);
73
+ }
74
+ if (pagination.hasMore) {
75
+ output.blank();
76
+ output.hint(`Use --offset ${(options.offset || 0) + projects.length} to see more`);
77
+ }
78
+ }
79
+ }
80
+ output.cleanup();
81
+ } catch (error) {
82
+ output.stopSpinner();
83
+ output.error('Failed to fetch projects', error);
84
+ output.cleanup();
85
+ process.exit(1);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Validate projects options
91
+ * @param {Object} _options - Command options
92
+ * @returns {string[]} Validation errors
93
+ */
94
+ export function validateProjectsOptions(_options = {}) {
95
+ return [];
96
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Review commands - approve, reject, and comment on comparisons/builds
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
+ * Approve a comparison
11
+ * @param {string} comparisonId - Comparison ID to approve
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 approveCommand(comparisonId, 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
+ let allOptions = {
30
+ ...globalOptions,
31
+ ...options
32
+ };
33
+ let config = await loadConfig(globalOptions.config, allOptions);
34
+ if (!config.apiKey) {
35
+ output.error('API token required');
36
+ output.hint('Use --token or set VIZZLY_TOKEN environment variable');
37
+ exit(1);
38
+ return;
39
+ }
40
+ output.startSpinner('Approving comparison...');
41
+ let client = createApiClient({
42
+ baseUrl: config.apiUrl,
43
+ token: config.apiKey,
44
+ command: 'approve'
45
+ });
46
+ let body = {};
47
+ if (options.comment) {
48
+ body.comment = options.comment;
49
+ }
50
+ let response = await client.request(`/api/sdk/comparisons/${comparisonId}/approve`, {
51
+ method: 'POST',
52
+ body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
53
+ headers: Object.keys(body).length > 0 ? {
54
+ 'Content-Type': 'application/json'
55
+ } : undefined
56
+ });
57
+ output.stopSpinner();
58
+ if (globalOptions.json) {
59
+ output.data({
60
+ approved: true,
61
+ comparisonId,
62
+ comparison: response.comparison
63
+ });
64
+ output.cleanup();
65
+ return;
66
+ }
67
+ output.complete(`Comparison ${comparisonId} approved`);
68
+ if (options.comment) {
69
+ output.hint(`Comment: "${options.comment}"`);
70
+ }
71
+ output.cleanup();
72
+ } catch (error) {
73
+ output.stopSpinner();
74
+ if (globalOptions.json) {
75
+ output.data({
76
+ approved: false,
77
+ comparisonId,
78
+ error: {
79
+ message: error.message,
80
+ code: error.code
81
+ }
82
+ });
83
+ output.cleanup();
84
+ exit(1);
85
+ return;
86
+ }
87
+ output.error('Failed to approve comparison', error);
88
+ output.cleanup();
89
+ exit(1);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Reject a comparison
95
+ * @param {string} comparisonId - Comparison ID to reject
96
+ * @param {Object} options - Command options
97
+ * @param {Object} globalOptions - Global CLI options
98
+ * @param {Object} deps - Dependencies for testing
99
+ */
100
+ export async function rejectCommand(comparisonId, options = {}, globalOptions = {}, deps = {}) {
101
+ let {
102
+ loadConfig = defaultLoadConfig,
103
+ createApiClient = defaultCreateApiClient,
104
+ output = defaultOutput,
105
+ exit = code => process.exit(code)
106
+ } = deps;
107
+ output.configure({
108
+ json: globalOptions.json,
109
+ verbose: globalOptions.verbose,
110
+ color: !globalOptions.noColor
111
+ });
112
+ try {
113
+ let allOptions = {
114
+ ...globalOptions,
115
+ ...options
116
+ };
117
+ let config = await loadConfig(globalOptions.config, allOptions);
118
+ if (!config.apiKey) {
119
+ output.error('API token required');
120
+ output.hint('Use --token or set VIZZLY_TOKEN environment variable');
121
+ exit(1);
122
+ return;
123
+ }
124
+ if (!options.reason) {
125
+ output.error('Reason required when rejecting');
126
+ output.hint('Use --reason "explanation" to provide a reason');
127
+ exit(1);
128
+ return;
129
+ }
130
+ output.startSpinner('Rejecting comparison...');
131
+ let client = createApiClient({
132
+ baseUrl: config.apiUrl,
133
+ token: config.apiKey,
134
+ command: 'reject'
135
+ });
136
+ let response = await client.request(`/api/sdk/comparisons/${comparisonId}/reject`, {
137
+ method: 'POST',
138
+ body: JSON.stringify({
139
+ reason: options.reason
140
+ }),
141
+ headers: {
142
+ 'Content-Type': 'application/json'
143
+ }
144
+ });
145
+ output.stopSpinner();
146
+ if (globalOptions.json) {
147
+ output.data({
148
+ rejected: true,
149
+ comparisonId,
150
+ reason: options.reason,
151
+ comparison: response.comparison
152
+ });
153
+ output.cleanup();
154
+ return;
155
+ }
156
+ output.complete(`Comparison ${comparisonId} rejected`);
157
+ output.hint(`Reason: "${options.reason}"`);
158
+ output.cleanup();
159
+ } catch (error) {
160
+ output.stopSpinner();
161
+ if (globalOptions.json) {
162
+ output.data({
163
+ rejected: false,
164
+ comparisonId,
165
+ error: {
166
+ message: error.message,
167
+ code: error.code
168
+ }
169
+ });
170
+ output.cleanup();
171
+ exit(1);
172
+ return;
173
+ }
174
+ output.error('Failed to reject comparison', error);
175
+ output.cleanup();
176
+ exit(1);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Add a comment to a build
182
+ * @param {string} buildId - Build ID to comment on
183
+ * @param {string} message - Comment message
184
+ * @param {Object} options - Command options
185
+ * @param {Object} globalOptions - Global CLI options
186
+ * @param {Object} deps - Dependencies for testing
187
+ */
188
+ export async function commentCommand(buildId, message, options = {}, globalOptions = {}, deps = {}) {
189
+ let {
190
+ loadConfig = defaultLoadConfig,
191
+ createApiClient = defaultCreateApiClient,
192
+ output = defaultOutput,
193
+ exit = code => process.exit(code)
194
+ } = deps;
195
+ output.configure({
196
+ json: globalOptions.json,
197
+ verbose: globalOptions.verbose,
198
+ color: !globalOptions.noColor
199
+ });
200
+ try {
201
+ let allOptions = {
202
+ ...globalOptions,
203
+ ...options
204
+ };
205
+ let config = await loadConfig(globalOptions.config, allOptions);
206
+ if (!config.apiKey) {
207
+ output.error('API token required');
208
+ output.hint('Use --token or set VIZZLY_TOKEN environment variable');
209
+ exit(1);
210
+ return;
211
+ }
212
+ if (!message || message.trim() === '') {
213
+ output.error('Comment message required');
214
+ exit(1);
215
+ return;
216
+ }
217
+ output.startSpinner('Adding comment...');
218
+ let client = createApiClient({
219
+ baseUrl: config.apiUrl,
220
+ token: config.apiKey,
221
+ command: 'comment'
222
+ });
223
+ let body = {
224
+ content: message,
225
+ type: options.type || 'general'
226
+ };
227
+ let response = await client.request(`/api/sdk/builds/${buildId}/comments`, {
228
+ method: 'POST',
229
+ body: JSON.stringify(body),
230
+ headers: {
231
+ 'Content-Type': 'application/json'
232
+ }
233
+ });
234
+ output.stopSpinner();
235
+ if (globalOptions.json) {
236
+ output.data({
237
+ created: true,
238
+ buildId,
239
+ comment: response.comment
240
+ });
241
+ output.cleanup();
242
+ return;
243
+ }
244
+ output.complete('Comment added');
245
+ output.labelValue('Build', buildId);
246
+ output.labelValue('Message', message);
247
+ output.cleanup();
248
+ } catch (error) {
249
+ output.stopSpinner();
250
+ if (globalOptions.json) {
251
+ output.data({
252
+ created: false,
253
+ buildId,
254
+ error: {
255
+ message: error.message,
256
+ code: error.code
257
+ }
258
+ });
259
+ output.cleanup();
260
+ exit(1);
261
+ return;
262
+ }
263
+ output.error('Failed to add comment', error);
264
+ output.cleanup();
265
+ exit(1);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Validate approve command options
271
+ * @param {string} comparisonId - Comparison ID
272
+ * @param {Object} options - Command options
273
+ * @returns {string[]} Array of error messages
274
+ */
275
+ export function validateApproveOptions(comparisonId, _options = {}) {
276
+ let errors = [];
277
+ if (!comparisonId || comparisonId.trim() === '') {
278
+ errors.push('Comparison ID is required');
279
+ }
280
+ return errors;
281
+ }
282
+
283
+ /**
284
+ * Validate reject command options
285
+ * @param {string} comparisonId - Comparison ID
286
+ * @param {Object} options - Command options
287
+ * @returns {string[]} Array of error messages
288
+ */
289
+ export function validateRejectOptions(comparisonId, options = {}) {
290
+ let errors = [];
291
+ if (!comparisonId || comparisonId.trim() === '') {
292
+ errors.push('Comparison ID is required');
293
+ }
294
+ if (!options.reason || options.reason.trim() === '') {
295
+ errors.push('--reason is required when rejecting');
296
+ }
297
+ return errors;
298
+ }
299
+
300
+ /**
301
+ * Validate comment command options
302
+ * @param {string} buildId - Build ID
303
+ * @param {string} message - Comment message
304
+ * @param {Object} options - Command options
305
+ * @returns {string[]} Array of error messages
306
+ */
307
+ export function validateCommentOptions(buildId, message, options = {}) {
308
+ let errors = [];
309
+ if (!buildId || buildId.trim() === '') {
310
+ errors.push('Build ID is required');
311
+ }
312
+ if (!message || message.trim() === '') {
313
+ errors.push('Comment message is required');
314
+ }
315
+ if (options.type && !['general', 'approval', 'rejection'].includes(options.type)) {
316
+ errors.push('--type must be one of: general, approval, rejection');
317
+ }
318
+ return errors;
319
+ }