init.ts618 lines · main
1import { mkdir, readFile, writeFile } from 'node:fs/promises';
2import { basename, dirname, resolve } from 'node:path';
3
4import { readProjectConfig, writeProjectConfig } from '../project-config.js';
5import { banner, blankLine, error as printError, link, step, success } from '../output.js';
6
7interface Args {
8 name?: string;
9 template: TemplateName;
10 force: boolean;
11 list: boolean;
12}
13
14type TemplateName =
15 | 'blank'
16 | 'todo-app'
17 | 'chat'
18 | 'convex-notes'
19 | 'supabase-auth-todos';
20
21interface Template {
22 description: string;
23 files: Record<string, string>;
24}
25
26/**
27 * Templates ship inline so `briven init` works on a fresh machine with
28 * no network. Adding a new template = adding an entry here. The
29 * sources mirror what's at /examples/<name>/ in the repo so the doc
30 * pages and the CLI can never drift apart.
31 */
32const TEMPLATES: Record<TemplateName, Template> = {
33 blank: {
34 description: 'one table (notes), one query — minimal scaffold for kicking the tyres',
35 files: {
36 'briven/schema.ts': `/**
37 * briven schema. Edit this file to add tables; the CLI diffs against
38 * the currently deployed schema and generates the migration when you
39 * run \`briven deploy\`.
40 */
41import { schema, table, text, timestamp } from '@briven/cli/schema';
42
43export default schema({
44 notes: table({
45 id: text().primaryKey(),
46 body: text().notNull(),
47 createdAt: timestamp().default('now()').notNull(),
48 }),
49});
50`,
51 'briven/functions/listNotes.ts': `/**
52 * Reactive query — every file under \`briven/functions/\` becomes a
53 * named, typed endpoint. This one resolves as \`listNotes\`.
54 */
55import { query, type Ctx } from '@briven/cli/server';
56
57export default query(async (ctx: Ctx): Promise<Array<{ id: string; body: string }>> => {
58 return ctx.db('notes').select(['id', 'body']).orderBy('createdAt', 'desc').limit(50);
59});
60`,
61 },
62 },
63
64 'todo-app': {
65 description: 'canonical hello-world: 1 table, 4 mutations, 1 reactive query',
66 files: {
67 'briven/schema.ts': `import { boolean, schema, table, text, timestamp } from '@briven/cli/schema';
68
69export default schema({
70 todos: table({
71 id: text().primaryKey(),
72 body: text().notNull(),
73 done: boolean().default(false).notNull(),
74 createdAt: timestamp().default('now()').notNull(),
75 completedAt: timestamp().nullable(),
76 }),
77});
78`,
79 'briven/functions/listTodos.ts': `import { query, type Ctx } from '@briven/cli/server';
80
81interface Args {
82 filter?: 'open' | 'done' | 'all';
83}
84
85export default query(async (ctx: Ctx, args: Args = {}) => {
86 let q = ctx.db('todos').select(['id', 'body', 'done', 'createdAt', 'completedAt']);
87 if (args.filter === 'open') q = q.where({ done: false });
88 if (args.filter === 'done') q = q.where({ done: true });
89 return q.orderBy('createdAt', 'desc').limit(200);
90});
91`,
92 'briven/functions/createTodo.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
93import { ulid } from '@briven/shared';
94
95interface Args {
96 body: string;
97}
98
99export default mutation(async (ctx: Ctx, args: Args) => {
100 const body = args.body?.trim();
101 if (!body) throw new brivenError('validation_failed', 'body is required', { status: 400 });
102 if (body.length > 280)
103 throw new brivenError('validation_failed', 'body too long (max 280 chars)', { status: 400 });
104
105 const id = ulid('td');
106 const [row] = await ctx.db('todos').insert({ id, body, done: false }).returning();
107 return row;
108});
109`,
110 'briven/functions/toggleTodo.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
111
112interface Args {
113 id: string;
114}
115
116export default mutation(async (ctx: Ctx, args: Args) => {
117 if (!args.id) throw new brivenError('validation_failed', 'id is required', { status: 400 });
118 const [existing] = await ctx.db('todos').select(['id', 'done']).where({ id: args.id }).limit(1);
119 if (!existing) throw new brivenError('not_found', \`no todo \${args.id}\`, { status: 404 });
120
121 const next = !existing.done;
122 await ctx
123 .db('todos')
124 .update({ done: next, completedAt: next ? new Date().toISOString() : null })
125 .where({ id: args.id });
126
127 return { id: args.id, done: next };
128});
129`,
130 'briven/functions/deleteTodo.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
131
132interface Args {
133 id: string;
134}
135
136export default mutation(async (ctx: Ctx, args: Args) => {
137 if (!args.id) throw new brivenError('validation_failed', 'id is required', { status: 400 });
138 const affected = await ctx.db('todos').delete().where({ id: args.id });
139 if (affected === 0) throw new brivenError('not_found', \`no todo \${args.id}\`, { status: 404 });
140 return { id: args.id, deleted: true };
141});
142`,
143 },
144 },
145
146 chat: {
147 description: 'two-table chat: rooms + messages, with per-room reactive queries',
148 files: {
149 'briven/schema.ts': `import { schema, table, text, timestamp } from '@briven/cli/schema';
150
151export default schema({
152 rooms: table({
153 id: text().primaryKey(),
154 name: text().notNull(),
155 createdAt: timestamp().default('now()').notNull(),
156 }),
157 messages: table({
158 id: text().primaryKey(),
159 roomId: text().notNull(),
160 authorName: text().notNull(),
161 body: text().notNull(),
162 createdAt: timestamp().default('now()').notNull(),
163 }),
164});
165`,
166 'briven/functions/listRooms.ts': `import { query, type Ctx } from '@briven/cli/server';
167
168export default query(async (ctx: Ctx) => {
169 return ctx.db('rooms').select(['id', 'name', 'createdAt']).orderBy('createdAt', 'desc').limit(200);
170});
171`,
172 'briven/functions/listMessages.ts': `import { brivenError, query, type Ctx } from '@briven/cli/server';
173
174interface Args {
175 roomId: string;
176 limit?: number;
177}
178
179export default query(async (ctx: Ctx, args: Args) => {
180 if (!args.roomId)
181 throw new brivenError('validation_failed', 'roomId is required', { status: 400 });
182 const limit = Math.min(Math.max(args.limit ?? 50, 1), 200);
183 return ctx
184 .db('messages')
185 .select(['id', 'roomId', 'authorName', 'body', 'createdAt'])
186 .where({ roomId: args.roomId })
187 .orderBy('createdAt', 'desc')
188 .limit(limit);
189});
190`,
191 'briven/functions/createRoom.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
192import { ulid } from '@briven/shared';
193
194interface Args {
195 name: string;
196}
197
198export default mutation(async (ctx: Ctx, args: Args) => {
199 const name = args.name?.trim();
200 if (!name) throw new brivenError('validation_failed', 'name is required', { status: 400 });
201 if (name.length > 64)
202 throw new brivenError('validation_failed', 'name too long (max 64 chars)', { status: 400 });
203
204 const id = ulid('rm');
205 const [row] = await ctx.db('rooms').insert({ id, name }).returning();
206 return row;
207});
208`,
209 'briven/functions/sendMessage.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
210import { ulid } from '@briven/shared';
211
212interface Args {
213 roomId: string;
214 authorName: string;
215 body: string;
216}
217
218export default mutation(async (ctx: Ctx, args: Args) => {
219 if (!args.roomId)
220 throw new brivenError('validation_failed', 'roomId is required', { status: 400 });
221 const author = args.authorName?.trim();
222 const body = args.body?.trim();
223 if (!author)
224 throw new brivenError('validation_failed', 'authorName is required', { status: 400 });
225 if (!body) throw new brivenError('validation_failed', 'body is required', { status: 400 });
226 if (body.length > 2000)
227 throw new brivenError('validation_failed', 'body too long (max 2000 chars)', { status: 400 });
228
229 const [room] = await ctx.db('rooms').select(['id']).where({ id: args.roomId }).limit(1);
230 if (!room) throw new brivenError('not_found', \`no room \${args.roomId}\`, { status: 404 });
231
232 const id = ulid('msg');
233 const [row] = await ctx
234 .db('messages')
235 .insert({ id, roomId: args.roomId, authorName: author, body })
236 .returning();
237 return row;
238});
239`,
240 },
241 },
242
243 /**
244 * convex-notes — mirrors the canonical convex notes-app shape. Multi-
245 * user notes, per-user reactive listing, optional tag filter. The
246 * useQuery hook signature on the client matches convex 1:1, so
247 * porting a convex notes app feels familiar.
248 */
249 'convex-notes': {
250 description: 'convex-style notes app: multi-user notes with per-user reactive listing',
251 files: {
252 'briven/schema.ts': `import { schema, table, text, timestamp } from '@briven/cli/schema';
253
254export default schema({
255 notes: table({
256 id: text().primaryKey(),
257 ownerId: text().notNull(),
258 title: text().notNull(),
259 body: text().notNull(),
260 tag: text().nullable(),
261 createdAt: timestamp().default('now()').notNull(),
262 updatedAt: timestamp().default('now()').notNull(),
263 }),
264});
265`,
266 'briven/functions/listNotes.ts': `import { brivenError, query, type Ctx } from '@briven/cli/server';
267
268interface Args {
269 ownerId: string;
270 tag?: string | null;
271}
272
273export default query(async (ctx: Ctx, args: Args) => {
274 if (!args.ownerId)
275 throw new brivenError('validation_failed', 'ownerId is required', { status: 400 });
276 let q = ctx
277 .db('notes')
278 .select(['id', 'title', 'body', 'tag', 'createdAt', 'updatedAt'])
279 .where({ ownerId: args.ownerId });
280 if (args.tag) q = q.where({ tag: args.tag });
281 return q.orderBy('updatedAt', 'desc').limit(200);
282});
283`,
284 'briven/functions/createNote.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
285import { ulid } from '@briven/shared';
286
287interface Args {
288 ownerId: string;
289 title: string;
290 body: string;
291 tag?: string | null;
292}
293
294export default mutation(async (ctx: Ctx, args: Args) => {
295 if (!args.ownerId)
296 throw new brivenError('validation_failed', 'ownerId is required', { status: 400 });
297 const title = args.title?.trim();
298 const body = args.body?.trim();
299 if (!title) throw new brivenError('validation_failed', 'title is required', { status: 400 });
300 if (title.length > 200)
301 throw new brivenError('validation_failed', 'title too long (max 200 chars)', { status: 400 });
302 if (body.length > 20000)
303 throw new brivenError('validation_failed', 'body too long (max 20000 chars)', { status: 400 });
304
305 const id = ulid('nt');
306 const [row] = await ctx
307 .db('notes')
308 .insert({ id, ownerId: args.ownerId, title, body, tag: args.tag ?? null })
309 .returning();
310 return row;
311});
312`,
313 'briven/functions/updateNote.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
314
315interface Args {
316 id: string;
317 ownerId: string;
318 title?: string;
319 body?: string;
320 tag?: string | null;
321}
322
323export default mutation(async (ctx: Ctx, args: Args) => {
324 if (!args.id) throw new brivenError('validation_failed', 'id is required', { status: 400 });
325 if (!args.ownerId)
326 throw new brivenError('validation_failed', 'ownerId is required', { status: 400 });
327
328 // Ownership guard — equivalent to convex's "the user can only edit
329 // their own notes" check; here we express it as an explicit where()
330 // rather than a row-level policy.
331 const [existing] = await ctx
332 .db('notes')
333 .select(['id', 'ownerId'])
334 .where({ id: args.id })
335 .limit(1);
336 if (!existing) throw new brivenError('not_found', \`no note \${args.id}\`, { status: 404 });
337 if (existing.ownerId !== args.ownerId)
338 throw new brivenError('forbidden', 'not your note', { status: 403 });
339
340 const patch: Record<string, unknown> = { updatedAt: new Date().toISOString() };
341 if (args.title !== undefined) patch.title = args.title.trim();
342 if (args.body !== undefined) patch.body = args.body;
343 if (args.tag !== undefined) patch.tag = args.tag;
344 await ctx.db('notes').update(patch).where({ id: args.id });
345 return { id: args.id, updated: true };
346});
347`,
348 'briven/functions/deleteNote.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
349
350interface Args {
351 id: string;
352 ownerId: string;
353}
354
355export default mutation(async (ctx: Ctx, args: Args) => {
356 if (!args.id) throw new brivenError('validation_failed', 'id is required', { status: 400 });
357 if (!args.ownerId)
358 throw new brivenError('validation_failed', 'ownerId is required', { status: 400 });
359 const affected = await ctx
360 .db('notes')
361 .delete()
362 .where({ id: args.id, ownerId: args.ownerId });
363 if (affected === 0)
364 throw new brivenError('not_found', 'no matching note (wrong id or owner)', { status: 404 });
365 return { id: args.id, deleted: true };
366});
367`,
368 },
369 },
370
371 /**
372 * supabase-auth-todos — translates the canonical Supabase pattern
373 * (user-scoped rows + row-level security) into briven's model: the
374 * scoping happens explicitly inside the function via ctx.session.
375 * Useful for supabase users who think in terms of "auth.uid() = row.user_id"
376 * — here it's the same thing, just written out.
377 */
378 'supabase-auth-todos': {
379 description: 'supabase-style auth-scoped todo list (RLS → explicit function guards)',
380 files: {
381 'briven/schema.ts': `import { boolean, schema, table, text, timestamp } from '@briven/cli/schema';
382
383export default schema({
384 todos: table({
385 id: text().primaryKey(),
386 userId: text().notNull(),
387 body: text().notNull(),
388 done: boolean().default(false).notNull(),
389 createdAt: timestamp().default('now()').notNull(),
390 completedAt: timestamp().nullable(),
391 }),
392});
393`,
394 'briven/functions/myTodos.ts': `import { brivenError, query, type Ctx } from '@briven/cli/server';
395
396/**
397 * Equivalent to Supabase's:
398 * create policy "users see their own todos"
399 * on todos for select using (auth.uid() = user_id);
400 * — except the predicate lives in code, where you can read it.
401 */
402export default query(async (ctx: Ctx) => {
403 if (!ctx.session?.userId)
404 throw new brivenError('unauthorized', 'sign in required', { status: 401 });
405 return ctx
406 .db('todos')
407 .select(['id', 'body', 'done', 'createdAt', 'completedAt'])
408 .where({ userId: ctx.session.userId })
409 .orderBy('createdAt', 'desc')
410 .limit(500);
411});
412`,
413 'briven/functions/createTodo.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
414import { ulid } from '@briven/shared';
415
416interface Args {
417 body: string;
418}
419
420export default mutation(async (ctx: Ctx, args: Args) => {
421 if (!ctx.session?.userId)
422 throw new brivenError('unauthorized', 'sign in required', { status: 401 });
423 const body = args.body?.trim();
424 if (!body) throw new brivenError('validation_failed', 'body is required', { status: 400 });
425 if (body.length > 280)
426 throw new brivenError('validation_failed', 'body too long (max 280 chars)', { status: 400 });
427
428 const id = ulid('td');
429 const [row] = await ctx
430 .db('todos')
431 .insert({ id, userId: ctx.session.userId, body, done: false })
432 .returning();
433 return row;
434});
435`,
436 'briven/functions/toggleTodo.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
437
438interface Args {
439 id: string;
440}
441
442export default mutation(async (ctx: Ctx, args: Args) => {
443 if (!ctx.session?.userId)
444 throw new brivenError('unauthorized', 'sign in required', { status: 401 });
445 if (!args.id) throw new brivenError('validation_failed', 'id is required', { status: 400 });
446
447 // Ownership guard equivalent to Supabase's update policy.
448 const [existing] = await ctx
449 .db('todos')
450 .select(['id', 'done', 'userId'])
451 .where({ id: args.id })
452 .limit(1);
453 if (!existing) throw new brivenError('not_found', \`no todo \${args.id}\`, { status: 404 });
454 if (existing.userId !== ctx.session.userId)
455 throw new brivenError('forbidden', 'not your todo', { status: 403 });
456
457 const next = !existing.done;
458 await ctx
459 .db('todos')
460 .update({ done: next, completedAt: next ? new Date().toISOString() : null })
461 .where({ id: args.id });
462 return { id: args.id, done: next };
463});
464`,
465 'briven/functions/deleteTodo.ts': `import { brivenError, mutation, type Ctx } from '@briven/cli/server';
466
467interface Args {
468 id: string;
469}
470
471export default mutation(async (ctx: Ctx, args: Args) => {
472 if (!ctx.session?.userId)
473 throw new brivenError('unauthorized', 'sign in required', { status: 401 });
474 if (!args.id) throw new brivenError('validation_failed', 'id is required', { status: 400 });
475 const affected = await ctx
476 .db('todos')
477 .delete()
478 .where({ id: args.id, userId: ctx.session.userId });
479 if (affected === 0)
480 throw new brivenError('not_found', 'no matching todo (wrong id or not yours)', {
481 status: 404,
482 });
483 return { id: args.id, deleted: true };
484});
485`,
486 },
487 },
488};
489
490const GITIGNORE_ENTRIES = ['.briven/', 'node_modules/', 'dist/'];
491
492function parse(argv: readonly string[]): Args {
493 const out: Args = { force: false, template: 'blank', list: false };
494 for (let i = 0; i < argv.length; i++) {
495 const a = argv[i];
496 if (a === '--name' && argv[i + 1]) out.name = argv[++i];
497 else if (a === '--template' && argv[i + 1]) {
498 const t = argv[++i] as TemplateName;
499 if (!(t in TEMPLATES)) {
500 // Defer error to runInit so we can show the template list.
501 out.template = t;
502 } else {
503 out.template = t;
504 }
505 } else if (a?.startsWith('--template=')) {
506 out.template = a.slice('--template='.length) as TemplateName;
507 } else if (a === '--force') out.force = true;
508 else if (a === '--list-templates') out.list = true;
509 else if (!a?.startsWith('-') && !out.name) out.name = a;
510 }
511 return out;
512}
513
514/**
515 * Templates are grouped so a user coming from convex / supabase sees
516 * the starter that matches their mental model called out first. Order
517 * within each group is stable and matches the order in TEMPLATES.
518 */
519const TEMPLATE_GROUPS: ReadonlyArray<{
520 label: string;
521 templates: readonly TemplateName[];
522}> = [
523 { label: 'starters', templates: ['blank', 'todo-app', 'chat'] },
524 {
525 label: 'migration-friendly',
526 templates: ['convex-notes', 'supabase-auth-todos'],
527 },
528];
529
530function printTemplates(): void {
531 banner('templates');
532 blankLine();
533 const widest = Math.max(...Object.keys(TEMPLATES).map((k) => k.length));
534 for (const group of TEMPLATE_GROUPS) {
535 step(`${group.label}:`);
536 for (const name of group.templates) {
537 const tpl = TEMPLATES[name];
538 step(` ${name.padEnd(widest)} ${tpl.description}`);
539 }
540 blankLine();
541 }
542 step('use one with: briven init --template <name>');
543 step('migrating from convex / supabase? see https://briven.tech/migrate');
544}
545
546export async function runInit(argv: readonly string[]): Promise<number> {
547 const args = parse(argv);
548 if (args.list) {
549 printTemplates();
550 return 0;
551 }
552 if (!(args.template in TEMPLATES)) {
553 printError(`unknown template '${args.template}'`);
554 blankLine();
555 printTemplates();
556 return 1;
557 }
558
559 const cwd = process.cwd();
560 const existing = await readProjectConfig(cwd);
561 if (existing && !args.force) {
562 printError('briven.json already exists — pass --force to overwrite.');
563 return 1;
564 }
565
566 const name = args.name ?? basename(cwd);
567 const tpl = TEMPLATES[args.template];
568
569 banner('init');
570 step(`creating briven project '${name}' (template: ${args.template})`);
571
572 await writeProjectConfig({ name }, cwd);
573 for (const [path, contents] of Object.entries(tpl.files)) {
574 const abs = resolve(cwd, path);
575 await mkdir(dirname(abs), { recursive: true });
576 await writeFile(abs, contents);
577 }
578 await updateGitignore(cwd);
579
580 success('scaffolded:');
581 step(' briven.json');
582 for (const path of Object.keys(tpl.files)) {
583 step(` ${path}`);
584 }
585 blankLine();
586 step('local files only. to create/link a cloud project + wire this folder:');
587 step(' briven setup');
588 step(' or: briven setup --name my-app');
589 step('then: briven deploy | briven dev');
590 link('https://docs.briven.tech/connect');
591 return 0;
592}
593
594async function updateGitignore(cwd: string): Promise<void> {
595 const path = resolve(cwd, '.gitignore');
596 let current = '';
597 try {
598 current = await readFile(path, 'utf8');
599 } catch (err) {
600 if (
601 !(
602 err &&
603 typeof err === 'object' &&
604 'code' in err &&
605 (err as { code: string }).code === 'ENOENT'
606 )
607 ) {
608 throw err;
609 }
610 }
611 const missing = GITIGNORE_ENTRIES.filter((line) => !current.split(/\r?\n/).includes(line));
612 if (missing.length === 0) return;
613 const block = `\n# briven\n${missing.join('\n')}\n`;
614 await writeFile(path, current + block);
615}
616
617// Exported for tests.
618export { TEMPLATES, parse as parseInitArgs };