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