setup.ts683 lines · main
1/**
2 * Product CLI paths (split like Convex’s “new vs existing”):
3 *
4 * briven setup → create a **new** cloud project + wire this folder + S3
5 * briven connect → attach an **existing** project + wire this folder + S3
6 *
7 * Templates are optional starters, not the product model.
8 */
9
10import { mkdir, readFile, writeFile } from 'node:fs/promises';
11import { basename, join } from 'node:path';
12import { createInterface } from 'node:readline';
13
14import { ApiCallError } from './api-client.js';
15import { generate, type SchemaSnapshot } from './codegen.js';
16import { runInit } from './commands/init.js';
17import { runStorage } from './commands/storage.js';
18import { mergeEnvFile } from './env-file.js';
19import { resolveOrigins, type Origins } from './origins.js';
20import {
21 createRemoteProject,
22 ensurePlatformSession,
23 fetchMe,
24 listRemoteProjects,
25 mintAndStoreKey,
26 type ProjectStorageBootstrap,
27 type RemoteProject,
28} from './platform.js';
29import { readProjectConfig, writeProjectConfig } from './project-config.js';
30import { REGIONS } from './regions.js';
31import { pullSchemaToDisk } from './schema-pull.js';
32import {
33 banner,
34 blankLine,
35 error as printError,
36 link as printLink,
37 step,
38 success,
39} from './output.js';
40
41const TEMPLATES = ['blank', 'todo-app', 'chat', 'convex-notes', 'supabase-auth-todos'] as const;
42export type SetupTemplate = (typeof TEMPLATES)[number];
43
44export interface SetupArgs {
45 name?: string;
46 /** @deprecated on setup — use `briven connect --project`. Still parsed so we can redirect. */
47 project?: string;
48 region: string;
49 template: SetupTemplate;
50 yes: boolean;
51 cwd: string;
52 origins: Origins;
53}
54
55/** Args shared by attach-existing (`briven connect`). */
56export interface ConnectProjectArgs {
57 project?: string;
58 template: SetupTemplate;
59 yes: boolean;
60 force: boolean;
61 cwd: string;
62 origins: Origins;
63}
64
65export type Branch = 'wizard' | 'auth-then-watch' | 'watch';
66
67export function decideBranch(state: { hasBrivenJson: boolean; hasUserToken: boolean }): Branch {
68 if (!state.hasBrivenJson) return 'wizard';
69 if (!state.hasUserToken) return 'auth-then-watch';
70 return 'watch';
71}
72
73export function parseSetupArgs(argv: readonly string[]): SetupArgs {
74 const out: SetupArgs = {
75 region: REGIONS[0]!.id,
76 template: 'blank',
77 yes: false,
78 cwd: process.cwd(),
79 origins: resolveOrigins(),
80 };
81
82 // Bare arg: `briven setup my-app` → new project name only.
83 // `p_…` is NOT accepted here — use `briven connect`.
84 const positionals: string[] = [];
85
86 for (let i = 0; i < argv.length; i += 1) {
87 const arg = argv[i];
88 if ((arg === '--name' || arg === '-n') && argv[i + 1]) {
89 out.name = argv[++i];
90 } else if (arg?.startsWith('--name=')) {
91 out.name = arg.slice('--name='.length);
92 } else if ((arg === '--project' || arg === '-p') && argv[i + 1]) {
93 out.project = argv[++i];
94 } else if (arg?.startsWith('--project=')) {
95 out.project = arg.slice('--project='.length);
96 } else if (arg === '--region' && argv[i + 1]) {
97 out.region = argv[++i]!;
98 } else if (arg?.startsWith('--region=')) {
99 out.region = arg.slice('--region='.length);
100 } else if ((arg === '--template' || arg === '-t') && argv[i + 1]) {
101 out.template = argv[++i] as SetupTemplate;
102 } else if (arg?.startsWith('--template=')) {
103 out.template = arg.slice('--template='.length) as SetupTemplate;
104 } else if (arg === '--yes' || arg === '-y') {
105 out.yes = true;
106 } else if (arg === '--api-origin' && argv[i + 1]) {
107 out.origins = resolveOrigins({
108 apiOrigin: argv[++i],
109 dashboardOrigin: out.origins.dashboardOrigin,
110 });
111 } else if (arg === '--dashboard-origin' && argv[i + 1]) {
112 out.origins = resolveOrigins({
113 apiOrigin: out.origins.apiOrigin,
114 dashboardOrigin: argv[++i],
115 });
116 } else if (arg && !arg.startsWith('-')) {
117 positionals.push(arg);
118 }
119 }
120
121 if (positionals.length > 0 && !out.name) {
122 out.name = positionals[0];
123 }
124
125 return out;
126}
127
128/** Project ids (`p_…`) — use with `briven connect`, not setup. */
129export function looksLikeProjectRef(value: string): boolean {
130 return /^p_[A-Za-z0-9]+$/u.test(value);
131}
132
133export function parseConnectProjectArgs(argv: readonly string[]): ConnectProjectArgs {
134 const out: ConnectProjectArgs = {
135 template: 'blank',
136 yes: false,
137 force: false,
138 cwd: process.cwd(),
139 origins: resolveOrigins(),
140 };
141 const positionals: string[] = [];
142
143 for (let i = 0; i < argv.length; i += 1) {
144 const arg = argv[i];
145 if ((arg === '--project' || arg === '-p') && argv[i + 1]) {
146 out.project = argv[++i];
147 } else if (arg?.startsWith('--project=')) {
148 out.project = arg.slice('--project='.length);
149 } else if ((arg === '--template' || arg === '-t') && argv[i + 1]) {
150 out.template = argv[++i] as SetupTemplate;
151 } else if (arg?.startsWith('--template=')) {
152 out.template = arg.slice('--template='.length) as SetupTemplate;
153 } else if (arg === '--yes' || arg === '-y') {
154 out.yes = true;
155 } else if (arg === '--force' || arg === '-f') {
156 out.force = true;
157 } else if (arg === '--api-origin' && argv[i + 1]) {
158 out.origins = resolveOrigins({
159 apiOrigin: argv[++i],
160 dashboardOrigin: out.origins.dashboardOrigin,
161 });
162 } else if (arg === '--dashboard-origin' && argv[i + 1]) {
163 out.origins = resolveOrigins({
164 apiOrigin: out.origins.apiOrigin,
165 dashboardOrigin: argv[++i],
166 });
167 } else if (arg && !arg.startsWith('-')) {
168 positionals.push(arg);
169 }
170 }
171
172 if (positionals.length > 0 && !out.project) {
173 out.project = positionals[0];
174 }
175
176 return out;
177}
178
179export function printSetupHelp(): void {
180 banner('setup');
181 blankLine();
182 step('create a **new** cloud project + S3 bucket/key + wire this folder');
183 blankLine();
184 step('usage:');
185 step(' briven setup interactive (recommended)');
186 step(' briven setup my-app create a new project named my-app');
187 step(' briven setup --name my-app same as above');
188 step(' briven setup --name app --template todo-app --region eu-west');
189 blankLine();
190 step('always includes:');
191 step(' · platform sign-in');
192 step(' · brand-new cloud project');
193 step(' · private S3 bucket + storage key → .env.local');
194 step(' · local scaffold + CLI project key');
195 blankLine();
196 step('options:');
197 step(' --name, -n <name> create a new cloud project with this name');
198 step(' --region <id> region (default: eu-west)');
199 step(' --template, -t <name> blank | todo-app | chat | convex-notes | …');
200 step(' --yes, -y defaults for remaining prompts (folder name, etc.)');
201 blankLine();
202 step('existing project? use:');
203 step(' briven connect pick an existing project');
204 step(' briven connect p_01HZ... attach that project');
205 blankLine();
206 step('after setup:');
207 step(' briven deploy | briven dev');
208 printLink('https://docs.briven.tech/connect');
209}
210
211export function printConnectProjectHelp(): void {
212 banner('connect');
213 blankLine();
214 step('attach an **existing** cloud project + S3 key + wire this folder');
215 blankLine();
216 step('usage:');
217 step(' briven connect sign in + pick a project (recommended)');
218 step(' briven connect p_01HZ... attach this project id');
219 step(' briven connect --project p_01HZ... same as above');
220 step(' briven connect my-slug attach by project slug');
221 blankLine();
222 step('also:');
223 step(' briven connect status show platform session + local keys');
224 step(' briven connect logout forget platform session (keep project keys)');
225 blankLine();
226 step('options:');
227 step(' --project, -p <id|slug> existing project on your account');
228 step(' --template, -t <name> scaffold template if folder has no briven/ yet');
229 step(' --yes, -y non-interactive when possible (first project if only one)');
230 step(' --force, -f re-authorize in the browser before attaching');
231 blankLine();
232 step('brand-new project? use:');
233 step(' briven setup my-app');
234 blankLine();
235 step('after connect:');
236 step(' briven deploy | briven dev');
237 printLink('https://docs.briven.tech/connect');
238}
239
240/**
241 * Create a **new** project and wire this folder. Returns process exit code.
242 */
243export async function runSetup(argv: readonly string[] = []): Promise<number> {
244 if (argv.includes('--help') || argv.includes('-h')) {
245 printSetupHelp();
246 return 0;
247 }
248
249 const args = parseSetupArgs(argv);
250
251 // Redirect: attach existing belongs to `briven connect`.
252 if (args.project) {
253 printError('setup creates a new project only');
254 step(`to attach an existing project: briven connect ${args.project}`);
255 step('or: briven connect --project ' + args.project);
256 return 1;
257 }
258 if (args.name && looksLikeProjectRef(args.name)) {
259 printError(`"${args.name}" looks like an existing project id`);
260 step(`to attach it: briven connect ${args.name}`);
261 step('to create a new project with a normal name: briven setup my-app');
262 return 1;
263 }
264
265 if (!TEMPLATES.includes(args.template)) {
266 printError(`unknown template: ${args.template}`);
267 step(`known: ${TEMPLATES.join(', ')}`);
268 return 1;
269 }
270
271 if (!REGIONS.some((r) => r.id === args.region)) {
272 printError(`unknown region: ${args.region}`);
273 step(`known: ${REGIONS.map((r) => r.id).join(', ')}`);
274 return 1;
275 }
276
277 banner('setup');
278 blankLine();
279 step('create a new briven cloud project and wire this folder');
280 blankLine();
281
282 try {
283 step('signing in to the platform…');
284 const user = await ensurePlatformSession({
285 quiet: true,
286 origins: args.origins,
287 });
288 const me = await fetchMe(user.apiOrigin, user.token);
289 success(`signed in as ${me.email}`);
290 blankLine();
291
292 if (args.name) {
293 return await createNew(args, user.token, args.name, args.region, args.template);
294 }
295
296 if (args.yes) {
297 const name = basename(args.cwd);
298 return await createNew(args, user.token, name, args.region, args.template);
299 }
300
301 return await interactiveNew(args, user.token);
302 } catch (err) {
303 if (err instanceof ApiCallError) {
304 printError(`setup failed: ${err.code} (${err.status}) — ${err.message}`);
305 } else {
306 printError(err instanceof Error ? err.message : 'setup failed');
307 }
308 return 1;
309 }
310}
311
312/**
313 * Attach an **existing** project and wire this folder. Used by `briven connect`.
314 */
315export async function runConnectProject(argv: readonly string[] = []): Promise<number> {
316 if (argv.includes('--help') || argv.includes('-h')) {
317 printConnectProjectHelp();
318 return 0;
319 }
320
321 const args = parseConnectProjectArgs(argv);
322
323 if (!TEMPLATES.includes(args.template)) {
324 printError(`unknown template: ${args.template}`);
325 step(`known: ${TEMPLATES.join(', ')}`);
326 return 1;
327 }
328
329 banner('connect');
330 blankLine();
331 step('attach an existing briven cloud project to this folder');
332 blankLine();
333
334 const setupLike: SetupArgs = {
335 region: REGIONS[0]!.id,
336 template: args.template,
337 yes: args.yes,
338 cwd: args.cwd,
339 origins: args.origins,
340 };
341
342 try {
343 step(args.force ? 're-authorizing in the browser…' : 'signing in to the platform…');
344 const user = await ensurePlatformSession({
345 force: args.force,
346 quiet: true,
347 origins: args.origins,
348 });
349 const me = await fetchMe(user.apiOrigin, user.token);
350 success(`signed in as ${me.email}`);
351 blankLine();
352
353 if (args.project) {
354 return await attachExisting(setupLike, user.token, args.project);
355 }
356
357 return await interactiveExisting(setupLike, user.token, args.yes);
358 } catch (err) {
359 if (err instanceof ApiCallError) {
360 printError(`connect failed: ${err.code} (${err.status}) — ${err.message}`);
361 } else {
362 printError(err instanceof Error ? err.message : 'connect failed');
363 }
364 return 1;
365 }
366}
367
368/**
369 * Used by `briven dev` when the folder is not yet wired.
370 * Same flow as `briven setup`, with origins from the env.
371 */
372export async function runWizard(env: {
373 apiOrigin: string;
374 dashboardOrigin: string;
375 cwd?: string;
376}): Promise<void> {
377 const prevApi = process.env.BRIVEN_API_ORIGIN;
378 const prevDash = process.env.BRIVEN_DASHBOARD_ORIGIN;
379 process.env.BRIVEN_API_ORIGIN = env.apiOrigin;
380 process.env.BRIVEN_DASHBOARD_ORIGIN = env.dashboardOrigin;
381 const prevCwd = process.cwd();
382 try {
383 if (env.cwd && env.cwd !== prevCwd) {
384 process.chdir(env.cwd);
385 }
386 await runSetup([]);
387 } finally {
388 if (env.cwd && env.cwd !== prevCwd) {
389 process.chdir(prevCwd);
390 }
391 if (prevApi === undefined) delete process.env.BRIVEN_API_ORIGIN;
392 else process.env.BRIVEN_API_ORIGIN = prevApi;
393 if (prevDash === undefined) delete process.env.BRIVEN_DASHBOARD_ORIGIN;
394 else process.env.BRIVEN_DASHBOARD_ORIGIN = prevDash;
395 }
396}
397
398async function interactiveNew(args: SetupArgs, token: string): Promise<number> {
399 const defaultName = basename(args.cwd);
400 const name = (await promptLine(`project name [${defaultName}]: `)).trim() || defaultName;
401
402 step('pick region:');
403 REGIONS.forEach((r, i) => step(` ${i + 1}. ${r.id} — ${r.label}`));
404 const regionPick = await promptLine('region [1]: ');
405 const regionIdx = Math.max(1, Number.parseInt(regionPick || '1', 10)) - 1;
406 const region = REGIONS[regionIdx]?.id ?? REGIONS[0]!.id;
407
408 step('pick template (optional starter — not required to use briven):');
409 TEMPLATES.forEach((t, i) => step(` ${i + 1}. ${t}`));
410 const tPick = await promptLine('template [1]: ');
411 const tIdx = Math.max(1, Number.parseInt(tPick || '1', 10)) - 1;
412 const template = TEMPLATES[tIdx] ?? 'blank';
413
414 return createNew(args, token, name, region, template);
415}
416
417async function interactiveExisting(
418 args: SetupArgs,
419 token: string,
420 yes = false,
421): Promise<number> {
422 const projects = await listRemoteProjects(args.origins.apiOrigin, token);
423 if (projects.length === 0) {
424 printError('no projects on your account yet');
425 step('create one first: briven setup my-app');
426 return 1;
427 }
428 if (yes && projects.length === 1) {
429 return finishExisting(args, token, projects[0]!);
430 }
431 if (yes && projects.length > 1) {
432 printError('several projects on your account — pass which one:');
433 step(' briven connect p_…');
434 step(' briven connect --project <slug>');
435 projects.forEach((p) => step(` · ${p.slug} (${p.id})`));
436 return 1;
437 }
438 step('your projects:');
439 projects.forEach((p, i) =>
440 step(` ${i + 1}. ${p.orgName ?? '—'}/${p.slug} (${p.id}) · ${p.region} · ${p.tier}`),
441 );
442 const pick = await promptLine(`pick [1-${projects.length}]: `);
443 const idx = Number.parseInt(pick.trim(), 10) - 1;
444 const project = projects[idx];
445 if (!project) {
446 printError('invalid selection');
447 return 1;
448 }
449 return finishExisting(args, token, project);
450}
451
452async function attachExisting(
453 args: SetupArgs,
454 token: string,
455 idOrSlug: string,
456): Promise<number> {
457 const projects = await listRemoteProjects(args.origins.apiOrigin, token);
458 const match = projects.find((p) => p.id === idOrSlug || p.slug === idOrSlug);
459 if (!match) {
460 printError(`project not found on your account: ${idOrSlug}`);
461 step('run: briven projects list --remote');
462 return 1;
463 }
464 return finishExisting(args, token, match);
465}
466
467async function createNew(
468 args: SetupArgs,
469 token: string,
470 name: string,
471 region: string,
472 template: SetupTemplate,
473): Promise<number> {
474 step(`creating project "${name}" in ${region}…`);
475 const created = await createRemoteProject(args.origins.apiOrigin, token, { name, region });
476 success(`created ${created.slug} (${created.id})`);
477
478 step(`scaffolding template: ${template}`);
479 await runInit(['--name', name, '--template', template, '--force']);
480 await writeProjectConfig({ name, projectId: created.id, region }, args.cwd);
481
482 step('minting cli credentials…');
483 await mintAndStoreKey(args.origins.apiOrigin, token, created.id);
484
485 await writeGeneratedFiles({
486 cwd: args.cwd,
487 snapshot: { version: 1, tables: {} },
488 functionFilenames: await listFunctionFilenames(args.cwd),
489 });
490 await writeEnvLocal({
491 cwd: args.cwd,
492 projectId: created.id,
493 apiOrigin: args.origins.apiOrigin,
494 storage: created.storage,
495 });
496
497 // Storage is required for a complete setup — not an optional extra step.
498 if (created.storage) {
499 success('S3 bucket + default storage key ready (saved in .env.local)');
500 step(`bucket ${created.storage.bucket}`);
501 } else {
502 const st = await ensureStorageInSetup(created.id);
503 if (st !== 0) return st;
504 }
505
506 blankLine();
507 success('folder fully wired (project + CLI key + S3)');
508 printDone(args, created.id, created.slug);
509 return 0;
510}
511
512async function finishExisting(
513 args: SetupArgs,
514 token: string,
515 project: RemoteProject,
516): Promise<number> {
517 step(`attaching ${project.slug} (${project.id})…`);
518
519 // Ensure local scaffold exists so deploy/dev have a place to work.
520 const local = await readProjectConfig(args.cwd);
521 if (!local) {
522 step(`scaffolding template: ${args.template}`);
523 await runInit(['--name', project.slug, '--template', args.template, '--force']);
524 }
525
526 try {
527 await pullSchemaToDisk({
528 apiOrigin: args.origins.apiOrigin,
529 bearer: token,
530 projectId: project.id,
531 cwd: args.cwd,
532 });
533 } catch (err) {
534 // Empty / brand-new projects may not export a schema yet — not fatal.
535 step(
536 `schema pull skipped: ${err instanceof Error ? err.message : 'unavailable'}`,
537 );
538 }
539
540 await writeProjectConfig(
541 {
542 name: local?.name || project.slug,
543 projectId: project.id,
544 region: project.region,
545 },
546 args.cwd,
547 );
548
549 step('minting cli credentials…');
550 await mintAndStoreKey(args.origins.apiOrigin, token, project.id);
551
552 await writeGeneratedFiles({
553 cwd: args.cwd,
554 snapshot: { version: 1, tables: {} },
555 functionFilenames: await listFunctionFilenames(args.cwd),
556 });
557 await writeEnvLocal({
558 cwd: args.cwd,
559 projectId: project.id,
560 apiOrigin: args.origins.apiOrigin,
561 });
562
563 // Required: attach always gets a fresh key + .env.local storage block.
564 const st = await ensureStorageInSetup(project.id);
565 if (st !== 0) return st;
566
567 blankLine();
568 success(`folder fully wired to ${project.slug} (project + CLI key + S3)`);
569 printDone(args, project.id, project.slug);
570 return 0;
571}
572
573/** Bucket + key + write env. Returns 0 on success; setup must abort on non-zero. */
574async function ensureStorageInSetup(projectId: string): Promise<number> {
575 step('ensuring S3 bucket + storage key (required)…');
576 try {
577 const code = await runStorage([
578 'setup',
579 '--name',
580 'default',
581 '--write-env',
582 '--project',
583 projectId,
584 ]);
585 if (code !== 0) {
586 printError(
587 'storage setup failed — briven setup is incomplete without an S3 bucket/key.',
588 );
589 step('platform: check MinIO (BRIVEN_MINIO_*) on the API host.');
590 step('or retry: briven storage setup --write-env');
591 return 1;
592 }
593 return 0;
594 } catch (err) {
595 printError(err instanceof Error ? err.message : 'storage setup failed');
596 return 1;
597 }
598}
599
600function printDone(args: SetupArgs, projectId: string, slug: string): void {
601 step(`project ${slug} (${projectId})`);
602 step(`dashboard ${args.origins.dashboardOrigin}/dashboard/projects/${projectId}`);
603 step('storage S3 bucket + key in .env.local (BRIVEN_STORAGE_* / AWS_*)');
604 blankLine();
605 step('next:');
606 step(' briven deploy push schema + functions once');
607 step(' briven dev watch mode (push on save)');
608 printLink('https://docs.briven.tech/connect');
609}
610
611async function writeEnvLocal(args: {
612 cwd: string;
613 projectId: string;
614 apiOrigin: string;
615 storage?: ProjectStorageBootstrap | null;
616}): Promise<void> {
617 const path = join(args.cwd, '.env.local');
618 let existing = '';
619 try {
620 existing = await readFile(path, 'utf8');
621 } catch (err) {
622 if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
623 existing = '';
624 }
625 const vars: Record<string, string> = {
626 BRIVEN_DEPLOYMENT: args.projectId,
627 NEXT_PUBLIC_BRIVEN_URL: args.apiOrigin,
628 };
629 if (args.storage) {
630 vars.BRIVEN_STORAGE_ENDPOINT = args.storage.endpoint;
631 vars.BRIVEN_STORAGE_BUCKET = args.storage.bucket;
632 vars.BRIVEN_STORAGE_ACCESS_KEY = args.storage.accessKey;
633 vars.BRIVEN_STORAGE_SECRET_KEY = args.storage.secretKey;
634 vars.AWS_ENDPOINT_URL = args.storage.endpoint;
635 vars.AWS_ACCESS_KEY_ID = args.storage.accessKey;
636 vars.AWS_SECRET_ACCESS_KEY = args.storage.secretKey;
637 vars.S3_BUCKET = args.storage.bucket;
638 }
639 const merged = mergeEnvFile(existing, vars);
640 await writeFile(path, merged, 'utf8');
641}
642
643async function writeGeneratedFiles(args: {
644 cwd: string;
645 snapshot: SchemaSnapshot;
646 functionFilenames: string[];
647}): Promise<void> {
648 const files = generate(args.snapshot, args.functionFilenames);
649 for (const [relPath, content] of files) {
650 const abs = join(args.cwd, relPath);
651 await mkdir(join(abs, '..'), { recursive: true });
652 let existing: string | null = null;
653 try {
654 existing = await readFile(abs, 'utf8');
655 } catch {
656 existing = null;
657 }
658 if (existing !== content) {
659 await writeFile(abs, content, 'utf8');
660 }
661 }
662}
663
664async function listFunctionFilenames(cwd: string): Promise<string[]> {
665 const { readdir } = await import('node:fs/promises');
666 try {
667 const all = await readdir(join(cwd, 'briven', 'functions'));
668 return all.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
669 } catch {
670 return [];
671 }
672}
673
674function promptLine(prompt: string): Promise<string> {
675 process.stdout.write(prompt);
676 return new Promise((resolve) => {
677 const rl = createInterface({ input: process.stdin, output: process.stdout });
678 rl.once('line', (line) => {
679 rl.close();
680 resolve(line);
681 });
682 });
683}