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