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