From 9bbda9b8a995469ec4e787a13b2079011b753477 Mon Sep 17 00:00:00 2001
From: Robert DeLuca
Date: Sun, 8 Feb 2026 19:46:55 -0600
Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=A5=20Remove=20project=20linking?=
=?UTF-8?q?=20system?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Simplify auth model to two clear paths: `vizzly login` (interactive)
and `VIZZLY_TOKEN` (CI). The per-directory project mapping system
(`project:select`) is no longer needed now that user JWTs work on all
API endpoints.
- Remove `project:select`, `project:list`, `project:token`,
`project:remove` commands and CLI registrations
- Remove project mapping storage from global config
- Remove project mapping lookup from config loader token resolution
- Remove mapping endpoints from projects router and project service
- Remove mapping operations and helpers from project/operations and
project/core
- Delete Projects tab from reporter dashboard, move DeviceFlowLogin
into Builds view inline
- Remove mapping hooks and API client methods from reporter
- Add `--project ` filter to `comparisons` command
- Clean up context display to remove mapping references
- Delete test files for removed code, update remaining tests
---
src/cli.js | 69 +--
src/commands/builds.js | 1 +
src/commands/comparisons.js | 1 +
src/commands/config-cmd.js | 24 -
src/commands/project.js | 473 ------------------
src/project/core.js | 90 ----
src/project/operations.js | 88 ----
src/reporter/src/api/client.js | 50 +-
src/reporter/src/components/app-router.jsx | 19 +-
src/reporter/src/components/layout/header.jsx | 2 -
.../src/components/views/builds-view.jsx | 158 +++++-
.../src/components/views/projects-view.jsx | 414 ---------------
.../src/hooks/queries/use-cloud-queries.js | 29 --
src/server/routers/projects.js | 98 +---
src/services/project-service.js | 60 +--
src/utils/config-loader.js | 39 +-
src/utils/context.js | 40 +-
src/utils/global-config.js | 77 +--
tests/commands/builds.test.js | 22 +
tests/commands/comparisons.test.js | 25 +
tests/commands/config-cmd.test.js | 35 --
tests/project/core.test.js | 112 -----
tests/project/operations.test.js | 154 ------
tests/server/routers/projects.test.js | 394 ---------------
tests/services/project-service.test.js | 371 --------------
tests/utils/context.test.js | 26 -
tests/utils/global-config.test.js | 107 ----
27 files changed, 215 insertions(+), 2763 deletions(-)
delete mode 100644 src/commands/project.js
delete mode 100644 src/reporter/src/components/views/projects-view.jsx
delete mode 100644 tests/server/routers/projects.test.js
delete mode 100644 tests/services/project-service.test.js
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