projects.ts306 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 const origins = resolveOrigins();
190 step(`dashboard: ${origins.dashboardOrigin}/dashboard/projects/${project.id}`);
191 blankLine();
192 step('next: briven init then briven link --project ' + project.id);
193 step(' or: briven projects use ' + project.id + ' --link');
194 return 0;
195 } catch (err) {
196 return handlePlatformError(err);
197 }
198}
199
200async function useProject(argv: readonly string[]): Promise<number> {
201 const projectId = argv.find((a) => !a.startsWith('-'));
202 const doLink = hasFlag(argv, '--link');
203
204 if (!projectId) {
205 printError('usage: briven projects use <projectId> [--link]');
206 return 1;
207 }
208
209 banner(`projects use ${projectId}`);
210 blankLine();
211
212 try {
213 const user = await ensurePlatformSession({ quiet: true });
214
215 // Confirm the project is on the account before minting a key.
216 const remote = await listRemoteProjects(user.apiOrigin, user.token);
217 const match = remote.find((p) => p.id === projectId || p.slug === projectId);
218 if (!match) {
219 printError(`project not found on your account: ${projectId}`);
220 step('run: briven projects list --remote');
221 return 1;
222 }
223
224 const id = match.id;
225 step(`minting cli credentials for ${match.slug}…`);
226 const { suffix } = await mintAndStoreKey(user.apiOrigin, user.token, id);
227 await setDefaultId(id);
228 success(`using ${match.slug} (${id}) · key ····${suffix}`);
229
230 if (doLink) {
231 const linked = await linkCwd(id, match.slug);
232 if (linked === 'ok') {
233 success(`linked briven.json → ${id}`);
234 } else if (linked === 'no-config') {
235 step('no briven.json here — run: briven init && briven link');
236 }
237 } else {
238 step('bind this folder: briven link --project ' + id);
239 step(' or: briven projects use ' + id + ' --link');
240 }
241 return 0;
242 } catch (err) {
243 return handlePlatformError(err);
244 }
245}
246
247async function unlinkProject(targetId: string): Promise<number> {
248 const file = await readCredentials();
249 if (!file.projects[targetId]) {
250 printError(`no local credentials for ${targetId}`);
251 return 1;
252 }
253 delete file.projects[targetId];
254 if (file.default === targetId) delete file.default;
255 await writeCredentials(file);
256 success(`unlinked local credentials for ${targetId}`);
257 step('project still exists on the platform — this only forgets the key on this machine.');
258 return 0;
259}
260
261async function setDefault(targetId: string): Promise<number> {
262 const file = await readCredentials();
263 if (!file.projects[targetId]) {
264 printError(`no credentials stored for ${targetId}`);
265 step(`run: briven projects use ${targetId}`);
266 step(` or: briven login --project ${targetId} --key <brk_...>`);
267 return 1;
268 }
269 await setDefaultId(targetId);
270 success(`default project set to ${targetId}`);
271 return 0;
272}
273
274async function setDefaultId(projectId: string): Promise<void> {
275 const file = await readCredentials();
276 const next: CredentialsFile = { ...file, default: projectId };
277 await writeCredentials(next);
278}
279
280async function linkCwd(
281 projectId: string,
282 slug: string,
283): Promise<'ok' | 'no-config'> {
284 const local = await readProjectConfig();
285 if (!local) return 'no-config';
286 await writeProjectConfig({
287 ...local,
288 name: local.name || slug || basename(process.cwd()),
289 projectId,
290 });
291 return 'ok';
292}
293
294function handlePlatformError(err: unknown): number {
295 if (err instanceof ApiCallError) {
296 if (err.status === 401 || err.status === 403) {
297 printSessionExpiredHint();
298 return 1;
299 }
300 printError(`${err.code}: ${err.message}`);
301 return 1;
302 }
303 printError(err instanceof Error ? err.message : 'unknown error');
304 return 1;
305}
306