@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
331 lines (312 loc) • 10.7 kB
JavaScript
/**
* 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 [];
}