Skip to content

Commit b9455fb

Browse files
authored
🔐 Add secure project link credentials (#271)
## Summary This PR layers the secure cloud auth flow on top of the CLI context work from #269. - Separates user login from project upload credentials - Adds `vizzly project link <org>/<project>` for minting project-scoped upload credentials - Stores linked project tokens in macOS Keychain when available, with config-file fallback - Uses linked project tokens for cloud uploads while keeping user JWTs for review actions - Prevents failed project-token requests from consuming user refresh tokens - Updates review commands so approval/comment actions require user auth instead of project tokens - Adds credential-store coverage for Keychain/file fallback behavior ## Testing - node --test tests/commands/project.test.js tests/utils/config-loader.test.js tests/project/core.test.js tests/project/operations.test.js tests/commands/login.test.js tests/commands/review.test.js tests/api/client.test.js - node --test tests/utils/project-link-store.test.js tests/commands/project.test.js tests/utils/config-loader.test.js tests/api/client.test.js tests/commands/review.test.js - npm run lint - npm run build - git diff --check
1 parent 839dc84 commit b9455fb

19 files changed

Lines changed: 902 additions & 48 deletions

docs/json-output.md

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -672,27 +672,19 @@ vizzly init --json
672672
}
673673
```
674674

675-
### `vizzly project:select`
675+
### `vizzly project link`
676676

677677
```bash
678-
vizzly project:select --json
678+
vizzly project link my-org/my-project --json
679679
```
680680

681-
Note: In JSON mode, the interactive prompts still appear because project selection requires user input.
682-
683681
```json
684682
{
685-
"status": "configured",
686-
"project": {
687-
"name": "My Project",
688-
"slug": "my-project"
689-
},
690-
"organization": {
691-
"name": "My Org",
692-
"slug": "my-org"
693-
},
694-
"directory": "/path/to/project",
695-
"tokenCreated": true
683+
"linked": true,
684+
"organizationSlug": "my-org",
685+
"projectSlug": "my-project",
686+
"tokenPrefix": "vzt_abc",
687+
"storage": "keychain"
696688
}
697689
```
698690

src/api/client.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
*/
2424
export const DEFAULT_API_URL = 'https://app.vizzly.dev';
2525

26+
function isProjectToken(token) {
27+
return typeof token === 'string' && token.startsWith('vzt_');
28+
}
29+
2630
/**
2731
* Create an API client with the given configuration
2832
*
@@ -84,7 +88,7 @@ export function createApiClient(options = {}) {
8488
shouldRetryWithRefresh(
8589
response.status,
8690
isRetry,
87-
await hasRefreshToken()
91+
!isProjectToken(token) && (await hasRefreshToken())
8892
)
8993
) {
9094
let refreshed = await attemptTokenRefresh();
@@ -97,7 +101,7 @@ export function createApiClient(options = {}) {
97101
// Auth error
98102
if (isAuthError(response.status)) {
99103
throw new AuthError(
100-
'Invalid or expired API token. Link a project via "vizzly project:select" or set VIZZLY_TOKEN.'
104+
'Invalid or expired API token. Run "vizzly project link <org>/<project>" or set VIZZLY_TOKEN.'
101105
);
102106
}
103107

@@ -146,7 +150,9 @@ export function createApiClient(options = {}) {
146150
await saveAuthTokens({
147151
accessToken: data.accessToken,
148152
refreshToken: data.refreshToken,
149-
expiresAt: data.expiresAt,
153+
expiresAt:
154+
data.expiresAt ||
155+
new Date(Date.now() + data.expiresIn * 1000).toISOString(),
150156
user: auth.user,
151157
});
152158

src/cli.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ import { loginCommand, validateLoginOptions } from './commands/login.js';
3434
import { logoutCommand, validateLogoutOptions } from './commands/logout.js';
3535
import { orgsCommand, validateOrgsOptions } from './commands/orgs.js';
3636
import { previewCommand, validatePreviewOptions } from './commands/preview.js';
37+
import {
38+
projectLinkCommand,
39+
validateProjectLinkOptions,
40+
} from './commands/project.js';
3741
import {
3842
projectsCommand,
3943
validateProjectsOptions,
@@ -1222,6 +1226,43 @@ Workflow:
12221226
await projectsCommand(options, globalOptions);
12231227
});
12241228

1229+
let projectCommand = program
1230+
.command('project')
1231+
.description('Manage the project linked to this local checkout');
1232+
1233+
projectCommand
1234+
.command('link [selector]')
1235+
.description('Link a Vizzly project for cloud uploads')
1236+
.option('--org <slug>', 'Organization slug')
1237+
.option('--project <slug>', 'Project slug')
1238+
.option('--name <name>', 'Credential name shown in Vizzly')
1239+
.option('--expires-at <iso>', 'Optional token expiration timestamp')
1240+
.addHelpText(
1241+
'after',
1242+
`
1243+
Examples:
1244+
$ vizzly project link vizzly/storybook
1245+
$ vizzly project link --org vizzly --project storybook
1246+
1247+
Note: run "vizzly login" first. The linked credential is project-scoped and is
1248+
used for cloud uploads; your user login remains separate for review actions.
1249+
`
1250+
)
1251+
.action(async (selector, options) => {
1252+
const globalOptions = program.opts();
1253+
1254+
const validationErrors = validateProjectLinkOptions(selector, options);
1255+
if (validationErrors.length > 0) {
1256+
output.error('Validation errors:');
1257+
for (let error of validationErrors) {
1258+
output.printErr(` - ${error}`);
1259+
}
1260+
process.exit(1);
1261+
}
1262+
1263+
await projectLinkCommand(selector, options, globalOptions);
1264+
});
1265+
12251266
program
12261267
.command('finalize')
12271268
.description('Finalize a parallel build after all shards complete')

src/commands/builds.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,20 @@ export async function buildsCommand(
4141
let allOptions = { ...globalOptions, ...options };
4242
let config = await loadConfig(globalOptions.config, allOptions);
4343

44-
// Validate API token
45-
if (!config.apiKey) {
44+
let token = config.apiKey || config.userToken;
45+
46+
// Validate cloud auth
47+
if (!token) {
4648
output.error(
47-
'API token required. Use --token or set VIZZLY_TOKEN environment variable'
49+
'Authentication required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"'
4850
);
4951
exit(1);
5052
return;
5153
}
5254

5355
let client = createApiClient({
5456
baseUrl: config.apiUrl,
55-
token: config.apiKey,
57+
token,
5658
command: 'builds',
5759
});
5860

src/commands/context.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
1616
import * as defaultOutput from '../utils/output.js';
1717

1818
function buildAuthErrorMessage() {
19-
return 'API token required. Use --token, set VIZZLY_TOKEN, or run "vizzly login"';
19+
return 'Authentication required. Use --token, set VIZZLY_TOKEN, run "vizzly login", or link a project.';
2020
}
2121

2222
function buildSourceErrorMessage() {
@@ -72,7 +72,7 @@ function validateScopedProjectOptions(options = {}) {
7272
function createClient(config, createApiClient) {
7373
return createApiClient({
7474
baseUrl: config.apiUrl,
75-
token: config.apiKey,
75+
token: config.apiKey || config.userToken,
7676
command: 'context',
7777
});
7878
}
@@ -88,7 +88,7 @@ async function loadContextConfig(globalOptions, options, deps) {
8888
let allOptions = { ...globalOptions, ...options };
8989
let config = await loadConfig(globalOptions.config, allOptions);
9090

91-
if (requireApiKey && !config.apiKey) {
91+
if (requireApiKey && !config.apiKey && !config.userToken) {
9292
output.error(buildAuthErrorMessage());
9393
output.cleanup();
9494
exit(1);
@@ -184,7 +184,7 @@ async function loadContextRuntime(
184184
}
185185
);
186186

187-
if (source === 'cloud' && !config.apiKey) {
187+
if (source === 'cloud' && !config.apiKey && !config.userToken) {
188188
if (
189189
shouldExplainLocalSimilarityGap(requestedSource, command, localProvider)
190190
) {

src/commands/login.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,9 @@ export async function loginCommand(
261261
}
262262

263263
output.blank();
264-
output.hint('You can now use Vizzly CLI commands without VIZZLY_TOKEN');
264+
output.hint(
265+
'Run "vizzly project link <org>/<project>" to enable cloud uploads'
266+
);
265267

266268
output.cleanup();
267269
} catch (error) {

src/commands/project.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { createApiClient as defaultCreateApiClient } from '../api/client.js';
2+
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
3+
import { getApiUrl as defaultGetApiUrl } from '../utils/environment-config.js';
4+
import { getAccessToken as defaultGetAccessToken } from '../utils/global-config.js';
5+
import * as defaultOutput from '../utils/output.js';
6+
import { saveProjectLink as defaultSaveProjectLink } from '../utils/project-link-store.js';
7+
8+
export function parseProjectSelector(selector, options = {}) {
9+
let organizationSlug = options.org || null;
10+
let projectSlug = options.project || null;
11+
12+
if (selector) {
13+
let parts = selector.split('/');
14+
if (parts.length === 2) {
15+
organizationSlug = organizationSlug || parts[0];
16+
projectSlug = projectSlug || parts[1];
17+
} else if (parts.length === 1) {
18+
projectSlug = projectSlug || parts[0];
19+
}
20+
}
21+
22+
return { organizationSlug, projectSlug };
23+
}
24+
25+
export function validateProjectLinkOptions(selector, options = {}) {
26+
let errors = [];
27+
let { organizationSlug, projectSlug } = parseProjectSelector(
28+
selector,
29+
options
30+
);
31+
32+
if (!organizationSlug) {
33+
errors.push(
34+
'Organization is required. Use <org>/<project> or --org <slug>.'
35+
);
36+
}
37+
if (!projectSlug) {
38+
errors.push(
39+
'Project is required. Use <org>/<project> or --project <slug>.'
40+
);
41+
}
42+
43+
return errors;
44+
}
45+
46+
export async function projectLinkCommand(
47+
selector,
48+
options = {},
49+
globalOptions = {},
50+
deps = {}
51+
) {
52+
let {
53+
createApiClient = defaultCreateApiClient,
54+
getAccessToken = defaultGetAccessToken,
55+
getApiUrl = defaultGetApiUrl,
56+
loadConfig = defaultLoadConfig,
57+
output = defaultOutput,
58+
saveProjectLink = defaultSaveProjectLink,
59+
exit = code => process.exit(code),
60+
} = deps;
61+
62+
output.configure({
63+
json: globalOptions.json,
64+
verbose: globalOptions.verbose,
65+
color: !globalOptions.noColor,
66+
});
67+
68+
let { organizationSlug, projectSlug } = parseProjectSelector(
69+
selector,
70+
options
71+
);
72+
73+
try {
74+
let config = await loadConfig(globalOptions.config, globalOptions);
75+
let userToken = config.userToken || (await getAccessToken());
76+
77+
if (!userToken) {
78+
output.error('Login required before linking a project');
79+
output.hint('Run "vizzly login" first, then try project link again');
80+
output.cleanup();
81+
exit(1);
82+
return;
83+
}
84+
85+
output.startSpinner(`Linking ${organizationSlug}/${projectSlug}...`);
86+
87+
let apiUrl = config.apiUrl || getApiUrl();
88+
let client = createApiClient({
89+
baseUrl: apiUrl,
90+
token: userToken,
91+
command: 'project-link',
92+
});
93+
94+
let response = await client.request(`/api/cli/${projectSlug}/link-token`, {
95+
method: 'POST',
96+
headers: {
97+
'Content-Type': 'application/json',
98+
'X-Organization': organizationSlug,
99+
},
100+
body: JSON.stringify({
101+
name: options.name,
102+
expiresAt: options.expiresAt,
103+
}),
104+
});
105+
106+
let linkedProject = await saveProjectLink({
107+
apiUrl,
108+
organizationSlug: response.organization?.slug || organizationSlug,
109+
organizationName: response.organization?.name,
110+
projectSlug: response.project?.slug || projectSlug,
111+
projectName: response.project?.name,
112+
token: response.token.token,
113+
tokenId: response.token.id,
114+
tokenPrefix: response.token.token_prefix,
115+
expiresAt: response.token.expires_at,
116+
createdAt: response.token.created_at,
117+
});
118+
119+
output.stopSpinner();
120+
121+
if (globalOptions.json) {
122+
output.data({
123+
linked: true,
124+
organizationSlug: linkedProject.organizationSlug,
125+
projectSlug: linkedProject.projectSlug,
126+
tokenPrefix: linkedProject.tokenPrefix,
127+
storage: linkedProject.storage,
128+
});
129+
output.cleanup();
130+
return;
131+
}
132+
133+
output.complete(
134+
`Linked ${linkedProject.organizationSlug}/${linkedProject.projectSlug}`
135+
);
136+
output.hint(`Cloud uploads will use ${linkedProject.tokenPrefix}...`);
137+
output.cleanup();
138+
} catch (error) {
139+
output.stopSpinner();
140+
output.error('Failed to link project', error);
141+
output.cleanup();
142+
exit(1);
143+
}
144+
}

0 commit comments

Comments
 (0)