@beignet/cli 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/choices.d.ts +5 -28
  3. package/dist/choices.d.ts.map +1 -1
  4. package/dist/choices.js +0 -35
  5. package/dist/choices.js.map +1 -1
  6. package/dist/create-prompts.d.ts +5 -6
  7. package/dist/create-prompts.d.ts.map +1 -1
  8. package/dist/create-prompts.js +19 -77
  9. package/dist/create-prompts.js.map +1 -1
  10. package/dist/create.d.ts +6 -4
  11. package/dist/create.d.ts.map +1 -1
  12. package/dist/create.js +15 -24
  13. package/dist/create.js.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +32 -39
  16. package/dist/index.js.map +1 -1
  17. package/dist/lib.d.ts +1 -1
  18. package/dist/lib.d.ts.map +1 -1
  19. package/dist/make.d.ts.map +1 -1
  20. package/dist/make.js +153 -1
  21. package/dist/make.js.map +1 -1
  22. package/dist/templates/base.d.ts +13 -0
  23. package/dist/templates/base.d.ts.map +1 -0
  24. package/dist/templates/base.js +300 -0
  25. package/dist/templates/base.js.map +1 -0
  26. package/dist/templates/db.d.ts +15 -0
  27. package/dist/templates/db.d.ts.map +1 -0
  28. package/dist/templates/db.js +1001 -0
  29. package/dist/templates/db.js.map +1 -0
  30. package/dist/templates/index.d.ts +14 -0
  31. package/dist/templates/index.d.ts.map +1 -0
  32. package/dist/templates/index.js +124 -0
  33. package/dist/templates/index.js.map +1 -0
  34. package/dist/templates/server.d.ts +26 -0
  35. package/dist/templates/server.d.ts.map +1 -0
  36. package/dist/templates/server.js +525 -0
  37. package/dist/templates/server.js.map +1 -0
  38. package/dist/templates/shadcn.d.ts +5 -0
  39. package/dist/templates/shadcn.d.ts.map +1 -0
  40. package/dist/templates/shadcn.js +555 -0
  41. package/dist/templates/shadcn.js.map +1 -0
  42. package/dist/templates/shared.d.ts +49 -0
  43. package/dist/templates/shared.d.ts.map +1 -0
  44. package/dist/templates/shared.js +57 -0
  45. package/dist/templates/shared.js.map +1 -0
  46. package/dist/templates/shell.d.ts +5 -0
  47. package/dist/templates/shell.d.ts.map +1 -0
  48. package/dist/templates/shell.js +1198 -0
  49. package/dist/templates/shell.js.map +1 -0
  50. package/dist/templates/todos.d.ts +17 -0
  51. package/dist/templates/todos.d.ts.map +1 -0
  52. package/dist/templates/todos.js +607 -0
  53. package/dist/templates/todos.js.map +1 -0
  54. package/package.json +2 -2
  55. package/src/choices.ts +4 -58
  56. package/src/create-prompts.ts +20 -88
  57. package/src/create.ts +21 -38
  58. package/src/index.ts +39 -45
  59. package/src/lib.ts +1 -1
  60. package/src/make.ts +208 -1
  61. package/src/templates/base.ts +377 -0
  62. package/src/templates/db.ts +1002 -0
  63. package/src/templates/index.ts +149 -0
  64. package/src/templates/server.ts +533 -0
  65. package/src/templates/shadcn.ts +570 -0
  66. package/src/templates/shared.ts +90 -0
  67. package/src/templates/shell.ts +1227 -0
  68. package/src/templates/todos.ts +607 -0
  69. package/dist/templates.d.ts +0 -26
  70. package/dist/templates.d.ts.map +0 -1
  71. package/dist/templates.js +0 -3995
  72. package/dist/templates.js.map +0 -1
  73. package/src/templates.ts +0 -4280
@@ -0,0 +1,1227 @@
1
+ import {
2
+ packageRunner,
3
+ type TemplateContext,
4
+ type TemplateFile,
5
+ } from "./shared.js";
6
+
7
+ /**
8
+ * Application shell for generated apps: root layout and providers, public
9
+ * landing page, auth pages, the authenticated sidebar shell, settings pages,
10
+ * the typed client setup, and the shadcn-styled todos worked example.
11
+ *
12
+ * These templates assume the shadcn stack from `shadcn.ts` is installed
13
+ * alongside them.
14
+ */
15
+
16
+ const appProviders = `"use client";
17
+
18
+ import { QueryClientProvider } from "@tanstack/react-query";
19
+ import { type ReactNode, useState } from "react";
20
+ import { makeQueryClient } from "@/client";
21
+
22
+ export function Providers({ children }: { children: ReactNode }) {
23
+ const [queryClient] = useState(() => makeQueryClient());
24
+
25
+ return (
26
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
27
+ );
28
+ }
29
+ `;
30
+
31
+ const rootLayout = `import "./globals.css";
32
+ import type { Metadata } from "next";
33
+ import { Inter } from "next/font/google";
34
+ import type { ReactNode } from "react";
35
+ import { ThemeProvider } from "@/components/theme-provider";
36
+ import { cn } from "@/lib/utils";
37
+ import { Providers } from "./providers";
38
+
39
+ const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
40
+
41
+ export const metadata: Metadata = {
42
+ title: "Beignet app",
43
+ description: "A contract-first Next.js app.",
44
+ };
45
+
46
+ export default function RootLayout({ children }: { children: ReactNode }) {
47
+ return (
48
+ <html
49
+ lang="en"
50
+ className={cn("font-sans", inter.variable)}
51
+ suppressHydrationWarning
52
+ >
53
+ <body>
54
+ <ThemeProvider
55
+ attribute="class"
56
+ defaultTheme="system"
57
+ enableSystem
58
+ disableTransitionOnChange
59
+ >
60
+ <Providers>{children}</Providers>
61
+ </ThemeProvider>
62
+ </body>
63
+ </html>
64
+ );
65
+ }
66
+ `;
67
+
68
+ const landingPage = `import Link from "next/link";
69
+ import { Button } from "@/components/ui/button";
70
+
71
+ export default function HomePage() {
72
+ return (
73
+ <main className="flex min-h-svh flex-col">
74
+ <header className="flex items-center justify-between px-6 py-4">
75
+ <span className="font-heading text-sm font-semibold">Beignet app</span>
76
+ <nav className="flex items-center gap-2" aria-label="Account">
77
+ <Button asChild variant="ghost">
78
+ <Link href="/sign-in">Sign in</Link>
79
+ </Button>
80
+ <Button asChild>
81
+ <Link href="/sign-up">Create account</Link>
82
+ </Button>
83
+ </nav>
84
+ </header>
85
+ <section className="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center">
86
+ <p className="text-[13px] font-bold tracking-[0.12em] text-primary uppercase">
87
+ Beignet starter
88
+ </p>
89
+ <h1 className="mt-3 max-w-xl font-heading text-4xl font-semibold tracking-tight">
90
+ Type-safe full-stack workflows without codegen.
91
+ </h1>
92
+ <p className="mt-4 max-w-lg text-muted-foreground">
93
+ Contracts, use cases, ports, a typed client, React Query, and form
94
+ validation are wired together so you can start shipping product code
95
+ immediately.
96
+ </p>
97
+ <div className="mt-8 flex gap-3">
98
+ <Button asChild size="lg">
99
+ <Link href="/sign-up">Get started</Link>
100
+ </Button>
101
+ <Button asChild size="lg" variant="outline">
102
+ <Link href="/sign-in">Sign in</Link>
103
+ </Button>
104
+ </div>
105
+ <nav
106
+ className="mt-10 flex gap-5 text-sm text-muted-foreground"
107
+ aria-label="Starter links"
108
+ >
109
+ <a className="hover:text-foreground" href="/api/health">
110
+ Health
111
+ </a>
112
+ <a className="hover:text-foreground" href="/api/openapi">
113
+ OpenAPI
114
+ </a>
115
+ <a className="hover:text-foreground" href="https://beignetjs.com">
116
+ Docs
117
+ </a>
118
+ </nav>
119
+ </section>
120
+ </main>
121
+ );
122
+ }
123
+ `;
124
+
125
+ const authLayout = `import type { ReactNode } from "react";
126
+
127
+ export default function AuthLayout({ children }: { children: ReactNode }) {
128
+ return (
129
+ <main className="flex min-h-svh items-center justify-center bg-muted/40 p-6">
130
+ <div className="w-full max-w-sm">{children}</div>
131
+ </main>
132
+ );
133
+ }
134
+ `;
135
+
136
+ const signInPage = `"use client";
137
+
138
+ import { rootFormError } from "@beignet/react-hook-form";
139
+ import Link from "next/link";
140
+ import { useRouter } from "next/navigation";
141
+ import { useForm } from "react-hook-form";
142
+ import { authClient } from "@/client/auth-client";
143
+ import { Button } from "@/components/ui/button";
144
+ import {
145
+ Card,
146
+ CardContent,
147
+ CardDescription,
148
+ CardHeader,
149
+ CardTitle,
150
+ } from "@/components/ui/card";
151
+ import { Input } from "@/components/ui/input";
152
+ import { Label } from "@/components/ui/label";
153
+
154
+ type SignInValues = {
155
+ email: string;
156
+ password: string;
157
+ };
158
+
159
+ export default function SignInPage() {
160
+ const router = useRouter();
161
+ const form = useForm<SignInValues>({
162
+ defaultValues: { email: "", password: "" },
163
+ });
164
+
165
+ const onSubmit = form.handleSubmit(async (values) => {
166
+ form.clearErrors("root");
167
+ const result = await authClient.signIn.email({
168
+ email: values.email,
169
+ password: values.password,
170
+ });
171
+
172
+ if (result.error) {
173
+ form.setError(
174
+ "root",
175
+ rootFormError(result.error, result.error.message || "Could not sign in."),
176
+ );
177
+ return;
178
+ }
179
+
180
+ router.push("/dashboard");
181
+ router.refresh();
182
+ });
183
+
184
+ return (
185
+ <Card>
186
+ <CardHeader>
187
+ <CardTitle>Sign in</CardTitle>
188
+ <CardDescription>Welcome back. Enter your details below.</CardDescription>
189
+ </CardHeader>
190
+ <CardContent>
191
+ <form className="flex flex-col gap-4" onSubmit={onSubmit}>
192
+ <div className="flex flex-col gap-2">
193
+ <Label htmlFor="email">Email</Label>
194
+ <Input
195
+ id="email"
196
+ type="email"
197
+ autoComplete="email"
198
+ placeholder="you@example.com"
199
+ required
200
+ {...form.register("email")}
201
+ />
202
+ </div>
203
+ <div className="flex flex-col gap-2">
204
+ <Label htmlFor="password">Password</Label>
205
+ <Input
206
+ id="password"
207
+ type="password"
208
+ autoComplete="current-password"
209
+ required
210
+ {...form.register("password")}
211
+ />
212
+ </div>
213
+ {form.formState.errors.root ? (
214
+ <p className="text-sm text-destructive">
215
+ {form.formState.errors.root.message}
216
+ </p>
217
+ ) : null}
218
+ <Button type="submit" disabled={form.formState.isSubmitting}>
219
+ {form.formState.isSubmitting ? "Signing in..." : "Sign in"}
220
+ </Button>
221
+ </form>
222
+ <p className="mt-4 text-center text-sm text-muted-foreground">
223
+ No account yet?{" "}
224
+ <Link
225
+ className="text-foreground underline underline-offset-4"
226
+ href="/sign-up"
227
+ >
228
+ Create one
229
+ </Link>
230
+ </p>
231
+ </CardContent>
232
+ </Card>
233
+ );
234
+ }
235
+ `;
236
+
237
+ const signUpPage = `"use client";
238
+
239
+ import { rootFormError } from "@beignet/react-hook-form";
240
+ import Link from "next/link";
241
+ import { useRouter } from "next/navigation";
242
+ import { useForm } from "react-hook-form";
243
+ import { authClient } from "@/client/auth-client";
244
+ import { Button } from "@/components/ui/button";
245
+ import {
246
+ Card,
247
+ CardContent,
248
+ CardDescription,
249
+ CardHeader,
250
+ CardTitle,
251
+ } from "@/components/ui/card";
252
+ import { Input } from "@/components/ui/input";
253
+ import { Label } from "@/components/ui/label";
254
+
255
+ type SignUpValues = {
256
+ name: string;
257
+ email: string;
258
+ password: string;
259
+ };
260
+
261
+ export default function SignUpPage() {
262
+ const router = useRouter();
263
+ const form = useForm<SignUpValues>({
264
+ defaultValues: { name: "", email: "", password: "" },
265
+ });
266
+
267
+ const onSubmit = form.handleSubmit(async (values) => {
268
+ form.clearErrors("root");
269
+ const result = await authClient.signUp.email({
270
+ name: values.name,
271
+ email: values.email,
272
+ password: values.password,
273
+ });
274
+
275
+ if (result.error) {
276
+ form.setError(
277
+ "root",
278
+ rootFormError(
279
+ result.error,
280
+ result.error.message || "Could not create your account.",
281
+ ),
282
+ );
283
+ return;
284
+ }
285
+
286
+ router.push("/dashboard");
287
+ router.refresh();
288
+ });
289
+
290
+ return (
291
+ <Card>
292
+ <CardHeader>
293
+ <CardTitle>Create account</CardTitle>
294
+ <CardDescription>Start building in a few seconds.</CardDescription>
295
+ </CardHeader>
296
+ <CardContent>
297
+ <form className="flex flex-col gap-4" onSubmit={onSubmit}>
298
+ <div className="flex flex-col gap-2">
299
+ <Label htmlFor="name">Name</Label>
300
+ <Input
301
+ id="name"
302
+ autoComplete="name"
303
+ placeholder="Ada Lovelace"
304
+ required
305
+ {...form.register("name")}
306
+ />
307
+ </div>
308
+ <div className="flex flex-col gap-2">
309
+ <Label htmlFor="email">Email</Label>
310
+ <Input
311
+ id="email"
312
+ type="email"
313
+ autoComplete="email"
314
+ placeholder="you@example.com"
315
+ required
316
+ {...form.register("email")}
317
+ />
318
+ </div>
319
+ <div className="flex flex-col gap-2">
320
+ <Label htmlFor="password">Password</Label>
321
+ <Input
322
+ id="password"
323
+ type="password"
324
+ autoComplete="new-password"
325
+ minLength={8}
326
+ required
327
+ {...form.register("password")}
328
+ />
329
+ </div>
330
+ {form.formState.errors.root ? (
331
+ <p className="text-sm text-destructive">
332
+ {form.formState.errors.root.message}
333
+ </p>
334
+ ) : null}
335
+ <Button type="submit" disabled={form.formState.isSubmitting}>
336
+ {form.formState.isSubmitting ? "Creating account..." : "Create account"}
337
+ </Button>
338
+ </form>
339
+ <p className="mt-4 text-center text-sm text-muted-foreground">
340
+ Already have an account?{" "}
341
+ <Link
342
+ className="text-foreground underline underline-offset-4"
343
+ href="/sign-in"
344
+ >
345
+ Sign in
346
+ </Link>
347
+ </p>
348
+ </CardContent>
349
+ </Card>
350
+ );
351
+ }
352
+ `;
353
+
354
+ const appLayout = `import { headers } from "next/headers";
355
+ import { redirect } from "next/navigation";
356
+ import type { ReactNode } from "react";
357
+ import { AppSidebar } from "@/components/app-sidebar";
358
+ import { auth } from "@/lib/better-auth";
359
+ import { isDevtoolsEnabled } from "@/lib/env";
360
+
361
+ export default async function AppLayout({ children }: { children: ReactNode }) {
362
+ const session = await auth.api.getSession({ headers: await headers() });
363
+
364
+ if (!session) {
365
+ redirect("/sign-in");
366
+ }
367
+
368
+ return (
369
+ <div className="flex min-h-svh">
370
+ <AppSidebar
371
+ user={{ name: session.user.name, email: session.user.email }}
372
+ showDevtools={isDevtoolsEnabled}
373
+ showOpenapi={true}
374
+ />
375
+ <main className="flex-1">
376
+ <div className="mx-auto w-full max-w-3xl px-6 py-8">{children}</div>
377
+ </main>
378
+ </div>
379
+ );
380
+ }
381
+ `;
382
+
383
+ function dashboardPage(ctx: TemplateContext): string {
384
+ return `import { headers } from "next/headers";
385
+ import Link from "next/link";
386
+ import {
387
+ Card,
388
+ CardContent,
389
+ CardDescription,
390
+ CardHeader,
391
+ CardTitle,
392
+ } from "@/components/ui/card";
393
+ import { auth } from "@/lib/better-auth";
394
+
395
+ export default async function DashboardPage() {
396
+ const session = await auth.api.getSession({ headers: await headers() });
397
+ const name = session?.user.name ?? "there";
398
+
399
+ return (
400
+ <div className="flex flex-col gap-6">
401
+ <div className="flex flex-col gap-1">
402
+ <h1 className="font-heading text-xl font-semibold">Welcome, {name}</h1>
403
+ <p className="text-sm text-muted-foreground">
404
+ Your app is running. Here is where to go next.
405
+ </p>
406
+ </div>
407
+ <div className="grid gap-4 sm:grid-cols-2">
408
+ <Link href="/todos" className="group">
409
+ <Card className="h-full transition-shadow group-hover:ring-foreground/20">
410
+ <CardHeader>
411
+ <CardTitle>Manage your todos</CardTitle>
412
+ <CardDescription>
413
+ The worked example: a contract-backed API with a typed client,
414
+ React Query, and form validation.
415
+ </CardDescription>
416
+ </CardHeader>
417
+ </Card>
418
+ </Link>
419
+ <Card>
420
+ <CardHeader>
421
+ <CardTitle>Next steps</CardTitle>
422
+ <CardDescription>Make this starter your own.</CardDescription>
423
+ </CardHeader>
424
+ <CardContent className="flex flex-col gap-2 text-sm">
425
+ <code className="rounded-lg bg-muted px-2 py-1.5 font-mono text-xs">
426
+ ${packageRunner(ctx)} make feature
427
+ </code>
428
+ <p className="text-muted-foreground">
429
+ Scaffold your next feature, then read the docs at{" "}
430
+ <a
431
+ className="text-foreground underline underline-offset-4"
432
+ href="https://beignetjs.com"
433
+ >
434
+ beignetjs.com
435
+ </a>
436
+ .
437
+ </p>
438
+ </CardContent>
439
+ </Card>
440
+ </div>
441
+ </div>
442
+ );
443
+ }
444
+ `;
445
+ }
446
+
447
+ const todosPage = `import { TodoApp } from "@/features/todos/components/todo-app";
448
+
449
+ export default function TodosPage() {
450
+ return (
451
+ <div className="flex flex-col gap-6">
452
+ <div className="flex flex-col gap-1">
453
+ <h1 className="font-heading text-xl font-semibold">Todos</h1>
454
+ <p className="text-sm text-muted-foreground">
455
+ A contract-backed feature with a typed client, React Query, and form
456
+ validation.
457
+ </p>
458
+ </div>
459
+ <TodoApp />
460
+ </div>
461
+ );
462
+ }
463
+ `;
464
+
465
+ const settingsLayout = `import type { ReactNode } from "react";
466
+ import { SettingsNav } from "@/components/settings-nav";
467
+
468
+ export default function SettingsLayout({ children }: { children: ReactNode }) {
469
+ return (
470
+ <div className="flex flex-col gap-6">
471
+ <div className="flex flex-col gap-1">
472
+ <h1 className="font-heading text-xl font-semibold">Settings</h1>
473
+ <p className="text-sm text-muted-foreground">
474
+ Manage your account and app preferences.
475
+ </p>
476
+ </div>
477
+ <SettingsNav />
478
+ {children}
479
+ </div>
480
+ );
481
+ }
482
+ `;
483
+
484
+ const settingsProfilePage = `"use client";
485
+
486
+ import { rootFormError } from "@beignet/react-hook-form";
487
+ import { useState } from "react";
488
+ import { useForm } from "react-hook-form";
489
+ import { authClient } from "@/client/auth-client";
490
+ import { Button } from "@/components/ui/button";
491
+ import {
492
+ Card,
493
+ CardContent,
494
+ CardDescription,
495
+ CardHeader,
496
+ CardTitle,
497
+ } from "@/components/ui/card";
498
+ import { Input } from "@/components/ui/input";
499
+ import { Label } from "@/components/ui/label";
500
+
501
+ type ProfileValues = {
502
+ name: string;
503
+ email: string;
504
+ };
505
+
506
+ export default function SettingsProfilePage() {
507
+ const session = authClient.useSession();
508
+ const [saved, setSaved] = useState(false);
509
+ const form = useForm<ProfileValues>({
510
+ defaultValues: { name: "", email: "" },
511
+ values: {
512
+ name: session.data?.user.name ?? "",
513
+ email: session.data?.user.email ?? "",
514
+ },
515
+ });
516
+
517
+ const onSubmit = form.handleSubmit(async (values) => {
518
+ form.clearErrors("root");
519
+ setSaved(false);
520
+
521
+ const updated = await authClient.updateUser({ name: values.name });
522
+ if (updated.error) {
523
+ form.setError(
524
+ "root",
525
+ rootFormError(
526
+ updated.error,
527
+ updated.error.message || "Could not update your profile.",
528
+ ),
529
+ );
530
+ return;
531
+ }
532
+
533
+ if (values.email !== session.data?.user.email) {
534
+ const changed = await authClient.changeEmail({ newEmail: values.email });
535
+ if (changed.error) {
536
+ form.setError(
537
+ "root",
538
+ rootFormError(
539
+ changed.error,
540
+ changed.error.message || "Could not change your email.",
541
+ ),
542
+ );
543
+ return;
544
+ }
545
+ }
546
+
547
+ await session.refetch();
548
+ setSaved(true);
549
+ });
550
+
551
+ return (
552
+ <Card>
553
+ <CardHeader>
554
+ <CardTitle>Profile</CardTitle>
555
+ <CardDescription>Update your name and email address.</CardDescription>
556
+ </CardHeader>
557
+ <CardContent>
558
+ <form className="flex max-w-sm flex-col gap-4" onSubmit={onSubmit}>
559
+ <div className="flex flex-col gap-2">
560
+ <Label htmlFor="name">Name</Label>
561
+ <Input
562
+ id="name"
563
+ autoComplete="name"
564
+ required
565
+ {...form.register("name")}
566
+ />
567
+ </div>
568
+ <div className="flex flex-col gap-2">
569
+ <Label htmlFor="email">Email</Label>
570
+ <Input
571
+ id="email"
572
+ type="email"
573
+ autoComplete="email"
574
+ required
575
+ {...form.register("email")}
576
+ />
577
+ </div>
578
+ {form.formState.errors.root ? (
579
+ <p className="text-sm text-destructive">
580
+ {form.formState.errors.root.message}
581
+ </p>
582
+ ) : null}
583
+ <div className="flex items-center gap-3">
584
+ <Button type="submit" disabled={form.formState.isSubmitting}>
585
+ {form.formState.isSubmitting ? "Saving..." : "Save changes"}
586
+ </Button>
587
+ {saved ? (
588
+ <p className="text-sm text-muted-foreground">Saved.</p>
589
+ ) : null}
590
+ </div>
591
+ </form>
592
+ </CardContent>
593
+ </Card>
594
+ );
595
+ }
596
+ `;
597
+
598
+ const settingsPasswordPage = `"use client";
599
+
600
+ import { rootFormError } from "@beignet/react-hook-form";
601
+ import { useState } from "react";
602
+ import { useForm } from "react-hook-form";
603
+ import { authClient } from "@/client/auth-client";
604
+ import { Button } from "@/components/ui/button";
605
+ import {
606
+ Card,
607
+ CardContent,
608
+ CardDescription,
609
+ CardHeader,
610
+ CardTitle,
611
+ } from "@/components/ui/card";
612
+ import { Input } from "@/components/ui/input";
613
+ import { Label } from "@/components/ui/label";
614
+
615
+ type PasswordValues = {
616
+ currentPassword: string;
617
+ newPassword: string;
618
+ };
619
+
620
+ export default function SettingsPasswordPage() {
621
+ const [saved, setSaved] = useState(false);
622
+ const form = useForm<PasswordValues>({
623
+ defaultValues: { currentPassword: "", newPassword: "" },
624
+ });
625
+
626
+ const onSubmit = form.handleSubmit(async (values) => {
627
+ form.clearErrors("root");
628
+ setSaved(false);
629
+
630
+ const result = await authClient.changePassword({
631
+ currentPassword: values.currentPassword,
632
+ newPassword: values.newPassword,
633
+ revokeOtherSessions: true,
634
+ });
635
+
636
+ if (result.error) {
637
+ form.setError(
638
+ "root",
639
+ rootFormError(
640
+ result.error,
641
+ result.error.message || "Could not change your password.",
642
+ ),
643
+ );
644
+ return;
645
+ }
646
+
647
+ form.reset();
648
+ setSaved(true);
649
+ });
650
+
651
+ return (
652
+ <Card>
653
+ <CardHeader>
654
+ <CardTitle>Password</CardTitle>
655
+ <CardDescription>
656
+ Changing your password signs you out everywhere else.
657
+ </CardDescription>
658
+ </CardHeader>
659
+ <CardContent>
660
+ <form className="flex max-w-sm flex-col gap-4" onSubmit={onSubmit}>
661
+ <div className="flex flex-col gap-2">
662
+ <Label htmlFor="current-password">Current password</Label>
663
+ <Input
664
+ id="current-password"
665
+ type="password"
666
+ autoComplete="current-password"
667
+ required
668
+ {...form.register("currentPassword")}
669
+ />
670
+ </div>
671
+ <div className="flex flex-col gap-2">
672
+ <Label htmlFor="new-password">New password</Label>
673
+ <Input
674
+ id="new-password"
675
+ type="password"
676
+ autoComplete="new-password"
677
+ minLength={8}
678
+ required
679
+ {...form.register("newPassword")}
680
+ />
681
+ </div>
682
+ {form.formState.errors.root ? (
683
+ <p className="text-sm text-destructive">
684
+ {form.formState.errors.root.message}
685
+ </p>
686
+ ) : null}
687
+ <div className="flex items-center gap-3">
688
+ <Button type="submit" disabled={form.formState.isSubmitting}>
689
+ {form.formState.isSubmitting ? "Saving..." : "Change password"}
690
+ </Button>
691
+ {saved ? (
692
+ <p className="text-sm text-muted-foreground">Password changed.</p>
693
+ ) : null}
694
+ </div>
695
+ </form>
696
+ </CardContent>
697
+ </Card>
698
+ );
699
+ }
700
+ `;
701
+
702
+ const settingsAppearancePage = `import {
703
+ Card,
704
+ CardAction,
705
+ CardDescription,
706
+ CardHeader,
707
+ CardTitle,
708
+ } from "@/components/ui/card";
709
+ import { ThemeToggle } from "@/components/theme-toggle";
710
+
711
+ export default function SettingsAppearancePage() {
712
+ return (
713
+ <Card>
714
+ <CardHeader>
715
+ <CardTitle>Appearance</CardTitle>
716
+ <CardDescription>
717
+ Choose light, dark, or follow your system preference. The choice is
718
+ saved on this device.
719
+ </CardDescription>
720
+ <CardAction>
721
+ <ThemeToggle />
722
+ </CardAction>
723
+ </CardHeader>
724
+ </Card>
725
+ );
726
+ }
727
+ `;
728
+
729
+ const settingsAccountPage = `"use client";
730
+
731
+ import { rootFormError } from "@beignet/react-hook-form";
732
+ import { useRouter } from "next/navigation";
733
+ import { useForm } from "react-hook-form";
734
+ import { authClient } from "@/client/auth-client";
735
+ import { Button } from "@/components/ui/button";
736
+ import {
737
+ Card,
738
+ CardContent,
739
+ CardDescription,
740
+ CardHeader,
741
+ CardTitle,
742
+ } from "@/components/ui/card";
743
+ import { Input } from "@/components/ui/input";
744
+ import { Label } from "@/components/ui/label";
745
+
746
+ type DeleteAccountValues = {
747
+ password: string;
748
+ };
749
+
750
+ export default function SettingsAccountPage() {
751
+ const router = useRouter();
752
+ const form = useForm<DeleteAccountValues>({
753
+ defaultValues: { password: "" },
754
+ });
755
+
756
+ const onSubmit = form.handleSubmit(async (values) => {
757
+ form.clearErrors("root");
758
+ const result = await authClient.deleteUser({ password: values.password });
759
+
760
+ if (result.error) {
761
+ form.setError(
762
+ "root",
763
+ rootFormError(
764
+ result.error,
765
+ result.error.message || "Could not delete your account.",
766
+ ),
767
+ );
768
+ return;
769
+ }
770
+
771
+ router.push("/");
772
+ router.refresh();
773
+ });
774
+
775
+ return (
776
+ <Card>
777
+ <CardHeader>
778
+ <CardTitle className="text-destructive">Delete account</CardTitle>
779
+ <CardDescription>
780
+ Permanently delete your account and all of its data. This cannot be
781
+ undone.
782
+ </CardDescription>
783
+ </CardHeader>
784
+ <CardContent>
785
+ <form className="flex max-w-sm flex-col gap-4" onSubmit={onSubmit}>
786
+ <div className="flex flex-col gap-2">
787
+ <Label htmlFor="password">Confirm your password</Label>
788
+ <Input
789
+ id="password"
790
+ type="password"
791
+ autoComplete="current-password"
792
+ required
793
+ {...form.register("password")}
794
+ />
795
+ </div>
796
+ {form.formState.errors.root ? (
797
+ <p className="text-sm text-destructive">
798
+ {form.formState.errors.root.message}
799
+ </p>
800
+ ) : null}
801
+ <Button
802
+ type="submit"
803
+ variant="destructive"
804
+ disabled={form.formState.isSubmitting}
805
+ >
806
+ {form.formState.isSubmitting ? "Deleting..." : "Delete account"}
807
+ </Button>
808
+ </form>
809
+ </CardContent>
810
+ </Card>
811
+ );
812
+ }
813
+ `;
814
+
815
+ const clientIndex = `import { createClient } from "@beignet/core/client";
816
+ import { createReactQuery } from "@beignet/react-query";
817
+ import { QueryClient } from "@tanstack/react-query";
818
+
819
+ export const apiClient = createClient({
820
+ validateInput: true,
821
+ });
822
+
823
+ export const rq = createReactQuery(apiClient);
824
+
825
+ export function makeQueryClient() {
826
+ return new QueryClient({
827
+ defaultOptions: {
828
+ queries: {
829
+ staleTime: 60 * 1000,
830
+ },
831
+ },
832
+ });
833
+ }
834
+ `;
835
+
836
+ const authClientFile = `import { createAuthClient } from "better-auth/react";
837
+
838
+ export const authClient = createAuthClient();
839
+ `;
840
+
841
+ const formsClient = `import { createReactHookForm } from "@beignet/react-hook-form";
842
+
843
+ export const rhf = createReactHookForm();
844
+ `;
845
+
846
+ const themeProvider = `"use client";
847
+
848
+ import { ThemeProvider as NextThemesProvider } from "next-themes";
849
+ import type { ComponentProps } from "react";
850
+
851
+ export function ThemeProvider({
852
+ children,
853
+ ...props
854
+ }: ComponentProps<typeof NextThemesProvider>) {
855
+ return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
856
+ }
857
+ `;
858
+
859
+ const themeToggle = `"use client";
860
+
861
+ import { MoonIcon, SunIcon } from "lucide-react";
862
+ import { useTheme } from "next-themes";
863
+ import { Button } from "@/components/ui/button";
864
+ import {
865
+ DropdownMenu,
866
+ DropdownMenuContent,
867
+ DropdownMenuItem,
868
+ DropdownMenuTrigger,
869
+ } from "@/components/ui/dropdown-menu";
870
+
871
+ export function ThemeToggle() {
872
+ const { setTheme } = useTheme();
873
+
874
+ return (
875
+ <DropdownMenu>
876
+ <DropdownMenuTrigger asChild>
877
+ <Button type="button" variant="ghost" size="icon" aria-label="Change theme">
878
+ <SunIcon className="dark:hidden" />
879
+ <MoonIcon className="hidden dark:block" />
880
+ </Button>
881
+ </DropdownMenuTrigger>
882
+ <DropdownMenuContent align="end">
883
+ <DropdownMenuItem onClick={() => setTheme("light")}>
884
+ Light
885
+ </DropdownMenuItem>
886
+ <DropdownMenuItem onClick={() => setTheme("dark")}>
887
+ Dark
888
+ </DropdownMenuItem>
889
+ <DropdownMenuItem onClick={() => setTheme("system")}>
890
+ System
891
+ </DropdownMenuItem>
892
+ </DropdownMenuContent>
893
+ </DropdownMenu>
894
+ );
895
+ }
896
+ `;
897
+
898
+ const appSidebar = `"use client";
899
+
900
+ import {
901
+ FileJsonIcon,
902
+ LayoutDashboardIcon,
903
+ ListTodoIcon,
904
+ LogOutIcon,
905
+ SettingsIcon,
906
+ WrenchIcon,
907
+ } from "lucide-react";
908
+ import Link from "next/link";
909
+ import { usePathname, useRouter } from "next/navigation";
910
+ import { authClient } from "@/client/auth-client";
911
+ import { Button } from "@/components/ui/button";
912
+ import { Separator } from "@/components/ui/separator";
913
+ import { cn } from "@/lib/utils";
914
+
915
+ const navLinks = [
916
+ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboardIcon },
917
+ { href: "/todos", label: "Todos", icon: ListTodoIcon },
918
+ { href: "/settings", label: "Settings", icon: SettingsIcon },
919
+ ];
920
+
921
+ const linkClassName =
922
+ "flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
923
+
924
+ export function AppSidebar({
925
+ user,
926
+ showDevtools,
927
+ showOpenapi,
928
+ }: {
929
+ user: { name: string; email: string };
930
+ showDevtools: boolean;
931
+ showOpenapi: boolean;
932
+ }) {
933
+ const pathname = usePathname();
934
+ const router = useRouter();
935
+
936
+ async function signOut() {
937
+ await authClient.signOut();
938
+ router.push("/");
939
+ router.refresh();
940
+ }
941
+
942
+ return (
943
+ <aside className="sticky top-0 flex h-svh w-60 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground">
944
+ <div className="px-5 py-4">
945
+ <Link href="/dashboard" className="font-heading text-sm font-semibold">
946
+ Beignet app
947
+ </Link>
948
+ </div>
949
+ <nav className="flex flex-1 flex-col gap-1 px-3" aria-label="Main">
950
+ {navLinks.map((link) => (
951
+ <Link
952
+ key={link.href}
953
+ href={link.href}
954
+ className={cn(
955
+ linkClassName,
956
+ (pathname === link.href ||
957
+ pathname.startsWith(\`\${link.href}/\`)) &&
958
+ "bg-sidebar-accent text-sidebar-accent-foreground",
959
+ )}
960
+ >
961
+ <link.icon className="size-4" />
962
+ {link.label}
963
+ </Link>
964
+ ))}
965
+ {showDevtools || showOpenapi ? <Separator className="my-2" /> : null}
966
+ {showDevtools ? (
967
+ <a href="/api/devtools" className={linkClassName}>
968
+ <WrenchIcon className="size-4" />
969
+ Devtools
970
+ </a>
971
+ ) : null}
972
+ {showOpenapi ? (
973
+ <a href="/api/openapi" className={linkClassName}>
974
+ <FileJsonIcon className="size-4" />
975
+ OpenAPI
976
+ </a>
977
+ ) : null}
978
+ </nav>
979
+ <div className="flex items-center gap-2 border-t p-3">
980
+ <div className="min-w-0 flex-1">
981
+ <p className="truncate text-sm font-medium">{user.name}</p>
982
+ <p className="truncate text-xs text-muted-foreground">{user.email}</p>
983
+ </div>
984
+ <Button
985
+ type="button"
986
+ variant="ghost"
987
+ size="icon"
988
+ aria-label="Sign out"
989
+ onClick={signOut}
990
+ >
991
+ <LogOutIcon />
992
+ </Button>
993
+ </div>
994
+ </aside>
995
+ );
996
+ }
997
+ `;
998
+
999
+ const settingsNav = `"use client";
1000
+
1001
+ import Link from "next/link";
1002
+ import { usePathname } from "next/navigation";
1003
+ import { cn } from "@/lib/utils";
1004
+
1005
+ const settingsLinks = [
1006
+ { href: "/settings", label: "Profile" },
1007
+ { href: "/settings/password", label: "Password" },
1008
+ { href: "/settings/appearance", label: "Appearance" },
1009
+ { href: "/settings/account", label: "Account" },
1010
+ ];
1011
+
1012
+ export function SettingsNav() {
1013
+ const pathname = usePathname();
1014
+
1015
+ return (
1016
+ <nav className="flex flex-wrap gap-1" aria-label="Settings">
1017
+ {settingsLinks.map((link) => (
1018
+ <Link
1019
+ key={link.href}
1020
+ href={link.href}
1021
+ className={cn(
1022
+ "rounded-lg px-2.5 py-1.5 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
1023
+ pathname === link.href && "bg-muted text-foreground",
1024
+ )}
1025
+ >
1026
+ {link.label}
1027
+ </Link>
1028
+ ))}
1029
+ </nav>
1030
+ );
1031
+ }
1032
+ `;
1033
+
1034
+ const todoApp = `"use client";
1035
+
1036
+ import { rootFormError } from "@beignet/react-hook-form";
1037
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1038
+ import { Trash2Icon } from "lucide-react";
1039
+ import { rq } from "@/client";
1040
+ import { rhf } from "@/client/forms";
1041
+ import { Button } from "@/components/ui/button";
1042
+ import { Card, CardContent } from "@/components/ui/card";
1043
+ import { Checkbox } from "@/components/ui/checkbox";
1044
+ import { Input } from "@/components/ui/input";
1045
+ import { Label } from "@/components/ui/label";
1046
+ import {
1047
+ createTodo,
1048
+ deleteTodo,
1049
+ listTodos,
1050
+ updateTodo,
1051
+ } from "@/features/todos/contracts";
1052
+ import { cn } from "@/lib/utils";
1053
+
1054
+ const createTodoForm = rhf(createTodo);
1055
+
1056
+ export function TodoApp() {
1057
+ const queryClient = useQueryClient();
1058
+ const todosQuery = useQuery(rq(listTodos).queryOptions());
1059
+ const form = createTodoForm.useForm({
1060
+ defaultValues: { title: "" },
1061
+ });
1062
+
1063
+ const invalidateTodos = () => rq(listTodos).invalidate(queryClient);
1064
+
1065
+ const createTodoMutation = useMutation(
1066
+ rq(createTodo).mutationOptions({
1067
+ onSuccess: async () => {
1068
+ form.reset();
1069
+ await invalidateTodos();
1070
+ },
1071
+ onError: (error) => {
1072
+ form.setError("root", rootFormError(error, "Could not create the todo."));
1073
+ },
1074
+ }),
1075
+ );
1076
+ const updateTodoMutation = useMutation(
1077
+ rq(updateTodo).mutationOptions({
1078
+ onSuccess: invalidateTodos,
1079
+ }),
1080
+ );
1081
+ const deleteTodoMutation = useMutation(
1082
+ rq(deleteTodo).mutationOptions({
1083
+ onSuccess: invalidateTodos,
1084
+ }),
1085
+ );
1086
+
1087
+ const onSubmit = form.handleSubmit((body) => {
1088
+ form.clearErrors("root");
1089
+ createTodoMutation.mutate({ body });
1090
+ });
1091
+
1092
+ const todos = todosQuery.data?.items ?? [];
1093
+
1094
+ return (
1095
+ <section className="flex flex-col gap-4">
1096
+ <form className="flex flex-col gap-2" onSubmit={onSubmit}>
1097
+ <Label htmlFor="todo-title">New todo</Label>
1098
+ <div className="flex gap-2">
1099
+ <Input
1100
+ id="todo-title"
1101
+ autoComplete="off"
1102
+ placeholder="Ship something type-safe"
1103
+ {...form.register("title")}
1104
+ />
1105
+ <Button type="submit" disabled={createTodoMutation.isPending}>
1106
+ {createTodoMutation.isPending ? "Adding..." : "Add"}
1107
+ </Button>
1108
+ </div>
1109
+ {form.formState.errors.title ? (
1110
+ <p className="text-sm text-destructive">
1111
+ {form.formState.errors.title.message}
1112
+ </p>
1113
+ ) : null}
1114
+ {form.formState.errors.root ? (
1115
+ <p className="text-sm text-destructive">
1116
+ {form.formState.errors.root.message}
1117
+ </p>
1118
+ ) : null}
1119
+ </form>
1120
+
1121
+ <Card className="py-0">
1122
+ <CardContent className="px-0">
1123
+ {todosQuery.isPending ? (
1124
+ <p className="px-4 py-6 text-sm text-muted-foreground">
1125
+ Loading todos...
1126
+ </p>
1127
+ ) : todosQuery.isError ? (
1128
+ <p className="px-4 py-6 text-sm text-destructive">
1129
+ Could not load todos.
1130
+ </p>
1131
+ ) : todos.length === 0 ? (
1132
+ <p className="px-4 py-6 text-sm text-muted-foreground">
1133
+ Nothing here yet — add your first todo.
1134
+ </p>
1135
+ ) : (
1136
+ <ul className="divide-y">
1137
+ {todos.map((todo) => (
1138
+ <li key={todo.id} className="flex items-center gap-3 px-4 py-3">
1139
+ <Checkbox
1140
+ id={\`todo-\${todo.id}\`}
1141
+ checked={todo.completed}
1142
+ disabled={updateTodoMutation.isPending}
1143
+ onCheckedChange={(checked) =>
1144
+ updateTodoMutation.mutate({
1145
+ path: { id: todo.id },
1146
+ body: { completed: checked === true },
1147
+ })
1148
+ }
1149
+ />
1150
+ <label
1151
+ htmlFor={\`todo-\${todo.id}\`}
1152
+ className={cn(
1153
+ "flex-1 text-sm",
1154
+ todo.completed && "text-muted-foreground line-through",
1155
+ )}
1156
+ >
1157
+ {todo.title}
1158
+ </label>
1159
+ <Button
1160
+ type="button"
1161
+ variant="ghost"
1162
+ size="icon-sm"
1163
+ aria-label="Delete todo"
1164
+ disabled={deleteTodoMutation.isPending}
1165
+ onClick={() =>
1166
+ deleteTodoMutation.mutate({ path: { id: todo.id } })
1167
+ }
1168
+ >
1169
+ <Trash2Icon />
1170
+ </Button>
1171
+ </li>
1172
+ ))}
1173
+ </ul>
1174
+ )}
1175
+ </CardContent>
1176
+ </Card>
1177
+ </section>
1178
+ );
1179
+ }
1180
+ `;
1181
+
1182
+ export function shellTemplateFiles(ctx: TemplateContext): TemplateFile[] {
1183
+ return [
1184
+ { path: "app/layout.tsx", content: rootLayout },
1185
+ { path: "app/providers.tsx", content: appProviders },
1186
+ { path: "app/page.tsx", content: landingPage },
1187
+ { path: "app/(auth)/layout.tsx", content: authLayout },
1188
+ { path: "app/(auth)/sign-in/page.tsx", content: signInPage },
1189
+ { path: "app/(auth)/sign-up/page.tsx", content: signUpPage },
1190
+ { path: "app/(app)/layout.tsx", content: appLayout },
1191
+ { path: "app/(app)/dashboard/page.tsx", content: dashboardPage(ctx) },
1192
+ { path: "app/(app)/todos/page.tsx", content: todosPage },
1193
+ { path: "app/(app)/settings/layout.tsx", content: settingsLayout },
1194
+ { path: "app/(app)/settings/page.tsx", content: settingsProfilePage },
1195
+ {
1196
+ path: "app/(app)/settings/password/page.tsx",
1197
+ content: settingsPasswordPage,
1198
+ },
1199
+ {
1200
+ path: "app/(app)/settings/appearance/page.tsx",
1201
+ content: settingsAppearancePage,
1202
+ },
1203
+ {
1204
+ path: "app/(app)/settings/account/page.tsx",
1205
+ content: settingsAccountPage,
1206
+ },
1207
+ { path: "client/index.ts", content: clientIndex },
1208
+ { path: "client/auth-client.ts", content: authClientFile },
1209
+ { path: "client/forms.ts", content: formsClient },
1210
+ { path: "components/theme-provider.tsx", content: themeProvider },
1211
+ { path: "components/theme-toggle.tsx", content: themeToggle },
1212
+ { path: "components/app-sidebar.tsx", content: appSidebar },
1213
+ { path: "components/settings-nav.tsx", content: settingsNav },
1214
+ {
1215
+ path: "features/todos/components/todo-app.tsx",
1216
+ content: todoApp,
1217
+ },
1218
+ ];
1219
+ }
1220
+
1221
+ export const shellDependencies: Record<string, string> = {
1222
+ "better-auth": "1.6.11",
1223
+ "lucide-react": "^1.16.0",
1224
+ "next-themes": "^0.4.6",
1225
+ };
1226
+
1227
+ export const shellDevDependencies: Record<string, string> = {};