@beignet/cli 0.0.3 → 0.0.5

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