@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
@@ -7,18 +7,127 @@ import { completeDeviceFlow, createAuthClient, createTokenStore, initiateDeviceF
7
7
  import { openBrowser } from '../utils/browser.js';
8
8
  import { getApiUrl } from '../utils/environment-config.js';
9
9
  import * as output from '../utils/output.js';
10
+ function createLoginDeps(deps = {}) {
11
+ return {
12
+ completeDeviceFlow: deps.completeDeviceFlow || completeDeviceFlow,
13
+ createAuthClient: deps.createAuthClient || createAuthClient,
14
+ createTokenStore: deps.createTokenStore || createTokenStore,
15
+ getApiUrl: deps.getApiUrl || getApiUrl,
16
+ initiateDeviceFlow: deps.initiateDeviceFlow || initiateDeviceFlow,
17
+ now: deps.now || (() => Date.now()),
18
+ openBrowser: deps.openBrowser || openBrowser,
19
+ output: deps.output || output,
20
+ pollDeviceAuthorization: deps.pollDeviceAuthorization || pollDeviceAuthorization,
21
+ waitForEnter: deps.waitForEnter || waitForEnter,
22
+ exit: deps.exit || (code => process.exit(code))
23
+ };
24
+ }
25
+ function configureOutput(output, globalOptions) {
26
+ output.configure({
27
+ json: globalOptions.json,
28
+ verbose: globalOptions.verbose,
29
+ color: !globalOptions.noColor
30
+ });
31
+ }
32
+ export function normalizeDeviceFlowResponse(deviceFlow) {
33
+ let verificationUri = deviceFlow.verification_uri || deviceFlow.verificationUri;
34
+ let userCode = deviceFlow.user_code || deviceFlow.userCode;
35
+ let deviceCode = deviceFlow.device_code || deviceFlow.deviceCode;
36
+ if (!verificationUri || !userCode || !deviceCode) {
37
+ throw new Error('Invalid device flow response from server');
38
+ }
39
+ return {
40
+ verificationUri,
41
+ userCode,
42
+ deviceCode,
43
+ urlWithCode: `${verificationUri}?code=${encodeURIComponent(userCode)}`
44
+ };
45
+ }
46
+ export function resolveAuthorizedTokenData(pollResponse) {
47
+ let accessToken = pollResponse.tokens?.accessToken || pollResponse.tokens?.access_token;
48
+ if (accessToken) {
49
+ return pollResponse;
50
+ }
51
+ if (pollResponse.status === 'pending') {
52
+ throw new Error('Authorization not complete yet. Please complete the authorization in your browser and try running "vizzly login" again.');
53
+ }
54
+ if (pollResponse.status === 'expired') {
55
+ throw new Error('Device code expired. Please try logging in again.');
56
+ }
57
+ if (pollResponse.status === 'denied') {
58
+ throw new Error('Authorization denied. Please try logging in again.');
59
+ }
60
+ throw new Error('Unexpected response from authorization server. Please try logging in again.');
61
+ }
62
+ export function buildTokens(tokenData, now = Date.now()) {
63
+ let tokensData = tokenData.tokens || tokenData;
64
+ let tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
65
+ let tokenExpiresAt = tokenExpiresIn ? new Date(now + tokenExpiresIn * 1000).toISOString() : tokenData.expires_at || tokenData.expiresAt;
66
+ return {
67
+ accessToken: tokensData.accessToken || tokensData.access_token,
68
+ refreshToken: tokensData.refreshToken || tokensData.refresh_token,
69
+ expiresAt: tokenExpiresAt,
70
+ user: tokenData.user,
71
+ organizations: tokenData.organizations
72
+ };
73
+ }
74
+ export function getTokenExpiryHint(expiresAt, now = Date.now()) {
75
+ if (!expiresAt) {
76
+ return null;
77
+ }
78
+ let expiry = new Date(expiresAt);
79
+ let msUntilExpiry = expiry.getTime() - now;
80
+ let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24));
81
+ let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60));
82
+ let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60));
83
+ if (daysUntilExpiry > 0) {
84
+ return `Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''}`;
85
+ }
86
+ if (hoursUntilExpiry > 0) {
87
+ return `Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''}`;
88
+ }
89
+ if (minutesUntilExpiry > 0) {
90
+ return `Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}`;
91
+ }
92
+ return null;
93
+ }
94
+ export async function waitForEnter(stdin = process.stdin) {
95
+ await new Promise(resolve => {
96
+ let canSetRawMode = typeof stdin.setRawMode === 'function';
97
+ if (canSetRawMode) {
98
+ stdin.setRawMode(true);
99
+ }
100
+ stdin.resume();
101
+ stdin.once('data', () => {
102
+ if (canSetRawMode) {
103
+ stdin.setRawMode(false);
104
+ }
105
+ stdin.pause();
106
+ resolve();
107
+ });
108
+ });
109
+ }
10
110
 
11
111
  /**
12
112
  * Login command implementation using OAuth device flow
13
113
  * @param {Object} options - Command options
14
114
  * @param {Object} globalOptions - Global CLI options
15
115
  */
16
- export async function loginCommand(options = {}, globalOptions = {}) {
17
- output.configure({
18
- json: globalOptions.json,
19
- verbose: globalOptions.verbose,
20
- color: !globalOptions.noColor
21
- });
116
+ export async function loginCommand(options = {}, globalOptions = {}, deps = {}) {
117
+ let {
118
+ completeDeviceFlow,
119
+ createAuthClient,
120
+ createTokenStore,
121
+ getApiUrl,
122
+ initiateDeviceFlow,
123
+ now,
124
+ openBrowser,
125
+ output,
126
+ pollDeviceAuthorization,
127
+ waitForEnter,
128
+ exit
129
+ } = createLoginDeps(deps);
130
+ configureOutput(output, globalOptions);
22
131
  let colors = output.getColors();
23
132
  try {
24
133
  output.header('login');
@@ -35,15 +144,11 @@ export async function loginCommand(options = {}, globalOptions = {}) {
35
144
  output.stopSpinner();
36
145
 
37
146
  // Handle both snake_case and camelCase field names
38
- let verificationUri = deviceFlow.verification_uri || deviceFlow.verificationUri;
39
- let userCode = deviceFlow.user_code || deviceFlow.userCode;
40
- let deviceCode = deviceFlow.device_code || deviceFlow.deviceCode;
41
- if (!verificationUri || !userCode || !deviceCode) {
42
- throw new Error('Invalid device flow response from server');
43
- }
44
-
45
- // Build URL with pre-filled code
46
- let urlWithCode = `${verificationUri}?code=${userCode}`;
147
+ let {
148
+ deviceCode,
149
+ urlWithCode,
150
+ userCode
151
+ } = normalizeDeviceFlowResponse(deviceFlow);
47
152
 
48
153
  // Display user code prominently in a box
49
154
  output.printBox(['Visit this URL to authorize:', '', colors.brand.info(urlWithCode), '', 'Your code:', '', colors.bold(colors.brand.amber(userCode))], {
@@ -64,48 +169,17 @@ export async function loginCommand(options = {}, globalOptions = {}) {
64
169
  output.hint('After authorizing, press Enter to continue...');
65
170
 
66
171
  // Wait for user to press Enter
67
- await new Promise(resolve => {
68
- process.stdin.setRawMode(true);
69
- process.stdin.resume();
70
- process.stdin.once('data', () => {
71
- process.stdin.setRawMode(false);
72
- process.stdin.pause();
73
- resolve();
74
- });
75
- });
172
+ await waitForEnter();
76
173
 
77
174
  // Check authorization status
78
175
  output.startSpinner('Checking authorization...');
79
176
  let pollResponse = await pollDeviceAuthorization(client, deviceCode);
80
177
  output.stopSpinner();
81
- let tokenData = null;
82
-
83
- // Check if authorization was successful by looking for tokens
84
- if (pollResponse.tokens?.accessToken) {
85
- // Success! We got tokens
86
- tokenData = pollResponse;
87
- } else if (pollResponse.status === 'pending') {
88
- throw new Error('Authorization not complete yet. Please complete the authorization in your browser and try running "vizzly login" again.');
89
- } else if (pollResponse.status === 'expired') {
90
- throw new Error('Device code expired. Please try logging in again.');
91
- } else if (pollResponse.status === 'denied') {
92
- throw new Error('Authorization denied. Please try logging in again.');
93
- } else {
94
- throw new Error('Unexpected response from authorization server. Please try logging in again.');
95
- }
178
+ let tokenData = resolveAuthorizedTokenData(pollResponse);
96
179
 
97
180
  // Complete device flow and save tokens
98
181
  // Handle both snake_case and camelCase for token data, and nested tokens object
99
- let tokensData = tokenData.tokens || tokenData;
100
- let tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
101
- let tokenExpiresAt = tokenExpiresIn ? new Date(Date.now() + tokenExpiresIn * 1000).toISOString() : tokenData.expires_at || tokenData.expiresAt;
102
- let tokens = {
103
- accessToken: tokensData.accessToken || tokensData.access_token,
104
- refreshToken: tokensData.refreshToken || tokensData.refresh_token,
105
- expiresAt: tokenExpiresAt,
106
- user: tokenData.user,
107
- organizations: tokenData.organizations
108
- };
182
+ let tokens = buildTokens(tokenData, now());
109
183
  await completeDeviceFlow(tokenStore, tokens);
110
184
 
111
185
  // Display success
@@ -131,21 +205,13 @@ export async function loginCommand(options = {}, globalOptions = {}) {
131
205
  // Show token expiry info
132
206
  if (tokens.expiresAt) {
133
207
  output.blank();
134
- let expiresAt = new Date(tokens.expiresAt);
135
- let msUntilExpiry = expiresAt.getTime() - Date.now();
136
- let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24));
137
- let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60));
138
- let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60));
139
- if (daysUntilExpiry > 0) {
140
- output.hint(`Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''}`);
141
- } else if (hoursUntilExpiry > 0) {
142
- output.hint(`Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''}`);
143
- } else if (minutesUntilExpiry > 0) {
144
- output.hint(`Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}`);
208
+ let expiryHint = getTokenExpiryHint(tokens.expiresAt, now());
209
+ if (expiryHint) {
210
+ output.hint(expiryHint);
145
211
  }
146
212
  }
147
213
  output.blank();
148
- output.hint('You can now use Vizzly CLI commands without VIZZLY_TOKEN');
214
+ output.hint('Run "vizzly project link <org>/<project>" to enable cloud uploads');
149
215
  output.cleanup();
150
216
  } catch (error) {
151
217
  output.stopSpinner();
@@ -156,15 +222,18 @@ export async function loginCommand(options = {}, globalOptions = {}) {
156
222
  output.blank();
157
223
  output.hint('Please try logging in again');
158
224
  output.hint("If you don't have an account, sign up at https://vizzly.dev");
159
- process.exit(1);
225
+ output.cleanup();
226
+ exit(1);
160
227
  } else if (error.code === 'RATE_LIMIT_ERROR') {
161
228
  output.error('Too many login attempts', error);
162
229
  output.blank();
163
230
  output.hint('Please wait a few minutes before trying again');
164
- process.exit(1);
231
+ output.cleanup();
232
+ exit(1);
165
233
  } else {
166
234
  output.error('Login failed', error);
167
- process.exit(1);
235
+ output.cleanup();
236
+ exit(1);
168
237
  }
169
238
  }
170
239
  }
@@ -174,7 +243,7 @@ export async function loginCommand(options = {}, globalOptions = {}) {
174
243
  * @param {Object} options - Command options
175
244
  */
176
245
  export function validateLoginOptions() {
177
- const errors = [];
246
+ let errors = [];
178
247
 
179
248
  // No specific validation needed for login command
180
249
  // OAuth device flow handles everything via browser
@@ -3,16 +3,26 @@
3
3
  * Clears stored authentication tokens
4
4
  */
5
5
 
6
- import { createAuthClient, createTokenStore, getAuthTokens, logout } from '../auth/index.js';
7
- import { getApiUrl } from '../utils/environment-config.js';
8
- import * as output from '../utils/output.js';
6
+ import { createAuthClient as defaultCreateAuthClient, createTokenStore as defaultCreateTokenStore, getAuthTokens as defaultGetAuthTokens, logout as defaultLogout } from '../auth/index.js';
7
+ import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
8
+ import * as defaultOutput from '../utils/output.js';
9
9
 
10
10
  /**
11
11
  * Logout command implementation
12
12
  * @param {Object} options - Command options
13
13
  * @param {Object} globalOptions - Global CLI options
14
+ * @param {Object} deps - Dependencies for testing
14
15
  */
15
- export async function logoutCommand(options = {}, globalOptions = {}) {
16
+ export async function logoutCommand(options = {}, globalOptions = {}, deps = {}) {
17
+ let {
18
+ createAuthClient = defaultCreateAuthClient,
19
+ createTokenStore = defaultCreateTokenStore,
20
+ getApiUrl = defaultGetApiUrl,
21
+ getAuthTokens = defaultGetAuthTokens,
22
+ logout = defaultLogout,
23
+ output = defaultOutput,
24
+ exit = code => process.exit(code)
25
+ } = deps;
16
26
  output.configure({
17
27
  json: globalOptions.json,
18
28
  verbose: globalOptions.verbose,
@@ -57,7 +67,8 @@ export async function logoutCommand(options = {}, globalOptions = {}) {
57
67
  } catch (error) {
58
68
  output.stopSpinner();
59
69
  output.error('Logout failed', error);
60
- process.exit(1);
70
+ output.cleanup();
71
+ exit(1);
61
72
  }
62
73
  }
63
74
 
@@ -66,7 +77,7 @@ export async function logoutCommand(options = {}, globalOptions = {}) {
66
77
  * @param {Object} options - Command options
67
78
  */
68
79
  export function validateLogoutOptions() {
69
- const errors = [];
80
+ let errors = [];
70
81
 
71
82
  // No specific validation needed for logout command
72
83
 
@@ -5,10 +5,10 @@
5
5
  * The build is automatically detected from session file or environment.
6
6
  */
7
7
 
8
- import { exec, execSync } from 'node:child_process';
8
+ import { execFile, execFileSync } from 'node:child_process';
9
9
  import { randomBytes } from 'node:crypto';
10
10
  import { existsSync, statSync } from 'node:fs';
11
- import { readFile, realpath, stat, unlink } from 'node:fs/promises';
11
+ import { readdir, readFile, realpath, stat, unlink } from 'node:fs/promises';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join, resolve } from 'node:path';
14
14
  import { promisify } from 'node:util';
@@ -17,23 +17,13 @@ import { openBrowser as defaultOpenBrowser } from '../utils/browser.js';
17
17
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
18
18
  import { detectBranch as defaultDetectBranch } from '../utils/git.js';
19
19
  import * as defaultOutput from '../utils/output.js';
20
+ import { createWildcardMatcher } from '../utils/patterns.js';
20
21
  import { formatSessionAge as defaultFormatSessionAge, readSession as defaultReadSession } from '../utils/session.js';
21
- let execAsync = promisify(exec);
22
+ let execFileAsync = promisify(execFile);
22
23
 
23
24
  // Maximum files to show in dry-run output (use --verbose for all)
24
25
  let DRY_RUN_FILE_LIMIT = 50;
25
26
 
26
- /**
27
- * Validate path for shell safety - prevents command injection
28
- * @param {string} path - Path to validate
29
- * @returns {boolean} true if path is safe for shell use
30
- */
31
- function isPathSafe(path) {
32
- // Reject paths with shell metacharacters that could enable command injection
33
- let dangerousChars = /[`$;&|<>(){}[\]\\!*?'"]/;
34
- return !dangerousChars.test(path);
35
- }
36
-
37
27
  /**
38
28
  * Check if a command exists on the system
39
29
  * @param {string} command - Command to check
@@ -42,7 +32,7 @@ function isPathSafe(path) {
42
32
  function commandExists(command) {
43
33
  try {
44
34
  let checkCmd = process.platform === 'win32' ? 'where' : 'which';
45
- execSync(`${checkCmd} ${command}`, {
35
+ execFileSync(checkCmd, [command], {
46
36
  stdio: 'ignore'
47
37
  });
48
38
  return true;
@@ -50,6 +40,9 @@ function commandExists(command) {
50
40
  return false;
51
41
  }
52
42
  }
43
+ function quotePowerShellString(value) {
44
+ return `'${String(value).replace(/'/g, "''")}'`;
45
+ }
53
46
 
54
47
  /**
55
48
  * Get the appropriate zip command for the current platform
@@ -92,9 +85,10 @@ let DEFAULT_EXCLUDED_FILES = ['package.json', 'package-lock.json', 'yarn.lock',
92
85
  function matchesPattern(filename, patterns) {
93
86
  for (let pattern of patterns) {
94
87
  if (pattern.includes('*')) {
95
- // Simple glob matching - convert to regex
96
- let regex = new RegExp(`^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`);
97
- if (regex.test(filename)) return true;
88
+ let matches = createWildcardMatcher(pattern, {
89
+ anchored: true
90
+ });
91
+ if (matches(filename)) return true;
98
92
  } else {
99
93
  if (filename === pattern) return true;
100
94
  }
@@ -123,48 +117,37 @@ async function createZipWithSystem(sourceDir, outputPath, exclusions = {}) {
123
117
  if (!available) {
124
118
  throw new Error('No zip command found. Please install zip or use PowerShell on Windows.');
125
119
  }
126
-
127
- // Validate paths to prevent command injection
128
- // Note: outputPath is internally generated (tmpdir + random), so always safe
129
- // sourceDir comes from user input, so we validate it
130
- if (!isPathSafe(sourceDir)) {
131
- throw new Error('Path contains unsupported characters. Please use a path without special shell characters.');
132
- }
133
-
134
- // Validate exclusion patterns to prevent command injection
135
- // Only allow safe characters in patterns: alphanumeric, dots, asterisks, underscores, hyphens, slashes
136
- let safePatternRegex = /^[a-zA-Z0-9.*_\-/]+$/;
137
- for (let pattern of [...dirs, ...files]) {
138
- if (!safePatternRegex.test(pattern)) {
139
- throw new Error(`Exclusion pattern contains unsafe characters: ${pattern}. Only alphanumeric, ., *, _, -, / are allowed.`);
140
- }
141
- }
142
120
  if (command === 'zip') {
143
121
  // Standard zip command - create ZIP from directory contents
144
- // Using cwd option is safe as it's not part of the command string
145
122
  // -r: recursive, -q: quiet, -x: exclude patterns
146
- let excludeArgs = [...dirs.map(dir => `-x "${dir}/*"`), ...files.map(pattern => `-x "${pattern}"`)].join(' ');
147
- await execAsync(`zip -r -q "${outputPath}" . ${excludeArgs}`, {
123
+ let excludeArgs = [];
124
+ for (let dir of dirs) {
125
+ excludeArgs.push('-x', `${dir}/*`);
126
+ }
127
+ for (let pattern of files) {
128
+ excludeArgs.push('-x', pattern);
129
+ }
130
+ await execFileAsync('zip', ['-r', '-q', outputPath, '.', ...excludeArgs], {
148
131
  cwd: sourceDir,
149
132
  maxBuffer: 1024 * 1024 * 100 // 100MB buffer
150
133
  });
151
134
  } else if (command === 'powershell') {
152
135
  // Windows PowerShell - Compress-Archive doesn't support exclusions,
153
136
  // so we create a temp directory with only the files we want
154
- let safeSrcDir = sourceDir.replace(/'/g, "''");
155
- let safeOutPath = outputPath.replace(/'/g, "''");
137
+ let safeSrcDir = quotePowerShellString(sourceDir);
138
+ let safeOutPath = quotePowerShellString(outputPath);
156
139
 
157
140
  // Build exclusion filter for PowerShell
158
141
  // We use Get-ChildItem with -Exclude and pipe to Compress-Archive
159
- let excludePatterns = [...dirs, ...files].map(p => `'${p}'`).join(',');
142
+ let excludePatterns = [...dirs, ...files].map(quotePowerShellString).join(',');
160
143
  if (excludePatterns) {
161
144
  // Use robocopy to copy files excluding patterns, then zip
162
145
  // This is more reliable than PowerShell's native filtering
163
- await execAsync(`powershell -Command "` + `$src = '${safeSrcDir}'; ` + `$dst = '${safeOutPath}'; ` + `$exclude = @(${excludePatterns}); ` + `$items = Get-ChildItem -Path $src -Recurse -File | Where-Object { ` + `$rel = $_.FullName.Substring($src.Length + 1); ` + `$dominated = $false; ` + `foreach ($ex in $exclude) { if ($rel -like $ex -or $rel -like \\"$ex/*\\" -or $_.Name -like $ex) { $dominated = $true; break } }; ` + `-not $dominated ` + `}; ` + `if ($items) { $items | Compress-Archive -DestinationPath $dst -Force }"`, {
146
+ await execFileAsync('powershell', ['-Command', `$src = ${safeSrcDir}; ` + `$dst = ${safeOutPath}; ` + `$exclude = @(${excludePatterns}); ` + `$items = Get-ChildItem -Path $src -Recurse -File | Where-Object { ` + `$rel = $_.FullName.Substring($src.Length + 1); ` + `$dominated = $false; ` + `foreach ($ex in $exclude) { if ($rel -like $ex -or $rel -like "$ex/*" -or $_.Name -like $ex) { $dominated = $true; break } }; ` + `-not $dominated ` + `}; ` + `if ($items) { $items | Compress-Archive -DestinationPath $dst -Force }`], {
164
147
  maxBuffer: 1024 * 1024 * 100
165
148
  });
166
149
  } else {
167
- await execAsync(`powershell -Command "Compress-Archive -LiteralPath '${safeSrcDir}\\*' -DestinationPath '${safeOutPath}' -Force"`, {
150
+ await execFileAsync('powershell', ['-Command', `Compress-Archive -LiteralPath ${quotePowerShellString(`${sourceDir}\\*`)} -DestinationPath ${safeOutPath} -Force`], {
168
151
  maxBuffer: 1024 * 1024 * 100
169
152
  });
170
153
  }
@@ -182,9 +165,6 @@ async function createZipWithSystem(sourceDir, outputPath, exclusions = {}) {
182
165
  * @returns {Promise<{ count: number, totalSize: number, files?: Array<{path: string, size: number}> }>}
183
166
  */
184
167
  async function countFiles(dir, options = {}) {
185
- let {
186
- readdir
187
- } = await import('node:fs/promises');
188
168
  let {
189
169
  collectPaths = false,
190
170
  excludedDirs = DEFAULT_EXCLUDED_DIRS,
@@ -327,15 +307,6 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
327
307
  // Resolve build ID
328
308
  let buildId = options.build;
329
309
  let buildSource = 'flag';
330
- if (!buildId && options.parallelId) {
331
- // TODO: Look up build by parallel ID
332
- output.error('Parallel ID lookup not yet implemented. Use --build to specify build ID directly.');
333
- exit(1);
334
- return {
335
- success: false,
336
- reason: 'parallel-id-not-implemented'
337
- };
338
- }
339
310
  if (!buildId) {
340
311
  // Try to read from session
341
312
  let currentBranch = await detectBranch();
@@ -583,8 +554,9 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
583
554
  // Always clean up temp file
584
555
  await unlink(zipPath).catch(() => {});
585
556
  }
586
- let compressionRatio = ((1 - zipBuffer.length / totalSize) * 100).toFixed(0);
587
- output.updateSpinner(`Compressed to ${formatBytes(zipBuffer.length)} (${compressionRatio}% smaller)`);
557
+ let compressionRatio = 1 - zipBuffer.length / totalSize;
558
+ let compressionPercent = Math.round(compressionRatio * 100);
559
+ output.updateSpinner(`Compressed to ${formatBytes(zipBuffer.length)} (${compressionPercent}% smaller)`);
588
560
 
589
561
  // Upload (reuse client created earlier)
590
562
  output.updateSpinner('Uploading preview...');
@@ -600,7 +572,7 @@ export async function previewCommand(path, options = {}, globalOptions = {}, dep
600
572
  files: result.uploaded || fileCount,
601
573
  bytes: totalSize,
602
574
  compressedBytes: zipBuffer.length,
603
- compressionRatio: parseFloat(compressionRatio) / 100,
575
+ compressionRatio,
604
576
  newBytes: result.newBytes,
605
577
  reusedBlobs: result.reusedBlobs || 0,
606
578
  deduplicationRatio: result.deduplicationRatio,
@@ -0,0 +1,120 @@
1
+ import { createApiClient as defaultCreateApiClient } from '../api/client.js';
2
+ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
3
+ import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
4
+ import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js';
5
+ import * as defaultOutput from '../utils/output.js';
6
+ import { saveProjectLink as defaultSaveProjectLink } from '../utils/project-link-store.js';
7
+ export function parseProjectSelector(selector, options = {}) {
8
+ let organizationSlug = options.org || null;
9
+ let projectSlug = options.project || null;
10
+ if (selector) {
11
+ let parts = selector.split('/');
12
+ if (parts.length === 2) {
13
+ organizationSlug = organizationSlug || parts[0];
14
+ projectSlug = projectSlug || parts[1];
15
+ } else if (parts.length === 1) {
16
+ projectSlug = projectSlug || parts[0];
17
+ }
18
+ }
19
+ return {
20
+ organizationSlug,
21
+ projectSlug
22
+ };
23
+ }
24
+ export function validateProjectLinkOptions(selector, options = {}) {
25
+ let errors = [];
26
+ let {
27
+ organizationSlug,
28
+ projectSlug
29
+ } = parseProjectSelector(selector, options);
30
+ if (!organizationSlug) {
31
+ errors.push('Organization is required. Use <org>/<project> or --org <slug>.');
32
+ }
33
+ if (!projectSlug) {
34
+ errors.push('Project is required. Use <org>/<project> or --project <slug>.');
35
+ }
36
+ return errors;
37
+ }
38
+ export async function projectLinkCommand(selector, options = {}, globalOptions = {}, deps = {}) {
39
+ let {
40
+ createApiClient = defaultCreateApiClient,
41
+ getAccessToken = defaultGetAccessToken,
42
+ getApiUrl = defaultGetApiUrl,
43
+ loadConfig = defaultLoadConfig,
44
+ output = defaultOutput,
45
+ saveProjectLink = defaultSaveProjectLink,
46
+ exit = code => process.exit(code)
47
+ } = deps;
48
+ output.configure({
49
+ json: globalOptions.json,
50
+ verbose: globalOptions.verbose,
51
+ color: !globalOptions.noColor
52
+ });
53
+ let {
54
+ organizationSlug,
55
+ projectSlug
56
+ } = parseProjectSelector(selector, options);
57
+ try {
58
+ let config = await loadConfig(globalOptions.config, globalOptions);
59
+ let userToken = config.userToken || (await getAccessToken());
60
+ if (!userToken) {
61
+ output.error('Login required before linking a project');
62
+ output.hint('Run "vizzly login" first, then try project link again');
63
+ output.cleanup();
64
+ exit(1);
65
+ return;
66
+ }
67
+ output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`);
68
+ let apiUrl = config.apiUrl || getApiUrl();
69
+ let client = createApiClient({
70
+ baseUrl: apiUrl,
71
+ token: userToken,
72
+ command: 'project-link'
73
+ });
74
+ let response = await client.request(`/api/cli/${projectSlug}/link-token`, {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Content-Type': 'application/json',
78
+ 'X-Organization': organizationSlug
79
+ },
80
+ body: JSON.stringify({
81
+ name: options.name,
82
+ expiresAt: options.expiresAt
83
+ })
84
+ });
85
+ let linkedProject = await saveProjectLink({
86
+ apiUrl,
87
+ organizationSlug: response.organization?.slug || organizationSlug,
88
+ organizationName: response.organization?.name,
89
+ projectSlug: response.project?.slug || projectSlug,
90
+ projectName: response.project?.name,
91
+ token: response.token.token,
92
+ tokenId: response.token.id,
93
+ tokenPrefix: response.token.token_prefix,
94
+ expiresAt: response.token.expires_at,
95
+ createdAt: response.token.created_at
96
+ });
97
+ output.stopSpinner();
98
+ if (globalOptions.json) {
99
+ output.data({
100
+ linked: true,
101
+ organizationSlug: linkedProject.organizationSlug,
102
+ projectSlug: linkedProject.projectSlug,
103
+ tokenPrefix: linkedProject.tokenPrefix,
104
+ storage: linkedProject.storage
105
+ });
106
+ output.cleanup();
107
+ return;
108
+ }
109
+ output.complete(`Linked ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`);
110
+ output.hint(`Active project: ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`);
111
+ output.hint(`Cloud uploads and project-scoped context will use ${linkedProject.tokenPrefix}...`);
112
+ output.hint(`Credential storage: ${linkedProject.storage}`);
113
+ output.cleanup();
114
+ } catch (error) {
115
+ output.stopSpinner();
116
+ output.error('Failed to link project', error);
117
+ output.cleanup();
118
+ exit(1);
119
+ }
120
+ }
@@ -106,6 +106,13 @@ export async function projectsCommand(options = {}, globalOptions = {}, deps = {
106
106
  * @param {Object} _options - Command options
107
107
  * @returns {string[]} Validation errors
108
108
  */
109
- export function validateProjectsOptions(_options = {}) {
110
- return [];
109
+ export function validateProjectsOptions(options = {}) {
110
+ let errors = [];
111
+ if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 250)) {
112
+ errors.push('--limit must be an integer between 1 and 250');
113
+ }
114
+ if (options.offset !== undefined && (!Number.isInteger(options.offset) || options.offset < 0)) {
115
+ errors.push('--offset must be a non-negative integer');
116
+ }
117
+ return errors;
111
118
  }