@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.
- package/README.md +168 -8
- package/claude-plugin/.claude-plugin/.mcp.json +8 -0
- package/claude-plugin/.claude-plugin/README.md +114 -0
- package/claude-plugin/.claude-plugin/marketplace.json +28 -0
- package/claude-plugin/.claude-plugin/plugin.json +14 -0
- package/claude-plugin/commands/debug-diff.md +153 -0
- package/claude-plugin/commands/setup.md +137 -0
- package/claude-plugin/commands/suggest-screenshots.md +111 -0
- package/claude-plugin/commands/tdd-status.md +43 -0
- package/claude-plugin/mcp/vizzly-server/cloud-api-provider.js +354 -0
- package/claude-plugin/mcp/vizzly-server/index.js +861 -0
- package/claude-plugin/mcp/vizzly-server/local-tdd-provider.js +422 -0
- package/claude-plugin/mcp/vizzly-server/token-resolver.js +185 -0
- package/dist/cli.js +64 -0
- package/dist/client/index.js +13 -3
- package/dist/commands/login.js +195 -0
- package/dist/commands/logout.js +71 -0
- package/dist/commands/project.js +351 -0
- package/dist/commands/run.js +30 -0
- package/dist/commands/whoami.js +162 -0
- package/dist/plugin-loader.js +9 -15
- package/dist/sdk/index.js +16 -4
- package/dist/services/api-service.js +50 -7
- package/dist/services/auth-service.js +226 -0
- package/dist/types/client/index.d.ts +9 -3
- package/dist/types/commands/login.d.ts +11 -0
- package/dist/types/commands/logout.d.ts +11 -0
- package/dist/types/commands/project.d.ts +28 -0
- package/dist/types/commands/whoami.d.ts +11 -0
- package/dist/types/sdk/index.d.ts +9 -4
- package/dist/types/services/api-service.d.ts +2 -1
- package/dist/types/services/auth-service.d.ts +59 -0
- package/dist/types/utils/browser.d.ts +6 -0
- package/dist/types/utils/config-loader.d.ts +1 -1
- package/dist/types/utils/config-schema.d.ts +8 -174
- package/dist/types/utils/file-helpers.d.ts +18 -0
- package/dist/types/utils/global-config.d.ts +84 -0
- package/dist/utils/browser.js +44 -0
- package/dist/utils/config-loader.js +69 -3
- package/dist/utils/file-helpers.js +64 -0
- package/dist/utils/global-config.js +259 -0
- package/docs/api-reference.md +177 -6
- package/docs/authentication.md +334 -0
- package/docs/getting-started.md +21 -2
- package/docs/plugins.md +27 -0
- package/docs/test-integration.md +60 -10
- package/package.json +5 -3
package/dist/client/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getServerUrl, getBuildId, isTddMode, setVizzlyEnabled } from '../utils/environment-config.js';
|
|
7
|
+
import { resolveImageBuffer } from '../utils/file-helpers.js';
|
|
7
8
|
import { existsSync, readFileSync } from 'fs';
|
|
8
9
|
import { join, parse, dirname } from 'path';
|
|
9
10
|
|
|
@@ -184,7 +185,7 @@ function createSimpleClient(serverUrl) {
|
|
|
184
185
|
* Take a screenshot for visual regression testing
|
|
185
186
|
*
|
|
186
187
|
* @param {string} name - Unique name for the screenshot
|
|
187
|
-
* @param {Buffer} imageBuffer - PNG image data as a Buffer
|
|
188
|
+
* @param {Buffer|string} imageBuffer - PNG image data as a Buffer, or a file path to an image
|
|
188
189
|
* @param {Object} [options] - Optional configuration
|
|
189
190
|
* @param {Record<string, any>} [options.properties] - Additional properties to attach to the screenshot
|
|
190
191
|
* @param {number} [options.threshold=0] - Pixel difference threshold (0-100)
|
|
@@ -193,13 +194,17 @@ function createSimpleClient(serverUrl) {
|
|
|
193
194
|
* @returns {Promise<void>}
|
|
194
195
|
*
|
|
195
196
|
* @example
|
|
196
|
-
* // Basic usage
|
|
197
|
+
* // Basic usage with Buffer
|
|
197
198
|
* import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
|
|
198
199
|
*
|
|
199
200
|
* const screenshot = await page.screenshot();
|
|
200
201
|
* await vizzlyScreenshot('homepage', screenshot);
|
|
201
202
|
*
|
|
202
203
|
* @example
|
|
204
|
+
* // Basic usage with file path
|
|
205
|
+
* await vizzlyScreenshot('homepage', './screenshots/homepage.png');
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
203
208
|
* // With properties and threshold
|
|
204
209
|
* await vizzlyScreenshot('checkout-form', screenshot, {
|
|
205
210
|
* properties: {
|
|
@@ -210,6 +215,8 @@ function createSimpleClient(serverUrl) {
|
|
|
210
215
|
* });
|
|
211
216
|
*
|
|
212
217
|
* @throws {VizzlyError} When screenshot capture fails or client is not initialized
|
|
218
|
+
* @throws {VizzlyError} When file path is provided but file doesn't exist
|
|
219
|
+
* @throws {VizzlyError} When file cannot be read due to permissions or I/O errors
|
|
213
220
|
*/
|
|
214
221
|
export async function vizzlyScreenshot(name, imageBuffer, options = {}) {
|
|
215
222
|
if (isVizzlyDisabled()) {
|
|
@@ -224,7 +231,10 @@ export async function vizzlyScreenshot(name, imageBuffer, options = {}) {
|
|
|
224
231
|
}
|
|
225
232
|
return;
|
|
226
233
|
}
|
|
227
|
-
|
|
234
|
+
|
|
235
|
+
// Resolve Buffer or file path using shared utility
|
|
236
|
+
const buffer = resolveImageBuffer(imageBuffer, 'screenshot');
|
|
237
|
+
return client.screenshot(name, buffer, options);
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
/**
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login command implementation
|
|
3
|
+
* Authenticates user via OAuth device flow
|
|
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 { openBrowser } from '../utils/browser.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Login command implementation using OAuth device flow
|
|
13
|
+
* @param {Object} options - Command options
|
|
14
|
+
* @param {Object} globalOptions - Global CLI options
|
|
15
|
+
*/
|
|
16
|
+
export async function loginCommand(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
|
+
ui.info('Starting Vizzly authentication...');
|
|
25
|
+
console.log(''); // Empty line for spacing
|
|
26
|
+
|
|
27
|
+
// Create auth service
|
|
28
|
+
let authService = new AuthService({
|
|
29
|
+
baseUrl: options.apiUrl || getApiUrl()
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Initiate device flow
|
|
33
|
+
ui.startSpinner('Connecting to Vizzly...');
|
|
34
|
+
let deviceFlow = await authService.initiateDeviceFlow();
|
|
35
|
+
ui.stopSpinner();
|
|
36
|
+
|
|
37
|
+
// 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}`;
|
|
47
|
+
|
|
48
|
+
// Display user code prominently
|
|
49
|
+
console.log(''); // Empty line for spacing
|
|
50
|
+
console.log('='.repeat(50));
|
|
51
|
+
console.log('');
|
|
52
|
+
console.log(' Please visit the following URL to authorize this device:');
|
|
53
|
+
console.log('');
|
|
54
|
+
console.log(` ${urlWithCode}`);
|
|
55
|
+
console.log('');
|
|
56
|
+
console.log(' Your code (pre-filled):');
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log(` ${ui.colors.bold(ui.colors.cyan(userCode))}`);
|
|
59
|
+
console.log('');
|
|
60
|
+
console.log('='.repeat(50));
|
|
61
|
+
console.log(''); // Empty line for spacing
|
|
62
|
+
|
|
63
|
+
// Try to open browser with pre-filled code
|
|
64
|
+
let browserOpened = await openBrowser(urlWithCode);
|
|
65
|
+
if (browserOpened) {
|
|
66
|
+
ui.info('Opening browser...');
|
|
67
|
+
} else {
|
|
68
|
+
ui.warning('Could not open browser automatically. Please open the URL manually.');
|
|
69
|
+
}
|
|
70
|
+
console.log(''); // Empty line for spacing
|
|
71
|
+
ui.info('After authorizing in your browser, press Enter to continue...');
|
|
72
|
+
|
|
73
|
+
// Wait for user to press Enter
|
|
74
|
+
await new Promise(resolve => {
|
|
75
|
+
process.stdin.setRawMode(true);
|
|
76
|
+
process.stdin.resume();
|
|
77
|
+
process.stdin.once('data', () => {
|
|
78
|
+
process.stdin.setRawMode(false);
|
|
79
|
+
process.stdin.pause();
|
|
80
|
+
resolve();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Check authorization status
|
|
85
|
+
ui.startSpinner('Checking authorization status...');
|
|
86
|
+
let pollResponse = await authService.pollDeviceAuthorization(deviceCode);
|
|
87
|
+
ui.stopSpinner();
|
|
88
|
+
let tokenData = null;
|
|
89
|
+
|
|
90
|
+
// Check if authorization was successful by looking for tokens
|
|
91
|
+
if (pollResponse.tokens && pollResponse.tokens.accessToken) {
|
|
92
|
+
// Success! We got tokens
|
|
93
|
+
tokenData = pollResponse;
|
|
94
|
+
} else if (pollResponse.status === 'pending') {
|
|
95
|
+
throw new Error('Authorization not complete yet. Please complete the authorization in your browser and try running "vizzly login" again.');
|
|
96
|
+
} else if (pollResponse.status === 'expired') {
|
|
97
|
+
throw new Error('Device code expired. Please try logging in again.');
|
|
98
|
+
} else if (pollResponse.status === 'denied') {
|
|
99
|
+
throw new Error('Authorization denied. Please try logging in again.');
|
|
100
|
+
} else {
|
|
101
|
+
throw new Error('Unexpected response from authorization server. Please try logging in again.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Complete device flow and save tokens
|
|
105
|
+
// Handle both snake_case and camelCase for token data, and nested tokens object
|
|
106
|
+
let tokensData = tokenData.tokens || tokenData;
|
|
107
|
+
let tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
|
|
108
|
+
let tokenExpiresAt = tokenExpiresIn ? new Date(Date.now() + tokenExpiresIn * 1000).toISOString() : tokenData.expires_at || tokenData.expiresAt;
|
|
109
|
+
let tokens = {
|
|
110
|
+
accessToken: tokensData.accessToken || tokensData.access_token,
|
|
111
|
+
refreshToken: tokensData.refreshToken || tokensData.refresh_token,
|
|
112
|
+
expiresAt: tokenExpiresAt,
|
|
113
|
+
user: tokenData.user,
|
|
114
|
+
organizations: tokenData.organizations
|
|
115
|
+
};
|
|
116
|
+
await authService.completeDeviceFlow(tokens);
|
|
117
|
+
|
|
118
|
+
// Display success message
|
|
119
|
+
ui.success('Successfully authenticated!');
|
|
120
|
+
console.log(''); // Empty line for spacing
|
|
121
|
+
|
|
122
|
+
// Show user info
|
|
123
|
+
if (tokens.user) {
|
|
124
|
+
ui.info(`User: ${tokens.user.name || tokens.user.username}`);
|
|
125
|
+
ui.info(`Email: ${tokens.user.email}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Show organization info
|
|
129
|
+
if (tokens.organizations && tokens.organizations.length > 0) {
|
|
130
|
+
console.log(''); // Empty line for spacing
|
|
131
|
+
ui.info('Organizations:');
|
|
132
|
+
for (let org of tokens.organizations) {
|
|
133
|
+
console.log(` - ${org.name}${org.slug ? ` (@${org.slug})` : ''}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Show token expiry info
|
|
138
|
+
if (tokens.expiresAt) {
|
|
139
|
+
console.log(''); // Empty line for spacing
|
|
140
|
+
let expiresAt = new Date(tokens.expiresAt);
|
|
141
|
+
let msUntilExpiry = expiresAt.getTime() - Date.now();
|
|
142
|
+
let daysUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60 * 24));
|
|
143
|
+
let hoursUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60 * 60));
|
|
144
|
+
let minutesUntilExpiry = Math.floor(msUntilExpiry / (1000 * 60));
|
|
145
|
+
if (daysUntilExpiry > 0) {
|
|
146
|
+
ui.info(`Token expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''} (${expiresAt.toLocaleDateString()})`);
|
|
147
|
+
} else if (hoursUntilExpiry > 0) {
|
|
148
|
+
ui.info(`Token expires in ${hoursUntilExpiry} hour${hoursUntilExpiry !== 1 ? 's' : ''}`);
|
|
149
|
+
} else if (minutesUntilExpiry > 0) {
|
|
150
|
+
ui.info(`Token expires in ${minutesUntilExpiry} minute${minutesUntilExpiry !== 1 ? 's' : ''}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
console.log(''); // Empty line for spacing
|
|
154
|
+
ui.info('You can now use Vizzly CLI commands without setting VIZZLY_TOKEN');
|
|
155
|
+
ui.cleanup();
|
|
156
|
+
} catch (error) {
|
|
157
|
+
ui.stopSpinner();
|
|
158
|
+
|
|
159
|
+
// Handle authentication errors with helpful messages
|
|
160
|
+
if (error.name === 'AuthError') {
|
|
161
|
+
ui.error('Authentication failed', error, 0);
|
|
162
|
+
console.log(''); // Empty line for spacing
|
|
163
|
+
console.log('Please try logging in again.');
|
|
164
|
+
console.log("If you don't have an account, sign up at https://vizzly.dev");
|
|
165
|
+
process.exit(1);
|
|
166
|
+
} else if (error.code === 'RATE_LIMIT_ERROR') {
|
|
167
|
+
ui.error('Too many login attempts', error, 0);
|
|
168
|
+
console.log(''); // Empty line for spacing
|
|
169
|
+
console.log('Please wait a few minutes before trying again.');
|
|
170
|
+
process.exit(1);
|
|
171
|
+
} else {
|
|
172
|
+
ui.error('Login failed', error, 0);
|
|
173
|
+
console.log(''); // Empty line for spacing
|
|
174
|
+
console.log('Error details:', error.message);
|
|
175
|
+
if (globalOptions.verbose && error.stack) {
|
|
176
|
+
console.error(''); // Empty line for spacing
|
|
177
|
+
console.error(error.stack);
|
|
178
|
+
}
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Validate login options
|
|
186
|
+
* @param {Object} options - Command options
|
|
187
|
+
*/
|
|
188
|
+
export function validateLoginOptions() {
|
|
189
|
+
let errors = [];
|
|
190
|
+
|
|
191
|
+
// No specific validation needed for login command
|
|
192
|
+
// OAuth device flow handles everything via browser
|
|
193
|
+
|
|
194
|
+
return errors;
|
|
195
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logout command implementation
|
|
3
|
+
* Clears stored authentication tokens
|
|
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
|
+
* Logout command implementation
|
|
13
|
+
* @param {Object} options - Command options
|
|
14
|
+
* @param {Object} globalOptions - Global CLI options
|
|
15
|
+
*/
|
|
16
|
+
export async function logoutCommand(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
|
+
ui.info('You are not logged in');
|
|
28
|
+
ui.cleanup();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Logout
|
|
33
|
+
ui.startSpinner('Logging out...');
|
|
34
|
+
let authService = new AuthService({
|
|
35
|
+
baseUrl: options.apiUrl || getApiUrl()
|
|
36
|
+
});
|
|
37
|
+
await authService.logout();
|
|
38
|
+
ui.stopSpinner();
|
|
39
|
+
ui.success('Successfully logged out');
|
|
40
|
+
if (globalOptions.json) {
|
|
41
|
+
ui.data({
|
|
42
|
+
loggedOut: true
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
console.log(''); // Empty line for spacing
|
|
46
|
+
ui.info('Your authentication tokens have been cleared');
|
|
47
|
+
ui.info('Run "vizzly login" to authenticate again');
|
|
48
|
+
}
|
|
49
|
+
ui.cleanup();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
ui.stopSpinner();
|
|
52
|
+
ui.error('Logout failed', error, 0);
|
|
53
|
+
if (globalOptions.verbose && error.stack) {
|
|
54
|
+
console.error(''); // Empty line for spacing
|
|
55
|
+
console.error(error.stack);
|
|
56
|
+
}
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate logout options
|
|
63
|
+
* @param {Object} options - Command options
|
|
64
|
+
*/
|
|
65
|
+
export function validateLogoutOptions() {
|
|
66
|
+
let errors = [];
|
|
67
|
+
|
|
68
|
+
// No specific validation needed for logout command
|
|
69
|
+
|
|
70
|
+
return errors;
|
|
71
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project management commands
|
|
3
|
+
* Select, list, and manage project tokens
|
|
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, saveProjectMapping, getProjectMapping, getProjectMappings, deleteProjectMapping } from '../utils/global-config.js';
|
|
10
|
+
import { resolve } from 'path';
|
|
11
|
+
import readline from 'readline';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Project select command - configure project for current directory
|
|
15
|
+
* @param {Object} options - Command options
|
|
16
|
+
* @param {Object} globalOptions - Global CLI options
|
|
17
|
+
*/
|
|
18
|
+
export async function projectSelectCommand(options = {}, globalOptions = {}) {
|
|
19
|
+
let ui = new ConsoleUI({
|
|
20
|
+
json: globalOptions.json,
|
|
21
|
+
verbose: globalOptions.verbose,
|
|
22
|
+
color: !globalOptions.noColor
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
// Check authentication
|
|
26
|
+
let auth = await getAuthTokens();
|
|
27
|
+
if (!auth || !auth.accessToken) {
|
|
28
|
+
ui.error('Not authenticated', null, 0);
|
|
29
|
+
console.log(''); // Empty line for spacing
|
|
30
|
+
ui.info('Run "vizzly login" to authenticate first');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
let authService = new AuthService({
|
|
34
|
+
baseUrl: options.apiUrl || getApiUrl()
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Get user info to show organizations
|
|
38
|
+
ui.startSpinner('Fetching organizations...');
|
|
39
|
+
let userInfo = await authService.whoami();
|
|
40
|
+
ui.stopSpinner();
|
|
41
|
+
if (!userInfo.organizations || userInfo.organizations.length === 0) {
|
|
42
|
+
ui.error('No organizations found', null, 0);
|
|
43
|
+
console.log(''); // Empty line for spacing
|
|
44
|
+
ui.info('Create an organization at https://vizzly.dev');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Select organization
|
|
49
|
+
console.log(''); // Empty line for spacing
|
|
50
|
+
ui.info('Select an organization:');
|
|
51
|
+
console.log(''); // Empty line for spacing
|
|
52
|
+
|
|
53
|
+
userInfo.organizations.forEach((org, index) => {
|
|
54
|
+
console.log(` ${index + 1}. ${org.name} (@${org.slug})`);
|
|
55
|
+
});
|
|
56
|
+
console.log(''); // Empty line for spacing
|
|
57
|
+
let orgChoice = await promptNumber('Enter number', 1, userInfo.organizations.length);
|
|
58
|
+
let selectedOrg = userInfo.organizations[orgChoice - 1];
|
|
59
|
+
|
|
60
|
+
// List projects for organization
|
|
61
|
+
ui.startSpinner(`Fetching projects for ${selectedOrg.name}...`);
|
|
62
|
+
let response = await makeAuthenticatedRequest(`${options.apiUrl || getApiUrl()}/api/project`, {
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
65
|
+
'X-Organization': selectedOrg.slug
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
ui.stopSpinner();
|
|
69
|
+
|
|
70
|
+
// Handle both array response and object with projects property
|
|
71
|
+
let projects = Array.isArray(response) ? response : response.projects || [];
|
|
72
|
+
if (projects.length === 0) {
|
|
73
|
+
ui.error('No projects found', null, 0);
|
|
74
|
+
console.log(''); // Empty line for spacing
|
|
75
|
+
ui.info(`Create a project in ${selectedOrg.name} at https://vizzly.dev`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Select project
|
|
80
|
+
console.log(''); // Empty line for spacing
|
|
81
|
+
ui.info('Select a project:');
|
|
82
|
+
console.log(''); // Empty line for spacing
|
|
83
|
+
|
|
84
|
+
projects.forEach((project, index) => {
|
|
85
|
+
console.log(` ${index + 1}. ${project.name} (${project.slug})`);
|
|
86
|
+
});
|
|
87
|
+
console.log(''); // Empty line for spacing
|
|
88
|
+
let projectChoice = await promptNumber('Enter number', 1, projects.length);
|
|
89
|
+
let selectedProject = projects[projectChoice - 1];
|
|
90
|
+
|
|
91
|
+
// Create API token for project
|
|
92
|
+
ui.startSpinner(`Creating API token for ${selectedProject.name}...`);
|
|
93
|
+
let tokenResponse = await makeAuthenticatedRequest(`${options.apiUrl || getApiUrl()}/api/project/${selectedProject.slug}/tokens`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {
|
|
96
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
97
|
+
'X-Organization': selectedOrg.slug,
|
|
98
|
+
'Content-Type': 'application/json'
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
name: `CLI Token - ${new Date().toLocaleDateString()}`,
|
|
102
|
+
description: `Generated by vizzly CLI for ${process.cwd()}`
|
|
103
|
+
})
|
|
104
|
+
});
|
|
105
|
+
ui.stopSpinner();
|
|
106
|
+
|
|
107
|
+
// Save project mapping
|
|
108
|
+
let currentDir = resolve(process.cwd());
|
|
109
|
+
await saveProjectMapping(currentDir, {
|
|
110
|
+
token: tokenResponse.token,
|
|
111
|
+
projectSlug: selectedProject.slug,
|
|
112
|
+
projectName: selectedProject.name,
|
|
113
|
+
organizationSlug: selectedOrg.slug
|
|
114
|
+
});
|
|
115
|
+
ui.success('Project configured!');
|
|
116
|
+
console.log(''); // Empty line for spacing
|
|
117
|
+
ui.info(`Project: ${selectedProject.name}`);
|
|
118
|
+
ui.info(`Organization: ${selectedOrg.name}`);
|
|
119
|
+
ui.info(`Directory: ${currentDir}`);
|
|
120
|
+
ui.cleanup();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
ui.stopSpinner();
|
|
123
|
+
ui.error('Failed to configure project', error, 0);
|
|
124
|
+
if (globalOptions.verbose && error.stack) {
|
|
125
|
+
console.error(''); // Empty line for spacing
|
|
126
|
+
console.error(error.stack);
|
|
127
|
+
}
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Project list command - show all configured projects
|
|
134
|
+
* @param {Object} _options - Command options (unused)
|
|
135
|
+
* @param {Object} globalOptions - Global CLI options
|
|
136
|
+
*/
|
|
137
|
+
export async function projectListCommand(_options = {}, globalOptions = {}) {
|
|
138
|
+
let ui = new ConsoleUI({
|
|
139
|
+
json: globalOptions.json,
|
|
140
|
+
verbose: globalOptions.verbose,
|
|
141
|
+
color: !globalOptions.noColor
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
let mappings = await getProjectMappings();
|
|
145
|
+
let paths = Object.keys(mappings);
|
|
146
|
+
if (paths.length === 0) {
|
|
147
|
+
ui.info('No projects configured');
|
|
148
|
+
console.log(''); // Empty line for spacing
|
|
149
|
+
ui.info('Run "vizzly project:select" to configure a project');
|
|
150
|
+
ui.cleanup();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (globalOptions.json) {
|
|
154
|
+
ui.data(mappings);
|
|
155
|
+
ui.cleanup();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
ui.info('Configured projects:');
|
|
159
|
+
console.log(''); // Empty line for spacing
|
|
160
|
+
|
|
161
|
+
let currentDir = resolve(process.cwd());
|
|
162
|
+
for (let path of paths) {
|
|
163
|
+
let mapping = mappings[path];
|
|
164
|
+
let isCurrent = path === currentDir;
|
|
165
|
+
let marker = isCurrent ? '→' : ' ';
|
|
166
|
+
|
|
167
|
+
// Extract token string (handle both string and object formats)
|
|
168
|
+
let tokenStr = typeof mapping.token === 'string' ? mapping.token : mapping.token?.token || '[invalid token]';
|
|
169
|
+
console.log(`${marker} ${path}`);
|
|
170
|
+
console.log(` Project: ${mapping.projectName} (${mapping.projectSlug})`);
|
|
171
|
+
console.log(` Organization: ${mapping.organizationSlug}`);
|
|
172
|
+
if (globalOptions.verbose) {
|
|
173
|
+
console.log(` Token: ${tokenStr.substring(0, 20)}...`);
|
|
174
|
+
console.log(` Created: ${new Date(mapping.createdAt).toLocaleString()}`);
|
|
175
|
+
}
|
|
176
|
+
console.log(''); // Empty line for spacing
|
|
177
|
+
}
|
|
178
|
+
ui.cleanup();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
ui.error('Failed to list projects', error, 0);
|
|
181
|
+
if (globalOptions.verbose && error.stack) {
|
|
182
|
+
console.error(''); // Empty line for spacing
|
|
183
|
+
console.error(error.stack);
|
|
184
|
+
}
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Project token command - show/regenerate token for current directory
|
|
191
|
+
* @param {Object} _options - Command options (unused)
|
|
192
|
+
* @param {Object} globalOptions - Global CLI options
|
|
193
|
+
*/
|
|
194
|
+
export async function projectTokenCommand(_options = {}, globalOptions = {}) {
|
|
195
|
+
let ui = new ConsoleUI({
|
|
196
|
+
json: globalOptions.json,
|
|
197
|
+
verbose: globalOptions.verbose,
|
|
198
|
+
color: !globalOptions.noColor
|
|
199
|
+
});
|
|
200
|
+
try {
|
|
201
|
+
let currentDir = resolve(process.cwd());
|
|
202
|
+
let mapping = await getProjectMapping(currentDir);
|
|
203
|
+
if (!mapping) {
|
|
204
|
+
ui.error('No project configured for this directory', null, 0);
|
|
205
|
+
console.log(''); // Empty line for spacing
|
|
206
|
+
ui.info('Run "vizzly project:select" to configure a project');
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Extract token string (handle both string and object formats)
|
|
211
|
+
let tokenStr = typeof mapping.token === 'string' ? mapping.token : mapping.token?.token || '[invalid token]';
|
|
212
|
+
if (globalOptions.json) {
|
|
213
|
+
ui.data({
|
|
214
|
+
token: tokenStr,
|
|
215
|
+
projectSlug: mapping.projectSlug,
|
|
216
|
+
organizationSlug: mapping.organizationSlug
|
|
217
|
+
});
|
|
218
|
+
ui.cleanup();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
ui.info('Project token:');
|
|
222
|
+
console.log(''); // Empty line for spacing
|
|
223
|
+
console.log(` ${tokenStr}`);
|
|
224
|
+
console.log(''); // Empty line for spacing
|
|
225
|
+
ui.info(`Project: ${mapping.projectName} (${mapping.projectSlug})`);
|
|
226
|
+
ui.info(`Organization: ${mapping.organizationSlug}`);
|
|
227
|
+
ui.cleanup();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
ui.error('Failed to get project token', error, 0);
|
|
230
|
+
if (globalOptions.verbose && error.stack) {
|
|
231
|
+
console.error(''); // Empty line for spacing
|
|
232
|
+
console.error(error.stack);
|
|
233
|
+
}
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Helper to make authenticated API request
|
|
240
|
+
*/
|
|
241
|
+
async function makeAuthenticatedRequest(url, options = {}) {
|
|
242
|
+
let response = await fetch(url, options);
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
let errorText = '';
|
|
245
|
+
try {
|
|
246
|
+
let errorData = await response.json();
|
|
247
|
+
errorText = errorData.error || errorData.message || '';
|
|
248
|
+
} catch {
|
|
249
|
+
errorText = await response.text();
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`);
|
|
252
|
+
}
|
|
253
|
+
return response.json();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Helper to prompt for a number
|
|
258
|
+
*/
|
|
259
|
+
function promptNumber(message, min, max) {
|
|
260
|
+
return new Promise(resolve => {
|
|
261
|
+
let rl = readline.createInterface({
|
|
262
|
+
input: process.stdin,
|
|
263
|
+
output: process.stdout
|
|
264
|
+
});
|
|
265
|
+
let ask = () => {
|
|
266
|
+
rl.question(`${message} (${min}-${max}): `, answer => {
|
|
267
|
+
let num = parseInt(answer, 10);
|
|
268
|
+
if (isNaN(num) || num < min || num > max) {
|
|
269
|
+
console.log(`Please enter a number between ${min} and ${max}`);
|
|
270
|
+
ask();
|
|
271
|
+
} else {
|
|
272
|
+
rl.close();
|
|
273
|
+
resolve(num);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
ask();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Project remove command - remove project configuration for current directory
|
|
283
|
+
* @param {Object} _options - Command options (unused)
|
|
284
|
+
* @param {Object} globalOptions - Global CLI options
|
|
285
|
+
*/
|
|
286
|
+
export async function projectRemoveCommand(_options = {}, globalOptions = {}) {
|
|
287
|
+
let ui = new ConsoleUI({
|
|
288
|
+
json: globalOptions.json,
|
|
289
|
+
verbose: globalOptions.verbose,
|
|
290
|
+
color: !globalOptions.noColor
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
let currentDir = resolve(process.cwd());
|
|
294
|
+
let mapping = await getProjectMapping(currentDir);
|
|
295
|
+
if (!mapping) {
|
|
296
|
+
ui.info('No project configured for this directory');
|
|
297
|
+
ui.cleanup();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Confirm removal
|
|
302
|
+
console.log(''); // Empty line for spacing
|
|
303
|
+
ui.info('Current project configuration:');
|
|
304
|
+
console.log(` Project: ${mapping.projectName} (${mapping.projectSlug})`);
|
|
305
|
+
console.log(` Organization: ${mapping.organizationSlug}`);
|
|
306
|
+
console.log(` Directory: ${currentDir}`);
|
|
307
|
+
console.log(''); // Empty line for spacing
|
|
308
|
+
|
|
309
|
+
let confirmed = await promptConfirm('Remove this project configuration?');
|
|
310
|
+
if (!confirmed) {
|
|
311
|
+
ui.info('Cancelled');
|
|
312
|
+
ui.cleanup();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await deleteProjectMapping(currentDir);
|
|
316
|
+
ui.success('Project configuration removed');
|
|
317
|
+
console.log(''); // Empty line for spacing
|
|
318
|
+
ui.info('Run "vizzly project:select" to configure a different project');
|
|
319
|
+
ui.cleanup();
|
|
320
|
+
} catch (error) {
|
|
321
|
+
ui.error('Failed to remove project configuration', error, 0);
|
|
322
|
+
if (globalOptions.verbose && error.stack) {
|
|
323
|
+
console.error(''); // Empty line for spacing
|
|
324
|
+
console.error(error.stack);
|
|
325
|
+
}
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Helper to prompt for confirmation
|
|
332
|
+
*/
|
|
333
|
+
function promptConfirm(message) {
|
|
334
|
+
return new Promise(resolve => {
|
|
335
|
+
let rl = readline.createInterface({
|
|
336
|
+
input: process.stdin,
|
|
337
|
+
output: process.stdout
|
|
338
|
+
});
|
|
339
|
+
rl.question(`${message} (y/n): `, answer => {
|
|
340
|
+
rl.close();
|
|
341
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Validate project command options
|
|
348
|
+
*/
|
|
349
|
+
export function validateProjectOptions() {
|
|
350
|
+
return [];
|
|
351
|
+
}
|