@vizzly-testing/cli 0.10.3 → 0.11.1

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 (47) hide show
  1. package/README.md +168 -8
  2. package/claude-plugin/.claude-plugin/.mcp.json +8 -0
  3. package/claude-plugin/.claude-plugin/README.md +114 -0
  4. package/claude-plugin/.claude-plugin/marketplace.json +28 -0
  5. package/claude-plugin/.claude-plugin/plugin.json +14 -0
  6. package/claude-plugin/commands/debug-diff.md +153 -0
  7. package/claude-plugin/commands/setup.md +137 -0
  8. package/claude-plugin/commands/suggest-screenshots.md +111 -0
  9. package/claude-plugin/commands/tdd-status.md +43 -0
  10. package/claude-plugin/mcp/vizzly-server/cloud-api-provider.js +354 -0
  11. package/claude-plugin/mcp/vizzly-server/index.js +861 -0
  12. package/claude-plugin/mcp/vizzly-server/local-tdd-provider.js +422 -0
  13. package/claude-plugin/mcp/vizzly-server/token-resolver.js +185 -0
  14. package/dist/cli.js +64 -0
  15. package/dist/client/index.js +13 -3
  16. package/dist/commands/login.js +195 -0
  17. package/dist/commands/logout.js +71 -0
  18. package/dist/commands/project.js +351 -0
  19. package/dist/commands/run.js +30 -0
  20. package/dist/commands/whoami.js +162 -0
  21. package/dist/plugin-loader.js +9 -15
  22. package/dist/sdk/index.js +16 -4
  23. package/dist/services/api-service.js +50 -7
  24. package/dist/services/auth-service.js +226 -0
  25. package/dist/types/client/index.d.ts +9 -3
  26. package/dist/types/commands/login.d.ts +11 -0
  27. package/dist/types/commands/logout.d.ts +11 -0
  28. package/dist/types/commands/project.d.ts +28 -0
  29. package/dist/types/commands/whoami.d.ts +11 -0
  30. package/dist/types/sdk/index.d.ts +9 -4
  31. package/dist/types/services/api-service.d.ts +2 -1
  32. package/dist/types/services/auth-service.d.ts +59 -0
  33. package/dist/types/utils/browser.d.ts +6 -0
  34. package/dist/types/utils/config-loader.d.ts +1 -1
  35. package/dist/types/utils/config-schema.d.ts +8 -174
  36. package/dist/types/utils/file-helpers.d.ts +18 -0
  37. package/dist/types/utils/global-config.d.ts +84 -0
  38. package/dist/utils/browser.js +44 -0
  39. package/dist/utils/config-loader.js +69 -3
  40. package/dist/utils/file-helpers.js +64 -0
  41. package/dist/utils/global-config.js +259 -0
  42. package/docs/api-reference.md +177 -6
  43. package/docs/authentication.md +334 -0
  44. package/docs/getting-started.md +21 -2
  45. package/docs/plugins.md +27 -0
  46. package/docs/test-integration.md +60 -10
  47. package/package.json +5 -3
@@ -57,8 +57,30 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
57
57
  ...globalOptions,
58
58
  ...options
59
59
  };
60
+
61
+ // Debug: Check options before loadConfig
62
+ if (process.env.DEBUG_CONFIG) {
63
+ console.log('[RUN] allOptions.token:', allOptions.token ? allOptions.token.substring(0, 8) + '***' : 'NONE');
64
+ }
60
65
  const config = await loadConfig(globalOptions.config, allOptions);
61
66
 
67
+ // Debug: Check config immediately after loadConfig
68
+ if (process.env.DEBUG_CONFIG) {
69
+ console.log('[RUN] Config after loadConfig:', {
70
+ hasApiKey: !!config.apiKey,
71
+ apiKeyPrefix: config.apiKey ? config.apiKey.substring(0, 8) + '***' : 'NONE'
72
+ });
73
+ }
74
+ if (globalOptions.verbose) {
75
+ ui.info('Token check:', {
76
+ hasApiKey: !!config.apiKey,
77
+ apiKeyType: typeof config.apiKey,
78
+ apiKeyPrefix: typeof config.apiKey === 'string' && config.apiKey ? config.apiKey.substring(0, 10) + '...' : 'none',
79
+ projectSlug: config.projectSlug || 'none',
80
+ organizationSlug: config.organizationSlug || 'none'
81
+ });
82
+ }
83
+
62
84
  // Validate API token (unless --allow-no-token is set)
63
85
  if (!config.apiKey && !config.allowNoToken) {
64
86
  ui.error('API token required. Use --token, set VIZZLY_TOKEN environment variable, or use --allow-no-token to run without uploading');
@@ -92,6 +114,14 @@ export async function runCommand(testCommand, options = {}, globalOptions = {})
92
114
  verbose: globalOptions.verbose,
93
115
  uploadAll: options.uploadAll || false
94
116
  };
117
+
118
+ // Debug: Check config before creating container
119
+ if (process.env.DEBUG_CONFIG) {
120
+ console.log('[RUN] Config before container:', {
121
+ hasApiKey: !!configWithVerbose.apiKey,
122
+ apiKeyPrefix: configWithVerbose.apiKey ? configWithVerbose.apiKey.substring(0, 8) + '***' : 'NONE'
123
+ });
124
+ }
95
125
  const command = 'run';
96
126
  const container = await createServiceContainer(configWithVerbose, command);
97
127
  testRunner = await container.get('testRunner'); // Assign to outer scope variable
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Whoami command implementation
3
+ * Shows current user and authentication status
4
+ */
5
+
6
+ import { ConsoleUI } from '../utils/console-ui.js';
7
+ import { AuthService } from '../services/auth-service.js';
8
+ import { getApiUrl } from '../utils/environment-config.js';
9
+ import { getAuthTokens } from '../utils/global-config.js';
10
+
11
+ /**
12
+ * Whoami command implementation
13
+ * @param {Object} options - Command options
14
+ * @param {Object} globalOptions - Global CLI options
15
+ */
16
+ export async function whoamiCommand(options = {}, globalOptions = {}) {
17
+ // Create UI handler
18
+ let ui = new ConsoleUI({
19
+ json: globalOptions.json,
20
+ verbose: globalOptions.verbose,
21
+ color: !globalOptions.noColor
22
+ });
23
+ try {
24
+ // Check if user is logged in
25
+ let auth = await getAuthTokens();
26
+ if (!auth || !auth.accessToken) {
27
+ if (globalOptions.json) {
28
+ ui.data({
29
+ authenticated: false
30
+ });
31
+ } else {
32
+ ui.info('You are not logged in');
33
+ console.log(''); // Empty line for spacing
34
+ ui.info('Run "vizzly login" to authenticate');
35
+ }
36
+ ui.cleanup();
37
+ return;
38
+ }
39
+
40
+ // Get current user info
41
+ ui.startSpinner('Fetching user information...');
42
+ let authService = new AuthService({
43
+ baseUrl: options.apiUrl || getApiUrl()
44
+ });
45
+ let response = await authService.whoami();
46
+ ui.stopSpinner();
47
+
48
+ // Output in JSON mode
49
+ if (globalOptions.json) {
50
+ ui.data({
51
+ authenticated: true,
52
+ user: response.user,
53
+ organizations: response.organizations || [],
54
+ tokenExpiresAt: auth.expiresAt
55
+ });
56
+ ui.cleanup();
57
+ return;
58
+ }
59
+
60
+ // Human-readable output
61
+ ui.success('Authenticated');
62
+ console.log(''); // Empty line for spacing
63
+
64
+ // Show user info
65
+ if (response.user) {
66
+ ui.info(`User: ${response.user.name || response.user.username}`);
67
+ ui.info(`Email: ${response.user.email}`);
68
+ if (response.user.username) {
69
+ ui.info(`Username: ${response.user.username}`);
70
+ }
71
+ if (globalOptions.verbose && response.user.id) {
72
+ ui.info(`User ID: ${response.user.id}`);
73
+ }
74
+ }
75
+
76
+ // Show organizations
77
+ if (response.organizations && response.organizations.length > 0) {
78
+ console.log(''); // Empty line for spacing
79
+ ui.info('Organizations:');
80
+ for (let org of response.organizations) {
81
+ let orgInfo = ` - ${org.name}`;
82
+ if (org.slug) {
83
+ orgInfo += ` (@${org.slug})`;
84
+ }
85
+ if (org.role) {
86
+ orgInfo += ` [${org.role}]`;
87
+ }
88
+ console.log(orgInfo);
89
+ if (globalOptions.verbose && org.id) {
90
+ console.log(` ID: ${org.id}`);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Show token expiry info
96
+ if (auth.expiresAt) {
97
+ console.log(''); // Empty line for spacing
98
+ let expiresAt = new Date(auth.expiresAt);
99
+ let now = new Date();
100
+ let msUntilExpiry = expiresAt.getTime() - now.getTime();
101
+ let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24));
102
+ let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60));
103
+ let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60));
104
+ if (msUntilExpiry <= 0) {
105
+ ui.warning('Token has expired');
106
+ console.log(''); // Empty line for spacing
107
+ ui.info('Run "vizzly login" to refresh your authentication');
108
+ } else if (daysUntilExpiry > 0) {
109
+ ui.info(`Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleDateString()})`);
110
+ } else if (hoursUntilExpiry > 0) {
111
+ ui.info(`Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleString()})`);
112
+ } else if (minutesUntilExpiry > 0) {
113
+ ui.info(`Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}`);
114
+ } else {
115
+ ui.warning('Token expires in less than a minute');
116
+ console.log(''); // Empty line for spacing
117
+ ui.info('Run "vizzly login" to refresh your authentication');
118
+ }
119
+ if (globalOptions.verbose) {
120
+ ui.info(`Token expires at: ${expiresAt.toISOString()}`);
121
+ }
122
+ }
123
+ ui.cleanup();
124
+ } catch (error) {
125
+ ui.stopSpinner();
126
+
127
+ // Handle authentication errors with helpful messages
128
+ if (error.name === 'AuthError') {
129
+ if (globalOptions.json) {
130
+ ui.data({
131
+ authenticated: false,
132
+ error: error.message
133
+ });
134
+ } else {
135
+ ui.error('Authentication token is invalid or expired', error, 0);
136
+ console.log(''); // Empty line for spacing
137
+ ui.info('Run "vizzly login" to authenticate again');
138
+ }
139
+ ui.cleanup();
140
+ process.exit(1);
141
+ } else {
142
+ ui.error('Failed to get user information', error, 0);
143
+ if (globalOptions.verbose && error.stack) {
144
+ console.error(''); // Empty line for spacing
145
+ console.error(error.stack);
146
+ }
147
+ process.exit(1);
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Validate whoami options
154
+ * @param {Object} options - Command options
155
+ */
156
+ export function validateWhoamiOptions() {
157
+ let errors = [];
158
+
159
+ // No specific validation needed for whoami command
160
+
161
+ return errors;
162
+ }
@@ -131,19 +131,16 @@ async function loadPlugin(pluginPath) {
131
131
 
132
132
  /**
133
133
  * Zod schema for validating plugin structure
134
+ * Note: Using passthrough() to allow configSchema without validating its structure
135
+ * to avoid Zod version conflicts when plugins have nested config objects
134
136
  */
135
137
  const pluginSchema = z.object({
136
138
  name: z.string().min(1, 'Plugin name is required'),
137
139
  version: z.string().optional(),
138
- register: z.function(z.tuple([z.any(), z.any()]), z.void()),
139
- configSchema: z.record(z.any()).optional()
140
- });
141
-
142
- /**
143
- * Zod schema for validating plugin config values
144
- * Supports: string, number, boolean, null, arrays, and nested objects
145
- */
146
- const configValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(configValueSchema), z.record(configValueSchema)]));
140
+ register: z.custom(val => typeof val === 'function', {
141
+ message: 'register must be a function'
142
+ })
143
+ }).passthrough();
147
144
 
148
145
  /**
149
146
  * Validate plugin has required structure
@@ -155,14 +152,11 @@ function validatePluginStructure(plugin) {
155
152
  // Validate basic plugin structure
156
153
  pluginSchema.parse(plugin);
157
154
 
158
- // If configSchema exists, validate it contains valid config values
159
- if (plugin.configSchema) {
160
- let configSchemaValidator = z.record(configValueSchema);
161
- configSchemaValidator.parse(plugin.configSchema);
162
- }
155
+ // Skip deep validation of configSchema to avoid Zod version conflicts
156
+ // configSchema is optional and primarily for documentation
163
157
  } catch (error) {
164
158
  if (error instanceof z.ZodError) {
165
- let messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
159
+ let messages = error.issues.map(e => `${e.path.join('.')}: ${e.message}`);
166
160
  throw new Error(`Invalid plugin structure: ${messages.join(', ')}`);
167
161
  }
168
162
  throw error;
package/dist/sdk/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { EventEmitter } from 'events';
14
+ import { resolveImageBuffer } from '../utils/file-helpers.js';
14
15
  import { createUploader } from '../services/uploader.js';
15
16
  import { createTDDService } from '../services/tdd-service.js';
16
17
  import { ScreenshotServer } from '../services/screenshot-server.js';
@@ -226,21 +227,27 @@ export class VizzlySDK extends EventEmitter {
226
227
  /**
227
228
  * Capture a screenshot
228
229
  * @param {string} name - Screenshot name
229
- * @param {Buffer} imageBuffer - Image data
230
+ * @param {Buffer|string} imageBuffer - Image data as a Buffer, or a file path to an image
230
231
  * @param {import('../types').ScreenshotOptions} [options] - Options
231
232
  * @returns {Promise<void>}
233
+ * @throws {VizzlyError} When server is not running
234
+ * @throws {VizzlyError} When file path is provided but file doesn't exist
235
+ * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
232
236
  */
233
237
  async screenshot(name, imageBuffer, options = {}) {
234
238
  if (!this.server || !this.server.isRunning()) {
235
239
  throw new VizzlyError('Server not running. Call start() first.', 'SERVER_NOT_RUNNING');
236
240
  }
237
241
 
242
+ // Resolve Buffer or file path using shared utility
243
+ const buffer = resolveImageBuffer(imageBuffer, 'screenshot');
244
+
238
245
  // Generate or use provided build ID
239
246
  const buildId = options.buildId || this.currentBuildId || 'default';
240
247
  this.currentBuildId = buildId;
241
248
 
242
249
  // Convert Buffer to base64 for JSON transport
243
- const imageBase64 = imageBuffer.toString('base64');
250
+ const imageBase64 = buffer.toString('base64');
244
251
  const screenshotData = {
245
252
  buildId,
246
253
  name,
@@ -331,8 +338,10 @@ export class VizzlySDK extends EventEmitter {
331
338
  /**
332
339
  * Run local comparison in TDD mode
333
340
  * @param {string} name - Screenshot name
334
- * @param {Buffer} imageBuffer - Current image
341
+ * @param {Buffer|string} imageBuffer - Current image as a Buffer, or a file path to an image
335
342
  * @returns {Promise<import('../types').ComparisonResult>} Comparison result
343
+ * @throws {VizzlyError} When file path is provided but file doesn't exist
344
+ * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
336
345
  */
337
346
  async compare(name, imageBuffer) {
338
347
  if (!this.services?.tddService) {
@@ -341,8 +350,11 @@ export class VizzlySDK extends EventEmitter {
341
350
  logger: this.logger
342
351
  });
343
352
  }
353
+
354
+ // Resolve Buffer or file path using shared utility
355
+ const buffer = resolveImageBuffer(imageBuffer, 'compare');
344
356
  try {
345
- const result = await this.services.tddService.compareScreenshot(name, imageBuffer);
357
+ const result = await this.services.tddService.compareScreenshot(name, buffer);
346
358
  this.emit('comparison:completed', result);
347
359
  return result;
348
360
  } catch (error) {
@@ -8,23 +8,26 @@ import { VizzlyError, AuthError } from '../errors/vizzly-error.js';
8
8
  import crypto from 'crypto';
9
9
  import { getPackageVersion } from '../utils/package-info.js';
10
10
  import { getApiUrl, getApiToken, getUserAgent } from '../utils/environment-config.js';
11
+ import { getAuthTokens, saveAuthTokens } from '../utils/global-config.js';
11
12
 
12
13
  /**
13
14
  * ApiService class for direct API communication
14
15
  */
15
16
  export class ApiService {
16
17
  constructor(options = {}) {
17
- this.baseUrl = options.baseUrl || getApiUrl();
18
- this.token = options.token || getApiToken();
18
+ // Accept config as-is, no fallbacks to environment
19
+ // Config-loader handles all env/file resolution
20
+ this.baseUrl = options.apiUrl || options.baseUrl || getApiUrl();
21
+ this.token = options.apiKey || options.token || getApiToken(); // Accept both apiKey and token
19
22
  this.uploadAll = options.uploadAll || false;
20
23
 
21
24
  // Build User-Agent string
22
- const command = options.command || 'run'; // Default to 'run' for API service
25
+ const command = options.command || 'run';
23
26
  const baseUserAgent = `vizzly-cli/${getPackageVersion()} (${command})`;
24
27
  const sdkUserAgent = options.userAgent || getUserAgent();
25
28
  this.userAgent = sdkUserAgent ? `${baseUserAgent} ${sdkUserAgent}` : baseUserAgent;
26
29
  if (!this.token && !options.allowNoToken) {
27
- throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable.');
30
+ throw new VizzlyError('No API token provided. Set VIZZLY_TOKEN environment variable or run "vizzly login".');
28
31
  }
29
32
  }
30
33
 
@@ -32,9 +35,10 @@ export class ApiService {
32
35
  * Make an API request
33
36
  * @param {string} endpoint - API endpoint
34
37
  * @param {Object} options - Fetch options
38
+ * @param {boolean} isRetry - Internal flag to prevent infinite retry loops
35
39
  * @returns {Promise<Object>} Response data
36
40
  */
37
- async request(endpoint, options = {}) {
41
+ async request(endpoint, options = {}, isRetry = false) {
38
42
  const url = `${this.baseUrl}${endpoint}`;
39
43
  const headers = {
40
44
  'User-Agent': this.userAgent,
@@ -59,9 +63,48 @@ export class ApiService {
59
63
  // ignore
60
64
  }
61
65
 
62
- // Handle authentication errors with user-friendly messages
66
+ // Handle authentication errors with automatic token refresh
67
+ if (response.status === 401 && !isRetry) {
68
+ // Attempt to refresh token if we have refresh token in global config
69
+ let auth = await getAuthTokens();
70
+ if (auth && auth.refreshToken) {
71
+ try {
72
+ // Attempt token refresh
73
+ let refreshResponse = await fetch(`${this.baseUrl}/api/auth/cli/refresh`, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ 'User-Agent': this.userAgent
78
+ },
79
+ body: JSON.stringify({
80
+ refreshToken: auth.refreshToken
81
+ })
82
+ });
83
+ if (refreshResponse.ok) {
84
+ let refreshData = await refreshResponse.json();
85
+
86
+ // Save new tokens to global config
87
+ await saveAuthTokens({
88
+ accessToken: refreshData.accessToken,
89
+ refreshToken: refreshData.refreshToken,
90
+ expiresAt: refreshData.expiresAt,
91
+ user: auth.user // Keep existing user data
92
+ });
93
+
94
+ // Update token for this service instance
95
+ this.token = refreshData.accessToken;
96
+
97
+ // Retry the original request with new token
98
+ return this.request(endpoint, options, true);
99
+ }
100
+ } catch {
101
+ // Token refresh failed, fall through to auth error
102
+ }
103
+ }
104
+ throw new AuthError('Invalid or expired API token. Please run "vizzly login" to authenticate.');
105
+ }
63
106
  if (response.status === 401) {
64
- throw new AuthError('Invalid or expired API token. Please check your VIZZLY_TOKEN environment variable and ensure it is valid.');
107
+ throw new AuthError('Invalid or expired API token. Please run "vizzly login" to authenticate.');
65
108
  }
66
109
  throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''} (URL: ${url})`);
67
110
  }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Authentication Service for Vizzly CLI
3
+ * Handles authentication flows with the Vizzly API
4
+ */
5
+
6
+ import { AuthError, VizzlyError } from '../errors/vizzly-error.js';
7
+ import { getApiUrl } from '../utils/environment-config.js';
8
+ import { getPackageVersion } from '../utils/package-info.js';
9
+ import { saveAuthTokens, clearAuthTokens, getAuthTokens } from '../utils/global-config.js';
10
+
11
+ /**
12
+ * AuthService class for CLI authentication
13
+ */
14
+ export class AuthService {
15
+ constructor(options = {}) {
16
+ this.baseUrl = options.baseUrl || getApiUrl();
17
+ this.userAgent = `vizzly-cli/${getPackageVersion()} (auth)`;
18
+ }
19
+
20
+ /**
21
+ * Make an unauthenticated API request
22
+ * @param {string} endpoint - API endpoint
23
+ * @param {Object} options - Fetch options
24
+ * @returns {Promise<Object>} Response data
25
+ */
26
+ async request(endpoint, options = {}) {
27
+ let url = `${this.baseUrl}${endpoint}`;
28
+ let headers = {
29
+ 'User-Agent': this.userAgent,
30
+ ...options.headers
31
+ };
32
+ let response = await fetch(url, {
33
+ ...options,
34
+ headers
35
+ });
36
+ if (!response.ok) {
37
+ let errorText = '';
38
+ let errorData = null;
39
+ try {
40
+ let contentType = response.headers.get('content-type');
41
+ if (contentType && contentType.includes('application/json')) {
42
+ errorData = await response.json();
43
+ errorText = errorData.error || errorData.message || '';
44
+ } else {
45
+ errorText = await response.text();
46
+ }
47
+ } catch {
48
+ errorText = response.statusText || '';
49
+ }
50
+ if (response.status === 401) {
51
+ throw new AuthError(errorText || 'Invalid credentials. Please check your email/username and password.');
52
+ }
53
+ if (response.status === 429) {
54
+ throw new VizzlyError('Too many login attempts. Please try again later.', 'RATE_LIMIT_ERROR');
55
+ }
56
+ throw new VizzlyError(`Authentication request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, 'AUTH_REQUEST_ERROR');
57
+ }
58
+ return response.json();
59
+ }
60
+
61
+ /**
62
+ * Make an authenticated API request
63
+ * @param {string} endpoint - API endpoint
64
+ * @param {Object} options - Fetch options
65
+ * @returns {Promise<Object>} Response data
66
+ */
67
+ async authenticatedRequest(endpoint, options = {}) {
68
+ let auth = await getAuthTokens();
69
+ if (!auth || !auth.accessToken) {
70
+ throw new AuthError('No authentication token found. Please run "vizzly login" first.');
71
+ }
72
+ let url = `${this.baseUrl}${endpoint}`;
73
+ let headers = {
74
+ 'User-Agent': this.userAgent,
75
+ Authorization: `Bearer ${auth.accessToken}`,
76
+ ...options.headers
77
+ };
78
+ let response = await fetch(url, {
79
+ ...options,
80
+ headers
81
+ });
82
+ if (!response.ok) {
83
+ let errorText = '';
84
+ try {
85
+ let contentType = response.headers.get('content-type');
86
+ if (contentType && contentType.includes('application/json')) {
87
+ let errorData = await response.json();
88
+ errorText = errorData.error || errorData.message || '';
89
+ } else {
90
+ errorText = await response.text();
91
+ }
92
+ } catch {
93
+ errorText = response.statusText || '';
94
+ }
95
+ if (response.status === 401) {
96
+ throw new AuthError('Authentication token is invalid or expired. Please run "vizzly login" again.');
97
+ }
98
+ throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, 'API_REQUEST_ERROR');
99
+ }
100
+ return response.json();
101
+ }
102
+
103
+ /**
104
+ * Initiate OAuth device flow
105
+ * @returns {Promise<Object>} Device code, user code, verification URL
106
+ */
107
+ async initiateDeviceFlow() {
108
+ return this.request('/api/auth/cli/device/initiate', {
109
+ method: 'POST',
110
+ headers: {
111
+ 'Content-Type': 'application/json'
112
+ }
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Poll for device authorization
118
+ * @param {string} deviceCode - Device code from initiate
119
+ * @returns {Promise<Object>} Token data or pending status
120
+ */
121
+ async pollDeviceAuthorization(deviceCode) {
122
+ return this.request('/api/auth/cli/device/poll', {
123
+ method: 'POST',
124
+ headers: {
125
+ 'Content-Type': 'application/json'
126
+ },
127
+ body: JSON.stringify({
128
+ device_code: deviceCode
129
+ })
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Complete device flow and save tokens
135
+ * @param {Object} tokenData - Token response from poll
136
+ * @returns {Promise<Object>} Token data with user info
137
+ */
138
+ async completeDeviceFlow(tokenData) {
139
+ // Save tokens to global config
140
+ await saveAuthTokens({
141
+ accessToken: tokenData.accessToken,
142
+ refreshToken: tokenData.refreshToken,
143
+ expiresAt: tokenData.expiresAt,
144
+ user: tokenData.user
145
+ });
146
+ return tokenData;
147
+ }
148
+
149
+ /**
150
+ * Refresh access token using refresh token
151
+ * @returns {Promise<Object>} New tokens
152
+ */
153
+ async refresh() {
154
+ let auth = await getAuthTokens();
155
+ if (!auth || !auth.refreshToken) {
156
+ throw new AuthError('No refresh token found. Please run "vizzly login" first.');
157
+ }
158
+ let response = await this.request('/api/auth/cli/refresh', {
159
+ method: 'POST',
160
+ headers: {
161
+ 'Content-Type': 'application/json'
162
+ },
163
+ body: JSON.stringify({
164
+ refreshToken: auth.refreshToken
165
+ })
166
+ });
167
+
168
+ // Update tokens in global config
169
+ await saveAuthTokens({
170
+ accessToken: response.accessToken,
171
+ refreshToken: response.refreshToken,
172
+ expiresAt: response.expiresAt,
173
+ user: auth.user // Keep existing user data
174
+ });
175
+ return response;
176
+ }
177
+
178
+ /**
179
+ * Logout and revoke tokens
180
+ * @returns {Promise<void>}
181
+ */
182
+ async logout() {
183
+ let auth = await getAuthTokens();
184
+ if (auth && auth.refreshToken) {
185
+ try {
186
+ // Attempt to revoke tokens on server
187
+ await this.request('/api/auth/cli/logout', {
188
+ method: 'POST',
189
+ headers: {
190
+ 'Content-Type': 'application/json'
191
+ },
192
+ body: JSON.stringify({
193
+ refreshToken: auth.refreshToken
194
+ })
195
+ });
196
+ } catch (error) {
197
+ // If server request fails, still clear local tokens
198
+ console.warn('Warning: Failed to revoke tokens on server:', error.message);
199
+ }
200
+ }
201
+
202
+ // Clear tokens from global config
203
+ await clearAuthTokens();
204
+ }
205
+
206
+ /**
207
+ * Get current user information
208
+ * @returns {Promise<Object>} User and organization data
209
+ */
210
+ async whoami() {
211
+ return this.authenticatedRequest('/api/auth/cli/whoami');
212
+ }
213
+
214
+ /**
215
+ * Check if user is authenticated
216
+ * @returns {Promise<boolean>} True if authenticated
217
+ */
218
+ async isAuthenticated() {
219
+ try {
220
+ await this.whoami();
221
+ return true;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+ }
@@ -2,7 +2,7 @@
2
2
  * Take a screenshot for visual regression testing
3
3
  *
4
4
  * @param {string} name - Unique name for the screenshot
5
- * @param {Buffer} imageBuffer - PNG image data as a Buffer
5
+ * @param {Buffer|string} imageBuffer - PNG image data as a Buffer, or a file path to an image
6
6
  * @param {Object} [options] - Optional configuration
7
7
  * @param {Record<string, any>} [options.properties] - Additional properties to attach to the screenshot
8
8
  * @param {number} [options.threshold=0] - Pixel difference threshold (0-100)
@@ -11,13 +11,17 @@
11
11
  * @returns {Promise<void>}
12
12
  *
13
13
  * @example
14
- * // Basic usage
14
+ * // Basic usage with Buffer
15
15
  * import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
16
16
  *
17
17
  * const screenshot = await page.screenshot();
18
18
  * await vizzlyScreenshot('homepage', screenshot);
19
19
  *
20
20
  * @example
21
+ * // Basic usage with file path
22
+ * await vizzlyScreenshot('homepage', './screenshots/homepage.png');
23
+ *
24
+ * @example
21
25
  * // With properties and threshold
22
26
  * await vizzlyScreenshot('checkout-form', screenshot, {
23
27
  * properties: {
@@ -28,8 +32,10 @@
28
32
  * });
29
33
  *
30
34
  * @throws {VizzlyError} When screenshot capture fails or client is not initialized
35
+ * @throws {VizzlyError} When file path is provided but file doesn't exist
36
+ * @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
31
37
  */
32
- export function vizzlyScreenshot(name: string, imageBuffer: Buffer, options?: {
38
+ export function vizzlyScreenshot(name: string, imageBuffer: Buffer | string, options?: {
33
39
  properties?: Record<string, any>;
34
40
  threshold?: number;
35
41
  fullPage?: boolean;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Login command implementation using OAuth device flow
3
+ * @param {Object} options - Command options
4
+ * @param {Object} globalOptions - Global CLI options
5
+ */
6
+ export function loginCommand(options?: any, globalOptions?: any): Promise<void>;
7
+ /**
8
+ * Validate login options
9
+ * @param {Object} options - Command options
10
+ */
11
+ export function validateLoginOptions(): any[];