Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

Expand Down
12 changes: 9 additions & 3 deletions src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -84,7 +88,7 @@ export function createApiClient(options = {}) {
shouldRetryWithRefresh(
response.status,
isRetry,
await hasRefreshToken()
!isProjectToken(token) && (await hasRefreshToken())
)
) {
let refreshed = await attemptTokenRefresh();
Expand All @@ -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 <org>/<project>" or set VIZZLY_TOKEN.'
);
}

Expand Down Expand Up @@ -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,
});

Expand Down
41 changes: 41 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <slug>', 'Organization slug')
.option('--project <slug>', 'Project slug')
.option('--name <name>', 'Credential name shown in Vizzly')
.option('--expires-at <iso>', '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')
Expand Down
10 changes: 6 additions & 4 deletions src/commands/builds.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,20 @@ 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;
}

let client = createApiClient({
baseUrl: config.apiUrl,
token: config.apiKey,
token,
command: 'builds',
});

Expand Down
8 changes: 4 additions & 4 deletions src/commands/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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',
});
}
Expand All @@ -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);
Expand Down Expand Up @@ -184,7 +184,7 @@ async function loadContextRuntime(
}
);

if (source === 'cloud' && !config.apiKey) {
if (source === 'cloud' && !config.apiKey && !config.userToken) {
if (
shouldExplainLocalSimilarityGap(requestedSource, command, localProvider)
) {
Expand Down
4 changes: 3 additions & 1 deletion src/commands/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org>/<project>" to enable cloud uploads'
);

output.cleanup();
} catch (error) {
Expand Down
144 changes: 144 additions & 0 deletions src/commands/project.js
Original file line number Diff line number Diff line change
@@ -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 <org>/<project> or --org <slug>.'
);
}
if (!projectSlug) {
errors.push(
'Project is required. Use <org>/<project> or --project <slug>.'
);
}

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);
}
}
Loading