UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

331 lines (312 loc) 10.7 kB
/** * Project management commands * Select, list, and manage project tokens */ import * as output from '../utils/output.js'; import { AuthService } from '../services/auth-service.js'; import { getApiUrl } from '../utils/environment-config.js'; import { getAuthTokens, saveProjectMapping, getProjectMapping, getProjectMappings, deleteProjectMapping } from '../utils/global-config.js'; import { resolve } from 'path'; import readline from 'readline'; /** * Project select command - configure project for current directory * @param {Object} options - Command options * @param {Object} globalOptions - Global CLI options */ export async function projectSelectCommand(options = {}, globalOptions = {}) { output.configure({ json: globalOptions.json, verbose: globalOptions.verbose, color: !globalOptions.noColor }); try { // Check authentication let auth = await getAuthTokens(); if (!auth || !auth.accessToken) { output.error('Not authenticated'); output.blank(); output.info('Run "vizzly login" to authenticate first'); process.exit(1); } let authService = new AuthService({ baseUrl: options.apiUrl || getApiUrl() }); // Get user info to show organizations output.startSpinner('Fetching organizations...'); let userInfo = await authService.whoami(); output.stopSpinner(); if (!userInfo.organizations || userInfo.organizations.length === 0) { output.error('No organizations found'); output.blank(); output.info('Create an organization at https://vizzly.dev'); process.exit(1); } // Select organization output.blank(); output.info('Select an organization:'); output.blank(); userInfo.organizations.forEach((org, index) => { output.print(` ${index + 1}. ${org.name} (@${org.slug})`); }); output.blank(); let orgChoice = await promptNumber('Enter number', 1, userInfo.organizations.length); let selectedOrg = userInfo.organizations[orgChoice - 1]; // List projects for organization output.startSpinner(`Fetching projects for ${selectedOrg.name}...`); let response = await makeAuthenticatedRequest(`${options.apiUrl || getApiUrl()}/api/project`, { headers: { Authorization: `Bearer ${auth.accessToken}`, 'X-Organization': selectedOrg.slug } }); output.stopSpinner(); // Handle both array response and object with projects property let projects = Array.isArray(response) ? response : response.projects || []; if (projects.length === 0) { output.error('No projects found'); output.blank(); output.info(`Create a project in ${selectedOrg.name} at https://vizzly.dev`); process.exit(1); } // Select project output.blank(); output.info('Select a project:'); output.blank(); projects.forEach((project, index) => { output.print(` ${index + 1}. ${project.name} (${project.slug})`); }); output.blank(); let projectChoice = await promptNumber('Enter number', 1, projects.length); let selectedProject = projects[projectChoice - 1]; // Create API token for project output.startSpinner(`Creating API token for ${selectedProject.name}...`); let tokenResponse = await makeAuthenticatedRequest(`${options.apiUrl || getApiUrl()}/api/project/${selectedProject.slug}/tokens`, { method: 'POST', headers: { Authorization: `Bearer ${auth.accessToken}`, 'X-Organization': selectedOrg.slug, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `CLI Token - ${new Date().toLocaleDateString()}`, description: `Generated by vizzly CLI for ${process.cwd()}` }) }); output.stopSpinner(); // Save project mapping let currentDir = resolve(process.cwd()); await saveProjectMapping(currentDir, { token: tokenResponse.token, projectSlug: selectedProject.slug, projectName: selectedProject.name, organizationSlug: selectedOrg.slug }); output.success('Project configured!'); output.blank(); output.info(`Project: ${selectedProject.name}`); output.info(`Organization: ${selectedOrg.name}`); output.info(`Directory: ${currentDir}`); output.cleanup(); } catch (error) { output.stopSpinner(); output.error('Failed to configure project', error); process.exit(1); } } /** * Project list command - show all configured projects * @param {Object} _options - Command options (unused) * @param {Object} globalOptions - Global CLI options */ export async function projectListCommand(_options = {}, globalOptions = {}) { output.configure({ json: globalOptions.json, verbose: globalOptions.verbose, color: !globalOptions.noColor }); try { let mappings = await getProjectMappings(); let paths = Object.keys(mappings); if (paths.length === 0) { output.info('No projects configured'); output.blank(); output.info('Run "vizzly project:select" to configure a project'); output.cleanup(); return; } if (globalOptions.json) { output.data(mappings); output.cleanup(); return; } output.info('Configured projects:'); output.blank(); let currentDir = resolve(process.cwd()); for (let path of paths) { let mapping = mappings[path]; let isCurrent = path === currentDir; let marker = isCurrent ? '→' : ' '; // Extract token string (handle both string and object formats) let tokenStr = typeof mapping.token === 'string' ? mapping.token : mapping.token?.token || '[invalid token]'; output.print(`${marker} ${path}`); output.print(` Project: ${mapping.projectName} (${mapping.projectSlug})`); output.print(` Organization: ${mapping.organizationSlug}`); if (globalOptions.verbose) { output.print(` Token: ${tokenStr.substring(0, 20)}...`); output.print(` Created: ${new Date(mapping.createdAt).toLocaleString()}`); } output.blank(); } output.cleanup(); } catch (error) { output.error('Failed to list projects', error); process.exit(1); } } /** * Project token command - show/regenerate token for current directory * @param {Object} _options - Command options (unused) * @param {Object} globalOptions - Global CLI options */ export async function projectTokenCommand(_options = {}, globalOptions = {}) { output.configure({ json: globalOptions.json, verbose: globalOptions.verbose, color: !globalOptions.noColor }); try { let currentDir = resolve(process.cwd()); let mapping = await getProjectMapping(currentDir); if (!mapping) { output.error('No project configured for this directory'); output.blank(); output.info('Run "vizzly project:select" to configure a project'); process.exit(1); } // Extract token string (handle both string and object formats) let tokenStr = typeof mapping.token === 'string' ? mapping.token : mapping.token?.token || '[invalid token]'; if (globalOptions.json) { output.data({ token: tokenStr, projectSlug: mapping.projectSlug, organizationSlug: mapping.organizationSlug }); output.cleanup(); return; } output.info('Project token:'); output.blank(); output.print(` ${tokenStr}`); output.blank(); output.info(`Project: ${mapping.projectName} (${mapping.projectSlug})`); output.info(`Organization: ${mapping.organizationSlug}`); output.cleanup(); } catch (error) { output.error('Failed to get project token', error); process.exit(1); } } /** * Helper to make authenticated API request */ async function makeAuthenticatedRequest(url, options = {}) { let response = await fetch(url, options); if (!response.ok) { let errorText = ''; try { let errorData = await response.json(); errorText = errorData.error || errorData.message || ''; } catch { errorText = await response.text(); } throw new Error(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`); } return response.json(); } /** * Helper to prompt for a number */ function promptNumber(message, min, max) { return new Promise(resolve => { let rl = readline.createInterface({ input: process.stdin, output: process.stdout }); let ask = () => { rl.question(`${message} (${min}-${max}): `, answer => { let num = parseInt(answer, 10); if (isNaN(num) || num < min || num > max) { output.print(`Please enter a number between ${min} and ${max}`); ask(); } else { rl.close(); resolve(num); } }); }; ask(); }); } /** * Project remove command - remove project configuration for current directory * @param {Object} _options - Command options (unused) * @param {Object} globalOptions - Global CLI options */ export async function projectRemoveCommand(_options = {}, globalOptions = {}) { output.configure({ json: globalOptions.json, verbose: globalOptions.verbose, color: !globalOptions.noColor }); try { let currentDir = resolve(process.cwd()); let mapping = await getProjectMapping(currentDir); if (!mapping) { output.info('No project configured for this directory'); output.cleanup(); return; } // Confirm removal output.blank(); output.info('Current project configuration:'); output.print(` Project: ${mapping.projectName} (${mapping.projectSlug})`); output.print(` Organization: ${mapping.organizationSlug}`); output.print(` Directory: ${currentDir}`); output.blank(); let confirmed = await promptConfirm('Remove this project configuration?'); if (!confirmed) { output.info('Cancelled'); output.cleanup(); return; } await deleteProjectMapping(currentDir); output.success('Project configuration removed'); output.blank(); output.info('Run "vizzly project:select" to configure a different project'); output.cleanup(); } catch (error) { output.error('Failed to remove project configuration', error); process.exit(1); } } /** * Helper to prompt for confirmation */ function promptConfirm(message) { return new Promise(resolve => { let rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`${message} (y/n): `, answer => { rl.close(); resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); }); }); } /** * Validate project command options */ export function validateProjectOptions() { return []; }