UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

360 lines (341 loc) 11.3 kB
/** * Project management commands * Select, list, and manage project tokens */ import { resolve } from 'node:path'; import readline from 'node:readline'; import { createAuthClient, createTokenStore, getAuthTokens, whoami } from '../auth/index.js'; import { getApiUrl } from '../utils/environment-config.js'; import { deleteProjectMapping, getProjectMapping, getProjectMappings, saveProjectMapping } from '../utils/global-config.js'; import * as output from '../utils/output.js'; /** * 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 { output.header('project:select'); // Check authentication let auth = await getAuthTokens(); if (!auth || !auth.accessToken) { output.error('Not authenticated'); output.hint('Run "vizzly login" to authenticate first'); process.exit(1); } let client = createAuthClient({ baseUrl: options.apiUrl || getApiUrl() }); let tokenStore = createTokenStore(); // Get user info to show organizations output.startSpinner('Fetching organizations...'); let userInfo = await whoami(client, tokenStore); output.stopSpinner(); if (!userInfo.organizations || userInfo.organizations.length === 0) { output.error('No organizations found'); output.hint('Create an organization at https://vizzly.dev'); process.exit(1); } // Select organization output.labelValue('Organizations', ''); 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.hint(`Create a project in ${selectedOrg.name} at https://vizzly.dev`); process.exit(1); } // Select project output.blank(); output.labelValue('Projects', ''); 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.complete('Project configured'); output.blank(); output.keyValue({ Project: selectedProject.name, Organization: selectedOrg.name, 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) { if (globalOptions.json) { output.data({}); } else { output.header('project:list'); output.print(' No projects configured'); output.blank(); output.hint('Run "vizzly project:select" to configure a project'); } output.cleanup(); return; } if (globalOptions.json) { output.data(mappings); output.cleanup(); return; } output.header('project:list'); let colors = output.getColors(); let currentDir = resolve(process.cwd()); for (let path of paths) { let mapping = mappings[path]; let isCurrent = path === currentDir; let marker = isCurrent ? colors.brand.amber('→') : ' '; output.print(`${marker} ${isCurrent ? colors.bold(path) : path}`); output.keyValue({ Project: `${mapping.projectName} (${mapping.projectSlug})`, Org: mapping.organizationSlug }, { indent: 4 }); if (globalOptions.verbose) { // Extract token string (handle both string and object formats) let tokenStr = typeof mapping.token === 'string' ? mapping.token : mapping.token?.token || '[invalid token]'; output.hint(`Token: ${tokenStr.substring(0, 20)}...`, { indent: 4 }); output.hint(`Created: ${new Date(mapping.createdAt).toLocaleString()}`, { indent: 4 }); } 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.hint('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.header('project:token'); output.printBox(tokenStr, { title: 'Token' }); output.blank(); output.keyValue({ Project: `${mapping.projectName} (${mapping.projectSlug})`, Org: 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 = {}) { const response = await fetch(url, options); if (!response.ok) { let errorText = ''; try { const 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 => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = () => { rl.question(`${message} (${min}-${max}): `, answer => { const num = parseInt(answer, 10); if (Number.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) { if (globalOptions.json) { output.data({ removed: false, reason: 'not_configured' }); } else { output.header('project:remove'); output.print(' No project configured for this directory'); } output.cleanup(); return; } // Confirm removal output.header('project:remove'); output.labelValue('Current configuration', ''); output.keyValue({ Project: `${mapping.projectName} (${mapping.projectSlug})`, Org: mapping.organizationSlug, Directory: currentDir }); output.blank(); let confirmed = await promptConfirm('Remove this project configuration?'); if (!confirmed) { output.print(' Cancelled'); output.cleanup(); return; } await deleteProjectMapping(currentDir); if (globalOptions.json) { output.data({ removed: true }); } else { output.complete('Project configuration removed'); output.blank(); output.hint('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 => { const 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 []; }