diff --git a/src/api/endpoints.js b/src/api/endpoints.js
index 5c1fc1b1..29cdfbfa 100644
--- a/src/api/endpoints.js
+++ b/src/api/endpoints.js
@@ -237,9 +237,10 @@ export async function searchComparisons(client, name, filters = {}) {
throw new VizzlyError('name is required and must be a non-empty string');
}
- let { branch, limit = 50, offset = 0 } = filters;
+ let { branch, project, limit = 50, offset = 0 } = filters;
let params = { name, limit: String(limit), offset: String(offset) };
if (branch) params.branch = branch;
+ if (project) params.project = project;
let endpoint = buildEndpointWithParams('/api/sdk/comparisons/search', params);
return client.request(endpoint);
diff --git a/src/cli.js b/src/cli.js
index 048ccd70..c8c26eb3 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -22,13 +22,6 @@ import { loginCommand, validateLoginOptions } from './commands/login.js';
import { logoutCommand, validateLogoutOptions } from './commands/logout.js';
import { orgsCommand, validateOrgsOptions } from './commands/orgs.js';
import { previewCommand, validatePreviewOptions } from './commands/preview.js';
-import {
- projectListCommand,
- projectRemoveCommand,
- projectSelectCommand,
- projectTokenCommand,
- validateProjectOptions,
-} from './commands/project.js';
import {
projectsCommand,
validateProjectsOptions,
@@ -138,17 +131,6 @@ const formatHelp = (cmd, helper) => {
title: 'Account',
names: ['login', 'logout', 'whoami', 'orgs', 'projects'],
},
- {
- key: 'project',
- icon: '▸',
- title: 'Projects',
- names: [
- 'project:select',
- 'project:list',
- 'project:token',
- 'project:remove',
- ],
- },
];
let grouped = {
@@ -157,7 +139,6 @@ const formatHelp = (cmd, helper) => {
setup: [],
advanced: [],
auth: [],
- project: [],
other: [],
};
@@ -644,6 +625,7 @@ program
'Filter by status (created, pending, processing, completed, failed)'
)
.option('--environment ', 'Filter by environment')
+ .option('-p, --project ', 'Filter by project slug')
.option(
'--limit ',
'Maximum results to return (1-250)',
@@ -658,6 +640,7 @@ program
Examples:
$ vizzly builds # List recent builds
$ vizzly builds --branch main # Filter by branch
+ $ vizzly builds --project abc123 # Filter by project
$ vizzly builds --status completed # Filter by status
$ vizzly builds -b abc123-def456 # Get specific build by ID
$ vizzly builds -b abc123 --comparisons # Include comparisons
@@ -695,6 +678,7 @@ program
50
)
.option('--offset ', 'Skip first N results', val => parseInt(val, 10), 0)
+ .option('-p, --project ', 'Filter by project slug')
.addHelpText(
'after',
`
@@ -1157,53 +1141,6 @@ program
await whoamiCommand(options, globalOptions);
});
-program
- .command('project:select')
- .description('Configure project for current directory')
- .option('--api-url ', 'API URL override')
- .action(async options => {
- const globalOptions = program.opts();
-
- // Validate options
- const validationErrors = validateProjectOptions(options);
- if (validationErrors.length > 0) {
- output.error('Validation errors:');
- for (let error of validationErrors) {
- output.printErr(` - ${error}`);
- }
- process.exit(1);
- }
-
- await projectSelectCommand(options, globalOptions);
- });
-
-program
- .command('project:list')
- .description('Show all configured projects')
- .action(async options => {
- const globalOptions = program.opts();
-
- await projectListCommand(options, globalOptions);
- });
-
-program
- .command('project:token')
- .description('Show project token for current directory')
- .action(async options => {
- const globalOptions = program.opts();
-
- await projectTokenCommand(options, globalOptions);
- });
-
-program
- .command('project:remove')
- .description('Remove project configuration for current directory')
- .action(async options => {
- const globalOptions = program.opts();
-
- await projectRemoveCommand(options, globalOptions);
- });
-
// Save user's PATH for menubar app (non-blocking, runs in background)
// This auto-configures the menubar app so it can find npx/node
saveUserPath().catch(() => {});
diff --git a/src/commands/builds.js b/src/commands/builds.js
index 2274763a..13ed7317 100644
--- a/src/commands/builds.js
+++ b/src/commands/builds.js
@@ -85,6 +85,7 @@ export async function buildsCommand(
if (options.branch) filters.branch = options.branch;
if (options.status) filters.status = options.status;
if (options.environment) filters.environment = options.environment;
+ if (options.project) filters.project = options.project;
let response = await getBuilds(client, filters);
output.stopSpinner();
diff --git a/src/commands/comparisons.js b/src/commands/comparisons.js
index 4bd9c0e8..ecdfd8bc 100644
--- a/src/commands/comparisons.js
+++ b/src/commands/comparisons.js
@@ -129,6 +129,7 @@ export async function comparisonsCommand(
output.startSpinner('Searching comparisons...');
let filters = {
branch: options.branch,
+ project: options.project,
limit: options.limit || 50,
offset: options.offset || 0,
};
diff --git a/src/commands/config-cmd.js b/src/commands/config-cmd.js
index c5368f0b..53bac437 100644
--- a/src/commands/config-cmd.js
+++ b/src/commands/config-cmd.js
@@ -2,9 +2,7 @@
* Config command - query and display configuration
*/
-import { resolve } from 'node:path';
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
-import { getProjectMapping as defaultGetProjectMapping } from '../utils/global-config.js';
import * as defaultOutput from '../utils/output.js';
/**
@@ -22,7 +20,6 @@ export async function configCommand(
) {
let {
loadConfig = defaultLoadConfig,
- getProjectMapping = defaultGetProjectMapping,
output = defaultOutput,
exit = code => process.exit(code),
} = deps;
@@ -41,10 +38,6 @@ export async function configCommand(
});
let configFile = config._configPath || null;
- // Get project mapping if available
- let currentDir = resolve(process.cwd());
- let projectMapping = await getProjectMapping(currentDir);
-
// Build the config object to display
let displayConfig = {
server: config.server || { port: 47392, timeout: 30000 },
@@ -94,13 +87,6 @@ export async function configCommand(
output.data({
configFile,
config: displayConfig,
- project: projectMapping
- ? {
- name: projectMapping.projectName,
- slug: projectMapping.projectSlug,
- organization: projectMapping.organizationSlug,
- }
- : null,
});
output.cleanup();
return;
@@ -117,16 +103,6 @@ export async function configCommand(
}
output.blank();
- // Project context if available
- if (projectMapping) {
- output.labelValue(
- 'Project',
- `${projectMapping.projectName} (${projectMapping.projectSlug})`
- );
- output.labelValue('Organization', projectMapping.organizationSlug);
- output.blank();
- }
-
// Display configuration sections
displaySection(output, 'Server', displayConfig.server);
displaySection(output, 'Comparison', displayConfig.comparison);
diff --git a/src/commands/project.js b/src/commands/project.js
deleted file mode 100644
index 9bd914d3..00000000
--- a/src/commands/project.js
+++ /dev/null
@@ -1,473 +0,0 @@
-/**
- * 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,
- });
-
- // JSON output for success
- if (globalOptions.json) {
- output.data({
- status: 'configured',
- project: {
- name: selectedProject.name,
- slug: selectedProject.slug,
- },
- organization: {
- name: selectedOrg.name,
- slug: selectedOrg.slug,
- },
- directory: currentDir,
- tokenCreated: true,
- });
- output.cleanup();
- return;
- }
-
- 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);
-
- let currentDir = resolve(process.cwd());
-
- if (paths.length === 0) {
- if (globalOptions.json) {
- output.data({ projects: [], current: null });
- } 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) {
- let projects = paths.map(path => {
- let mapping = mappings[path];
- return {
- directory: path,
- isCurrent: path === currentDir,
- project: {
- name: mapping.projectName,
- slug: mapping.projectSlug,
- },
- organization: mapping.organizationSlug,
- createdAt: mapping.createdAt,
- };
- });
- let current = projects.find(p => p.isCurrent) || null;
- output.data({ projects, current });
- output.cleanup();
- return;
- }
-
- output.header('project:list');
-
- let colors = output.getColors();
-
- 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,
- directory: currentDir,
- project: {
- name: mapping.projectName,
- slug: mapping.projectSlug,
- },
- organization: mapping.organizationSlug,
- createdAt: mapping.createdAt,
- });
- 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;
- }
-
- // In JSON mode, skip confirmation (for scripting)
- if (globalOptions.json) {
- await deleteProjectMapping(currentDir);
- output.data({
- removed: true,
- directory: currentDir,
- project: {
- name: mapping.projectName,
- slug: mapping.projectSlug,
- },
- organization: mapping.organizationSlug,
- });
- output.cleanup();
- return;
- }
-
- // Confirm removal (interactive mode only)
- 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);
-
- 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 [];
-}
diff --git a/src/project/core.js b/src/project/core.js
index 913c28c6..d14fd118 100644
--- a/src/project/core.js
+++ b/src/project/core.js
@@ -6,96 +6,6 @@
import { VizzlyError } from '../errors/vizzly-error.js';
-// ============================================================================
-// Validation
-// ============================================================================
-
-/**
- * Validate that a directory path is provided
- * @param {string} directory - Directory path to validate
- * @returns {{ valid: boolean, error: Error|null }}
- */
-export function validateDirectory(directory) {
- if (!directory) {
- return {
- valid: false,
- error: new VizzlyError('Directory path is required', 'INVALID_DIRECTORY'),
- };
- }
- return { valid: true, error: null };
-}
-
-/**
- * Validate project data for mapping creation
- * @param {Object} projectData - Project data to validate
- * @param {string} [projectData.projectSlug] - Project slug
- * @param {string} [projectData.organizationSlug] - Organization slug
- * @param {string} [projectData.token] - Project token
- * @returns {{ valid: boolean, error: Error|null }}
- */
-export function validateProjectData(projectData) {
- if (!projectData.projectSlug) {
- return {
- valid: false,
- error: new VizzlyError(
- 'Project slug is required',
- 'INVALID_PROJECT_DATA'
- ),
- };
- }
-
- if (!projectData.organizationSlug) {
- return {
- valid: false,
- error: new VizzlyError(
- 'Organization slug is required',
- 'INVALID_PROJECT_DATA'
- ),
- };
- }
-
- if (!projectData.token) {
- return {
- valid: false,
- error: new VizzlyError(
- 'Project token is required',
- 'INVALID_PROJECT_DATA'
- ),
- };
- }
-
- return { valid: true, error: null };
-}
-
-// ============================================================================
-// Mapping Transformations
-// ============================================================================
-
-/**
- * Convert mappings object to array with directory paths included
- * @param {Object} mappings - Object with directory paths as keys
- * @returns {Array