@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.
- package/README.md +9 -9
- package/dist/api/client.js +6 -3
- package/dist/cli.js +58 -29
- package/dist/client/index.js +21 -29
- package/dist/commands/api.js +145 -90
- package/dist/commands/baselines.js +78 -47
- package/dist/commands/builds.js +9 -8
- package/dist/commands/comparisons.js +7 -8
- package/dist/commands/config-cmd.js +15 -7
- package/dist/commands/context.js +329 -18
- package/dist/commands/doctor.js +116 -81
- package/dist/commands/init.js +202 -201
- package/dist/commands/login.js +134 -65
- package/dist/commands/logout.js +17 -6
- package/dist/commands/preview.js +29 -57
- package/dist/commands/project.js +120 -0
- package/dist/commands/projects.js +9 -2
- package/dist/commands/review.js +236 -202
- package/dist/commands/run.js +75 -72
- package/dist/commands/status.js +228 -150
- package/dist/commands/tdd-daemon-process.js +98 -0
- package/dist/commands/tdd-daemon.js +318 -212
- package/dist/commands/tdd.js +23 -8
- package/dist/commands/upload.js +10 -9
- package/dist/commands/whoami.js +63 -25
- package/dist/config/core.js +36 -35
- package/dist/context/local-workspace-provider.js +132 -12
- package/dist/context/provider-resolver.js +5 -1
- package/dist/index.js +5 -4
- package/dist/plugin-loader.js +55 -49
- package/dist/project/core.js +2 -2
- package/dist/project/operations.js +6 -3
- package/dist/reporter/reporter-bundle.css +1 -1
- package/dist/reporter/reporter-bundle.iife.js +7 -7
- package/dist/sdk/index.js +162 -107
- package/dist/server/handlers/tdd-handler.js +30 -54
- package/dist/server/routers/baseline.js +195 -178
- package/dist/server/routers/events.js +212 -174
- package/dist/server/routers/projects.js +14 -5
- package/dist/server/routers/screenshot.js +1 -31
- package/dist/services/build-manager.js +19 -97
- package/dist/services/config-service.js +26 -34
- package/dist/services/index.js +2 -4
- package/dist/services/static-report-generator.js +11 -12
- package/dist/services/test-runner.js +21 -27
- package/dist/tdd/index.js +1 -1
- package/dist/tdd/metadata/region-metadata.js +0 -37
- package/dist/tdd/runtime-context.js +78 -0
- package/dist/tdd/server-registry.js +38 -17
- package/dist/tdd/services/baseline-manager.js +23 -0
- package/dist/tdd/services/hotspot-service.js +0 -13
- package/dist/tdd/tdd-service.js +82 -113
- package/dist/types/client.d.ts +36 -1
- package/dist/types/index.d.ts +114 -22
- package/dist/types/sdk.d.ts +40 -0
- package/dist/uploader/core.js +5 -4
- package/dist/uploader/index.js +79 -2
- package/dist/uploader/operations.js +40 -5
- package/dist/utils/api-url.js +14 -0
- package/dist/utils/async-utils.js +26 -0
- package/dist/utils/ci-env.js +19 -12
- package/dist/utils/config-loader.js +39 -65
- package/dist/utils/config-schema.js +19 -55
- package/dist/utils/fetch-utils.js +12 -5
- package/dist/utils/git.js +35 -72
- package/dist/utils/global-config.js +20 -1
- package/dist/utils/output.js +19 -7
- package/dist/utils/patterns.js +14 -0
- package/dist/utils/project-link-store.js +105 -0
- package/dist/utils/screenshot-options.js +28 -0
- package/dist/utils/secret-store.js +65 -0
- package/package.json +11 -8
- package/dist/services/screenshot-server.js +0 -65
- package/dist/services/server-manager.js +0 -81
- package/dist/services/uploader.js +0 -100
- package/dist/tdd/services/region-service.js +0 -75
- package/dist/utils/build-history.js +0 -103
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
231
|
+
output.cleanup();
|
|
232
|
+
exit(1);
|
|
165
233
|
} else {
|
|
166
234
|
output.error('Login failed', error);
|
|
167
|
-
|
|
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
|
-
|
|
246
|
+
let errors = [];
|
|
178
247
|
|
|
179
248
|
// No specific validation needed for login command
|
|
180
249
|
// OAuth device flow handles everything via browser
|
package/dist/commands/logout.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
let errors = [];
|
|
70
81
|
|
|
71
82
|
// No specific validation needed for logout command
|
|
72
83
|
|
package/dist/commands/preview.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* The build is automatically detected from session file or environment.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 = [
|
|
147
|
-
|
|
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
|
|
155
|
-
let safeOutPath = outputPath
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
587
|
-
|
|
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
|
|
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(
|
|
110
|
-
|
|
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
|
}
|