@murumets-ee/create 0.1.0

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.
package/dist/cli.js ADDED
@@ -0,0 +1,2041 @@
1
+ #!/usr/bin/env node
2
+ import{a as e,b as u,c as d,d as p,e as A}from"./chunk-HAE5YHLL.js";import*as c from"@clack/prompts";import v from"picocolors";import*as s from"@clack/prompts";async function T(t){s.intro("@murumets-ee/create");let n=t??await s.text({message:"Project name:",placeholder:"my-project",validate:m=>{if(!m)return"Project name is required";if(!/^[a-z0-9-]+$/.test(m))return"Use lowercase letters, numbers, and hyphens only"}});if(s.isCancel(n))return s.cancel("Cancelled."),null;let r=await s.select({message:"App setup:",options:[{value:"single",label:"Single app",hint:"everything in one Next.js project"},{value:"multi",label:"Admin + Frontend",hint:"two apps in a pnpm workspace"}]});if(s.isCancel(r))return s.cancel("Cancelled."),null;let a=await s.confirm({message:"Install dependencies?",initialValue:!0});return s.isCancel(a)?(s.cancel("Cancelled."),null):{name:n,mode:r,installDeps:a}}import{join as k}from"path";import{join as l}from"path";async function N(t,n){await e(l(t,"lib/auth.ts"),`import { getToolkitApp } from './app'
3
+ import { getAuth } from '@murumets-ee/auth'
4
+
5
+ await getToolkitApp()
6
+
7
+ export const auth = getAuth()
8
+ `),await e(l(t,"lib/auth-client.ts"),`import { createClient } from '@murumets-ee/auth/client'
9
+
10
+ export const authClient = createClient()
11
+ `),await e(l(t,"app/api/auth/[...all]/route.ts"),`import { toNextJsHandler } from 'better-auth/next-js'
12
+ import { auth } from '../../../../lib/auth'
13
+
14
+ export const { GET, POST } = toNextJsHandler(auth)
15
+ `),await e(l(t,"app/[locale]/auth/layout.tsx"),`import { AuthProviders } from './providers'
16
+ import type { ReactNode } from 'react'
17
+
18
+ export default function AuthLayout({ children }: { children: ReactNode }) {
19
+ return (
20
+ <AuthProviders>
21
+ <div className="flex min-h-[calc(100vh-3rem)] items-center justify-center px-4">
22
+ {children}
23
+ </div>
24
+ </AuthProviders>
25
+ )
26
+ }
27
+ `),await e(l(t,"app/[locale]/auth/providers.tsx"),`'use client'
28
+
29
+ import { AuthUIProvider } from '@murumets-ee/auth-ui'
30
+ import { authClient } from '@/lib/auth-client'
31
+ import Link from 'next/link'
32
+ import { useRouter } from 'next/navigation'
33
+ import { useLocale } from 'next-intl'
34
+ import type { ReactNode } from 'react'
35
+
36
+ export function AuthProviders({ children }: { children: ReactNode }) {
37
+ const router = useRouter()
38
+ const locale = useLocale()
39
+
40
+ return (
41
+ <AuthUIProvider
42
+ authClient={authClient as never}
43
+ basePath="/auth"
44
+ redirectTo="/"
45
+ Link={Link}
46
+ navigate={(url) => router.push(url)}
47
+ resetPasswordUrl={\`/\${locale}/auth/reset-password\`}
48
+ >
49
+ {children}
50
+ </AuthUIProvider>
51
+ )
52
+ }
53
+ `),await e(l(t,"app/[locale]/auth/sign-in/page.tsx"),`import { SignInForm } from '@murumets-ee/auth-ui'
54
+
55
+ export default function SignInPage() {
56
+ return <SignInForm />
57
+ }
58
+ `),await e(l(t,"app/[locale]/auth/sign-up/page.tsx"),`import { SignUpForm } from '@murumets-ee/auth-ui'
59
+
60
+ export default function SignUpPage() {
61
+ return <SignUpForm />
62
+ }
63
+ `),await e(l(t,"app/[locale]/auth/forgot-password/page.tsx"),`import { ForgotPasswordForm } from '@murumets-ee/auth-ui'
64
+
65
+ export default function ForgotPasswordPage() {
66
+ return <ForgotPasswordForm />
67
+ }
68
+ `),await e(l(t,"app/[locale]/auth/reset-password/page.tsx"),`import { Suspense } from 'react'
69
+ import { ResetPasswordContent } from './content'
70
+
71
+ export default function ResetPasswordPage() {
72
+ return (
73
+ <Suspense>
74
+ <ResetPasswordContent />
75
+ </Suspense>
76
+ )
77
+ }
78
+ `),await e(l(t,"app/[locale]/auth/reset-password/content.tsx"),`'use client'
79
+
80
+ import { useSearchParams } from 'next/navigation'
81
+ import { ResetPasswordForm } from '@murumets-ee/auth-ui'
82
+
83
+ export function ResetPasswordContent() {
84
+ const searchParams = useSearchParams()
85
+ const token = searchParams.get('token')
86
+
87
+ if (!token) {
88
+ return (
89
+ <div className="text-center">
90
+ <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
91
+ Invalid or expired link
92
+ </h1>
93
+ <p className="mt-2 text-zinc-500">
94
+ Please request a new password reset.
95
+ </p>
96
+ </div>
97
+ )
98
+ }
99
+
100
+ return <ResetPasswordForm token={token} />
101
+ }
102
+ `),await e(l(t,"app/[locale]/setup/page.tsx"),`import { redirect } from 'next/navigation'
103
+ import { sql } from 'drizzle-orm'
104
+ import { getToolkitApp } from '@/lib/app'
105
+ import { SetupForm } from './form'
106
+
107
+ export default async function SetupPage() {
108
+ const app = await getToolkitApp()
109
+ const result = await app.db.readOnly.execute<{ count: string }>(
110
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
111
+ )
112
+
113
+ if (Number(result[0]?.count) > 0) {
114
+ redirect('/')
115
+ }
116
+
117
+ return (
118
+ <div className="max-w-md mx-auto p-8">
119
+ <h1 className="text-2xl font-bold mb-2">Create Admin</h1>
120
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
121
+ No users found. Create the first admin account.
122
+ </p>
123
+ <SetupForm />
124
+ </div>
125
+ )
126
+ }
127
+ `),await e(l(t,"app/[locale]/setup/form.tsx"),`'use client'
128
+
129
+ import { useState } from 'react'
130
+ import { createFirstAdmin } from './actions'
131
+
132
+ export function SetupForm() {
133
+ const [result, setResult] = useState<{ ok?: boolean; error?: string; email?: string } | null>(null)
134
+ const [loading, setLoading] = useState(false)
135
+
136
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
137
+ e.preventDefault()
138
+ setLoading(true)
139
+ setResult(null)
140
+ const res = await createFirstAdmin(new FormData(e.currentTarget))
141
+ setResult(res)
142
+ setLoading(false)
143
+ }
144
+
145
+ if (result?.ok) {
146
+ return (
147
+ <div className="space-y-2">
148
+ <p className="text-green-600 dark:text-green-400">Admin created: {result.email}</p>
149
+ <a href="/auth/sign-in" className="text-blue-600 dark:text-blue-400 hover:underline">
150
+ Sign in &rarr;
151
+ </a>
152
+ </div>
153
+ )
154
+ }
155
+
156
+ return (
157
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
158
+ <label className="text-sm font-medium">
159
+ Name
160
+ <input
161
+ name="name"
162
+ required
163
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
164
+ />
165
+ </label>
166
+ <label className="text-sm font-medium">
167
+ Email
168
+ <input
169
+ name="email"
170
+ type="email"
171
+ required
172
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
173
+ />
174
+ </label>
175
+ <label className="text-sm font-medium">
176
+ Password (min 8 chars)
177
+ <input
178
+ name="password"
179
+ type="password"
180
+ required
181
+ minLength={8}
182
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
183
+ />
184
+ </label>
185
+
186
+ {result?.error && <p className="text-red-600 dark:text-red-400 text-sm">{result.error}</p>}
187
+
188
+ <button
189
+ type="submit"
190
+ disabled={loading}
191
+ className="px-4 py-2 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
192
+ >
193
+ {loading ? 'Creating...' : 'Create Admin'}
194
+ </button>
195
+ </form>
196
+ )
197
+ }
198
+ `),await e(l(t,"app/[locale]/setup/actions.ts"),`'use server'
199
+
200
+ import { sql } from 'drizzle-orm'
201
+ import { getToolkitApp } from '@/lib/app'
202
+ import { getAuth } from '@murumets-ee/auth'
203
+
204
+ /**
205
+ * Create the first admin user. Locked down:
206
+ * - Advisory lock prevents race conditions (two simultaneous requests)
207
+ * - User count check inside the lock ensures only one admin can be created
208
+ * - Once any user exists, this action permanently refuses
209
+ */
210
+ export async function createFirstAdmin(formData: FormData) {
211
+ const email = formData.get('email') as string
212
+ const password = formData.get('password') as string
213
+ const name = formData.get('name') as string
214
+
215
+ if (!email || !password || !name) {
216
+ return { error: 'All fields are required' }
217
+ }
218
+
219
+ if (password.length < 8) {
220
+ return { error: 'Password must be at least 8 characters' }
221
+ }
222
+
223
+ const app = await getToolkitApp()
224
+
225
+ // Acquire advisory lock + check atomically in a transaction
226
+ const canCreate = await app.db.readWrite.transaction(async (tx) => {
227
+ // Advisory lock 1 = "setup lock". Blocks concurrent setup attempts.
228
+ await tx.execute(sql\`SELECT pg_advisory_xact_lock(1)\`)
229
+
230
+ const result = await tx.execute<{ count: string }>(
231
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
232
+ )
233
+ return Number(result[0]?.count) === 0
234
+ })
235
+
236
+ if (!canCreate) {
237
+ return { error: 'Admin user already exists. Setup is complete.' }
238
+ }
239
+
240
+ const auth = getAuth()
241
+
242
+ const adminUser = await auth.api.signUpEmail({
243
+ body: { email, password, name },
244
+ })
245
+
246
+ await app.db.readWrite.execute(
247
+ sql\`UPDATE "user" SET role = 'admin' WHERE id = \${adminUser.user.id}\`,
248
+ )
249
+
250
+ return { ok: true, email: adminUser.user.email }
251
+ }
252
+ `)}import{join as h}from"path";async function z(t,n){let{name:r}=n;await e(h(t,".env.example"),`DATABASE_URL=postgresql://${r}:${r}_dev_password@localhost:5432/${r}_dev
253
+ BETTER_AUTH_SECRET=dev-secret-change-me-in-production-min-32-chars
254
+ BETTER_AUTH_URL=http://localhost:3000
255
+ LOG_LEVEL=debug
256
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
257
+ QUEUE_WORKER=true
258
+ RESEND_API_KEY=
259
+ RESEND_WEBHOOK_SECRET=
260
+ MAIL_FROM=noreply@example.com
261
+ CSAT_SECRET=
262
+ `),await e(h(t,".npmrc"),`@murumets-ee:registry=https://npm.pkg.github.com
263
+ //npm.pkg.github.com/:_authToken=\${GITHUB_TOKEN}
264
+ `),await A(h(t,".gitignore"),`
265
+ # Toolkit generated files
266
+ generated/
267
+
268
+ # Environment
269
+ .env*
270
+ !.env.example
271
+ `),await e(h(t,"docker-compose.yml"),`services:
272
+ postgres:
273
+ image: postgres:17-alpine
274
+ container_name: ${r}-postgres
275
+ restart: unless-stopped
276
+ environment:
277
+ POSTGRES_USER: ${r}
278
+ POSTGRES_PASSWORD: ${r}_dev_password
279
+ POSTGRES_DB: ${r}_dev
280
+ ports:
281
+ - "5432:5432"
282
+ volumes:
283
+ - postgres_data:/var/lib/postgresql/data
284
+ healthcheck:
285
+ test: ["CMD-SHELL", "pg_isready -U ${r} -d ${r}_dev"]
286
+ interval: 10s
287
+ timeout: 5s
288
+ retries: 5
289
+
290
+ volumes:
291
+ postgres_data:
292
+ name: ${r}_postgres_data
293
+ `)}import{join as w}from"path";async function S(t,n){await e(w(t,"entities/index.ts"),`export { Article } from './article'
294
+ export { Category } from './category'
295
+ `),await e(w(t,"entities/article.ts"),`import { behavior, defineEntity, field } from '@murumets-ee/entity'
296
+
297
+ export const Article = defineEntity({
298
+ name: 'article',
299
+ fields: {
300
+ title: field.text({ required: true, maxLength: 200, indexed: true, translatable: true }),
301
+ slug: field.slug({ from: 'title', unique: true }),
302
+ excerpt: field.text({ maxLength: 500, translatable: true }),
303
+ body: field.richtext(),
304
+ viewCount: field.number({ default: 0, integer: true }),
305
+ featured: field.boolean({ default: false }),
306
+ publishDate: field.date(),
307
+ contentType: field.select({ options: ['news', 'tutorial', 'announcement'], default: 'news' }),
308
+ category: field.reference({ entity: 'category', required: false }),
309
+ tags: field.reference({ entity: 'category', cardinality: 'many' }),
310
+ coverImage: field.media({ accept: ['image/*'] }),
311
+ },
312
+ behaviors: [
313
+ behavior.publishable(),
314
+ behavior.auditable(),
315
+ behavior.sluggable('title'),
316
+ behavior.revisionable(),
317
+ ],
318
+ scope: 'global',
319
+ access: {
320
+ view: 'public',
321
+ create: 'group.editor',
322
+ update: 'group.editor',
323
+ delete: 'group.admin',
324
+ },
325
+ })
326
+ `),await e(w(t,"entities/category.ts"),`import { defineEntity, field } from '@murumets-ee/entity'
327
+
328
+ export const Category = defineEntity({
329
+ name: 'category',
330
+ fields: {
331
+ name: field.text({ required: true, maxLength: 100, indexed: true, translatable: true }),
332
+ slug: field.slug({ from: 'name', unique: true }),
333
+ description: field.text({ maxLength: 500, translatable: true }),
334
+ },
335
+ scope: 'global',
336
+ access: {
337
+ view: 'public',
338
+ create: 'group.editor',
339
+ update: 'group.editor',
340
+ delete: 'group.admin',
341
+ },
342
+ })
343
+ `)}import{join as E}from"path";async function P(t,n){await e(E(t,"i18n/routing.ts"),`import { defineRouting } from 'next-intl/routing'
344
+
345
+ export const routing = defineRouting({
346
+ locales: ['en'],
347
+ defaultLocale: 'en',
348
+ })
349
+ `),await e(E(t,"i18n/request.ts"),`import { getRequestConfig } from 'next-intl/server'
350
+ import { hasLocale } from 'next-intl'
351
+ import { routing } from './routing'
352
+ import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
353
+
354
+ export default getRequestConfig(async ({ requestLocale }) => {
355
+ const requested = await requestLocale
356
+ const locale = hasLocale(routing.locales, requested)
357
+ ? requested
358
+ : routing.defaultLocale
359
+
360
+ const authMessages = await getAuthMessages(locale)
361
+
362
+ return {
363
+ locale,
364
+ messages: {
365
+ ...authMessages,
366
+ },
367
+ }
368
+ })
369
+ `)}import{mkdir as R}from"fs/promises";import{join as o}from"path";var i={myorgCore:"0.1.0",myorgDb:"0.1.0",myorgEntity:"0.1.0",myorgLogging:"0.1.0",myorgAuth:"0.1.0",myorgAuthUi:"0.1.0",betterAuth:"1.4.0",drizzleOrm:"0.45.1",nextIntl:"4.8.2",nextThemes:"0.4.6",lucideReact:"0.563.0",postgres:"3.4.5",reactHookForm:"7.71.1",hookformResolvers:"5.2.2",zod:"3.24.1",drizzleKit:"0.27.2",tsx:"4.19.2",babelReactCompiler:"1.0.0"};async function C(t,n,r){await U(t,n),r?.("Workspace root created"),r?.("Creating admin app..."),await F(t,n),r?.("Admin app created"),r?.("Creating web app..."),await D(t,n),r?.("Web app created"),await I(t,n),r?.("Shared config package created")}async function U(t,n){let{name:r}=n;await R(t,{recursive:!0}),await e(o(t,"pnpm-workspace.yaml"),`packages:
370
+ - 'packages/*'
371
+ - 'apps/*'
372
+
373
+ ignoredBuiltDependencies:
374
+ - sharp
375
+ - unrs-resolver
376
+ `),await e(o(t,"package.json"),`${JSON.stringify({name:`@${r}/monorepo`,version:"0.0.0",private:!0,type:"module",scripts:{dev:"turbo dev",build:"turbo build","db:generate":"tsx --env-file=.env packages/config/scripts/generate-schema.ts","db:migrate:generate":"drizzle-kit generate --config packages/config/drizzle.config.ts","db:migrate":"tsx --env-file=.env packages/config/scripts/migrate.ts","db:reset":"tsx --env-file=.env packages/config/scripts/reset-db.ts"},devDependencies:{turbo:"^2.3.3",typescript:"^5.7.3",tsx:`^${i.tsx}`,"drizzle-kit":`^${i.drizzleKit}`}},null,2)}
377
+ `),await e(o(t,".gitignore"),`# dependencies
378
+ node_modules/
379
+
380
+ # build
381
+ dist/
382
+ .next/
383
+ .turbo/
384
+
385
+ # env
386
+ .env*
387
+ !.env.example
388
+
389
+ # toolkit generated
390
+ generated/
391
+
392
+ # IDE
393
+ .vscode/
394
+ .idea/
395
+
396
+ # OS
397
+ .DS_Store
398
+ Thumbs.db
399
+
400
+ # logs
401
+ *.log
402
+
403
+ # migrations meta
404
+ migrations/
405
+
406
+ # turbo
407
+ .turbo/
408
+ `),await e(o(t,".env.example"),`DATABASE_URL=postgresql://${r}:${r}_dev_password@localhost:5432/${r}_dev
409
+ BETTER_AUTH_SECRET=dev-secret-change-me-in-production-min-32-chars
410
+ BETTER_AUTH_URL=http://localhost:3000
411
+ LOG_LEVEL=debug
412
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
413
+ QUEUE_WORKER=true
414
+ RESEND_API_KEY=
415
+ RESEND_WEBHOOK_SECRET=
416
+ MAIL_FROM=noreply@example.com
417
+ CSAT_SECRET=
418
+ `),await e(o(t,".npmrc"),`@murumets-ee:registry=https://npm.pkg.github.com
419
+ //npm.pkg.github.com/:_authToken=\${GITHUB_TOKEN}
420
+ `),await e(o(t,"docker-compose.yml"),`services:
421
+ postgres:
422
+ image: postgres:17-alpine
423
+ container_name: ${r}-postgres
424
+ restart: unless-stopped
425
+ environment:
426
+ POSTGRES_USER: ${r}
427
+ POSTGRES_PASSWORD: ${r}_dev_password
428
+ POSTGRES_DB: ${r}_dev
429
+ ports:
430
+ - "5432:5432"
431
+ volumes:
432
+ - postgres_data:/var/lib/postgresql/data
433
+ healthcheck:
434
+ test: ["CMD-SHELL", "pg_isready -U ${r} -d ${r}_dev"]
435
+ interval: 10s
436
+ timeout: 5s
437
+ retries: 5
438
+
439
+ volumes:
440
+ postgres_data:
441
+ name: ${r}_postgres_data
442
+ `)}async function I(t,n){let{name:r}=n,a=o(t,"packages/config");await e(o(a,"package.json"),`${JSON.stringify({name:`@${r}/config`,version:"0.1.0",private:!0,type:"module",exports:{".":"./toolkit.config.ts","./entities":"./entities/index.ts","./entities/*":"./entities/*","./app":"./lib/app.ts","./auth":"./lib/auth.ts","./auth-client":"./lib/auth-client.ts"},dependencies:{"@murumets-ee/core":`^${i.myorgCore}`,"@murumets-ee/db":`^${i.myorgDb}`,"@murumets-ee/entity":`^${i.myorgEntity}`,"@murumets-ee/logging":`^${i.myorgLogging}`,"@murumets-ee/auth":`^${i.myorgAuth}`,"better-auth":`^${i.betterAuth}`,"drizzle-orm":`^${i.drizzleOrm}`,postgres:`^${i.postgres}`,zod:`^${i.zod}`}},null,2)}
443
+ `),await e(o(a,"tsconfig.json"),`${JSON.stringify({compilerOptions:{target:"ES2022",module:"ESNext",moduleResolution:"Bundler",strict:!0,esModuleInterop:!0,skipLibCheck:!0,resolveJsonModule:!0},include:["**/*.ts"]},null,2)}
444
+ `),await e(o(a,"toolkit.config.ts"),`import { auth } from '@murumets-ee/auth/plugin'
445
+ import { content } from '@murumets-ee/content/plugin'
446
+ import { defineConfig } from '@murumets-ee/core'
447
+ import { mail, ResendMailProvider } from '@murumets-ee/mail'
448
+ import { media } from '@murumets-ee/media/plugin'
449
+ import { queue } from '@murumets-ee/queue/plugin'
450
+ import { settings } from '@murumets-ee/settings/plugin'
451
+ import { storage } from '@murumets-ee/storage/plugin'
452
+ import { taxonomy } from '@murumets-ee/taxonomy/plugin'
453
+ import { ticketing } from '@murumets-ee/ticketing/plugin'
454
+ import { Article, Category } from './entities'
455
+ import * as authSchema from './generated/auth-schema'
456
+
457
+ if (!process.env.DATABASE_URL) {
458
+ throw new Error('DATABASE_URL environment variable is required')
459
+ }
460
+
461
+ export default defineConfig({
462
+ db: {
463
+ url: process.env.DATABASE_URL,
464
+ poolMin: 2,
465
+ poolMax: 10,
466
+ },
467
+ logging: {
468
+ level: (process.env.LOG_LEVEL || 'info') as 'debug' | 'info' | 'warn' | 'error',
469
+ name: '${r}',
470
+ },
471
+ entities: [Category, Article],
472
+ plugins: [
473
+ auth({ providers: ['email'], schema: authSchema }),
474
+ content({
475
+ locales: [{ code: 'en', label: 'English' }],
476
+ defaultLocale: 'en',
477
+ }),
478
+ settings(),
479
+ storage(),
480
+ media(),
481
+ taxonomy(),
482
+ queue(),
483
+ mail({
484
+ provider: process.env.RESEND_API_KEY
485
+ ? new ResendMailProvider({ apiKey: process.env.RESEND_API_KEY })
486
+ : undefined,
487
+ defaultFrom: process.env.MAIL_FROM ?? 'noreply@example.com',
488
+ webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
489
+ }),
490
+ ticketing({
491
+ csatSecret: process.env.CSAT_SECRET,
492
+ }),
493
+ ],
494
+ projectRoot: import.meta.dirname,
495
+ })
496
+ `),await e(o(a,"auth.config.ts"),`import { betterAuth } from 'better-auth'
497
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
498
+ import { admin } from 'better-auth/plugins'
499
+ import { organization } from 'better-auth/plugins/organization'
500
+ import { drizzle } from 'drizzle-orm/postgres-js'
501
+ import postgres from 'postgres'
502
+
503
+ const sql = postgres(process.env.DATABASE_URL!)
504
+ const db = drizzle(sql)
505
+
506
+ export const auth = betterAuth({
507
+ database: drizzleAdapter(db, { provider: 'pg' }),
508
+ emailAndPassword: { enabled: true },
509
+ plugins: [
510
+ admin(),
511
+ organization(),
512
+ ],
513
+ })
514
+ `),await e(o(a,"drizzle.config.ts"),`import type { Config } from 'drizzle-kit'
515
+
516
+ if (!process.env.DATABASE_URL) {
517
+ throw new Error('DATABASE_URL environment variable is required')
518
+ }
519
+
520
+ export default {
521
+ schema: ['./generated/schema.ts', './generated/auth-schema.ts'],
522
+ out: './migrations',
523
+ dialect: 'postgresql',
524
+ dbCredentials: {
525
+ url: process.env.DATABASE_URL,
526
+ },
527
+ } satisfies Config
528
+ `),await e(o(a,"entities/index.ts"),`export { Article } from './article'
529
+ export { Category } from './category'
530
+ `),await e(o(a,"entities/article.ts"),`import { behavior, defineEntity, field } from '@murumets-ee/entity'
531
+
532
+ export const Article = defineEntity({
533
+ name: 'article',
534
+ fields: {
535
+ title: field.text({ required: true, maxLength: 200, indexed: true, translatable: true }),
536
+ slug: field.slug({ from: 'title', unique: true }),
537
+ excerpt: field.text({ maxLength: 500, translatable: true }),
538
+ body: field.richtext(),
539
+ viewCount: field.number({ default: 0, integer: true }),
540
+ featured: field.boolean({ default: false }),
541
+ publishDate: field.date(),
542
+ contentType: field.select({ options: ['news', 'tutorial', 'announcement'], default: 'news' }),
543
+ category: field.reference({ entity: 'category', required: false }),
544
+ tags: field.reference({ entity: 'category', cardinality: 'many' }),
545
+ coverImage: field.media({ accept: ['image/*'] }),
546
+ },
547
+ behaviors: [
548
+ behavior.publishable(),
549
+ behavior.auditable(),
550
+ behavior.sluggable('title'),
551
+ behavior.revisionable(),
552
+ ],
553
+ scope: 'global',
554
+ access: {
555
+ view: 'public',
556
+ create: 'group.editor',
557
+ update: 'group.editor',
558
+ delete: 'group.admin',
559
+ },
560
+ })
561
+ `),await e(o(a,"entities/category.ts"),`import { defineEntity, field } from '@murumets-ee/entity'
562
+
563
+ export const Category = defineEntity({
564
+ name: 'category',
565
+ fields: {
566
+ name: field.text({ required: true, maxLength: 100, indexed: true, translatable: true }),
567
+ slug: field.slug({ from: 'name', unique: true }),
568
+ description: field.text({ maxLength: 500, translatable: true }),
569
+ },
570
+ scope: 'global',
571
+ access: {
572
+ view: 'public',
573
+ create: 'group.editor',
574
+ update: 'group.editor',
575
+ delete: 'group.admin',
576
+ },
577
+ })
578
+ `),await e(o(a,"lib/app.ts"),`import { createApp, setApp, type ToolkitApp } from '@murumets-ee/core'
579
+ import config from '../toolkit.config'
580
+
581
+ let appInstance: ToolkitApp | null = null
582
+
583
+ export async function getToolkitApp(): Promise<ToolkitApp> {
584
+ if (!appInstance) {
585
+ console.log('Initializing toolkit app...')
586
+ appInstance = await createApp(config)
587
+ setApp(appInstance)
588
+ console.log('Toolkit app initialized')
589
+ }
590
+ return appInstance
591
+ }
592
+ `),await e(o(a,"lib/auth.ts"),`import { getToolkitApp } from './app'
593
+ import { getAuth } from '@murumets-ee/auth'
594
+
595
+ await getToolkitApp()
596
+
597
+ export const auth = getAuth()
598
+ `),await e(o(a,"lib/auth-client.ts"),`import { createClient } from '@murumets-ee/auth/client'
599
+
600
+ export const authClient = createClient()
601
+ `),await e(o(a,"generated/auth-schema.ts"),`// This file is generated by better-auth CLI.
602
+ // Run: npx @better-auth/cli generate --config auth.config.ts -y
603
+ export {}
604
+ `),await e(o(a,"scripts/generate-schema.ts"),`/**
605
+ * Generate Drizzle schemas from entity definitions
606
+ */
607
+
608
+ import { mkdir, writeFile } from 'node:fs/promises'
609
+ import { join } from 'node:path'
610
+ import { generateSchemaCode, generateTranslationSchemaCode } from '@murumets-ee/entity'
611
+ import config from '../toolkit.config'
612
+
613
+ async function generateSchemas() {
614
+ console.log('Generating Drizzle schemas from entity definitions...')
615
+
616
+ const schemaDir = join(import.meta.dirname, '..', 'generated')
617
+ await mkdir(schemaDir, { recursive: true })
618
+
619
+ const imports: string[] = []
620
+ const schemas: string[] = []
621
+ let hasTranslations = false
622
+
623
+ for (const entity of config.entities) {
624
+ console.log(\` - Generating schema for \${entity.name}\`)
625
+
626
+ const schemaCode = generateSchemaCode(entity)
627
+ schemas.push(\`\\n// \${entity.name} table\`)
628
+ schemas.push(schemaCode)
629
+
630
+ const translationCode = generateTranslationSchemaCode(entity)
631
+ if (translationCode) {
632
+ hasTranslations = true
633
+ schemas.push(\`\\n// \${entity.name} translations\`)
634
+ schemas.push(translationCode)
635
+ }
636
+ }
637
+
638
+ const pgCoreImports = hasTranslations
639
+ ? \`import { pgTable, varchar, text, boolean, timestamp, integer, jsonb, uuid, unique } from 'drizzle-orm/pg-core'\\n\`
640
+ : \`import { pgTable, varchar, text, boolean, timestamp, integer, jsonb, uuid } from 'drizzle-orm/pg-core'\\n\`
641
+ imports.push(pgCoreImports)
642
+
643
+ const schemaFile = join(schemaDir, 'schema.ts')
644
+ await writeFile(schemaFile, [...imports, ...schemas].join('\\n'))
645
+
646
+ console.log(\`\\nSchemas written to: \${schemaFile}\`)
647
+ console.log('Run \\\`pnpm db:migrate:generate\\\` to create migrations')
648
+ }
649
+
650
+ generateSchemas().catch(console.error)
651
+ `),await e(o(a,"scripts/migrate.ts"),`/**
652
+ * Run pending migrations
653
+ */
654
+
655
+ import { createDbClient, runMigrations } from '@murumets-ee/db'
656
+
657
+ async function migrate() {
658
+ console.log('Running migrations...')
659
+
660
+ try {
661
+ if (!process.env.DATABASE_URL) {
662
+ throw new Error('DATABASE_URL environment variable is required')
663
+ }
664
+
665
+ const db = createDbClient({ url: process.env.DATABASE_URL })
666
+ await runMigrations(db, import.meta.dirname + '/..')
667
+
668
+ console.log('Migrations completed successfully')
669
+ process.exit(0)
670
+ } catch (error) {
671
+ console.error('Migration failed:', error)
672
+ process.exit(1)
673
+ }
674
+ }
675
+
676
+ migrate()
677
+ `),await e(o(a,"scripts/reset-db.ts"),`/**
678
+ * Reset database (DROP all tables)
679
+ * WARNING: Only for development
680
+ */
681
+
682
+ import postgres from 'postgres'
683
+
684
+ async function resetDb() {
685
+ const DATABASE_URL = process.env.DATABASE_URL
686
+
687
+ if (!DATABASE_URL) {
688
+ throw new Error('DATABASE_URL not set')
689
+ }
690
+
691
+ console.warn('WARNING: This will DROP ALL TABLES')
692
+ console.log('Database:', DATABASE_URL.split('@')[1])
693
+
694
+ const sql = postgres(DATABASE_URL)
695
+
696
+ try {
697
+ await sql\`
698
+ DROP SCHEMA public CASCADE;
699
+ CREATE SCHEMA public;
700
+ GRANT ALL ON SCHEMA public TO PUBLIC;
701
+ \`
702
+
703
+ console.log('Database reset complete')
704
+ } finally {
705
+ await sql.end()
706
+ }
707
+ }
708
+
709
+ resetDb().catch(console.error)
710
+ `)}async function F(t,n){let{name:r}=n,a=o(t,"apps/admin");await R(o(t,"apps"),{recursive:!0}),p(`pnpm create next-app@${"16"} ${a} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await d(a,["app/page.tsx","app/page.module.css","app/fonts","README.md","pnpm-workspace.yaml"]),await u(o(a,"package.json"),{dependencies:{[`@${r}/config`]:"workspace:*","@murumets-ee/auth-ui":`^${i.myorgAuthUi}`,"better-auth":`^${i.betterAuth}`,"next-intl":`^${i.nextIntl}`,"next-themes":`^${i.nextThemes}`,"lucide-react":`^${i.lucideReact}`,"react-hook-form":`^${i.reactHookForm}`,"@hookform/resolvers":`^${i.hookformResolvers}`,zod:`^${i.zod}`},devDependencies:{"babel-plugin-react-compiler":i.babelReactCompiler}});let{appendToFile:m}=await import("./utils-4KO55GSJ.js");await m(o(a,".gitignore"),`
711
+ # Environment
712
+ .env*
713
+ !.env.example
714
+ `),await e(o(a,"next.config.ts"),`import type { NextConfig } from 'next'
715
+ import createNextIntlPlugin from 'next-intl/plugin'
716
+
717
+ const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
718
+
719
+ const nextConfig: NextConfig = {
720
+ reactCompiler: true,
721
+ transpilePackages: [
722
+ '@${r}/config',
723
+ '@murumets-ee/core',
724
+ '@murumets-ee/entity',
725
+ '@murumets-ee/db',
726
+ '@murumets-ee/logging',
727
+ '@murumets-ee/auth-ui',
728
+ ],
729
+ serverExternalPackages: ['drizzle-orm', 'postgres'],
730
+ experimental: {
731
+ serverActions: {
732
+ bodySizeLimit: '2mb',
733
+ },
734
+ },
735
+ }
736
+
737
+ export default withNextIntl(nextConfig)
738
+ `),await e(o(a,"proxy.ts"),`import { NextRequest, NextResponse } from 'next/server'
739
+ import createMiddleware from 'next-intl/middleware'
740
+ import { routing } from './i18n/routing'
741
+
742
+ const intlMiddleware = createMiddleware(routing)
743
+
744
+ const protectedPaths = ['/setup']
745
+
746
+ export function proxy(request: NextRequest) {
747
+ const { pathname } = request.nextUrl
748
+
749
+ const localePattern = new RegExp(\`^/(\${routing.locales.join('|')})\`)
750
+ const pathWithoutLocale = pathname.replace(localePattern, '') || '/'
751
+
752
+ if (protectedPaths.some((p) => pathWithoutLocale.startsWith(p))) {
753
+ const session = request.cookies.get('better-auth.session_token')
754
+ if (!session) {
755
+ const locale = pathname.match(localePattern)?.[1] || routing.defaultLocale
756
+ return NextResponse.redirect(new URL(\`/\${locale}/auth/sign-in\`, request.url))
757
+ }
758
+ }
759
+
760
+ return intlMiddleware(request)
761
+ }
762
+
763
+ export const config = {
764
+ matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
765
+ }
766
+ `),await e(o(a,"i18n/routing.ts"),`import { defineRouting } from 'next-intl/routing'
767
+
768
+ export const routing = defineRouting({
769
+ locales: ['en'],
770
+ defaultLocale: 'en',
771
+ })
772
+ `),await e(o(a,"i18n/request.ts"),`import { getRequestConfig } from 'next-intl/server'
773
+ import { hasLocale } from 'next-intl'
774
+ import { routing } from './routing'
775
+ import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
776
+
777
+ export default getRequestConfig(async ({ requestLocale }) => {
778
+ const requested = await requestLocale
779
+ const locale = hasLocale(routing.locales, requested)
780
+ ? requested
781
+ : routing.defaultLocale
782
+
783
+ const authMessages = await getAuthMessages(locale)
784
+
785
+ return {
786
+ locale,
787
+ messages: {
788
+ ...authMessages,
789
+ },
790
+ }
791
+ })
792
+ `),await e(o(a,"app/globals.css"),`@import "tailwindcss";
793
+ @source "../node_modules/@murumets-ee/auth-ui/dist";
794
+
795
+ @custom-variant dark (&:where(.dark, .dark *));
796
+
797
+ :root {
798
+ --background: #ffffff;
799
+ --foreground: #171717;
800
+ }
801
+
802
+ @theme inline {
803
+ --color-background: var(--background);
804
+ --color-foreground: var(--foreground);
805
+ --font-sans: var(--font-geist-sans);
806
+ --font-mono: var(--font-geist-mono);
807
+ }
808
+
809
+ .dark {
810
+ --background: #0a0a0a;
811
+ --foreground: #ededed;
812
+ }
813
+
814
+ body {
815
+ background: var(--background);
816
+ color: var(--foreground);
817
+ font-family: Arial, Helvetica, sans-serif;
818
+ }
819
+ `),await e(o(a,"app/theme-provider.tsx"),`'use client'
820
+
821
+ import { ThemeProvider as NextThemesProvider } from 'next-themes'
822
+ import type { ReactNode } from 'react'
823
+
824
+ export function ThemeProvider({ children }: { children: ReactNode }) {
825
+ return (
826
+ <NextThemesProvider
827
+ attribute="class"
828
+ defaultTheme="system"
829
+ enableSystem
830
+ disableTransitionOnChange
831
+ >
832
+ {children}
833
+ </NextThemesProvider>
834
+ )
835
+ }
836
+ `),await e(o(a,"app/theme-toggle.tsx"),`'use client'
837
+
838
+ import { useTheme } from 'next-themes'
839
+ import { useState, useEffect } from 'react'
840
+ import { Sun, Moon } from 'lucide-react'
841
+
842
+ export function ThemeToggle() {
843
+ const [mounted, setMounted] = useState(false)
844
+ const { resolvedTheme, setTheme } = useTheme()
845
+
846
+ useEffect(() => { setMounted(true) }, [])
847
+
848
+ if (!mounted) {
849
+ return <div className="h-8 w-8" />
850
+ }
851
+
852
+ return (
853
+ <button
854
+ type="button"
855
+ onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
856
+ className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200 cursor-pointer"
857
+ aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
858
+ >
859
+ {resolvedTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
860
+ </button>
861
+ )
862
+ }
863
+ `),await e(o(a,"app/nav-header.tsx"),`'use client'
864
+
865
+ import Link from 'next/link'
866
+ import { usePathname } from 'next/navigation'
867
+ import { ThemeToggle } from './theme-toggle'
868
+
869
+ const links = [
870
+ { href: '/', label: 'Home' },
871
+ { href: '/auth/sign-in', label: 'Sign In' },
872
+ { href: '/setup', label: 'Setup' },
873
+ ]
874
+
875
+ export function NavHeader() {
876
+ const pathname = usePathname()
877
+
878
+ function isActive(href: string) {
879
+ return href === '/' ? pathname === '/' : pathname.startsWith(href)
880
+ }
881
+
882
+ const linkClass = (href: string) =>
883
+ \`px-3 py-1.5 rounded-md text-sm transition-colors \${
884
+ isActive(href)
885
+ ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
886
+ : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800/50'
887
+ }\`
888
+
889
+ return (
890
+ <header className="sticky top-0 z-50 backdrop-blur-md border-b bg-white/80 border-zinc-200 dark:bg-zinc-950/80 dark:border-zinc-800">
891
+ <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
892
+ <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
893
+ ${r} Admin
894
+ </span>
895
+ {links.map(({ href, label }) => (
896
+ <Link key={href} href={href} className={linkClass(href)}>
897
+ {label}
898
+ </Link>
899
+ ))}
900
+ <div className="ml-auto">
901
+ <ThemeToggle />
902
+ </div>
903
+ </nav>
904
+ </header>
905
+ )
906
+ }
907
+ `),await e(o(a,"app/layout.tsx"),`import './globals.css'
908
+
909
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
910
+ return children
911
+ }
912
+ `),await e(o(a,"app/[locale]/layout.tsx"),`import type { Metadata } from 'next'
913
+ import { Geist, Geist_Mono } from 'next/font/google'
914
+ import { NextIntlClientProvider } from 'next-intl'
915
+ import { getMessages } from 'next-intl/server'
916
+ import { notFound } from 'next/navigation'
917
+ import { routing } from '@/i18n/routing'
918
+ import { ThemeProvider } from '../theme-provider'
919
+ import { NavHeader } from '../nav-header'
920
+
921
+ const geistSans = Geist({
922
+ variable: '--font-geist-sans',
923
+ subsets: ['latin'],
924
+ })
925
+
926
+ const geistMono = Geist_Mono({
927
+ variable: '--font-geist-mono',
928
+ subsets: ['latin'],
929
+ })
930
+
931
+ export const metadata: Metadata = {
932
+ title: '${r} Admin',
933
+ description: 'Built with Lumi CMS Toolkit',
934
+ }
935
+
936
+ export default async function LocaleLayout({
937
+ children,
938
+ params,
939
+ }: {
940
+ children: React.ReactNode
941
+ params: Promise<{ locale: string }>
942
+ }) {
943
+ const { locale } = await params
944
+ if (!routing.locales.includes(locale as any)) notFound()
945
+
946
+ const messages = await getMessages()
947
+
948
+ return (
949
+ <html lang={locale} suppressHydrationWarning>
950
+ <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
951
+ <ThemeProvider>
952
+ <NextIntlClientProvider locale={locale} messages={messages}>
953
+ <NavHeader />
954
+ {children}
955
+ </NextIntlClientProvider>
956
+ </ThemeProvider>
957
+ </body>
958
+ </html>
959
+ )
960
+ }
961
+ `),await e(o(a,"app/[locale]/page.tsx"),`import { createQueryClient } from '@murumets-ee/core/clients'
962
+ import { Article, Category } from '@${r}/config/entities'
963
+ import { getToolkitApp } from '@${r}/config/app'
964
+
965
+ export default async function HomePage() {
966
+ await getToolkitApp()
967
+ const articles = createQueryClient(Article)
968
+ const categories = createQueryClient(Category)
969
+
970
+ const [articleList, categoryList] = await Promise.all([
971
+ articles.findMany({ limit: 10 }),
972
+ categories.findMany({}),
973
+ ])
974
+
975
+ return (
976
+ <div className="min-h-screen p-8">
977
+ <div className="max-w-4xl mx-auto">
978
+ <h1 className="text-4xl font-bold mb-8">Welcome to ${r}</h1>
979
+
980
+ <section className="mb-12">
981
+ <h2 className="text-2xl font-semibold mb-4">
982
+ Categories ({categoryList.length})
983
+ </h2>
984
+ <div className="grid gap-4 md:grid-cols-2">
985
+ {categoryList.map((cat) => (
986
+ <div
987
+ key={cat.id}
988
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
989
+ >
990
+ <h3 className="font-semibold text-lg">{cat.name}</h3>
991
+ <p className="text-sm text-zinc-600 dark:text-zinc-400">
992
+ {cat.description}
993
+ </p>
994
+ </div>
995
+ ))}
996
+ </div>
997
+ </section>
998
+
999
+ <section>
1000
+ <h2 className="text-2xl font-semibold mb-4">
1001
+ Articles ({articleList.length})
1002
+ </h2>
1003
+ {articleList.length === 0 ? (
1004
+ <p className="text-zinc-500">No published articles yet.</p>
1005
+ ) : (
1006
+ <div className="space-y-4">
1007
+ {articleList.map((article) => (
1008
+ <div
1009
+ key={article.id}
1010
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
1011
+ >
1012
+ <h3 className="text-xl font-bold">{article.title}</h3>
1013
+ <p className="text-zinc-600 dark:text-zinc-400 mt-2">
1014
+ {article.excerpt}
1015
+ </p>
1016
+ </div>
1017
+ ))}
1018
+ </div>
1019
+ )}
1020
+ </section>
1021
+ </div>
1022
+ </div>
1023
+ )
1024
+ }
1025
+ `),await e(o(a,"app/api/auth/[...all]/route.ts"),`import { toNextJsHandler } from 'better-auth/next-js'
1026
+ import { auth } from '@${r}/config/auth'
1027
+
1028
+ export const { GET, POST } = toNextJsHandler(auth)
1029
+ `),await e(o(a,"app/[locale]/auth/layout.tsx"),`import { AuthProviders } from './providers'
1030
+ import type { ReactNode } from 'react'
1031
+
1032
+ export default function AuthLayout({ children }: { children: ReactNode }) {
1033
+ return (
1034
+ <AuthProviders>
1035
+ <div className="flex min-h-[calc(100vh-3rem)] items-center justify-center px-4">
1036
+ {children}
1037
+ </div>
1038
+ </AuthProviders>
1039
+ )
1040
+ }
1041
+ `),await e(o(a,"app/[locale]/auth/providers.tsx"),`'use client'
1042
+
1043
+ import { AuthUIProvider } from '@murumets-ee/auth-ui'
1044
+ import { authClient } from '@${r}/config/auth-client'
1045
+ import Link from 'next/link'
1046
+ import { useRouter } from 'next/navigation'
1047
+ import { useLocale } from 'next-intl'
1048
+ import type { ReactNode } from 'react'
1049
+
1050
+ export function AuthProviders({ children }: { children: ReactNode }) {
1051
+ const router = useRouter()
1052
+ const locale = useLocale()
1053
+
1054
+ return (
1055
+ <AuthUIProvider
1056
+ authClient={authClient as never}
1057
+ basePath="/auth"
1058
+ redirectTo="/"
1059
+ Link={Link}
1060
+ navigate={(url) => router.push(url)}
1061
+ resetPasswordUrl={\`/\${locale}/auth/reset-password\`}
1062
+ >
1063
+ {children}
1064
+ </AuthUIProvider>
1065
+ )
1066
+ }
1067
+ `),await e(o(a,"app/[locale]/auth/sign-in/page.tsx"),`import { SignInForm } from '@murumets-ee/auth-ui'
1068
+
1069
+ export default function SignInPage() {
1070
+ return <SignInForm />
1071
+ }
1072
+ `),await e(o(a,"app/[locale]/auth/sign-up/page.tsx"),`import { SignUpForm } from '@murumets-ee/auth-ui'
1073
+
1074
+ export default function SignUpPage() {
1075
+ return <SignUpForm />
1076
+ }
1077
+ `),await e(o(a,"app/[locale]/auth/forgot-password/page.tsx"),`import { ForgotPasswordForm } from '@murumets-ee/auth-ui'
1078
+
1079
+ export default function ForgotPasswordPage() {
1080
+ return <ForgotPasswordForm />
1081
+ }
1082
+ `),await e(o(a,"app/[locale]/auth/reset-password/page.tsx"),`import { Suspense } from 'react'
1083
+ import { ResetPasswordContent } from './content'
1084
+
1085
+ export default function ResetPasswordPage() {
1086
+ return (
1087
+ <Suspense>
1088
+ <ResetPasswordContent />
1089
+ </Suspense>
1090
+ )
1091
+ }
1092
+ `),await e(o(a,"app/[locale]/auth/reset-password/content.tsx"),`'use client'
1093
+
1094
+ import { useSearchParams } from 'next/navigation'
1095
+ import { ResetPasswordForm } from '@murumets-ee/auth-ui'
1096
+
1097
+ export function ResetPasswordContent() {
1098
+ const searchParams = useSearchParams()
1099
+ const token = searchParams.get('token')
1100
+
1101
+ if (!token) {
1102
+ return (
1103
+ <div className="text-center">
1104
+ <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
1105
+ Invalid or expired link
1106
+ </h1>
1107
+ <p className="mt-2 text-zinc-500">
1108
+ Please request a new password reset.
1109
+ </p>
1110
+ </div>
1111
+ )
1112
+ }
1113
+
1114
+ return <ResetPasswordForm token={token} />
1115
+ }
1116
+ `),await e(o(a,"app/[locale]/setup/page.tsx"),`import { redirect } from 'next/navigation'
1117
+ import { sql } from 'drizzle-orm'
1118
+ import { getToolkitApp } from '@${r}/config/app'
1119
+ import { SetupForm } from './form'
1120
+
1121
+ export default async function SetupPage() {
1122
+ const app = await getToolkitApp()
1123
+ const result = await app.db.readOnly.execute<{ count: string }>(
1124
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
1125
+ )
1126
+
1127
+ if (Number(result[0]?.count) > 0) {
1128
+ redirect('/')
1129
+ }
1130
+
1131
+ return (
1132
+ <div className="max-w-md mx-auto p-8">
1133
+ <h1 className="text-2xl font-bold mb-2">Create Admin</h1>
1134
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
1135
+ No users found. Create the first admin account.
1136
+ </p>
1137
+ <SetupForm />
1138
+ </div>
1139
+ )
1140
+ }
1141
+ `),await e(o(a,"app/[locale]/setup/form.tsx"),`'use client'
1142
+
1143
+ import { useState } from 'react'
1144
+ import { createFirstAdmin } from './actions'
1145
+
1146
+ export function SetupForm() {
1147
+ const [result, setResult] = useState<{ ok?: boolean; error?: string; email?: string } | null>(null)
1148
+ const [loading, setLoading] = useState(false)
1149
+
1150
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
1151
+ e.preventDefault()
1152
+ setLoading(true)
1153
+ setResult(null)
1154
+ const res = await createFirstAdmin(new FormData(e.currentTarget))
1155
+ setResult(res)
1156
+ setLoading(false)
1157
+ }
1158
+
1159
+ if (result?.ok) {
1160
+ return (
1161
+ <div className="space-y-2">
1162
+ <p className="text-green-600 dark:text-green-400">Admin created: {result.email}</p>
1163
+ <a href="/auth/sign-in" className="text-blue-600 dark:text-blue-400 hover:underline">
1164
+ Sign in &rarr;
1165
+ </a>
1166
+ </div>
1167
+ )
1168
+ }
1169
+
1170
+ return (
1171
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
1172
+ <label className="text-sm font-medium">
1173
+ Name
1174
+ <input
1175
+ name="name"
1176
+ required
1177
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
1178
+ />
1179
+ </label>
1180
+ <label className="text-sm font-medium">
1181
+ Email
1182
+ <input
1183
+ name="email"
1184
+ type="email"
1185
+ required
1186
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
1187
+ />
1188
+ </label>
1189
+ <label className="text-sm font-medium">
1190
+ Password (min 8 chars)
1191
+ <input
1192
+ name="password"
1193
+ type="password"
1194
+ required
1195
+ minLength={8}
1196
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
1197
+ />
1198
+ </label>
1199
+
1200
+ {result?.error && <p className="text-red-600 dark:text-red-400 text-sm">{result.error}</p>}
1201
+
1202
+ <button
1203
+ type="submit"
1204
+ disabled={loading}
1205
+ className="px-4 py-2 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
1206
+ >
1207
+ {loading ? 'Creating...' : 'Create Admin'}
1208
+ </button>
1209
+ </form>
1210
+ )
1211
+ }
1212
+ `),await e(o(a,"app/[locale]/setup/actions.ts"),`'use server'
1213
+
1214
+ import { sql } from 'drizzle-orm'
1215
+ import { getToolkitApp } from '@${r}/config/app'
1216
+ import { getAuth } from '@murumets-ee/auth'
1217
+
1218
+ export async function createFirstAdmin(formData: FormData) {
1219
+ const email = formData.get('email') as string
1220
+ const password = formData.get('password') as string
1221
+ const name = formData.get('name') as string
1222
+
1223
+ if (!email || !password || !name) {
1224
+ return { error: 'All fields are required' }
1225
+ }
1226
+
1227
+ if (password.length < 8) {
1228
+ return { error: 'Password must be at least 8 characters' }
1229
+ }
1230
+
1231
+ const app = await getToolkitApp()
1232
+
1233
+ const canCreate = await app.db.readWrite.transaction(async (tx) => {
1234
+ await tx.execute(sql\`SELECT pg_advisory_xact_lock(1)\`)
1235
+
1236
+ const result = await tx.execute<{ count: string }>(
1237
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
1238
+ )
1239
+ return Number(result[0]?.count) === 0
1240
+ })
1241
+
1242
+ if (!canCreate) {
1243
+ return { error: 'Admin user already exists. Setup is complete.' }
1244
+ }
1245
+
1246
+ const auth = getAuth()
1247
+
1248
+ const adminUser = await auth.api.signUpEmail({
1249
+ body: { email, password, name },
1250
+ })
1251
+
1252
+ await app.db.readWrite.execute(
1253
+ sql\`UPDATE "user" SET role = 'admin' WHERE id = \${adminUser.user.id}\`,
1254
+ )
1255
+
1256
+ return { ok: true, email: adminUser.user.email }
1257
+ }
1258
+ `)}async function D(t,n){let{name:r}=n,a=o(t,"apps/web");p(`pnpm create next-app@${"16"} ${a} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await d(a,["app/page.tsx","app/page.module.css","app/fonts","README.md","pnpm-workspace.yaml"]),await u(o(a,"package.json"),{dependencies:{[`@${r}/config`]:"workspace:*","@murumets-ee/core":`^${i.myorgCore}`,"@murumets-ee/auth-ui":`^${i.myorgAuthUi}`,"next-intl":`^${i.nextIntl}`,"next-themes":`^${i.nextThemes}`,"lucide-react":`^${i.lucideReact}`},devDependencies:{"babel-plugin-react-compiler":i.babelReactCompiler}});let{appendToFile:m}=await import("./utils-4KO55GSJ.js");await m(o(a,".gitignore"),`
1259
+ # Environment
1260
+ .env*
1261
+ !.env.example
1262
+ `),await e(o(a,"next.config.ts"),`import type { NextConfig } from 'next'
1263
+ import createNextIntlPlugin from 'next-intl/plugin'
1264
+
1265
+ const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
1266
+
1267
+ const nextConfig: NextConfig = {
1268
+ reactCompiler: true,
1269
+ transpilePackages: [
1270
+ '@${r}/config',
1271
+ '@murumets-ee/core',
1272
+ '@murumets-ee/entity',
1273
+ '@murumets-ee/db',
1274
+ '@murumets-ee/logging',
1275
+ '@murumets-ee/auth-ui',
1276
+ ],
1277
+ serverExternalPackages: ['drizzle-orm', 'postgres'],
1278
+ }
1279
+
1280
+ export default withNextIntl(nextConfig)
1281
+ `),await e(o(a,"proxy.ts"),`import createMiddleware from 'next-intl/middleware'
1282
+ import { routing } from './i18n/routing'
1283
+
1284
+ export default createMiddleware(routing)
1285
+
1286
+ export const config = {
1287
+ matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
1288
+ }
1289
+ `),await e(o(a,"i18n/routing.ts"),`import { defineRouting } from 'next-intl/routing'
1290
+
1291
+ export const routing = defineRouting({
1292
+ locales: ['en'],
1293
+ defaultLocale: 'en',
1294
+ })
1295
+ `),await e(o(a,"i18n/request.ts"),`import { getRequestConfig } from 'next-intl/server'
1296
+ import { hasLocale } from 'next-intl'
1297
+ import { routing } from './routing'
1298
+ import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
1299
+
1300
+ export default getRequestConfig(async ({ requestLocale }) => {
1301
+ const requested = await requestLocale
1302
+ const locale = hasLocale(routing.locales, requested)
1303
+ ? requested
1304
+ : routing.defaultLocale
1305
+
1306
+ const authMessages = await getAuthMessages(locale)
1307
+
1308
+ return {
1309
+ locale,
1310
+ messages: {
1311
+ ...authMessages,
1312
+ },
1313
+ }
1314
+ })
1315
+ `),await e(o(a,"app/globals.css"),`@import "tailwindcss";
1316
+ @source "../node_modules/@murumets-ee/auth-ui/dist";
1317
+
1318
+ @custom-variant dark (&:where(.dark, .dark *));
1319
+
1320
+ :root {
1321
+ --background: #ffffff;
1322
+ --foreground: #171717;
1323
+ }
1324
+
1325
+ @theme inline {
1326
+ --color-background: var(--background);
1327
+ --color-foreground: var(--foreground);
1328
+ --font-sans: var(--font-geist-sans);
1329
+ --font-mono: var(--font-geist-mono);
1330
+ }
1331
+
1332
+ .dark {
1333
+ --background: #0a0a0a;
1334
+ --foreground: #ededed;
1335
+ }
1336
+
1337
+ body {
1338
+ background: var(--background);
1339
+ color: var(--foreground);
1340
+ font-family: Arial, Helvetica, sans-serif;
1341
+ }
1342
+ `),await e(o(a,"app/theme-provider.tsx"),`'use client'
1343
+
1344
+ import { ThemeProvider as NextThemesProvider } from 'next-themes'
1345
+ import type { ReactNode } from 'react'
1346
+
1347
+ export function ThemeProvider({ children }: { children: ReactNode }) {
1348
+ return (
1349
+ <NextThemesProvider
1350
+ attribute="class"
1351
+ defaultTheme="system"
1352
+ enableSystem
1353
+ disableTransitionOnChange
1354
+ >
1355
+ {children}
1356
+ </NextThemesProvider>
1357
+ )
1358
+ }
1359
+ `),await e(o(a,"app/theme-toggle.tsx"),`'use client'
1360
+
1361
+ import { useTheme } from 'next-themes'
1362
+ import { useState, useEffect } from 'react'
1363
+ import { Sun, Moon } from 'lucide-react'
1364
+
1365
+ export function ThemeToggle() {
1366
+ const [mounted, setMounted] = useState(false)
1367
+ const { resolvedTheme, setTheme } = useTheme()
1368
+
1369
+ useEffect(() => { setMounted(true) }, [])
1370
+
1371
+ if (!mounted) {
1372
+ return <div className="h-8 w-8" />
1373
+ }
1374
+
1375
+ return (
1376
+ <button
1377
+ type="button"
1378
+ onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
1379
+ className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200 cursor-pointer"
1380
+ aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
1381
+ >
1382
+ {resolvedTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
1383
+ </button>
1384
+ )
1385
+ }
1386
+ `),await e(o(a,"app/nav-header.tsx"),`'use client'
1387
+
1388
+ import Link from 'next/link'
1389
+ import { usePathname } from 'next/navigation'
1390
+ import { ThemeToggle } from './theme-toggle'
1391
+
1392
+ const links = [
1393
+ { href: '/', label: 'Home' },
1394
+ ]
1395
+
1396
+ export function NavHeader() {
1397
+ const pathname = usePathname()
1398
+
1399
+ function isActive(href: string) {
1400
+ return href === '/' ? pathname === '/' : pathname.startsWith(href)
1401
+ }
1402
+
1403
+ const linkClass = (href: string) =>
1404
+ \`px-3 py-1.5 rounded-md text-sm transition-colors \${
1405
+ isActive(href)
1406
+ ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
1407
+ : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800/50'
1408
+ }\`
1409
+
1410
+ return (
1411
+ <header className="sticky top-0 z-50 backdrop-blur-md border-b bg-white/80 border-zinc-200 dark:bg-zinc-950/80 dark:border-zinc-800">
1412
+ <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
1413
+ <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
1414
+ ${r}
1415
+ </span>
1416
+ {links.map(({ href, label }) => (
1417
+ <Link key={href} href={href} className={linkClass(href)}>
1418
+ {label}
1419
+ </Link>
1420
+ ))}
1421
+ <div className="ml-auto">
1422
+ <ThemeToggle />
1423
+ </div>
1424
+ </nav>
1425
+ </header>
1426
+ )
1427
+ }
1428
+ `),await e(o(a,"app/layout.tsx"),`import './globals.css'
1429
+
1430
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
1431
+ return children
1432
+ }
1433
+ `),await e(o(a,"app/[locale]/layout.tsx"),`import type { Metadata } from 'next'
1434
+ import { Geist, Geist_Mono } from 'next/font/google'
1435
+ import { NextIntlClientProvider } from 'next-intl'
1436
+ import { getMessages } from 'next-intl/server'
1437
+ import { notFound } from 'next/navigation'
1438
+ import { routing } from '@/i18n/routing'
1439
+ import { ThemeProvider } from '../theme-provider'
1440
+ import { NavHeader } from '../nav-header'
1441
+
1442
+ const geistSans = Geist({
1443
+ variable: '--font-geist-sans',
1444
+ subsets: ['latin'],
1445
+ })
1446
+
1447
+ const geistMono = Geist_Mono({
1448
+ variable: '--font-geist-mono',
1449
+ subsets: ['latin'],
1450
+ })
1451
+
1452
+ export const metadata: Metadata = {
1453
+ title: '${r}',
1454
+ description: 'Built with Lumi CMS Toolkit',
1455
+ }
1456
+
1457
+ export default async function LocaleLayout({
1458
+ children,
1459
+ params,
1460
+ }: {
1461
+ children: React.ReactNode
1462
+ params: Promise<{ locale: string }>
1463
+ }) {
1464
+ const { locale } = await params
1465
+ if (!routing.locales.includes(locale as any)) notFound()
1466
+
1467
+ const messages = await getMessages()
1468
+
1469
+ return (
1470
+ <html lang={locale} suppressHydrationWarning>
1471
+ <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
1472
+ <ThemeProvider>
1473
+ <NextIntlClientProvider locale={locale} messages={messages}>
1474
+ <NavHeader />
1475
+ {children}
1476
+ </NextIntlClientProvider>
1477
+ </ThemeProvider>
1478
+ </body>
1479
+ </html>
1480
+ )
1481
+ }
1482
+ `),await e(o(a,"app/[locale]/page.tsx"),`import { createQueryClient } from '@murumets-ee/core/clients'
1483
+ import { Article, Category } from '@${r}/config/entities'
1484
+ import { getToolkitApp } from '@${r}/config/app'
1485
+
1486
+ export default async function HomePage() {
1487
+ await getToolkitApp()
1488
+ const articles = createQueryClient(Article)
1489
+ const categories = createQueryClient(Category)
1490
+
1491
+ const [articleList, categoryList] = await Promise.all([
1492
+ articles.findMany({ limit: 10 }),
1493
+ categories.findMany({}),
1494
+ ])
1495
+
1496
+ return (
1497
+ <div className="min-h-screen p-8">
1498
+ <div className="max-w-4xl mx-auto">
1499
+ <h1 className="text-4xl font-bold mb-8">Welcome to ${r}</h1>
1500
+
1501
+ <section className="mb-12">
1502
+ <h2 className="text-2xl font-semibold mb-4">
1503
+ Categories ({categoryList.length})
1504
+ </h2>
1505
+ <div className="grid gap-4 md:grid-cols-2">
1506
+ {categoryList.map((cat) => (
1507
+ <div
1508
+ key={cat.id}
1509
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
1510
+ >
1511
+ <h3 className="font-semibold text-lg">{cat.name}</h3>
1512
+ <p className="text-sm text-zinc-600 dark:text-zinc-400">
1513
+ {cat.description}
1514
+ </p>
1515
+ </div>
1516
+ ))}
1517
+ </div>
1518
+ </section>
1519
+
1520
+ <section>
1521
+ <h2 className="text-2xl font-semibold mb-4">
1522
+ Articles ({articleList.length})
1523
+ </h2>
1524
+ {articleList.length === 0 ? (
1525
+ <p className="text-zinc-500">No published articles yet.</p>
1526
+ ) : (
1527
+ <div className="space-y-4">
1528
+ {articleList.map((article) => (
1529
+ <div
1530
+ key={article.id}
1531
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
1532
+ >
1533
+ <h3 className="text-xl font-bold">{article.title}</h3>
1534
+ <p className="text-zinc-600 dark:text-zinc-400 mt-2">
1535
+ {article.excerpt}
1536
+ </p>
1537
+ </div>
1538
+ ))}
1539
+ </div>
1540
+ )}
1541
+ </section>
1542
+ </div>
1543
+ </div>
1544
+ )
1545
+ }
1546
+ `)}import{join as g}from"path";async function L(t,n){let{name:r}=n;await e(g(t,"next.config.ts"),`import type { NextConfig } from 'next'
1547
+ import createNextIntlPlugin from 'next-intl/plugin'
1548
+
1549
+ const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
1550
+
1551
+ const nextConfig: NextConfig = {
1552
+ reactCompiler: true,
1553
+ transpilePackages: ['@murumets-ee/core', '@murumets-ee/entity', '@murumets-ee/db', '@murumets-ee/logging', '@murumets-ee/auth-ui'],
1554
+ serverExternalPackages: ['drizzle-orm', 'postgres'],
1555
+ experimental: {
1556
+ serverActions: {
1557
+ bodySizeLimit: '2mb',
1558
+ },
1559
+ },
1560
+ }
1561
+
1562
+ export default withNextIntl(nextConfig)
1563
+ `),await e(g(t,"proxy.ts"),`import { NextRequest, NextResponse } from 'next/server'
1564
+ import createMiddleware from 'next-intl/middleware'
1565
+ import { routing } from './i18n/routing'
1566
+
1567
+ const intlMiddleware = createMiddleware(routing)
1568
+
1569
+ const protectedPaths = ['/setup']
1570
+
1571
+ export function proxy(request: NextRequest) {
1572
+ const { pathname } = request.nextUrl
1573
+
1574
+ // Strip locale prefix to check actual path
1575
+ const localePattern = new RegExp(\`^/(\${routing.locales.join('|')})\`)
1576
+ const pathWithoutLocale = pathname.replace(localePattern, '') || '/'
1577
+
1578
+ // Auth check for protected paths
1579
+ if (protectedPaths.some((p) => pathWithoutLocale.startsWith(p))) {
1580
+ const session = request.cookies.get('better-auth.session_token')
1581
+ if (!session) {
1582
+ const locale = pathname.match(localePattern)?.[1] || routing.defaultLocale
1583
+ return NextResponse.redirect(new URL(\`/\${locale}/auth/sign-in\`, request.url))
1584
+ }
1585
+ }
1586
+
1587
+ // Locale routing
1588
+ return intlMiddleware(request)
1589
+ }
1590
+
1591
+ export const config = {
1592
+ matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
1593
+ }
1594
+ `),await e(g(t,"app/layout.tsx"),`import './globals.css'
1595
+
1596
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
1597
+ return children
1598
+ }
1599
+ `),await e(g(t,"app/[locale]/layout.tsx"),`import type { Metadata } from 'next'
1600
+ import { Geist, Geist_Mono } from 'next/font/google'
1601
+ import { NextIntlClientProvider } from 'next-intl'
1602
+ import { getMessages } from 'next-intl/server'
1603
+ import { notFound } from 'next/navigation'
1604
+ import { routing } from '@/i18n/routing'
1605
+ import { ThemeProvider } from '../theme-provider'
1606
+ import { NavHeader } from '../nav-header'
1607
+
1608
+ const geistSans = Geist({
1609
+ variable: '--font-geist-sans',
1610
+ subsets: ['latin'],
1611
+ })
1612
+
1613
+ const geistMono = Geist_Mono({
1614
+ variable: '--font-geist-mono',
1615
+ subsets: ['latin'],
1616
+ })
1617
+
1618
+ export const metadata: Metadata = {
1619
+ title: '${r}',
1620
+ description: 'Built with Lumi CMS Toolkit',
1621
+ }
1622
+
1623
+ export default async function LocaleLayout({
1624
+ children,
1625
+ params,
1626
+ }: {
1627
+ children: React.ReactNode
1628
+ params: Promise<{ locale: string }>
1629
+ }) {
1630
+ const { locale } = await params
1631
+ if (!routing.locales.includes(locale as any)) notFound()
1632
+
1633
+ const messages = await getMessages()
1634
+
1635
+ return (
1636
+ <html lang={locale} suppressHydrationWarning>
1637
+ <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
1638
+ <ThemeProvider>
1639
+ <NextIntlClientProvider locale={locale} messages={messages}>
1640
+ <NavHeader />
1641
+ {children}
1642
+ </NextIntlClientProvider>
1643
+ </ThemeProvider>
1644
+ </body>
1645
+ </html>
1646
+ )
1647
+ }
1648
+ `),await e(g(t,"app/[locale]/page.tsx"),`import { createQueryClient } from '@murumets-ee/core/clients'
1649
+ import { Article, Category } from '@/entities'
1650
+ import { getToolkitApp } from '@/lib/app'
1651
+
1652
+ export default async function HomePage() {
1653
+ await getToolkitApp()
1654
+ const articles = createQueryClient(Article)
1655
+ const categories = createQueryClient(Category)
1656
+
1657
+ const [articleList, categoryList] = await Promise.all([
1658
+ articles.findMany({ limit: 10 }),
1659
+ categories.findMany({}),
1660
+ ])
1661
+
1662
+ return (
1663
+ <div className="min-h-screen p-8">
1664
+ <div className="max-w-4xl mx-auto">
1665
+ <h1 className="text-4xl font-bold mb-8">Welcome to ${r}</h1>
1666
+
1667
+ <section className="mb-12">
1668
+ <h2 className="text-2xl font-semibold mb-4">
1669
+ Categories ({categoryList.length})
1670
+ </h2>
1671
+ <div className="grid gap-4 md:grid-cols-2">
1672
+ {categoryList.map((cat) => (
1673
+ <div
1674
+ key={cat.id}
1675
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
1676
+ >
1677
+ <h3 className="font-semibold text-lg">{cat.name}</h3>
1678
+ <p className="text-sm text-zinc-600 dark:text-zinc-400">
1679
+ {cat.description}
1680
+ </p>
1681
+ </div>
1682
+ ))}
1683
+ </div>
1684
+ </section>
1685
+
1686
+ <section>
1687
+ <h2 className="text-2xl font-semibold mb-4">
1688
+ Articles ({articleList.length})
1689
+ </h2>
1690
+ {articleList.length === 0 ? (
1691
+ <p className="text-zinc-500">No published articles yet.</p>
1692
+ ) : (
1693
+ <div className="space-y-4">
1694
+ {articleList.map((article) => (
1695
+ <div
1696
+ key={article.id}
1697
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
1698
+ >
1699
+ <h3 className="text-xl font-bold">{article.title}</h3>
1700
+ <p className="text-zinc-600 dark:text-zinc-400 mt-2">
1701
+ {article.excerpt}
1702
+ </p>
1703
+ </div>
1704
+ ))}
1705
+ </div>
1706
+ )}
1707
+ </section>
1708
+ </div>
1709
+ </div>
1710
+ )
1711
+ }
1712
+ `)}import{join as y}from"path";async function _(t,n){await e(y(t,"scripts/generate-schema.ts"),`/**
1713
+ * Generate Drizzle schemas from entity definitions
1714
+ * This script reads entity definitions and outputs Drizzle schema files
1715
+ */
1716
+
1717
+ import { mkdir, writeFile } from 'node:fs/promises'
1718
+ import { join } from 'node:path'
1719
+ import { generateSchemaCode, generateTranslationSchemaCode } from '@murumets-ee/entity'
1720
+ import config from '../toolkit.config'
1721
+
1722
+ async function generateSchemas() {
1723
+ console.log('Generating Drizzle schemas from entity definitions...')
1724
+
1725
+ const schemaDir = join(import.meta.dirname, '..', 'generated')
1726
+ await mkdir(schemaDir, { recursive: true })
1727
+
1728
+ const imports: string[] = []
1729
+ const schemas: string[] = []
1730
+ let hasTranslations = false
1731
+
1732
+ for (const entity of config.entities) {
1733
+ console.log(\` - Generating schema for \${entity.name}\`)
1734
+
1735
+ // Generate main table (returns TypeScript code string)
1736
+ const schemaCode = generateSchemaCode(entity)
1737
+ schemas.push(\`\\n// \${entity.name} table\`)
1738
+ schemas.push(schemaCode)
1739
+
1740
+ // Generate translation table if needed (returns TypeScript code string or null)
1741
+ const translationCode = generateTranslationSchemaCode(entity)
1742
+ if (translationCode) {
1743
+ hasTranslations = true
1744
+ schemas.push(\`\\n// \${entity.name} translations\`)
1745
+ schemas.push(translationCode)
1746
+ }
1747
+ }
1748
+
1749
+ // Add imports (include unique() if translation tables exist)
1750
+ const pgCoreImports = hasTranslations
1751
+ ? \`import { pgTable, varchar, text, boolean, timestamp, integer, jsonb, uuid, unique } from 'drizzle-orm/pg-core'\\n\`
1752
+ : \`import { pgTable, varchar, text, boolean, timestamp, integer, jsonb, uuid } from 'drizzle-orm/pg-core'\\n\`
1753
+ imports.push(pgCoreImports)
1754
+
1755
+ const schemaFile = join(schemaDir, 'schema.ts')
1756
+ await writeFile(schemaFile, [...imports, ...schemas].join('\\n'))
1757
+
1758
+ console.log(\`\\nSchemas written to: \${schemaFile}\`)
1759
+ console.log('Run \\\`pnpm db:migrate:generate\\\` to create migrations')
1760
+ }
1761
+
1762
+ generateSchemas().catch(console.error)
1763
+ `),await e(y(t,"scripts/migrate.ts"),`/**
1764
+ * Run pending migrations
1765
+ * Applies both toolkit migrations (.toolkit/) and project migrations
1766
+ */
1767
+
1768
+ import { createDbClient, runMigrations } from '@murumets-ee/db'
1769
+
1770
+ async function migrate() {
1771
+ console.log('Running migrations...')
1772
+
1773
+ try {
1774
+ if (!process.env.DATABASE_URL) {
1775
+ throw new Error('DATABASE_URL environment variable is required')
1776
+ }
1777
+
1778
+ const db = createDbClient({ url: process.env.DATABASE_URL })
1779
+ await runMigrations(db, import.meta.dirname + '/..')
1780
+
1781
+ console.log('Migrations completed successfully')
1782
+ process.exit(0)
1783
+ } catch (error) {
1784
+ console.error('Migration failed:', error)
1785
+ process.exit(1)
1786
+ }
1787
+ }
1788
+
1789
+ migrate()
1790
+ `),await e(y(t,"scripts/reset-db.ts"),`/**
1791
+ * Reset database (DROP all tables)
1792
+ * WARNING: Only for development
1793
+ */
1794
+
1795
+ import postgres from 'postgres'
1796
+
1797
+ async function resetDb() {
1798
+ const DATABASE_URL = process.env.DATABASE_URL
1799
+
1800
+ if (!DATABASE_URL) {
1801
+ throw new Error('DATABASE_URL not set')
1802
+ }
1803
+
1804
+ console.warn('WARNING: This will DROP ALL TABLES')
1805
+ console.log('Database:', DATABASE_URL.split('@')[1])
1806
+
1807
+ const sql = postgres(DATABASE_URL)
1808
+
1809
+ try {
1810
+ // Drop all tables (cascade to handle foreign keys)
1811
+ await sql\`
1812
+ DROP SCHEMA public CASCADE;
1813
+ CREATE SCHEMA public;
1814
+ GRANT ALL ON SCHEMA public TO PUBLIC;
1815
+ \`
1816
+
1817
+ console.log('Database reset complete')
1818
+ } finally {
1819
+ await sql.end()
1820
+ }
1821
+ }
1822
+
1823
+ resetDb().catch(console.error)
1824
+ `)}import{join as b}from"path";async function $(t,n){let{name:r}=n;await e(b(t,"app/globals.css"),`@import "tailwindcss";
1825
+ @source "../node_modules/@murumets-ee/auth-ui/dist";
1826
+
1827
+ @custom-variant dark (&:where(.dark, .dark *));
1828
+
1829
+ :root {
1830
+ --background: #ffffff;
1831
+ --foreground: #171717;
1832
+ }
1833
+
1834
+ @theme inline {
1835
+ --color-background: var(--background);
1836
+ --color-foreground: var(--foreground);
1837
+ --font-sans: var(--font-geist-sans);
1838
+ --font-mono: var(--font-geist-mono);
1839
+ }
1840
+
1841
+ .dark {
1842
+ --background: #0a0a0a;
1843
+ --foreground: #ededed;
1844
+ }
1845
+
1846
+ body {
1847
+ background: var(--background);
1848
+ color: var(--foreground);
1849
+ font-family: Arial, Helvetica, sans-serif;
1850
+ }
1851
+ `),await e(b(t,"app/theme-provider.tsx"),`'use client'
1852
+
1853
+ import { ThemeProvider as NextThemesProvider } from 'next-themes'
1854
+ import type { ReactNode } from 'react'
1855
+
1856
+ export function ThemeProvider({ children }: { children: ReactNode }) {
1857
+ return (
1858
+ <NextThemesProvider
1859
+ attribute="class"
1860
+ defaultTheme="system"
1861
+ enableSystem
1862
+ disableTransitionOnChange
1863
+ >
1864
+ {children}
1865
+ </NextThemesProvider>
1866
+ )
1867
+ }
1868
+ `),await e(b(t,"app/theme-toggle.tsx"),`'use client'
1869
+
1870
+ import { useTheme } from 'next-themes'
1871
+ import { useState, useEffect } from 'react'
1872
+ import { Sun, Moon } from 'lucide-react'
1873
+
1874
+ export function ThemeToggle() {
1875
+ const [mounted, setMounted] = useState(false)
1876
+ const { resolvedTheme, setTheme } = useTheme()
1877
+
1878
+ useEffect(() => { setMounted(true) }, [])
1879
+
1880
+ if (!mounted) {
1881
+ return <div className="h-8 w-8" />
1882
+ }
1883
+
1884
+ return (
1885
+ <button
1886
+ type="button"
1887
+ onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
1888
+ className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200 cursor-pointer"
1889
+ aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
1890
+ >
1891
+ {resolvedTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
1892
+ </button>
1893
+ )
1894
+ }
1895
+ `),await e(b(t,"app/nav-header.tsx"),`'use client'
1896
+
1897
+ import Link from 'next/link'
1898
+ import { usePathname } from 'next/navigation'
1899
+ import { ThemeToggle } from './theme-toggle'
1900
+
1901
+ const links = [
1902
+ { href: '/', label: 'Home' },
1903
+ { href: '/auth/sign-in', label: 'Sign In' },
1904
+ { href: '/setup', label: 'Setup' },
1905
+ ]
1906
+
1907
+ export function NavHeader() {
1908
+ const pathname = usePathname()
1909
+
1910
+ function isActive(href: string) {
1911
+ return href === '/' ? pathname === '/' : pathname.startsWith(href)
1912
+ }
1913
+
1914
+ const linkClass = (href: string) =>
1915
+ \`px-3 py-1.5 rounded-md text-sm transition-colors \${
1916
+ isActive(href)
1917
+ ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
1918
+ : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800/50'
1919
+ }\`
1920
+
1921
+ return (
1922
+ <header className="sticky top-0 z-50 backdrop-blur-md border-b bg-white/80 border-zinc-200 dark:bg-zinc-950/80 dark:border-zinc-800">
1923
+ <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
1924
+ <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
1925
+ ${r}
1926
+ </span>
1927
+ {links.map(({ href, label }) => (
1928
+ <Link key={href} href={href} className={linkClass(href)}>
1929
+ {label}
1930
+ </Link>
1931
+ ))}
1932
+ <div className="ml-auto">
1933
+ <ThemeToggle />
1934
+ </div>
1935
+ </nav>
1936
+ </header>
1937
+ )
1938
+ }
1939
+ `)}import{join as f}from"path";async function M(t,n){let{name:r}=n;await e(f(t,"toolkit.config.ts"),`import { auth } from '@murumets-ee/auth/plugin'
1940
+ import { content } from '@murumets-ee/content/plugin'
1941
+ import { defineConfig } from '@murumets-ee/core'
1942
+ import { mail, ResendMailProvider } from '@murumets-ee/mail'
1943
+ import { media } from '@murumets-ee/media/plugin'
1944
+ import { queue } from '@murumets-ee/queue/plugin'
1945
+ import { settings } from '@murumets-ee/settings/plugin'
1946
+ import { storage } from '@murumets-ee/storage/plugin'
1947
+ import { taxonomy } from '@murumets-ee/taxonomy/plugin'
1948
+ import { ticketing } from '@murumets-ee/ticketing/plugin'
1949
+ import { Article, Category } from './entities'
1950
+ import * as authSchema from './generated/auth-schema'
1951
+
1952
+ if (!process.env.DATABASE_URL) {
1953
+ throw new Error('DATABASE_URL environment variable is required')
1954
+ }
1955
+
1956
+ export default defineConfig({
1957
+ db: {
1958
+ url: process.env.DATABASE_URL,
1959
+ poolMin: 2,
1960
+ poolMax: 10,
1961
+ },
1962
+ logging: {
1963
+ level: (process.env.LOG_LEVEL || 'info') as 'debug' | 'info' | 'warn' | 'error',
1964
+ name: '${r}',
1965
+ },
1966
+ entities: [Category, Article],
1967
+ plugins: [
1968
+ auth({ providers: ['email'], schema: authSchema }),
1969
+ content({
1970
+ locales: [{ code: 'en', label: 'English' }],
1971
+ defaultLocale: 'en',
1972
+ }),
1973
+ settings(),
1974
+ storage(),
1975
+ media(),
1976
+ taxonomy(),
1977
+ queue(),
1978
+ mail({
1979
+ provider: process.env.RESEND_API_KEY
1980
+ ? new ResendMailProvider({ apiKey: process.env.RESEND_API_KEY })
1981
+ : undefined,
1982
+ defaultFrom: process.env.MAIL_FROM ?? 'noreply@example.com',
1983
+ webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
1984
+ }),
1985
+ ticketing({
1986
+ csatSecret: process.env.CSAT_SECRET,
1987
+ }),
1988
+ ],
1989
+ projectRoot: import.meta.dirname,
1990
+ })
1991
+ `),await e(f(t,"lib/app.ts"),`import { createApp, setApp, type ToolkitApp } from '@murumets-ee/core'
1992
+ import config from '../toolkit.config'
1993
+
1994
+ let appInstance: ToolkitApp | null = null
1995
+
1996
+ export async function getToolkitApp(): Promise<ToolkitApp> {
1997
+ if (!appInstance) {
1998
+ console.log('Initializing toolkit app...')
1999
+ appInstance = await createApp(config)
2000
+ setApp(appInstance)
2001
+ console.log('Toolkit app initialized')
2002
+ }
2003
+ return appInstance
2004
+ }
2005
+ `),await e(f(t,"drizzle.config.ts"),`import type { Config } from 'drizzle-kit'
2006
+
2007
+ if (!process.env.DATABASE_URL) {
2008
+ throw new Error('DATABASE_URL environment variable is required')
2009
+ }
2010
+
2011
+ export default {
2012
+ schema: ['./generated/schema.ts', './generated/auth-schema.ts'],
2013
+ out: './migrations',
2014
+ dialect: 'postgresql',
2015
+ dbCredentials: {
2016
+ url: process.env.DATABASE_URL,
2017
+ },
2018
+ } satisfies Config
2019
+ `),await e(f(t,"auth.config.ts"),`import { betterAuth } from 'better-auth'
2020
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
2021
+ import { admin } from 'better-auth/plugins'
2022
+ import { organization } from 'better-auth/plugins/organization'
2023
+ import { drizzle } from 'drizzle-orm/postgres-js'
2024
+ import postgres from 'postgres'
2025
+
2026
+ const sql = postgres(process.env.DATABASE_URL!)
2027
+ const db = drizzle(sql)
2028
+
2029
+ export const auth = betterAuth({
2030
+ database: drizzleAdapter(db, { provider: 'pg' }),
2031
+ emailAndPassword: { enabled: true },
2032
+ plugins: [
2033
+ admin(),
2034
+ organization(),
2035
+ ],
2036
+ })
2037
+ `),await e(f(t,"generated/auth-schema.ts"),`// This file is generated by better-auth CLI.
2038
+ // Run: npx @better-auth/cli generate --config auth.config.ts -y
2039
+ export {}
2040
+ `)}async function q(t,n){t.mode==="single"?await B(t,n):await H(t,n)}async function B(t,n){let r=k(process.cwd(),t.name);n?.("Creating Next.js app..."),p(`pnpm create next-app@${"16"} ${t.name} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await d(r,["app/page.tsx","app/page.module.css","app/fonts","README.md"]),await u(k(r,"package.json"),{dependencies:{"@murumets-ee/core":`^${i.myorgCore}`,"@murumets-ee/db":`^${i.myorgDb}`,"@murumets-ee/entity":`^${i.myorgEntity}`,"@murumets-ee/logging":`^${i.myorgLogging}`,"@murumets-ee/auth":`^${i.myorgAuth}`,"@murumets-ee/auth-ui":`^${i.myorgAuthUi}`,"better-auth":`^${i.betterAuth}`,"drizzle-orm":`^${i.drizzleOrm}`,"next-intl":`^${i.nextIntl}`,"next-themes":`^${i.nextThemes}`,"lucide-react":`^${i.lucideReact}`,postgres:`^${i.postgres}`,"react-hook-form":`^${i.reactHookForm}`,"@hookform/resolvers":`^${i.hookformResolvers}`,zod:`^${i.zod}`},devDependencies:{"drizzle-kit":`^${i.drizzleKit}`,tsx:`^${i.tsx}`,"babel-plugin-react-compiler":i.babelReactCompiler},scripts:{"db:generate":"tsx --env-file=.env scripts/generate-schema.ts","db:migrate:generate":"drizzle-kit generate","db:migrate":"tsx --env-file=.env scripts/migrate.ts","db:reset":"tsx --env-file=.env scripts/reset-db.ts"}}),n?.("Adding toolkit files..."),await z(r,t),await S(r,t),await M(r,t),await N(r,t),await P(r,t),await L(r,t),await $(r,t),await _(r,t),t.installDeps&&(n?.("Installing dependencies..."),p("pnpm install",{cwd:r}))}async function H(t,n){let r=k(process.cwd(),t.name);n?.("Creating workspace..."),await C(r,t,n),t.installDeps&&(n?.("Installing dependencies..."),p("pnpm install",{cwd:r}))}async function j(){let t=process.argv[2],n=await T(t);if(!n)return;let r=c.spinner();try{r.start("Creating Next.js app..."),await q(n,O=>{r.stop(`${v.green("\u2713")} Done`),r.start(O)}),r.stop(`${v.green("\u2713")} Done`);let m=n.mode==="multi"?[`cd ${n.name}`,"docker compose up -d","cp .env.example .env","npx @better-auth/cli generate --config packages/config/auth.config.ts -y","pnpm db:generate","pnpm db:migrate:generate","pnpm db:migrate","pnpm dev","# Visit /setup to create your first admin user"]:[`cd ${n.name}`,"docker compose up -d","cp .env.example .env","npx @better-auth/cli generate --config auth.config.ts -y","pnpm db:generate","pnpm db:migrate:generate","pnpm db:migrate","pnpm dev","# Visit /setup to create your first admin user"];c.note(m.join(`
2041
+ `),"Next steps"),c.outro(`${v.green("Success!")} Your project is ready.`)}catch(a){r.stop(v.red("Failed.")),c.log.error(a instanceof Error?a.message:String(a)),process.exit(1)}}j();