projects.ts326 lines · main
1import { basename } from 'node:path';
2
3import { ApiCallError } from '../api-client.js';
4import {
5 readCredentials,
6 writeCredentials,
7 type CredentialsFile,
8} from '../config.js';
9import { resolveOrigins } from '../origins.js';
10import {
11 createRemoteProject,
12 ensurePlatformSession,
13 listRemoteProjects,
14 mintAndStoreKey,
15 printRemoteProjects,
16 printSessionExpiredHint,
17} from '../platform.js';
18import { readProjectConfig, writeProjectConfig } from '../project-config.js';
19import { REGIONS } from '../regions.js';
20import { banner, blankLine, error as printError, step, success } from '../output.js';
21
22/**
23 * Project lifecycle from the shell.
24 *
25 * Local (no platform session):
26 * list / set-default / unlink — credentials.json only
27 *
28 * Remote (needs `briven connect`):
29 * list --remote / create / use — control plane + mint CLI keys
30 */
31
32function printUsage(): void {
33 banner('projects');
34 blankLine();
35 step('usage:');
36 step(' briven projects list [--remote] local keys, or account projects');
37 step(' briven projects create --name <name> create on platform + mint cli key');
38 step(' [--region <id>] [--slug <slug>] [--no-use]');
39 step(' briven projects use <p_...> mint/store key + set default');
40 step(' [--link] also write projectId into briven.json');
41 step(' briven projects unlink <p_...> drop local key for a project');
42 step(' briven projects set-default <p_...> set default for other commands');
43 blankLine();
44 step('platform session required for --remote / create / use:');
45 step(' briven connect');
46 blankLine();
47 step('regions:');
48 for (const r of REGIONS) {
49 step(` ${r.id} — ${r.label}`);
50 }
51}
52
53function hasFlag(argv: readonly string[], name: string): boolean {
54 return argv.includes(name);
55}
56
57function flagValue(argv: readonly string[], name: string): string | undefined {
58 for (let i = 0; i < argv.length; i += 1) {
59 const arg = argv[i];
60 if (arg === name && argv[i + 1]) return argv[i + 1];
61 if (arg?.startsWith(`${name}=`)) return arg.slice(name.length + 1);
62 }
63 return undefined;
64}
65
66export async function runProjects(argv: readonly string[]): Promise<number> {
67 const [sub, ...rest] = argv;
68
69 if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
70 printUsage();
71 return sub ? 0 : 1;
72 }
73
74 if (sub === 'list') {
75 return listProjects(rest);
76 }
77 if (sub === 'create') {
78 return createProject(rest);
79 }
80 if (sub === 'use') {
81 return useProject(rest);
82 }
83 if (sub === 'unlink') {
84 const targetId = rest[0];
85 if (!targetId || targetId.startsWith('-')) {
86 printError('usage: briven projects unlink <projectId>');
87 return 1;
88 }
89 return unlinkProject(targetId);
90 }
91 if (sub === 'set-default') {
92 const targetId = rest[0];
93 if (!targetId) {
94 printError('usage: briven projects set-default <projectId>');
95 return 1;
96 }
97 return setDefault(targetId);
98 }
99
100 printError(`unknown subcommand: ${sub}`);
101 step("run 'briven projects --help' for usage");
102 return 1;
103}
104
105async function listProjects(argv: readonly string[]): Promise<number> {
106 const remote = hasFlag(argv, '--remote') || hasFlag(argv, '-r');
107
108 if (remote) {
109 banner('projects (remote)');
110 blankLine();
111 try {
112 const user = await ensurePlatformSession({ quiet: true });
113 const projects = await listRemoteProjects(user.apiOrigin, user.token);
114 printRemoteProjects(projects);
115 return 0;
116 } catch (err) {
117 return handlePlatformError(err);
118 }
119 }
120
121 const file = await readCredentials();
122 const ids = Object.keys(file.projects);
123
124 banner('projects');
125 blankLine();
126
127 if (ids.length === 0) {
128 step('no projects authenticated on this machine.');
129 step('run: briven connect');
130 step('then: briven projects create --name my-app');
131 step(' or: briven projects use <p_...>');
132 step(' or: briven login --project <p_...> --key <brk_...>');
133 return 0;
134 }
135
136 step(`${ids.length} project${ids.length === 1 ? '' : 's'} authenticated:`);
137 for (const id of ids.sort()) {
138 const cred = file.projects[id]!;
139 const isDefault = file.default === id;
140 const marker = isDefault ? '*' : ' ';
141 step(` ${marker} ${id} · ${cred.apiOrigin} · key …${cred.suffix}`);
142 }
143 if (file.default) {
144 blankLine();
145 step(`default: ${file.default}`);
146 }
147 blankLine();
148 step('account projects: briven projects list --remote');
149 return 0;
150}
151
152async function createProject(argv: readonly string[]): Promise<number> {
153 const name = flagValue(argv, '--name') ?? flagValue(argv, '-n');
154 const region = flagValue(argv, '--region') ?? REGIONS[0]!.id;
155 const slug = flagValue(argv, '--slug');
156 const noUse = hasFlag(argv, '--no-use');
157
158 if (!name) {
159 printError('usage: briven projects create --name <name> [--region eu-west]');
160 return 1;
161 }
162
163 if (!REGIONS.some((r) => r.id === region)) {
164 printError(`unknown region: ${region}`);
165 step(`known: ${REGIONS.map((r) => r.id).join(', ')}`);
166 return 1;
167 }
168
169 banner('projects create');
170 blankLine();
171
172 try {
173 const user = await ensurePlatformSession({ quiet: true });
174 step(`creating "${name}" in ${region}…`);
175 const project = await createRemoteProject(user.apiOrigin, user.token, {
176 name,
177 region,
178 slug,
179 });
180 success(`created ${project.slug} (${project.id})`);
181
182 if (!noUse) {
183 step('minting cli credentials…');
184 const { suffix } = await mintAndStoreKey(user.apiOrigin, user.token, project.id);
185 await setDefaultId(project.id);
186 success(`cli key stored (····${suffix}) · default set`);
187 }
188
189 // New projects get Auth on immediately (same as briven setup).
190 try {
191 const { enableAuthForProject } = await import('./auth.js');
192 step('enabling Briven Auth…');
193 const auth = await enableAuthForProject({
194 projectId: project.id,
195 apiOrigin: user.apiOrigin,
196 bearer: user.token,
197 quiet: true,
198 });
199 if (auth.ok) {
200 success('Auth on (starter pack + browser key)');
201 } else {
202 step(`auth enable skipped: ${auth.message}`);
203 step('later: briven auth enable --project ' + project.id);
204 }
205 } catch {
206 step('auth enable skipped — run: briven auth enable --project ' + project.id);
207 }
208
209 const origins = resolveOrigins();
210 step(`dashboard: ${origins.dashboardOrigin}/dashboard/projects/${project.id}`);
211 blankLine();
212 step('next: briven init then briven link --project ' + project.id);
213 step(' or: briven projects use ' + project.id + ' --link');
214 return 0;
215 } catch (err) {
216 return handlePlatformError(err);
217 }
218}
219
220async function useProject(argv: readonly string[]): Promise<number> {
221 const projectId = argv.find((a) => !a.startsWith('-'));
222 const doLink = hasFlag(argv, '--link');
223
224 if (!projectId) {
225 printError('usage: briven projects use <projectId> [--link]');
226 return 1;
227 }
228
229 banner(`projects use ${projectId}`);
230 blankLine();
231
232 try {
233 const user = await ensurePlatformSession({ quiet: true });
234
235 // Confirm the project is on the account before minting a key.
236 const remote = await listRemoteProjects(user.apiOrigin, user.token);
237 const match = remote.find((p) => p.id === projectId || p.slug === projectId);
238 if (!match) {
239 printError(`project not found on your account: ${projectId}`);
240 step('run: briven projects list --remote');
241 return 1;
242 }
243
244 const id = match.id;
245 step(`minting cli credentials for ${match.slug}…`);
246 const { suffix } = await mintAndStoreKey(user.apiOrigin, user.token, id);
247 await setDefaultId(id);
248 success(`using ${match.slug} (${id}) · key ····${suffix}`);
249
250 if (doLink) {
251 const linked = await linkCwd(id, match.slug);
252 if (linked === 'ok') {
253 success(`linked briven.json → ${id}`);
254 } else if (linked === 'no-config') {
255 step('no briven.json here — run: briven init && briven link');
256 }
257 } else {
258 step('bind this folder: briven link --project ' + id);
259 step(' or: briven projects use ' + id + ' --link');
260 }
261 return 0;
262 } catch (err) {
263 return handlePlatformError(err);
264 }
265}
266
267async function unlinkProject(targetId: string): Promise<number> {
268 const file = await readCredentials();
269 if (!file.projects[targetId]) {
270 printError(`no local credentials for ${targetId}`);
271 return 1;
272 }
273 delete file.projects[targetId];
274 if (file.default === targetId) delete file.default;
275 await writeCredentials(file);
276 success(`unlinked local credentials for ${targetId}`);
277 step('project still exists on the platform — this only forgets the key on this machine.');
278 return 0;
279}
280
281async function setDefault(targetId: string): Promise<number> {
282 const file = await readCredentials();
283 if (!file.projects[targetId]) {
284 printError(`no credentials stored for ${targetId}`);
285 step(`run: briven projects use ${targetId}`);
286 step(` or: briven login --project ${targetId} --key <brk_...>`);
287 return 1;
288 }
289 await setDefaultId(targetId);
290 success(`default project set to ${targetId}`);
291 return 0;
292}
293
294async function setDefaultId(projectId: string): Promise<void> {
295 const file = await readCredentials();
296 const next: CredentialsFile = { ...file, default: projectId };
297 await writeCredentials(next);
298}
299
300async function linkCwd(
301 projectId: string,
302 slug: string,
303): Promise<'ok' | 'no-config'> {
304 const local = await readProjectConfig();
305 if (!local) return 'no-config';
306 await writeProjectConfig({
307 ...local,
308 name: local.name || slug || basename(process.cwd()),
309 projectId,
310 });
311 return 'ok';
312}
313
314function handlePlatformError(err: unknown): number {
315 if (err instanceof ApiCallError) {
316 if (err.status === 401 || err.status === 403) {
317 printSessionExpiredHint();
318 return 1;
319 }
320 printError(`${err.code}: ${err.message}`);
321 return 1;
322 }
323 printError(err instanceof Error ? err.message : 'unknown error');
324 return 1;
325}
326