diff --git a/docs/json-output.md b/docs/json-output.md index 8ed3eaf1..097ce407 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -672,27 +672,19 @@ vizzly init --json } ``` -### `vizzly project:select` +### `vizzly project link` ```bash -vizzly project:select --json +vizzly project link my-org/my-project --json ``` -Note: In JSON mode, the interactive prompts still appear because project selection requires user input. - ```json { - "status": "configured", - "project": { - "name": "My Project", - "slug": "my-project" - }, - "organization": { - "name": "My Org", - "slug": "my-org" - }, - "directory": "/path/to/project", - "tokenCreated": true + "linked": true, + "organizationSlug": "my-org", + "projectSlug": "my-project", + "tokenPrefix": "vzt_abc", + "storage": "keychain" } ``` diff --git a/src/api/client.js b/src/api/client.js index c4a663fb..ee3dfd62 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -23,6 +23,10 @@ import { */ export const DEFAULT_API_URL = 'https://app.vizzly.dev'; +function isProjectToken(token) { + return typeof token === 'string' && token.startsWith('vzt_'); +} + /** * Create an API client with the given configuration * @@ -84,7 +88,7 @@ export function createApiClient(options = {}) { shouldRetryWithRefresh( response.status, isRetry, - await hasRefreshToken() + !isProjectToken(token) && (await hasRefreshToken()) ) ) { let refreshed = await attemptTokenRefresh(); @@ -97,7 +101,7 @@ export function createApiClient(options = {}) { // Auth error if (isAuthError(response.status)) { throw new AuthError( - 'Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.' + 'Invalid or expired API token. Run "vizzly project link /" or set VIZZLY_TOKEN.' ); } @@ -146,7 +150,9 @@ export function createApiClient(options = {}) { await saveAuthTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken, - expiresAt: data.expiresAt, + expiresAt: + data.expiresAt || + new Date(Date.now() + data.expiresIn * 1000).toISOString(), user: auth.user, }); diff --git a/src/cli.js b/src/cli.js index c32f3845..5a84e9cc 100644 --- a/src/cli.js +++ b/src/cli.js @@ -34,6 +34,10 @@ 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 { + projectLinkCommand, + validateProjectLinkOptions, +} from './commands/project.js'; import { projectsCommand, validateProjectsOptions, @@ -1222,6 +1226,43 @@ Workflow: await projectsCommand(options, globalOptions); }); +let projectCommand = program + .command('project') + .description('Manage the project linked to this local checkout'); + +projectCommand + .command('link [selector]') + .description('Link a Vizzly project for cloud uploads') + .option('--org ', 'Organization slug') + .option('--project ', 'Project slug') + .option('--name ', 'Credential name shown in Vizzly') + .option('--expires-at ', 'Optional token expiration timestamp') + .addHelpText( + 'after', + ` +Examples: + $ vizzly project link vizzly/storybook + $ vizzly project link --org vizzly --project storybook + +Note: run "vizzly login" first. The linked credential is project-scoped and is +used for cloud uploads; your user login remains separate for review actions. +` + ) + .action(async (selector, options) => { + const globalOptions = program.opts(); + + const validationErrors = validateProjectLinkOptions(selector, options); + if (validationErrors.length > 0) { + output.error('Validation errors:'); + for (let error of validationErrors) { + output.printErr(` - ${error}`); + } + process.exit(1); + } + + await projectLinkCommand(selector, options, globalOptions); + }); + program .command('finalize') .description('Finalize a parallel build after all shards complete') diff --git a/src/commands/builds.js b/src/commands/builds.js index 58b6085e..85332b18 100644 --- a/src/commands/builds.js +++ b/src/commands/builds.js @@ -41,10 +41,12 @@ export async function buildsCommand( let allOptions = { ...globalOptions, ...options }; let config = await loadConfig(globalOptions.config, allOptions); - // Validate API token - if (!config.apiKey) { + let token = config.apiKey || config.userToken; + + // Validate cloud auth + if (!token) { output.error( - 'API token required. Use --token or set VIZZLY_TOKEN environment variable' + 'Authentication required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"' ); exit(1); return; @@ -52,7 +54,7 @@ export async function buildsCommand( let client = createApiClient({ baseUrl: config.apiUrl, - token: config.apiKey, + token, command: 'builds', }); diff --git a/src/commands/context.js b/src/commands/context.js index 3be8886a..128d735c 100644 --- a/src/commands/context.js +++ b/src/commands/context.js @@ -16,7 +16,7 @@ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import * as defaultOutput from '../utils/output.js'; function buildAuthErrorMessage() { - return 'API token required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"'; + return 'Authentication required. Use --token, set VIZZLY_TOKEN, run "vizzly login", or link a project.'; } function buildSourceErrorMessage() { @@ -72,7 +72,7 @@ function validateScopedProjectOptions(options = {}) { function createClient(config, createApiClient) { return createApiClient({ baseUrl: config.apiUrl, - token: config.apiKey, + token: config.apiKey || config.userToken, command: 'context', }); } @@ -88,7 +88,7 @@ async function loadContextConfig(globalOptions, options, deps) { let allOptions = { ...globalOptions, ...options }; let config = await loadConfig(globalOptions.config, allOptions); - if (requireApiKey && !config.apiKey) { + if (requireApiKey && !config.apiKey && !config.userToken) { output.error(buildAuthErrorMessage()); output.cleanup(); exit(1); @@ -184,7 +184,7 @@ async function loadContextRuntime( } ); - if (source === 'cloud' && !config.apiKey) { + if (source === 'cloud' && !config.apiKey && !config.userToken) { if ( shouldExplainLocalSimilarityGap(requestedSource, command, localProvider) ) { diff --git a/src/commands/login.js b/src/commands/login.js index d0e2c478..7f7b47d1 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -261,7 +261,9 @@ export async function loginCommand( } output.blank(); - output.hint('You can now use Vizzly CLI commands without VIZZLY_TOKEN'); + output.hint( + 'Run "vizzly project link /" to enable cloud uploads' + ); output.cleanup(); } catch (error) { diff --git a/src/commands/project.js b/src/commands/project.js new file mode 100644 index 00000000..525d689b --- /dev/null +++ b/src/commands/project.js @@ -0,0 +1,144 @@ +import { createApiClient as defaultCreateApiClient } from '../api/client.js'; +import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; +import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js'; +import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js'; +import * as defaultOutput from '../utils/output.js'; +import { saveProjectLink as defaultSaveProjectLink } from '../utils/project-link-store.js'; + +export function parseProjectSelector(selector, options = {}) { + let organizationSlug = options.org || null; + let projectSlug = options.project || null; + + if (selector) { + let parts = selector.split('/'); + if (parts.length === 2) { + organizationSlug = organizationSlug || parts[0]; + projectSlug = projectSlug || parts[1]; + } else if (parts.length === 1) { + projectSlug = projectSlug || parts[0]; + } + } + + return { organizationSlug, projectSlug }; +} + +export function validateProjectLinkOptions(selector, options = {}) { + let errors = []; + let { organizationSlug, projectSlug } = parseProjectSelector( + selector, + options + ); + + if (!organizationSlug) { + errors.push( + 'Organization is required. Use / or --org .' + ); + } + if (!projectSlug) { + errors.push( + 'Project is required. Use / or --project .' + ); + } + + return errors; +} + +export async function projectLinkCommand( + selector, + options = {}, + globalOptions = {}, + deps = {} +) { + let { + createApiClient = defaultCreateApiClient, + getAccessToken = defaultGetAccessToken, + getApiUrl = defaultGetApiUrl, + loadConfig = defaultLoadConfig, + output = defaultOutput, + saveProjectLink = defaultSaveProjectLink, + exit = code => process.exit(code), + } = deps; + + output.configure({ + json: globalOptions.json, + verbose: globalOptions.verbose, + color: !globalOptions.noColor, + }); + + let { organizationSlug, projectSlug } = parseProjectSelector( + selector, + options + ); + + try { + let config = await loadConfig(globalOptions.config, globalOptions); + let userToken = config.userToken || (await getAccessToken()); + + if (!userToken) { + output.error('Login required before linking a project'); + output.hint('Run "vizzly login" first, then try project link again'); + output.cleanup(); + exit(1); + return; + } + + output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`); + + let apiUrl = config.apiUrl || getApiUrl(); + let client = createApiClient({ + baseUrl: apiUrl, + token: userToken, + command: 'project-link', + }); + + let response = await client.request(`/api/cli/${projectSlug}/link-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Organization': organizationSlug, + }, + body: JSON.stringify({ + name: options.name, + expiresAt: options.expiresAt, + }), + }); + + let linkedProject = await saveProjectLink({ + apiUrl, + organizationSlug: response.organization?.slug || organizationSlug, + organizationName: response.organization?.name, + projectSlug: response.project?.slug || projectSlug, + projectName: response.project?.name, + token: response.token.token, + tokenId: response.token.id, + tokenPrefix: response.token.token_prefix, + expiresAt: response.token.expires_at, + createdAt: response.token.created_at, + }); + + output.stopSpinner(); + + if (globalOptions.json) { + output.data({ + linked: true, + organizationSlug: linkedProject.organizationSlug, + projectSlug: linkedProject.projectSlug, + tokenPrefix: linkedProject.tokenPrefix, + storage: linkedProject.storage, + }); + output.cleanup(); + return; + } + + output.complete( + `Linked ${linkedProject.organizationSlug}/${linkedProject.projectSlug}` + ); + output.hint(`Cloud uploads will use ${linkedProject.tokenPrefix}...`); + output.cleanup(); + } catch (error) { + output.stopSpinner(); + output.error('Failed to link project', error); + output.cleanup(); + exit(1); + } +} diff --git a/src/commands/review.js b/src/commands/review.js index b93c9db9..c02f63b6 100644 --- a/src/commands/review.js +++ b/src/commands/review.js @@ -4,12 +4,14 @@ import { createApiClient as defaultCreateApiClient } from '../api/index.js'; import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; +import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js'; import * as defaultOutput from '../utils/output.js'; function createReviewDeps(deps = {}) { return { loadConfig: deps.loadConfig || defaultLoadConfig, createApiClient: deps.createApiClient || defaultCreateApiClient, + getAccessToken: deps.getAccessToken || defaultGetAccessToken, output: deps.output || defaultOutput, exit: deps.exit || (code => process.exit(code)), }; @@ -28,10 +30,31 @@ async function loadReviewConfig({ loadConfig, options, globalOptions }) { return await loadConfig(globalOptions.config, allOptions); } -function createReviewClient({ createApiClient, config, command }) { +function isProjectToken(token) { + return typeof token === 'string' && token.startsWith('vzt_'); +} + +async function getReviewToken(config, getAccessToken) { + if (config.userToken) { + return config.userToken; + } + + let userToken = await getAccessToken(); + if (userToken) { + return userToken; + } + + if (config.apiKey && !isProjectToken(config.apiKey)) { + return config.apiKey; + } + + return null; +} + +function createReviewClient({ createApiClient, config, command, token }) { return createApiClient({ baseUrl: config.apiUrl, - token: config.apiKey, + token, command, }); } @@ -61,7 +84,8 @@ async function runReviewMutation({ deps, configure = true, }) { - let { loadConfig, createApiClient, output, exit } = createReviewDeps(deps); + let { loadConfig, createApiClient, getAccessToken, output, exit } = + createReviewDeps(deps); if (configure) { configureOutput(output, globalOptions); @@ -69,10 +93,11 @@ async function runReviewMutation({ try { let config = await loadReviewConfig({ loadConfig, options, globalOptions }); + let token = await getReviewToken(config, getAccessToken); - if (!config.apiKey) { - output.error('API token required'); - output.hint('Use --token or set VIZZLY_TOKEN environment variable'); + if (!token) { + output.error('User login required for review actions'); + output.hint('Run "vizzly login" to approve, reject, or comment'); output.cleanup(); exit(1); return; @@ -80,7 +105,12 @@ async function runReviewMutation({ output.startSpinner(spinnerMessage); - let client = createReviewClient({ createApiClient, config, command }); + let client = createReviewClient({ + createApiClient, + config, + command, + token, + }); let response = await client.request(endpoint, { method: 'POST', ...jsonBody(requestBody), diff --git a/src/project/core.js b/src/project/core.js index d14fd118..6dfe979c 100644 --- a/src/project/core.js +++ b/src/project/core.js @@ -68,8 +68,8 @@ export function buildBuildsUrl(projectSlug, options = {}) { * @param {string} [tokenId] - Optional token ID for specific token operations * @returns {string} API URL path */ -export function buildTokensUrl(organizationSlug, projectSlug, tokenId) { - let base = `/api/cli/organizations/${organizationSlug}/projects/${projectSlug}/tokens`; +export function buildTokensUrl(_organizationSlug, projectSlug, tokenId) { + let base = `/api/project/${projectSlug}/tokens`; return tokenId ? `${base}/${tokenId}` : base; } diff --git a/src/project/operations.js b/src/project/operations.js index d35afc79..4c32f566 100644 --- a/src/project/operations.js +++ b/src/project/operations.js @@ -330,7 +330,10 @@ export async function createProjectToken( buildTokensUrl(organizationSlug, projectSlug), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...buildOrgHeader(organizationSlug), + }, body: JSON.stringify(tokenData), } ); @@ -361,6 +364,7 @@ export async function listProjectTokens( buildTokensUrl(organizationSlug, projectSlug), { method: 'GET', + headers: buildOrgHeader(organizationSlug), } ); return extractTokens(response); @@ -392,6 +396,7 @@ export async function revokeProjectToken( buildTokensUrl(organizationSlug, projectSlug, tokenId), { method: 'DELETE', + headers: buildOrgHeader(organizationSlug), } ); } catch (error) { diff --git a/src/utils/config-loader.js b/src/utils/config-loader.js index 6bb63fbf..f464e63a 100644 --- a/src/utils/config-loader.js +++ b/src/utils/config-loader.js @@ -10,6 +10,7 @@ import { } from './environment-config.js'; import { getAccessToken } from './global-config.js'; import * as output from './output.js'; +import { getActiveProjectLink } from './project-link-store.js'; export async function loadConfig(configPath = null, cliOverrides = {}) { // 1. Load from config file using cosmiconfig @@ -55,17 +56,34 @@ export async function loadConfig(configPath = null, cliOverrides = {}) { applyCLIOverrides(config, cliOverrides); - // 5. Fall back to user auth token if no other token found - // This enables interactive commands (builds, comparisons, approve, etc.) - // to work without a project token when the user is logged in + // 5. Fall back to a linked project token for cloud upload commands. if (!config.apiKey) { - let userToken = await getAccessToken(); - if (userToken) { - config.apiKey = userToken; - output.debug('config', 'using token from user login'); + let linkedProject = await getActiveProjectLink({ apiUrl: config.apiUrl }); + if (linkedProject?.token) { + config.apiKey = linkedProject.token; + config.linkedProject = { + account: linkedProject.account, + apiUrl: linkedProject.apiUrl, + organizationSlug: linkedProject.organizationSlug, + organizationName: linkedProject.organizationName, + projectSlug: linkedProject.projectSlug, + projectName: linkedProject.projectName, + tokenId: linkedProject.tokenId, + tokenPrefix: linkedProject.tokenPrefix, + expiresAt: linkedProject.expiresAt, + storage: linkedProject.storage, + }; + output.debug('config', 'using linked project token'); } } + // 6. Keep user auth separate from upload credentials. + let userToken = await getAccessToken(); + if (userToken) { + config.userToken = userToken; + output.debug('config', 'using user login for user-authenticated commands'); + } + return config; } diff --git a/src/utils/project-link-store.js b/src/utils/project-link-store.js new file mode 100644 index 00000000..4b57c9b9 --- /dev/null +++ b/src/utils/project-link-store.js @@ -0,0 +1,129 @@ +import { loadGlobalConfig, saveGlobalConfig } from './global-config.js'; +import { + deleteSecret as defaultDeleteSecret, + getSecret as defaultGetSecret, + saveSecret as defaultSaveSecret, +} from './secret-store.js'; + +export function buildProjectLinkAccount({ + apiUrl, + organizationSlug, + projectSlug, +}) { + return `${apiUrl || 'https://app.vizzly.dev'}|${organizationSlug}/${projectSlug}`; +} + +function getProjectLinkConfig(config) { + return config.projectLink || { active: null, links: {} }; +} + +export async function saveProjectLink(link, deps = {}) { + let { + loadConfig = loadGlobalConfig, + saveConfig = saveGlobalConfig, + saveSecret = defaultSaveSecret, + now = () => new Date(), + } = deps; + + let config = await loadConfig(); + let projectLink = getProjectLinkConfig(config); + let account = buildProjectLinkAccount(link); + let storedInKeychain = await saveSecret(account, link.token); + + projectLink.active = account; + projectLink.links = { + ...projectLink.links, + [account]: { + apiUrl: link.apiUrl, + organizationSlug: link.organizationSlug, + organizationName: link.organizationName, + projectSlug: link.projectSlug, + projectName: link.projectName, + tokenId: link.tokenId, + tokenPrefix: link.tokenPrefix, + expiresAt: link.expiresAt || null, + createdAt: link.createdAt || now().toISOString(), + storage: storedInKeychain ? 'keychain' : 'file', + token: storedInKeychain ? undefined : link.token, + }, + }; + + config.projectLink = projectLink; + await saveConfig(config); + + return { + ...projectLink.links[account], + account, + token: link.token, + }; +} + +export async function getActiveProjectLink(options = {}, deps = {}) { + let { loadConfig = loadGlobalConfig, getSecret = defaultGetSecret } = deps; + + let config = await loadConfig(); + let projectLink = getProjectLinkConfig(config); + let account = options.account || projectLink.active; + let link = account ? projectLink.links?.[account] : null; + + if ( + !link && + options.apiUrl && + options.organizationSlug && + options.projectSlug + ) { + account = buildProjectLinkAccount(options); + link = projectLink.links?.[account] || null; + } + + if (!link) { + return null; + } + + if (options.apiUrl && link.apiUrl && link.apiUrl !== options.apiUrl) { + return null; + } + + let token = + link.storage === 'keychain' ? await getSecret(account) : link.token; + if (!token) { + return null; + } + + return { + ...link, + account, + token, + }; +} + +export async function clearActiveProjectLink(deps = {}) { + let { + loadConfig = loadGlobalConfig, + saveConfig = saveGlobalConfig, + deleteSecret = defaultDeleteSecret, + } = deps; + + let config = await loadConfig(); + let projectLink = getProjectLinkConfig(config); + let account = projectLink.active; + + if (!account || !projectLink.links?.[account]) { + return null; + } + + let link = projectLink.links[account]; + if (link.storage === 'keychain') { + await deleteSecret(account); + } + + delete projectLink.links[account]; + projectLink.active = null; + config.projectLink = projectLink; + await saveConfig(config); + + return { + ...link, + account, + }; +} diff --git a/src/utils/secret-store.js b/src/utils/secret-store.js new file mode 100644 index 00000000..dd19e571 --- /dev/null +++ b/src/utils/secret-store.js @@ -0,0 +1,103 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +let execFileAsync = promisify(execFile); +let KEYCHAIN_SERVICE = 'vizzly-cli'; + +export function canUseKeychain(platform = process.platform, env = process.env) { + return platform === 'darwin' && env.VIZZLY_DISABLE_KEYCHAIN !== 'true'; +} + +export async function saveSecret(account, secret, options = {}) { + let { + service = KEYCHAIN_SERVICE, + platform = process.platform, + env = process.env, + execFileFn = execFileAsync, + } = options; + + if (!canUseKeychain(platform, env)) { + return false; + } + + try { + try { + await execFileFn('security', [ + 'delete-generic-password', + '-s', + service, + '-a', + account, + ]); + } catch { + // Missing secrets are fine; add-generic-password creates the current value. + } + + await execFileFn('security', [ + 'add-generic-password', + '-s', + service, + '-a', + account, + '-w', + secret, + '-U', + ]); + return true; + } catch { + return false; + } +} + +export async function getSecret(account, options = {}) { + let { + service = KEYCHAIN_SERVICE, + platform = process.platform, + env = process.env, + execFileFn = execFileAsync, + } = options; + + if (!canUseKeychain(platform, env)) { + return null; + } + + try { + let { stdout } = await execFileFn('security', [ + 'find-generic-password', + '-s', + service, + '-a', + account, + '-w', + ]); + return stdout.trim() || null; + } catch { + return null; + } +} + +export async function deleteSecret(account, options = {}) { + let { + service = KEYCHAIN_SERVICE, + platform = process.platform, + env = process.env, + execFileFn = execFileAsync, + } = options; + + if (!canUseKeychain(platform, env)) { + return false; + } + + try { + await execFileFn('security', [ + 'delete-generic-password', + '-s', + service, + '-a', + account, + ]); + return true; + } catch { + return false; + } +} diff --git a/tests/api/client.test.js b/tests/api/client.test.js index 23237696..0d2de51d 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -1,6 +1,10 @@ import assert from 'node:assert'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import { createApiClient, DEFAULT_API_URL } from '../../src/api/client.js'; +import { saveAuthTokens } from '../../src/utils/global-config.js'; describe('api/client', () => { describe('DEFAULT_API_URL', () => { @@ -112,15 +116,26 @@ describe('api/client', () => { describe('request', () => { let originalFetch; let mockFetch; + let originalVizzlyHome; + let tempHome; beforeEach(() => { originalFetch = globalThis.fetch; + originalVizzlyHome = process.env.VIZZLY_HOME; + tempHome = mkdtempSync(join(tmpdir(), 'vizzly-api-client-test-')); + process.env.VIZZLY_HOME = tempHome; mockFetch = mock.fn(); globalThis.fetch = mockFetch; }); afterEach(() => { globalThis.fetch = originalFetch; + if (originalVizzlyHome) { + process.env.VIZZLY_HOME = originalVizzlyHome; + } else { + delete process.env.VIZZLY_HOME; + } + rmSync(tempHome, { recursive: true, force: true }); }); it('makes request to correct URL', async () => { @@ -188,6 +203,31 @@ describe('api/client', () => { ); }); + it('does not exchange user refresh tokens for failed project-token requests', async () => { + await saveAuthTokens({ + accessToken: 'user-access-token', + refreshToken: 'user-refresh-token', + expiresAt: '2999-01-01T00:00:00.000Z', + }); + + let client = createApiClient({ + token: 'vzt_project_token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 401, + headers: new Map(), + text: async () => 'Unauthorized', + })); + + await assert.rejects(() => client.request('/api/sdk/builds'), { + name: 'AuthError', + }); + assert.strictEqual(mockFetch.mock.calls.length, 1); + }); + it('throws VizzlyError for server errors', async () => { let client = createApiClient({ token: 'test-token', diff --git a/tests/commands/project.test.js b/tests/commands/project.test.js new file mode 100644 index 00000000..17cc0e34 --- /dev/null +++ b/tests/commands/project.test.js @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + parseProjectSelector, + projectLinkCommand, + validateProjectLinkOptions, +} from '../../src/commands/project.js'; + +function createOutput() { + let calls = []; + return { + calls, + configure: options => calls.push({ method: 'configure', args: [options] }), + startSpinner: message => + calls.push({ method: 'startSpinner', args: [message] }), + stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }), + complete: message => calls.push({ method: 'complete', args: [message] }), + hint: message => calls.push({ method: 'hint', args: [message] }), + error: (message, error) => + calls.push({ method: 'error', args: [message, error] }), + data: value => calls.push({ method: 'data', args: [value] }), + cleanup: () => calls.push({ method: 'cleanup', args: [] }), + }; +} + +describe('commands/project', () => { + describe('parseProjectSelector', () => { + it('parses org/project shorthand and option fallbacks', () => { + assert.deepStrictEqual(parseProjectSelector('vizzly/storybook'), { + organizationSlug: 'vizzly', + projectSlug: 'storybook', + }); + assert.deepStrictEqual( + parseProjectSelector('storybook', { org: 'vizzly' }), + { + organizationSlug: 'vizzly', + projectSlug: 'storybook', + } + ); + }); + }); + + describe('validateProjectLinkOptions', () => { + it('requires both organization and project', () => { + assert.deepStrictEqual( + validateProjectLinkOptions('vizzly/storybook'), + [] + ); + assert.deepStrictEqual( + validateProjectLinkOptions(null, { org: 'vizzly' }), + ['Project is required. Use / or --project .'] + ); + }); + }); + + describe('projectLinkCommand', () => { + it('links a project with user auth and stores only the scoped upload credential', async () => { + let output = createOutput(); + let capturedRequest = null; + let savedLink = null; + + await projectLinkCommand( + 'vizzly/storybook', + { name: 'Local Link' }, + {}, + { + output, + loadConfig: async () => ({ + apiUrl: 'https://app.vizzly.dev', + userToken: 'user-jwt', + }), + getAccessToken: async () => { + throw new Error('config user token should be used first'); + }, + createApiClient: ({ token }) => ({ + request: async (endpoint, options) => { + capturedRequest = { endpoint, options, token }; + return { + organization: { slug: 'vizzly', name: 'Vizzly' }, + project: { slug: 'storybook', name: 'Storybook' }, + token: { + id: 'token-id', + token: 'vzt_secret', + token_prefix: 'vzt_sec', + created_at: '2026-05-20T12:00:00.000Z', + expires_at: null, + }, + }; + }, + }), + saveProjectLink: async link => { + savedLink = link; + return { + ...link, + storage: 'keychain', + }; + }, + } + ); + + assert.strictEqual(capturedRequest.token, 'user-jwt'); + assert.strictEqual( + capturedRequest.endpoint, + '/api/cli/storybook/link-token' + ); + assert.strictEqual( + capturedRequest.options.headers['X-Organization'], + 'vizzly' + ); + assert.deepStrictEqual(JSON.parse(capturedRequest.options.body), { + name: 'Local Link', + }); + assert.strictEqual(savedLink.token, 'vzt_secret'); + assert.strictEqual(savedLink.organizationSlug, 'vizzly'); + assert.strictEqual(savedLink.projectSlug, 'storybook'); + assert.ok( + output.calls.some( + call => + call.method === 'complete' && + call.args[0] === 'Linked vizzly/storybook' + ) + ); + }); + + it('asks the user to login before linking', async () => { + let output = createOutput(); + let exitCode = null; + + await projectLinkCommand( + 'vizzly/storybook', + {}, + {}, + { + output, + loadConfig: async () => ({ apiUrl: 'https://app.vizzly.dev' }), + getAccessToken: async () => null, + exit: code => { + exitCode = code; + }, + } + ); + + assert.strictEqual(exitCode, 1); + assert.ok( + output.calls.some( + call => + call.method === 'hint' && + call.args[0] === + 'Run "vizzly login" first, then try project link again' + ) + ); + }); + }); +}); diff --git a/tests/commands/review.test.js b/tests/commands/review.test.js index c06dc455..f135227a 100644 --- a/tests/commands/review.test.js +++ b/tests/commands/review.test.js @@ -50,9 +50,10 @@ function createReviewHarness(response = {}) { }, deps: { loadConfig: async () => ({ - apiKey: 'token-123', + userToken: 'token-123', apiUrl: 'https://api.example.test', }), + getAccessToken: async () => null, createApiClient: config => { clientConfig = config; return { @@ -299,6 +300,7 @@ describe('commands/review', () => { {}, { loadConfig: async () => ({ apiUrl: 'https://api.example.test' }), + getAccessToken: async () => null, output, exit: code => { exitCode = code; @@ -325,9 +327,10 @@ describe('commands/review', () => { { json: true }, { loadConfig: async () => ({ - apiKey: 'token-123', + userToken: 'token-123', apiUrl: 'https://api.example.test', }), + getAccessToken: async () => null, createApiClient: () => ({ request: async () => { throw error; diff --git a/tests/project/core.test.js b/tests/project/core.test.js index d4395b64..12852529 100644 --- a/tests/project/core.test.js +++ b/tests/project/core.test.js @@ -82,14 +82,14 @@ describe('project/core', () => { it('builds tokens API URL without token ID', () => { assert.strictEqual( buildTokensUrl('my-org', 'my-project'), - '/api/cli/organizations/my-org/projects/my-project/tokens' + '/api/project/my-project/tokens' ); }); it('builds tokens API URL with token ID', () => { assert.strictEqual( buildTokensUrl('my-org', 'my-project', 'tok_123'), - '/api/cli/organizations/my-org/projects/my-project/tokens/tok_123' + '/api/project/my-project/tokens/tok_123' ); }); }); diff --git a/tests/utils/config-loader.test.js b/tests/utils/config-loader.test.js index 762160bc..80a64010 100644 --- a/tests/utils/config-loader.test.js +++ b/tests/utils/config-loader.test.js @@ -6,6 +6,7 @@ import { getScreenshotPaths, loadConfig, } from '../../src/utils/config-loader.js'; +import { saveGlobalConfig } from '../../src/utils/global-config.js'; describe('utils/config-loader', () => { describe('getScreenshotPaths', () => { @@ -139,6 +140,46 @@ describe('utils/config-loader', () => { assert.strictEqual(config.parallelId, 'parallel-123'); }); + it('keeps user login separate from cloud upload credentials', async () => { + await saveGlobalConfig({ + auth: { + accessToken: 'user-access-token', + refreshToken: 'refresh-token', + expiresAt: '2999-01-01T00:00:00.000Z', + }, + }); + + let config = await loadConfig(); + + assert.strictEqual(config.apiKey, undefined); + assert.strictEqual(config.userToken, 'user-access-token'); + }); + + it('uses the active linked project token for cloud upload credentials', async () => { + await saveGlobalConfig({ + projectLink: { + active: 'https://app.vizzly.dev|vizzly/storybook', + links: { + 'https://app.vizzly.dev|vizzly/storybook': { + apiUrl: 'https://app.vizzly.dev', + organizationSlug: 'vizzly', + projectSlug: 'storybook', + tokenId: 'token-id', + tokenPrefix: 'vzt_lin', + storage: 'file', + token: 'vzt_linked_secret', + }, + }, + }, + }); + + let config = await loadConfig(); + + assert.strictEqual(config.apiKey, 'vzt_linked_secret'); + assert.strictEqual(config.linkedProject.organizationSlug, 'vizzly'); + assert.strictEqual(config.linkedProject.projectSlug, 'storybook'); + }); + it('applies VIZZLY_BUILD_NAME environment variable', async () => { process.env.VIZZLY_BUILD_NAME = 'CI Build #123'; diff --git a/tests/utils/project-link-store.test.js b/tests/utils/project-link-store.test.js new file mode 100644 index 00000000..8eb29ac2 --- /dev/null +++ b/tests/utils/project-link-store.test.js @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + buildProjectLinkAccount, + clearActiveProjectLink, + getActiveProjectLink, + saveProjectLink, +} from '../../src/utils/project-link-store.js'; + +function createConfigStore(initialConfig = {}) { + let config = initialConfig; + + return { + loadConfig: async () => config, + saveConfig: async nextConfig => { + config = nextConfig; + }, + getConfig: () => config, + }; +} + +function createLink(overrides = {}) { + return { + apiUrl: 'https://app.vizzly.dev', + organizationSlug: 'vizzly', + organizationName: 'Vizzly', + projectSlug: 'storybook', + projectName: 'Storybook', + token: 'vzt_linked_secret', + tokenId: 'token-id', + tokenPrefix: 'vzt_lin', + createdAt: '2026-05-20T12:00:00.000Z', + expiresAt: null, + ...overrides, + }; +} + +describe('utils/project-link-store', () => { + it('builds stable account keys scoped to API, org, and project', () => { + assert.strictEqual( + buildProjectLinkAccount({ + apiUrl: 'https://app.vizzly.dev', + organizationSlug: 'vizzly', + projectSlug: 'storybook', + }), + 'https://app.vizzly.dev|vizzly/storybook' + ); + }); + + it('falls back to the config file when the secure store is unavailable', async () => { + let store = createConfigStore(); + let savedLink = await saveProjectLink(createLink(), { + loadConfig: store.loadConfig, + saveConfig: store.saveConfig, + saveSecret: async () => false, + }); + + assert.strictEqual(savedLink.storage, 'file'); + assert.strictEqual(savedLink.token, 'vzt_linked_secret'); + + let config = store.getConfig(); + let account = 'https://app.vizzly.dev|vizzly/storybook'; + assert.strictEqual(config.projectLink.active, account); + assert.strictEqual(config.projectLink.links[account].storage, 'file'); + assert.strictEqual( + config.projectLink.links[account].token, + 'vzt_linked_secret' + ); + + let activeLink = await getActiveProjectLink( + { apiUrl: 'https://app.vizzly.dev' }, + { loadConfig: store.loadConfig } + ); + + assert.strictEqual(activeLink.account, account); + assert.strictEqual(activeLink.token, 'vzt_linked_secret'); + assert.strictEqual(activeLink.organizationSlug, 'vizzly'); + assert.strictEqual(activeLink.projectSlug, 'storybook'); + }); + + it('keeps linked project tokens out of config when the secure store is available', async () => { + let store = createConfigStore(); + let secrets = new Map(); + + let savedLink = await saveProjectLink(createLink(), { + loadConfig: store.loadConfig, + saveConfig: store.saveConfig, + saveSecret: async (account, token) => { + secrets.set(account, token); + return true; + }, + }); + + let account = 'https://app.vizzly.dev|vizzly/storybook'; + let config = store.getConfig(); + assert.strictEqual(savedLink.storage, 'keychain'); + assert.strictEqual(config.projectLink.links[account].storage, 'keychain'); + assert.strictEqual(config.projectLink.links[account].token, undefined); + assert.strictEqual(secrets.get(account), 'vzt_linked_secret'); + + let activeLink = await getActiveProjectLink( + { apiUrl: 'https://app.vizzly.dev' }, + { + loadConfig: store.loadConfig, + getSecret: async requestedAccount => secrets.get(requestedAccount), + } + ); + + assert.strictEqual(activeLink.token, 'vzt_linked_secret'); + }); + + it('clears the active secure-store link and deletes its saved secret', async () => { + let account = 'https://app.vizzly.dev|vizzly/storybook'; + let store = createConfigStore({ + projectLink: { + active: account, + links: { + [account]: { + apiUrl: 'https://app.vizzly.dev', + organizationSlug: 'vizzly', + projectSlug: 'storybook', + storage: 'keychain', + tokenPrefix: 'vzt_lin', + }, + }, + }, + }); + let deletedAccounts = []; + + let clearedLink = await clearActiveProjectLink({ + loadConfig: store.loadConfig, + saveConfig: store.saveConfig, + deleteSecret: async deletedAccount => { + deletedAccounts.push(deletedAccount); + return true; + }, + }); + + assert.strictEqual(clearedLink.account, account); + assert.deepStrictEqual(deletedAccounts, [account]); + assert.strictEqual(store.getConfig().projectLink.active, null); + assert.deepStrictEqual(store.getConfig().projectLink.links, {}); + }); +});