@vizzly-testing/cli 0.30.0 → 0.31.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.
Files changed (77) hide show
  1. package/README.md +9 -9
  2. package/dist/api/client.js +6 -3
  3. package/dist/cli.js +58 -29
  4. package/dist/client/index.js +21 -29
  5. package/dist/commands/api.js +145 -90
  6. package/dist/commands/baselines.js +78 -47
  7. package/dist/commands/builds.js +9 -8
  8. package/dist/commands/comparisons.js +7 -8
  9. package/dist/commands/config-cmd.js +15 -7
  10. package/dist/commands/context.js +329 -18
  11. package/dist/commands/doctor.js +116 -81
  12. package/dist/commands/init.js +202 -201
  13. package/dist/commands/login.js +134 -65
  14. package/dist/commands/logout.js +17 -6
  15. package/dist/commands/preview.js +29 -57
  16. package/dist/commands/project.js +120 -0
  17. package/dist/commands/projects.js +9 -2
  18. package/dist/commands/review.js +236 -202
  19. package/dist/commands/run.js +75 -72
  20. package/dist/commands/status.js +228 -150
  21. package/dist/commands/tdd-daemon-process.js +98 -0
  22. package/dist/commands/tdd-daemon.js +318 -212
  23. package/dist/commands/tdd.js +23 -8
  24. package/dist/commands/upload.js +10 -9
  25. package/dist/commands/whoami.js +63 -25
  26. package/dist/config/core.js +36 -35
  27. package/dist/context/local-workspace-provider.js +132 -12
  28. package/dist/context/provider-resolver.js +5 -1
  29. package/dist/index.js +5 -4
  30. package/dist/plugin-loader.js +55 -49
  31. package/dist/project/core.js +2 -2
  32. package/dist/project/operations.js +6 -3
  33. package/dist/reporter/reporter-bundle.css +1 -1
  34. package/dist/reporter/reporter-bundle.iife.js +7 -7
  35. package/dist/sdk/index.js +162 -107
  36. package/dist/server/handlers/tdd-handler.js +30 -54
  37. package/dist/server/routers/baseline.js +195 -178
  38. package/dist/server/routers/events.js +212 -174
  39. package/dist/server/routers/projects.js +14 -5
  40. package/dist/server/routers/screenshot.js +1 -31
  41. package/dist/services/build-manager.js +19 -97
  42. package/dist/services/config-service.js +26 -34
  43. package/dist/services/index.js +2 -4
  44. package/dist/services/static-report-generator.js +11 -12
  45. package/dist/services/test-runner.js +21 -27
  46. package/dist/tdd/index.js +1 -1
  47. package/dist/tdd/metadata/region-metadata.js +0 -37
  48. package/dist/tdd/runtime-context.js +78 -0
  49. package/dist/tdd/server-registry.js +38 -17
  50. package/dist/tdd/services/baseline-manager.js +23 -0
  51. package/dist/tdd/services/hotspot-service.js +0 -13
  52. package/dist/tdd/tdd-service.js +82 -113
  53. package/dist/types/client.d.ts +36 -1
  54. package/dist/types/index.d.ts +114 -22
  55. package/dist/types/sdk.d.ts +40 -0
  56. package/dist/uploader/core.js +5 -4
  57. package/dist/uploader/index.js +79 -2
  58. package/dist/uploader/operations.js +40 -5
  59. package/dist/utils/api-url.js +14 -0
  60. package/dist/utils/async-utils.js +26 -0
  61. package/dist/utils/ci-env.js +19 -12
  62. package/dist/utils/config-loader.js +39 -65
  63. package/dist/utils/config-schema.js +19 -55
  64. package/dist/utils/fetch-utils.js +12 -5
  65. package/dist/utils/git.js +35 -72
  66. package/dist/utils/global-config.js +20 -1
  67. package/dist/utils/output.js +19 -7
  68. package/dist/utils/patterns.js +14 -0
  69. package/dist/utils/project-link-store.js +105 -0
  70. package/dist/utils/screenshot-options.js +28 -0
  71. package/dist/utils/secret-store.js +65 -0
  72. package/package.json +11 -8
  73. package/dist/services/screenshot-server.js +0 -65
  74. package/dist/services/server-manager.js +0 -81
  75. package/dist/services/uploader.js +0 -100
  76. package/dist/tdd/services/region-service.js +0 -75
  77. package/dist/utils/build-history.js +0 -103
@@ -5,6 +5,114 @@
5
5
  import { createApiClient as defaultCreateApiClient } from '../api/index.js';
6
6
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
7
7
  import * as defaultOutput from '../utils/output.js';
8
+ let ALLOWED_POST_ENDPOINTS = [/^\/api\/sdk\/comparisons\/[^/]+\/approve$/, /^\/api\/sdk\/comparisons\/[^/]+\/reject$/, /^\/api\/sdk\/builds\/[^/]+\/comments$/];
9
+ function createApiCommandDeps(deps = {}) {
10
+ return {
11
+ loadConfig: deps.loadConfig || defaultLoadConfig,
12
+ createApiClient: deps.createApiClient || defaultCreateApiClient,
13
+ output: deps.output || defaultOutput,
14
+ exit: deps.exit || (code => process.exit(code))
15
+ };
16
+ }
17
+ function configureOutput(output, globalOptions) {
18
+ output.configure({
19
+ json: globalOptions.json,
20
+ verbose: globalOptions.verbose,
21
+ color: !globalOptions.noColor
22
+ });
23
+ }
24
+ export function normalizeApiEndpoint(endpoint) {
25
+ let normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
26
+ if (!normalizedEndpoint.startsWith('/api/')) {
27
+ normalizedEndpoint = `/api${normalizedEndpoint}`;
28
+ }
29
+ return normalizedEndpoint;
30
+ }
31
+ export function normalizeApiMethod(method = 'GET') {
32
+ return method.toUpperCase();
33
+ }
34
+ export function isAllowedPostEndpoint(endpoint) {
35
+ return ALLOWED_POST_ENDPOINTS.some(pattern => pattern.test(endpoint));
36
+ }
37
+ export function parseApiHeaders(headerOption) {
38
+ let headers = {};
39
+ let headerList = Array.isArray(headerOption) ? headerOption : [headerOption];
40
+ for (let header of headerList.filter(Boolean)) {
41
+ let [key, ...valueParts] = header.split(':');
42
+ if (key && valueParts.length > 0) {
43
+ headers[key.trim()] = valueParts.join(':').trim();
44
+ }
45
+ }
46
+ return headers;
47
+ }
48
+ export function appendApiQuery(endpoint, queryOption) {
49
+ if (!queryOption) {
50
+ return endpoint;
51
+ }
52
+ let params = new URLSearchParams();
53
+ let queryList = Array.isArray(queryOption) ? queryOption : [queryOption];
54
+ for (let query of queryList) {
55
+ let [key, ...valueParts] = query.split('=');
56
+ if (key && valueParts.length > 0) {
57
+ params.append(key.trim(), valueParts.join('=').trim());
58
+ }
59
+ }
60
+ let queryString = params.toString();
61
+ if (!queryString) {
62
+ return endpoint;
63
+ }
64
+ return endpoint + (endpoint.includes('?') ? '&' : '?') + queryString;
65
+ }
66
+ export function validateApiRequest({
67
+ endpoint,
68
+ method
69
+ }) {
70
+ let errors = [];
71
+ if (method !== 'GET' && method !== 'POST') {
72
+ errors.push(`Method ${method} not allowed. Use GET for queries or POST for approve/reject/comment.`);
73
+ return errors;
74
+ }
75
+ if (method === 'POST' && !isAllowedPostEndpoint(endpoint)) {
76
+ errors.push(`POST not allowed for ${endpoint}. Only approve, reject, and comment endpoints support POST.`);
77
+ }
78
+ return errors;
79
+ }
80
+ export function buildApiRequest({
81
+ endpoint,
82
+ options = {}
83
+ }) {
84
+ let normalizedEndpoint = normalizeApiEndpoint(endpoint);
85
+ let method = normalizeApiMethod(options.method || 'GET');
86
+ let errors = validateApiRequest({
87
+ endpoint: normalizedEndpoint,
88
+ method
89
+ });
90
+ if (errors.length > 0) {
91
+ return {
92
+ errors,
93
+ method,
94
+ normalizedEndpoint,
95
+ requestOptions: null
96
+ };
97
+ }
98
+ let headers = parseApiHeaders(options.header);
99
+ let requestOptions = {
100
+ method
101
+ };
102
+ if (options.data && method === 'POST') {
103
+ headers['Content-Type'] = headers['Content-Type'] || 'application/json';
104
+ requestOptions.body = options.data;
105
+ }
106
+ if (Object.keys(headers).length > 0) {
107
+ requestOptions.headers = headers;
108
+ }
109
+ return {
110
+ errors: [],
111
+ method,
112
+ normalizedEndpoint: appendApiQuery(normalizedEndpoint, options.query),
113
+ requestOptions
114
+ };
115
+ }
8
116
 
9
117
  /**
10
118
  * API command - make raw API requests
@@ -15,16 +123,14 @@ import * as defaultOutput from '../utils/output.js';
15
123
  */
16
124
  export async function apiCommand(endpoint, options = {}, globalOptions = {}, deps = {}) {
17
125
  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
- });
126
+ loadConfig,
127
+ createApiClient,
128
+ output,
129
+ exit
130
+ } = createApiCommandDeps(deps);
131
+ let displayEndpoint = normalizeApiEndpoint(endpoint);
132
+ let displayMethod = normalizeApiMethod(options.method || 'GET');
133
+ configureOutput(output, globalOptions);
28
134
  try {
29
135
  // Load configuration
30
136
  let allOptions = {
@@ -36,71 +142,31 @@ export async function apiCommand(endpoint, options = {}, globalOptions = {}, dep
36
142
  // Validate API token
37
143
  if (!config.apiKey) {
38
144
  output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
145
+ output.cleanup();
39
146
  exit(1);
40
147
  return;
41
148
  }
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.`);
149
+ let {
150
+ errors,
151
+ method,
152
+ normalizedEndpoint,
153
+ requestOptions
154
+ } = buildApiRequest({
155
+ endpoint,
156
+ options
157
+ });
158
+ displayEndpoint = normalizedEndpoint;
159
+ displayMethod = method;
160
+ if (errors.length > 0) {
161
+ output.error(errors[0]);
162
+ if (method === 'POST') {
163
+ output.hint('Use GET for queries, or use dedicated commands (vizzly approve, vizzly reject, vizzly comment)');
164
+ }
165
+ output.hint('Most raw API use should stay read-only; prefer dedicated commands for mutations.');
166
+ output.cleanup();
61
167
  exit(1);
62
168
  return;
63
169
  }
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
170
 
105
171
  // Make the request
106
172
  output.startSpinner(`${method} ${normalizedEndpoint}`);
@@ -140,8 +206,8 @@ export async function apiCommand(endpoint, options = {}, globalOptions = {}, dep
140
206
  output.stopSpinner();
141
207
  if (globalOptions.json) {
142
208
  output.data({
143
- endpoint,
144
- method: options.method || 'GET',
209
+ endpoint: displayEndpoint,
210
+ method: displayMethod,
145
211
  error: {
146
212
  message: error.message,
147
213
  code: error.code,
@@ -158,19 +224,6 @@ export async function apiCommand(endpoint, options = {}, globalOptions = {}, dep
158
224
  }
159
225
  }
160
226
 
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
227
  /**
175
228
  * Validate API command options
176
229
  */
@@ -179,12 +232,14 @@ export function validateApiOptions(endpoint, options = {}) {
179
232
  if (!endpoint || endpoint.trim() === '') {
180
233
  errors.push('Endpoint is required');
181
234
  }
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.`);
235
+ if (!endpoint || endpoint.trim() === '') {
236
+ return errors;
188
237
  }
238
+ let normalizedEndpoint = normalizeApiEndpoint(endpoint);
239
+ let method = normalizeApiMethod(options.method || 'GET');
240
+ errors.push(...validateApiRequest({
241
+ endpoint: normalizedEndpoint,
242
+ method
243
+ }));
189
244
  return errors;
190
245
  }
@@ -2,15 +2,16 @@
2
2
  * Baselines command - query local TDD baselines
3
3
  */
4
4
 
5
- import { existsSync, readdirSync, statSync } from 'node:fs';
5
+ import { existsSync as defaultExistsSync, readdirSync as defaultReaddirSync, statSync as defaultStatSync } from 'node:fs';
6
6
  import { basename, join, resolve } from 'node:path';
7
- import { loadBaselineMetadata } from '../tdd/metadata/baseline-metadata.js';
7
+ import { loadBaselineMetadata as defaultLoadBaselineMetadata } from '../tdd/metadata/baseline-metadata.js';
8
8
  import * as defaultOutput from '../utils/output.js';
9
+ import { createWildcardMatcher } from '../utils/patterns.js';
9
10
 
10
11
  /**
11
12
  * Extract filename from a path
12
13
  */
13
- function getFilename(screenshot) {
14
+ export function getFilename(screenshot) {
14
15
  if (screenshot.filename) return screenshot.filename;
15
16
  if (screenshot.path) return basename(screenshot.path);
16
17
  return null;
@@ -19,7 +20,7 @@ function getFilename(screenshot) {
19
20
  /**
20
21
  * Extract viewport from screenshot properties
21
22
  */
22
- function getViewport(screenshot) {
23
+ export function getViewport(screenshot) {
23
24
  if (screenshot.viewport) return screenshot.viewport;
24
25
  if (screenshot.properties?.viewport_width && screenshot.properties?.viewport_height) {
25
26
  return {
@@ -29,6 +30,59 @@ function getViewport(screenshot) {
29
30
  }
30
31
  return null;
31
32
  }
33
+ export function createBaselineNameMatcher(pattern) {
34
+ return createWildcardMatcher(pattern);
35
+ }
36
+ export function getBaselineFiles(baselinesDir, fs = {}) {
37
+ let {
38
+ existsSync = defaultExistsSync,
39
+ readdirSync = defaultReaddirSync,
40
+ statSync = defaultStatSync
41
+ } = fs;
42
+ if (!existsSync(baselinesDir)) {
43
+ return [];
44
+ }
45
+ return readdirSync(baselinesDir).filter(filename => filename.endsWith('.png')).map(filename => {
46
+ let filePath = join(baselinesDir, filename);
47
+ try {
48
+ let stat = statSync(filePath);
49
+ return {
50
+ filename,
51
+ path: filePath,
52
+ size: stat.size,
53
+ modifiedAt: stat.mtime.toISOString()
54
+ };
55
+ } catch {
56
+ // File was deleted between readdir and stat, skip it.
57
+ return null;
58
+ }
59
+ }).filter(Boolean);
60
+ }
61
+ export function createBaselineList({
62
+ baselinesDir,
63
+ baselineFiles,
64
+ screenshots
65
+ }) {
66
+ return screenshots.map(screenshot => {
67
+ let filename = getFilename(screenshot);
68
+ let viewport = getViewport(screenshot);
69
+ let file = baselineFiles.find(candidate => candidate.filename === filename);
70
+ return {
71
+ name: screenshot.name,
72
+ signature: screenshot.signature,
73
+ filename,
74
+ path: screenshot.path || file?.path || getFallbackPath(baselinesDir, filename),
75
+ sha256: screenshot.sha256,
76
+ viewport,
77
+ browser: screenshot.browser || null,
78
+ createdAt: screenshot.createdAt,
79
+ fileSize: file?.size
80
+ };
81
+ });
82
+ }
83
+ function getFallbackPath(baselinesDir, filename) {
84
+ return filename ? join(baselinesDir, filename) : null;
85
+ }
32
86
 
33
87
  /**
34
88
  * Baselines command - list and query local TDD baselines
@@ -40,7 +94,11 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
40
94
  let {
41
95
  output = defaultOutput,
42
96
  exit = code => process.exit(code),
43
- cwd = process.cwd
97
+ cwd = process.cwd,
98
+ existsSync = defaultExistsSync,
99
+ loadBaselineMetadata = defaultLoadBaselineMetadata,
100
+ readdirSync = defaultReaddirSync,
101
+ statSync = defaultStatSync
44
102
  } = deps;
45
103
  output.configure({
46
104
  json: globalOptions.json,
@@ -71,33 +129,17 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
71
129
  // Load metadata
72
130
  let metadata = loadBaselineMetadata(baselinesDir);
73
131
  let screenshots = metadata?.screenshots || [];
74
-
75
- // Get actual baseline files
76
- let baselineFiles = [];
77
- if (existsSync(baselinesDir)) {
78
- baselineFiles = readdirSync(baselinesDir).filter(f => f.endsWith('.png')).map(f => {
79
- let filePath = join(baselinesDir, f);
80
- try {
81
- let stat = statSync(filePath);
82
- return {
83
- filename: f,
84
- path: filePath,
85
- size: stat.size,
86
- modifiedAt: stat.mtime.toISOString()
87
- };
88
- } catch {
89
- // File was deleted between readdir and stat, skip it
90
- return null;
91
- }
92
- }).filter(Boolean);
93
- }
132
+ let baselineFiles = getBaselineFiles(baselinesDir, {
133
+ existsSync,
134
+ readdirSync,
135
+ statSync
136
+ });
94
137
 
95
138
  // Filter by name if provided
96
139
  if (options.name) {
97
- let pattern = options.name.replace(/\*/g, '.*');
98
- let regex = new RegExp(pattern, 'i');
99
- screenshots = screenshots.filter(s => regex.test(s.name));
100
- baselineFiles = baselineFiles.filter(f => regex.test(f.filename));
140
+ let matchesName = createBaselineNameMatcher(options.name);
141
+ screenshots = screenshots.filter(s => matchesName(s.name));
142
+ baselineFiles = baselineFiles.filter(f => matchesName(f.filename));
101
143
  }
102
144
 
103
145
  // Get specific baseline info
@@ -125,7 +167,7 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
125
167
  name: screenshot.name,
126
168
  signature: screenshot.signature,
127
169
  filename,
128
- path: screenshot.path || file?.path || join(baselinesDir, filename),
170
+ path: screenshot.path || file?.path || getFallbackPath(baselinesDir, filename),
129
171
  sha256: screenshot.sha256,
130
172
  viewport,
131
173
  browser: screenshot.browser || null,
@@ -162,21 +204,10 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
162
204
 
163
205
  // JSON output for list
164
206
  if (globalOptions.json) {
165
- let baselines = screenshots.map(s => {
166
- let filename = getFilename(s);
167
- let viewport = getViewport(s);
168
- let file = baselineFiles.find(f => f.filename === filename);
169
- return {
170
- name: s.name,
171
- signature: s.signature,
172
- filename,
173
- path: s.path || file?.path || join(baselinesDir, filename),
174
- sha256: s.sha256,
175
- viewport,
176
- browser: s.browser || null,
177
- createdAt: s.createdAt,
178
- fileSize: file?.size
179
- };
207
+ let baselines = createBaselineList({
208
+ baselinesDir,
209
+ baselineFiles,
210
+ screenshots
180
211
  });
181
212
  output.data({
182
213
  baselines,
@@ -208,7 +239,7 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
208
239
  if (metadata.branch && metadata.branch !== 'local') {
209
240
  output.labelValue('Branch', metadata.branch);
210
241
  }
211
- output.labelValue('Threshold', `${metadata.threshold || 2.0}%`);
242
+ output.labelValue('Threshold', `${metadata.threshold ?? 2.0} CIEDE2000`);
212
243
  output.blank();
213
244
  }
214
245
  output.labelValue('Count', String(screenshots.length));
@@ -251,7 +282,7 @@ export async function baselinesCommand(options = {}, globalOptions = {}, deps =
251
282
  /**
252
283
  * Format bytes to human readable
253
284
  */
254
- function formatBytes(bytes) {
285
+ export function formatBytes(bytes) {
255
286
  if (bytes < 1024) return `${bytes} B`;
256
287
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
257
288
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -33,16 +33,17 @@ export async function buildsCommand(options = {}, globalOptions = {}, deps = {})
33
33
  ...options
34
34
  };
35
35
  let config = await loadConfig(globalOptions.config, allOptions);
36
+ let token = config.apiKey || config.userToken;
36
37
 
37
- // Validate API token
38
- if (!config.apiKey) {
39
- output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
38
+ // Validate cloud auth
39
+ if (!token) {
40
+ output.error('Authentication required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"');
40
41
  exit(1);
41
42
  return;
42
43
  }
43
44
  let client = createApiClient({
44
45
  baseUrl: config.apiUrl,
45
- token: config.apiKey,
46
+ token,
46
47
  command: 'builds'
47
48
  });
48
49
 
@@ -328,11 +329,11 @@ function getComparisonStatusIcon(colors, status) {
328
329
  */
329
330
  export function validateBuildsOptions(options = {}) {
330
331
  let errors = [];
331
- if (options.limit && (Number.isNaN(options.limit) || options.limit < 1 || options.limit > 250)) {
332
- errors.push('--limit must be a number between 1 and 250');
332
+ if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 250)) {
333
+ errors.push('--limit must be an integer between 1 and 250');
333
334
  }
334
- if (options.offset && (Number.isNaN(options.offset) || options.offset < 0)) {
335
- errors.push('--offset must be a non-negative number');
335
+ if (options.offset !== undefined && (!Number.isInteger(options.offset) || options.offset < 0)) {
336
+ errors.push('--offset must be a non-negative integer');
336
337
  }
337
338
  return errors;
338
339
  }
@@ -5,6 +5,7 @@
5
5
  import { createApiClient as defaultCreateApiClient, getBuild as defaultGetBuild, getComparison as defaultGetComparison, searchComparisons as defaultSearchComparisons } from '../api/index.js';
6
6
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
7
7
  import * as defaultOutput from '../utils/output.js';
8
+ import { createWildcardMatcher } from '../utils/patterns.js';
8
9
 
9
10
  /**
10
11
  * Comparisons command - query comparisons with various filters
@@ -77,9 +78,8 @@ export async function comparisonsCommand(options = {}, globalOptions = {}, deps
77
78
 
78
79
  // Apply name filter if provided
79
80
  if (options.name) {
80
- let pattern = options.name.replace(/\*/g, '.*');
81
- let regex = new RegExp(pattern, 'i');
82
- comparisons = comparisons.filter(c => regex.test(c.name));
81
+ let matchesName = createWildcardMatcher(options.name);
82
+ comparisons = comparisons.filter(c => matchesName(c.name));
83
83
  }
84
84
  if (globalOptions.json) {
85
85
  output.data({
@@ -202,7 +202,6 @@ function formatComparisonForJson(comparison) {
202
202
  * Display a single comparison in detail
203
203
  */
204
204
  function displayComparison(output, comparison, verbose) {
205
- let _colors = output.getColors();
206
205
  output.header('comparison', comparison.status);
207
206
  output.keyValue({
208
207
  Name: comparison.name,
@@ -388,11 +387,11 @@ function getStatusIcon(colors, status) {
388
387
  */
389
388
  export function validateComparisonsOptions(options = {}) {
390
389
  let errors = [];
391
- if (options.limit && (Number.isNaN(options.limit) || options.limit < 1 || options.limit > 250)) {
392
- errors.push('--limit must be a number between 1 and 250');
390
+ if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 250)) {
391
+ errors.push('--limit must be an integer between 1 and 250');
393
392
  }
394
- if (options.offset && (Number.isNaN(options.offset) || options.offset < 0)) {
395
- errors.push('--offset must be a non-negative number');
393
+ if (options.offset !== undefined && (!Number.isInteger(options.offset) || options.offset < 0)) {
394
+ errors.push('--offset must be a non-negative integer');
396
395
  }
397
396
  return errors;
398
397
  }
@@ -2,6 +2,7 @@
2
2
  * Config command - query and display configuration
3
3
  */
4
4
 
5
+ import { CONFIG_DEFAULTS } from '../config/core.js';
5
6
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
6
7
  import * as defaultOutput from '../utils/output.js';
7
8
 
@@ -33,15 +34,10 @@ export async function configCommand(key = null, options = {}, globalOptions = {}
33
34
 
34
35
  // Build the config object to display
35
36
  let displayConfig = {
36
- server: config.server || {
37
- port: 47392,
38
- timeout: 30000
39
- },
37
+ server: config.server || CONFIG_DEFAULTS.server,
40
38
  build: config.build || {},
41
39
  upload: config.upload || {},
42
- comparison: config.comparison || {
43
- threshold: 2.0
44
- },
40
+ comparison: config.comparison || CONFIG_DEFAULTS.comparison,
45
41
  tdd: config.tdd || {}
46
42
  };
47
43
 
@@ -53,6 +49,15 @@ export async function configCommand(key = null, options = {}, globalOptions = {}
53
49
  tokenPrefix: `${config.apiKey.substring(0, 8)}...`
54
50
  };
55
51
  }
52
+ if (config.linkedProject) {
53
+ displayConfig.linkedProject = {
54
+ organization: config.linkedProject.organizationSlug,
55
+ project: config.linkedProject.projectSlug,
56
+ tokenPrefix: config.linkedProject.tokenPrefix,
57
+ storage: config.linkedProject.storage,
58
+ expiresAt: config.linkedProject.expiresAt || null
59
+ };
60
+ }
56
61
 
57
62
  // If a specific key is requested, extract it
58
63
  if (key) {
@@ -110,6 +115,9 @@ export async function configCommand(key = null, options = {}, globalOptions = {}
110
115
  if (displayConfig.api) {
111
116
  displaySection(output, 'API', displayConfig.api);
112
117
  }
118
+ if (displayConfig.linkedProject) {
119
+ displaySection(output, 'Linked Project', displayConfig.linkedProject);
120
+ }
113
121
  } else {
114
122
  output.hint('Use --verbose to see all configuration options');
115
123
  }